generated from nhcarrigan/template
feat: add daily challenges system
Three PST-midnight-resetting challenges generated deterministically per day from click, boss, quest, and prestige types. Progress tracked server-side for bosses and prestige, client-side for clicks and quests. Crystal rewards awarded on completion and preserved through prestige resets.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import type { DailyChallengeType } from "@elysium/types";
|
||||
|
||||
interface DailyChallengeTemplate {
|
||||
type: DailyChallengeType;
|
||||
label: string;
|
||||
target: number;
|
||||
rewardCrystals: number;
|
||||
}
|
||||
|
||||
export const DAILY_CHALLENGE_TEMPLATES: DailyChallengeTemplate[] = [
|
||||
// Clicks — always requires active play
|
||||
{ type: "clicks", label: "Click 500 times", target: 500, rewardCrystals: 50 },
|
||||
{ type: "clicks", label: "Click 1,000 times", target: 1_000, rewardCrystals: 100 },
|
||||
{ type: "clicks", label: "Click 5,000 times", target: 5_000, rewardCrystals: 300 },
|
||||
// Boss defeats — requires active combat
|
||||
{ type: "bossesDefeated", label: "Defeat 1 boss", target: 1, rewardCrystals: 75 },
|
||||
{ type: "bossesDefeated", label: "Defeat 3 bosses", target: 3, rewardCrystals: 200 },
|
||||
{ type: "bossesDefeated", label: "Defeat 5 bosses", target: 5, rewardCrystals: 400 },
|
||||
// Quest completions — requires starting quests
|
||||
{ type: "questsCompleted", label: "Complete 3 quests", target: 3, rewardCrystals: 100 },
|
||||
{ type: "questsCompleted", label: "Complete 5 quests", target: 5, rewardCrystals: 200 },
|
||||
{ type: "questsCompleted", label: "Complete 10 quests", target: 10, rewardCrystals: 400 },
|
||||
// Prestige — the big one
|
||||
{ type: "prestige", label: "Prestige once", target: 1, rewardCrystals: 750 },
|
||||
];
|
||||
@@ -2,6 +2,7 @@ import type { BossChallengeResponse, GameState } from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
|
||||
export const bossRouter = new Hono();
|
||||
|
||||
@@ -173,6 +174,17 @@ bossRouter.post("/challenge", async (context) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Update daily boss challenge progress
|
||||
if (state.dailyChallenges) {
|
||||
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
|
||||
state.dailyChallenges,
|
||||
"bossesDefeated",
|
||||
1,
|
||||
);
|
||||
state.dailyChallenges = updatedChallenges;
|
||||
state.resources.crystals += crystalsAwarded;
|
||||
}
|
||||
|
||||
rewards = {
|
||||
gold: boss.goldReward,
|
||||
essence: boss.essenceReward,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js";
|
||||
import { DEFAULT_ADVENTURERS } from "../data/adventurers.js";
|
||||
import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||
|
||||
const RESOURCE_CAP = 1e300;
|
||||
@@ -327,6 +328,9 @@ gameRouter.get("/load", async (context) => {
|
||||
state.resources.essence += offlineEssence;
|
||||
}
|
||||
|
||||
// Generate or reset daily challenges if a new day has begun
|
||||
state.dailyChallenges = getOrResetDailyChallenges(state);
|
||||
|
||||
state.lastTickAt = now;
|
||||
|
||||
if (needsBackfill || offlineGold > 0 || offlineEssence > 0) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { DEFAULT_PRESTIGE_UPGRADES } from "../data/prestigeUpgrades.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import {
|
||||
buildPostPrestigeState,
|
||||
computeRunestoneMultipliers,
|
||||
@@ -37,15 +38,34 @@ prestigeRouter.post("/", async (context) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Update daily prestige challenge progress before resetting the run
|
||||
let updatedDailyChallenges = state.dailyChallenges;
|
||||
let challengeCrystals = 0;
|
||||
if (updatedDailyChallenges) {
|
||||
const result = updateChallengeProgress(updatedDailyChallenges, "prestige", 1);
|
||||
updatedDailyChallenges = result.updatedChallenges;
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
}
|
||||
|
||||
const { newState, newPrestigeData, runestonesEarned } = buildPostPrestigeState(
|
||||
state,
|
||||
characterName,
|
||||
);
|
||||
|
||||
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
||||
const finalState: GameState = {
|
||||
...newState,
|
||||
dailyChallenges: updatedDailyChallenges,
|
||||
resources: {
|
||||
...newState.resources,
|
||||
crystals: newState.resources.crystals + challengeCrystals,
|
||||
},
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
where: { discordId },
|
||||
data: { state: newState as object, updatedAt: now },
|
||||
data: { state: finalState as object, updatedAt: now },
|
||||
});
|
||||
|
||||
await prisma.player.update({
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type {
|
||||
DailyChallenge,
|
||||
DailyChallengeState,
|
||||
DailyChallengeType,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
import { DAILY_CHALLENGE_TEMPLATES } from "../data/dailyChallenges.js";
|
||||
|
||||
// Use the server's PST/PDT timezone so challenges roll over at PST midnight
|
||||
const getTodayString = (): string =>
|
||||
new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles" }).format(new Date());
|
||||
|
||||
/** Simple deterministic pseudo-random based on a numeric seed. */
|
||||
const seededRandom = (seed: number): number => {
|
||||
const x = Math.sin(seed + 1) * 10_000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
/** Converts a date string into a stable numeric seed. */
|
||||
const dateSeed = (dateStr: string): number =>
|
||||
dateStr.split("").reduce((acc, char, i) => acc + char.charCodeAt(0) * (i + 1), 0);
|
||||
|
||||
/** Deterministically shuffles an array using a numeric seed. */
|
||||
const shuffleWithSeed = <T>(arr: T[], seed: number): T[] => {
|
||||
const result = [...arr];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(seededRandom(seed + i) * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const CHALLENGE_TYPES: DailyChallengeType[] = [
|
||||
"clicks",
|
||||
"bossesDefeated",
|
||||
"questsCompleted",
|
||||
"prestige",
|
||||
];
|
||||
|
||||
/**
|
||||
* Generates 3 daily challenges for the given date string, deterministically.
|
||||
* Picks one challenge from 3 different randomly-selected types.
|
||||
*/
|
||||
export const generateDailyChallenges = (dateStr: string): DailyChallenge[] => {
|
||||
const seed = dateSeed(dateStr);
|
||||
const selectedTypes = shuffleWithSeed([...CHALLENGE_TYPES], seed).slice(0, 3);
|
||||
|
||||
return selectedTypes.map((type, index) => {
|
||||
const templates = DAILY_CHALLENGE_TEMPLATES.filter((t) => t.type === type);
|
||||
const templateIndex = Math.floor(seededRandom(seed + index * 100) * templates.length);
|
||||
const template = templates[templateIndex];
|
||||
|
||||
return {
|
||||
id: `${dateStr}_${type}`,
|
||||
type: template.type,
|
||||
label: template.label,
|
||||
target: template.target,
|
||||
progress: 0,
|
||||
completed: false,
|
||||
rewardCrystals: template.rewardCrystals,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current daily challenge state, generating fresh challenges if
|
||||
* the stored date doesn't match today (i.e. a new day has begun).
|
||||
*/
|
||||
export const getOrResetDailyChallenges = (state: GameState): DailyChallengeState => {
|
||||
const today = getTodayString();
|
||||
if (state.dailyChallenges?.date === today) {
|
||||
return state.dailyChallenges;
|
||||
}
|
||||
return { date: today, challenges: generateDailyChallenges(today) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Increments progress for challenges matching the given type.
|
||||
* Returns the updated challenge state and total crystals awarded for newly completed challenges.
|
||||
*/
|
||||
export const updateChallengeProgress = (
|
||||
challengeState: DailyChallengeState,
|
||||
type: DailyChallengeType,
|
||||
amount: number,
|
||||
): { updatedChallenges: DailyChallengeState; crystalsAwarded: number } => {
|
||||
let crystalsAwarded = 0;
|
||||
|
||||
const updatedChallenges: DailyChallengeState = {
|
||||
...challengeState,
|
||||
challenges: challengeState.challenges.map((challenge) => {
|
||||
if (challenge.type !== type || challenge.completed) return challenge;
|
||||
|
||||
const newProgress = Math.min(challenge.progress + amount, challenge.target);
|
||||
const nowCompleted = newProgress >= challenge.target;
|
||||
|
||||
if (nowCompleted) crystalsAwarded += challenge.rewardCrystals;
|
||||
|
||||
return { ...challenge, progress: newProgress, completed: nowCompleted };
|
||||
}),
|
||||
};
|
||||
|
||||
return { updatedChallenges, crystalsAwarded };
|
||||
};
|
||||
Reference in New Issue
Block a user