generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @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 = <T>(array: Array<T>, seed: number): Array<T> => {
|
||||
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<DailyChallengeType> = [
|
||||
"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<DailyChallenge> => {
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user