generated from nhcarrigan/template
aaeece1a18
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.
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
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 };
|
|
};
|