feat: initial prototype — core game systems (#30)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s

## 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:
2026-03-08 15:53:39 -07:00
committed by Naomi Carrigan
parent c69e155de3
commit 29c817230d
172 changed files with 50706 additions and 0 deletions
+180
View File
@@ -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,
};