/** * @file Daily challenge generation and progress tracking utilities. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import { dailyChallengeTemplates } from "../data/dailyChallenges.js"; import type { DailyChallenge, DailyChallengeState, DailyChallengeType, GameState, } from "@elysium/types"; /** * Returns today's date string in PST/PDT so challenges roll over at midnight Pacific. * @returns A date string in YYYY-MM-DD format. */ const getTodayString = (): string => { return new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles", }).format(new Date()); }; /** * Simple deterministic pseudo-random number based on a numeric seed. * @param seed - The numeric seed value. * @returns A pseudo-random float in [0, 1). */ 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. * @param dateString - A date string such as "2025-01-01". * @returns A numeric seed derived from the date characters. */ const dateSeed = (dateString: string): number => { let accumulator = 0; let index = 0; for (const char of dateString) { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const charValue = char.codePointAt(0) ?? 0; const contribution = charValue * (index + 1); accumulator = accumulator + contribution; index = index + 1; } return accumulator; }; /** * Deterministically shuffles an array using a numeric seed (Fisher-Yates). * @param array - The array to shuffle. * @param seed - The seed controlling shuffle order. * @returns A new shuffled array. */ const shuffleWithSeed = (array: Array, seed: number): Array => { const result = [ ...array ]; for (let index = result.length - 1; index > 0; index = index - 1) { const swapIndex = Math.floor(seededRandom(seed + index) * (index + 1)); /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index and swapIndex are always in bounds */ const fromSwap = result[swapIndex]!; /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index and swapIndex are always in bounds */ const fromIndex = result[index]!; result[index] = fromSwap; result[swapIndex] = fromIndex; } return result; }; const challengeTypes: Array = [ "clicks", "bossesDefeated", "questsCompleted", "prestige", ]; /** * Generates 3 daily challenges for the given date string, deterministically. * Picks one challenge from 3 different randomly-selected types. * @param dateString - The date string (YYYY-MM-DD) to generate challenges for. * @returns An array of 3 DailyChallenge objects. */ const generateDailyChallenges = ( dateString: string, ): Array => { const seed = dateSeed(dateString); const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed). slice(0, 3); return selectedTypes.map((type, index) => { const templates = dailyChallengeTemplates.filter((template) => { return template.type === type; }); const indexOffset = index * 100; const templateIndex = Math.floor( seededRandom(seed + indexOffset) * templates.length, ); /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- templateIndex is always valid: seededRandom returns [0,1) so floor * length is always in bounds */ const template = templates[templateIndex]!; return { completed: false, id: `${dateString}_${type}`, label: template.label, progress: 0, rewardCrystals: template.rewardCrystals, target: template.target, type: template.type, }; }); }; /** * Returns the current daily challenge state, generating fresh challenges when * the stored date does not match today. * @param state - The current game state. * @returns The current or freshly-generated DailyChallengeState. */ const getOrResetDailyChallenges = ( state: GameState, ): DailyChallengeState => { const today = getTodayString(); if (state.dailyChallenges?.date === today) { return state.dailyChallenges; } return { challenges: generateDailyChallenges(today), date: today }; }; /** * Increments progress for challenges matching the given type. * Returns the updated challenge state and total crystals awarded for newly completed challenges. * @param challengeState - The current daily challenge state. * @param type - The challenge type to increment progress for. * @param amount - The amount to increment progress by. * @returns The updated challenge state and total crystals awarded. */ 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 updatedProgress = Math.min( challenge.progress + amount, challenge.target, ); const nowCompleted = updatedProgress >= challenge.target; if (nowCompleted) { crystalsAwarded = crystalsAwarded + challenge.rewardCrystals; } return { ...challenge, completed: nowCompleted, progress: updatedProgress, }; }), }; return { crystalsAwarded, updatedChallenges }; }; export { generateDailyChallenges, getOrResetDailyChallenges, updateChallengeProgress, };