5 Commits

Author SHA1 Message Date
hikari 9bb1d01d2b fix: resolve all 8 open bug tickets (#242–#249) (#250)
CI / Lint, Build & Test (push) Successful in 2m15s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m13s
## Summary

- **#242** — Crystals in the resource bar now use `formatNumber` to respect the player's notation setting (suffix/scientific/engineering)
- **#243** — Companion unlock progress includes current-run gold (`totalGoldEarned`) on both client and server, so companions unlock at the correct threshold
- **#244** — Empty green reward bubbles no longer render for quest crystal rewards with a zero amount
- **#245/#248** — Auto-save skips when `isAutoPrestigingReference.current` is true, preventing it from racing with an in-flight prestige and breaking the optimistic lock
- **#246** — Generated and uploaded CDN images for `crystal_pulse`, `crystal_surge`, and `crystal_tempest` upgrades
- **#247** — `validateAndSanitize` merges daily challenge progress by taking the max of client vs. server progress per challenge, so stale auto-saves can no longer roll back server-side completions
- **#249** — Cached save signature is cleared after `buyPrestigeUpgrade` succeeds, preventing a stale-signature mismatch on the next auto-save

## Test plan

- [ ] Lint passes (`pnpm lint`)
- [ ] Build passes (`pnpm build`)
- [ ] Tests pass with 100% coverage (`pnpm test`)
- [ ] Crystals display in resource bar respects notation setting
- [ ] No empty reward bubbles on quests that don't award crystals
- [ ] Companion progress bar shows correct value including current-run gold
- [ ] Auto-prestige no longer causes save errors
- [ ] Crafting a recipe updates daily challenge progress persistently (not rolled back by next auto-save)
- [ ] Buying a prestige upgrade does not cause a signature mismatch error on next save
- [ ] Crystal upgrade images display correctly in-game

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #250
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 09:50:20 -07:00
naomi e341db56af release: v0.5.0
CI / Lint, Build & Test (push) Successful in 1m15s
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 44s
2026-04-06 20:19:18 -07:00
hikari 2bc47b79aa fix: suppress expired-token log noise and redirect expired sessions to login (#241)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m11s
## Summary

- **Server**: `authMiddleware` no longer calls `logger.error` for expired tokens — expiry is expected behaviour, not an error. Only tampered signatures and malformed tokens (genuinely suspicious) still log.
- **Client**: `fetchJson` now handles 401 responses by clearing `elysium_token` and `elysium_save_signature` from localStorage and redirecting to `/`. Players whose 30-day token has expired will see the login page instead of a stuck "Invalid or expired token" error screen with no recovery path.

Closes #241

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #241
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 20:17:28 -07:00
hikari 3afe64e48a feat: comprehensive balance and bug fix pass (#240)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

- **fix(#148)**: Boss fights now return a fresh HMAC signature in the response; both the manual and auto-boss paths update `signatureReference` from it, ending the signature-mismatch loop that stopped auto-boss after the first fight
- **fix(#145)**: Militia `baseCost` lowered from 100g → 65g, smoothing the peasant→militia jump from 10× to ~6.5×
- **fix(#144)**: `crystal_shard` buffed from `1.65×/1.2×` → `1.9×/1.3×` — now competitive as an epic trinket
- **fix(#142)**: Click-power recipe progression smoothed across zones 13–18 and ceiling raised: z13 1.20→1.22, z15 1.22→1.25, z17 1.25→1.28, z18 1.28→1.30
- **close(#143)**: `elder_bark_shield` (1.2×), `void_fragment_amulet` (1.15×), and `soul_bound_catalyst` (1.2×) are all already at or above their target values from a prior pass

Closes #148
Closes #145
Closes #144
Closes #142

Reviewed-on: #240
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 19:33:05 -07:00
hikari e7164257c5 feat: comprehensive balance pass (#239)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m12s
## Summary

Working through all 15 open balance tickets in a coordinated multi-pass approach.

### Pass 1 — Quest failure rates (closes #172)
- Capped all zone quest failure chances at 15% (down from up to 40%)
- Proportional scaling preserved (harder zones still fail more than easier ones)

### Pass 2 — Crystal economy (closes #165, #173, #215)
- Added `crystal_pulse` (3,000 crystals), `crystal_surge` (20,000), `crystal_tempest` (150,000) upgrades to fill the dead zone between 600 and 2M crystal sinks
- Bumped `click_deity`, `prestige_master`, and `prestige_legend` achievement crystal rewards (5K→15K, 5K→15K, 25K→75K)
- Added crystal rewards to `first_steps` (+5) and `goblin_camp` (+10) early quests

### Pass 3 — Runestone/prestige loop (closes #166, #170)
- Bumped `runestonesPerPrestigeLevel` from 15 → 20 (~33% yield increase for mid-game runs)
- Reduced `income_10` cost from 22,500 → 15,000 and `income_11` from 60,000 → 35,000
- Kept client/server parity: `runestonesPerPrestigeLevelClient` in tick.ts updated to match

### Pass 4 — Quest content (#175, #178)
- Both already resolved in commit 666a5b2: quests now reach 5e141 CP across reality_forge, cosmic_maelstrom, primeval_sanctum, and the_absolute — fully covering P60–P212

### Pass 5 — Daily challenges (closes #167)
- Added `crafting` as a new `DailyChallengeType`
- Added 3 crafting challenge templates (craft 1/2/3 recipes)
- Changed generation to guarantee: 1 clicks + 1 crafting + 1 from progression pool
- Added crafting challenge tracking in `craft.ts` (awards crystals on recipe craft)
- Stuck players now have 2/3 daily challenges always completable

### Pass 6 — Transcendence costs (#179)
- Already resolved in commit 666a5b2: echo meta costs are 15/45/100 (was 25/75/200)

### Also closed as stale
- #171 (milestone bonus already quadratic)
- #174 (production multiplier already 1.3^n)
- #176 (expanse_sovereign HP already at 3e39)
- #177 (recipe costs already in expected range)
- #178 (post-absolute quests already present)
- #179 (echo meta costs already reduced)

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #239
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 18:58:04 -07:00
32 changed files with 357 additions and 73 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.4.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+3 -3
View File
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "☄️", icon: "☄️",
id: "click_deity", id: "click_deity",
name: "Click Deity", name: "Click Deity",
reward: { crystals: 5000 }, reward: { crystals: 15_000 },
unlockedAt: null, unlockedAt: null,
}, },
// Endgame gold milestones // Endgame gold milestones
@@ -405,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "💫", icon: "💫",
id: "prestige_master", id: "prestige_master",
name: "Master of Cycles", name: "Master of Cycles",
reward: { crystals: 5000 }, reward: { crystals: 15_000 },
unlockedAt: null, unlockedAt: null,
}, },
{ {
@@ -414,7 +414,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "🌠", icon: "🌠",
id: "prestige_legend", id: "prestige_legend",
name: "Legend of Eternity", name: "Legend of Eternity",
reward: { crystals: 25_000 }, reward: { crystals: 75_000 },
unlockedAt: null, unlockedAt: null,
}, },
{ {
+1 -1
View File
@@ -21,7 +21,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: true, unlocked: true,
}, },
{ {
baseCost: 100, baseCost: 65,
class: "warrior", class: "warrior",
combatPower: 3, combatPower: 3,
count: 0, count: 0,
+14
View File
@@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
target: 5000, target: 5000,
type: "clicks", type: "clicks",
}, },
// Crafting — requires materials but no zone/boss progression
{ label: "Craft 1 recipe", rewardCrystals: 75, target: 1, type: "crafting" },
{
label: "Craft 2 recipes",
rewardCrystals: 175,
target: 2,
type: "crafting",
},
{
label: "Craft 3 recipes",
rewardCrystals: 350,
target: 3,
type: "crafting",
},
// Boss defeats — requires active combat // Boss defeats — requires active combat
{ {
label: "Defeat 1 boss", label: "Defeat 1 boss",
+1 -1
View File
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 }, bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 },
description: description:
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.", "A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
equipped: false, equipped: false,
+2 -2
View File
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_10", id: "income_10",
multiplier: 200, multiplier: 200,
name: "Eternal Rune I", name: "Eternal Rune I",
runestonesCost: 22_500, runestonesCost: 15_000,
}, },
{ {
category: "income", category: "income",
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_11", id: "income_11",
multiplier: 500, multiplier: 500,
name: "Eternal Rune II", name: "Eternal Rune II",
runestonesCost: 60_000, runestonesCost: 35_000,
}, },
// ── Click Power ─────────────────────────────────────────────────────────── // ── Click Power ───────────────────────────────────────────────────────────
{ {
+2
View File
@@ -19,6 +19,7 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [], prerequisiteIds: [],
rewards: [ rewards: [
{ amount: 500, type: "gold" }, { amount: 500, type: "gold" },
{ amount: 5, type: "crystals" },
{ targetId: "militia", type: "adventurer" }, { targetId: "militia", type: "adventurer" },
], ],
status: "available", status: "available",
@@ -33,6 +34,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 2000, type: "gold" }, { amount: 2000, type: "gold" },
{ amount: 5, type: "essence" }, { amount: 5, type: "essence" },
{ amount: 10, type: "crystals" },
{ targetId: "peasant_1", type: "upgrade" }, { targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice_1", type: "upgrade" }, { targetId: "apprentice_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" }, { targetId: "apprentice", type: "adventurer" },
+4 -4
View File
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 13: primordial_chaos // Zone 13: primordial_chaos
{ {
bonus: { type: "click_power", value: 1.2 }, bonus: { type: "click_power", value: 1.22 },
description: description:
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.", "Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
id: "chaos_lens", id: "chaos_lens",
@@ -387,7 +387,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
bonus: { type: "click_power", value: 1.22 }, bonus: { type: "click_power", value: 1.25 },
description: description:
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.", "A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
id: "universe_seed", id: "universe_seed",
@@ -439,7 +439,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
bonus: { type: "click_power", value: 1.25 }, bonus: { type: "click_power", value: 1.28 },
description: description:
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.", "The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
id: "first_artefact", id: "first_artefact",
@@ -522,7 +522,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 18: the_absolute // Zone 18: the_absolute
{ {
bonus: { type: "click_power", value: 1.28 }, bonus: { type: "click_power", value: 1.3 },
description: description:
"Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.", "Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.",
id: "absolute_focus", id: "absolute_focus",
+37
View File
@@ -496,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false, unlocked: false,
}, },
// ── Purchasable essence/crystal sink upgrades ───────────────────────────── // ── Purchasable essence/crystal sink upgrades ─────────────────────────────
{
costCrystals: 3000,
costEssence: 0,
costGold: 0,
description: "Crystalline energy pulses through your guild's operations. All income +50%.",
id: "crystal_pulse",
multiplier: 1.5,
name: "Crystal Pulse",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 20_000,
costEssence: 0,
costGold: 0,
description:
"Crystal resonance surges into every process your guild undertakes. All income doubled.",
id: "crystal_surge",
multiplier: 2,
name: "Crystal Surge",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 150_000,
costEssence: 0,
costGold: 0,
description: "Your guild's operations are saturated with crystalline power. All income x3.",
id: "crystal_tempest",
multiplier: 3,
name: "Crystal Tempest",
purchased: false,
target: "global",
unlocked: true,
},
{ {
costCrystals: 0, costCrystals: 0,
costEssence: 5_000_000, costEssence: 5_000_000,
+4
View File
@@ -35,12 +35,16 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
const payload = verifyToken(token); const payload = verifyToken(token);
context.set("discordId", payload.discordId); context.set("discordId", payload.discordId);
} catch (error) { } catch (error) {
const isExpiredToken
= error instanceof Error && error.message === "Token has expired";
if (!isExpiredToken) {
void logger.error( void logger.error(
"auth_middleware", "auth_middleware",
error instanceof Error error instanceof Error
? error ? error
: new Error(String(error)), : new Error(String(error)),
); );
}
return context.json({ error: "Invalid or expired token" }, 401); return context.json({ error: "Invalid or expired token" }, 401);
} }
+20
View File
@@ -9,6 +9,7 @@
/* eslint-disable complexity -- Boss handler has inherent complexity */ /* eslint-disable complexity -- Boss handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Long lines in combat logic */ /* eslint-disable stylistic/max-len -- Long lines in combat logic */
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */ /* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
import { createHmac } from "node:crypto";
import { import {
computeSetBonuses, computeSetBonuses,
getActiveCompanionBonus, getActiveCompanionBonus,
@@ -25,6 +26,17 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
/**
* Computes the HMAC-SHA256 of data using the given secret.
* @param data - The data string to sign.
* @param secret - The HMAC secret key.
* @returns The hex-encoded HMAC digest.
*/
const computeHmac = (data: string, secret: string): string => {
return createHmac("sha256", secret).update(data).
digest("hex");
};
/** /**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount). * Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression. * Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
@@ -379,6 +391,11 @@ bossRouter.post("/challenge", async(context) => {
where: { discordId }, where: { discordId },
}); });
const secret = process.env.ANTI_CHEAT_SECRET;
const updatedSignature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
const { bossId } = body; const { bossId } = body;
void logger.metric("boss_challenge", 1, { bossId, discordId, won }); void logger.metric("boss_challenge", 1, { bossId, discordId, won });
@@ -401,6 +418,9 @@ bossRouter.post("/challenge", async(context) => {
if (casualties !== undefined) { if (casualties !== undefined) {
response.casualties = casualties; response.casualties = casualties;
} }
if (updatedSignature !== undefined) {
response.signature = updatedSignature;
}
return context.json(response); return context.json(response);
} catch (error) { } catch (error) {
+11
View File
@@ -11,6 +11,7 @@ import { Hono } from "hono";
import { defaultRecipes } from "../data/recipes.js"; import { defaultRecipes } from "../data/recipes.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { import type {
@@ -138,6 +139,16 @@ craftRouter.post("/", async(context) => {
state.exploration.craftedCombatMultiplier state.exploration.craftedCombatMultiplier
= updatedMultipliers.craftedCombatMultiplier; = updatedMultipliers.craftedCombatMultiplier;
if (state.dailyChallenges !== undefined) {
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
state.dailyChallenges,
"crafting",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
await prisma.gameState.update({ await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: Date.now() }, data: { state: state as object, updatedAt: Date.now() },
+42 -1
View File
@@ -681,6 +681,45 @@ const validateAndSanitize = (
storySpread = { story: previous.story }; storySpread = { story: previous.story };
} }
/*
* Merge daily challenge progress: take the maximum progress for each
* challenge so a stale auto-save arriving after a craft/boss/etc. update
* cannot silently roll back server-side challenge completions.
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 35 -- @preserve */
let dailyChallengesSpread: object = {};
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (incoming.dailyChallenges !== undefined && previous.dailyChallenges !== undefined) {
const previousChallengeMap = new Map(
previous.dailyChallenges.challenges.map((challenge) => {
return [ challenge.id, challenge ];
}),
);
// eslint-disable-next-line stylistic/max-len -- Long chain; splitting would reduce readability
const mergedChallenges = incoming.dailyChallenges.challenges.map((challenge) => {
const serverChallenge = previousChallengeMap.get(challenge.id);
if (serverChallenge === undefined) {
return challenge;
}
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
const bestProgress = Math.max(challenge.progress, serverChallenge.progress);
return {
...challenge,
completed: bestProgress >= challenge.target,
progress: bestProgress,
};
});
dailyChallengesSpread = {
dailyChallenges: {
...incoming.dailyChallenges,
challenges: mergedChallenges,
},
};
} else if (previous.dailyChallenges !== undefined) {
dailyChallengesSpread = { dailyChallenges: previous.dailyChallenges };
}
return { return {
...incoming, ...incoming,
achievements, achievements,
@@ -693,6 +732,7 @@ const validateAndSanitize = (
...apotheosisSpread, ...apotheosisSpread,
...explorationSpread, ...explorationSpread,
...storySpread, ...storySpread,
...dailyChallengesSpread,
}; };
}; };
@@ -1024,7 +1064,8 @@ gameRouter.post("/save", async(context) => {
const companionUnlocks = computeUnlockedCompanionIds({ const companionUnlocks = computeUnlockedCompanionIds({
apotheosisCount: stateToSave.apotheosis?.count ?? 0, apotheosisCount: stateToSave.apotheosis?.count ?? 0,
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0, lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0, // eslint-disable-next-line stylistic/max-len -- Long property; splitting would reduce readability
lifetimeGoldEarned: (playerRecord?.lifetimeGoldEarned ?? 0) + stateToSave.player.totalGoldEarned,
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0, lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
prestigeCount: stateToSave.prestige.count, prestigeCount: stateToSave.prestige.count,
transcendenceCount: stateToSave.transcendence?.count ?? 0, transcendenceCount: stateToSave.transcendence?.count ?? 0,
+16 -3
View File
@@ -71,6 +71,10 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
return result; return result;
}; };
const nonProgressionChallengeTypes: Array<DailyChallengeType> = [
"crafting",
];
const progressionChallengeTypes: Array<DailyChallengeType> = [ const progressionChallengeTypes: Array<DailyChallengeType> = [
"bossesDefeated", "bossesDefeated",
"questsCompleted", "questsCompleted",
@@ -79,8 +83,10 @@ const progressionChallengeTypes: Array<DailyChallengeType> = [
/** /**
* Generates 3 daily challenges for the given date string, deterministically. * Generates 3 daily challenges for the given date string, deterministically.
* Always includes a "clicks" challenge (always completable regardless of * Always includes a "clicks" challenge and a "crafting" challenge (both
* progression), then picks 2 more from the remaining types. * completable regardless of zone/boss progression), then picks 1 more from
* the progression types. This ensures stuck players always have 2 completable
* challenges available.
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for. * @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
* @returns An array of 3 DailyChallenge objects. * @returns An array of 3 DailyChallenge objects.
*/ */
@@ -90,7 +96,14 @@ const generateDailyChallenges = (
const seed = dateSeed(dateString); const seed = dateSeed(dateString);
const selectedTypes: Array<DailyChallengeType> = [ const selectedTypes: Array<DailyChallengeType> = [
"clicks", "clicks",
...shuffleWithSeed([ ...progressionChallengeTypes ], seed).slice(0, 2), ...shuffleWithSeed(
[ ...nonProgressionChallengeTypes ],
seed + 500,
).slice(0, 1),
...shuffleWithSeed(
[ ...progressionChallengeTypes ],
seed,
).slice(0, 1),
]; ];
return selectedTypes.map((type, index) => { return selectedTypes.map((type, index) => {
+1 -1
View File
@@ -15,7 +15,7 @@ import type {
} from "@elysium/types"; } from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000; const basePrestigeGoldThreshold = 1_000_000;
const runestonesPerPrestigeLevel = 15; const runestonesPerPrestigeLevel = 20;
const milestoneInterval = 5; const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25; const milestoneRunestonesPerInterval = 25;
+36 -5
View File
@@ -6,18 +6,26 @@ vi.mock("../../src/services/jwt.js", () => ({
verifyToken: vi.fn(), verifyToken: vi.fn(),
})); }));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
},
}));
describe("authMiddleware", () => { describe("authMiddleware", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.clearAllMocks();
}); });
const makeApp = async () => { const makeApp = async () => {
const { authMiddleware } = await import("../../src/middleware/auth.js"); const { authMiddleware } = await import("../../src/middleware/auth.js");
const { verifyToken } = await import("../../src/services/jwt.js"); const { verifyToken } = await import("../../src/services/jwt.js");
const { logger } = await import("../../src/services/logger.js");
const app = new Hono<{ Variables: { discordId: string } }>(); const app = new Hono<{ Variables: { discordId: string } }>();
app.use("*", authMiddleware); app.use("*", authMiddleware);
app.get("/test", (c) => c.json({ discordId: c.get("discordId") })); app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
return { app, verifyToken }; return { app, logger, verifyToken };
}; };
it("returns 401 when Authorization header is missing", async () => { it("returns 401 when Authorization header is missing", async () => {
@@ -45,8 +53,8 @@ describe("authMiddleware", () => {
expect(body.discordId).toBe("user_123"); expect(body.discordId).toBe("user_123");
}); });
it("returns 401 when verifyToken throws", async () => { it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
const { app, verifyToken } = await makeApp(); const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => { vi.mocked(verifyToken).mockImplementationOnce(() => {
throw new Error("Invalid token"); throw new Error("Invalid token");
}); });
@@ -54,10 +62,15 @@ describe("authMiddleware", () => {
headers: { Authorization: "Bearer bad_token" }, headers: { Authorization: "Bearer bad_token" },
})); }));
expect(res.status).toBe(401); expect(res.status).toBe(401);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"auth_middleware",
expect.any(Error),
);
}); });
it("returns 401 when verifyToken throws a non-Error value", async () => { it("returns 401 and logs when verifyToken throws a non-Error value", async () => {
const { app, verifyToken } = await makeApp(); const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => { vi.mocked(verifyToken).mockImplementationOnce(() => {
throw "raw string error"; throw "raw string error";
}); });
@@ -65,5 +78,23 @@ describe("authMiddleware", () => {
headers: { Authorization: "Bearer bad_token" }, headers: { Authorization: "Bearer bad_token" },
})); }));
expect(res.status).toBe(401); expect(res.status).toBe(401);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"auth_middleware",
expect.any(Error),
);
});
it("returns 401 without logging when token has expired", async () => {
const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw new Error("Token has expired");
});
const res = await app.fetch(new Request("http://localhost/test", {
headers: { Authorization: "Bearer expired_token" },
}));
expect(res.status).toBe(401);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
expect((logger.error as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
}); });
}); });
+31
View File
@@ -340,6 +340,37 @@ describe("boss route", () => {
expect(area?.status).toBe("locked"); expect(area?.status).toBe("locked");
}); });
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState({
bosses: [makeBoss()] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("omits signature in response when ANTI_CHEAT_SECRET is not set", async () => {
delete process.env.ANTI_CHEAT_SECRET;
const state = makeState({
bosses: [makeBoss()] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeUndefined();
});
it("returns 500 when the database throws", async () => { it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await challenge({ bossId: "test_boss" }); const res = await challenge({ bossId: "test_boss" });
+28
View File
@@ -144,6 +144,34 @@ describe("craft route", () => {
expect(body.bonusType).toBe("gold_income"); expect(body.bonusType).toBe("gold_income");
}); });
it("updates crafting challenge progress and awards crystals when dailyChallenges is defined", async () => {
const state = makeState({
dailyChallenges: {
date: "2024-01-15",
challenges: [
{
completed: false,
id: "2024-01-15_crafting",
label: "Craft 1 recipe",
progress: 0,
rewardCrystals: 75,
target: 1,
type: "crafting",
},
],
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: GameState };
};
expect(updateArg.data.state.dailyChallenges?.challenges[0]?.completed).toBe(true);
expect(updateArg.data.state.resources.crystals).toBe(75);
});
it("returns 500 when the database throws", async () => { it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID }); const res = await post({ recipeId: TEST_RECIPE_ID });
+1 -1
View File
@@ -597,7 +597,7 @@ describe("debug route", () => {
it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => { it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({ const state = makeState({
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 100, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"], adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 65, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
}); });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
+18 -5
View File
@@ -55,15 +55,28 @@ describe("generateDailyChallenges", () => {
expect(day2.some((c) => c.type === "clicks")).toBe(true); expect(day2.some((c) => c.type === "clicks")).toBe(true);
}); });
it("generates different challenges for different dates", async () => { it("always includes a crafting challenge regardless of date", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15); vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js"); const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
const day1 = generateDailyChallenges("2024-01-15"); const day1 = generateDailyChallenges("2024-01-15");
const day2 = generateDailyChallenges("2024-01-16"); const day2 = generateDailyChallenges("2024-01-16");
// The 2 non-clicks types should vary by seed between dates expect(day1.some((c) => c.type === "crafting")).toBe(true);
const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type); expect(day2.some((c) => c.type === "crafting")).toBe(true);
const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type); });
expect(day1NonClicks).not.toEqual(day2NonClicks);
it("progression challenge slot varies across different dates", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
// 2024-01-01 picks bossesDefeated, 2024-01-02 picks prestige (verified by seed)
const day1 = generateDailyChallenges("2024-01-01");
const day2 = generateDailyChallenges("2024-01-02");
const day1ProgressionType = day1.find((c) => {
return c.type !== "clicks" && c.type !== "crafting";
})?.type;
const day2ProgressionType = day2.find((c) => {
return c.type !== "clicks" && c.type !== "crafting";
})?.type;
expect(day1ProgressionType).not.toBe(day2ProgressionType);
}); });
}); });
+7 -7
View File
@@ -102,25 +102,25 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => { describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => { it("calculates basic runestones formula", () => {
// floor(cbrt(4_000_000 / 1_000_000)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15 // floor(cbrt(4_000_000 / 1_000_000)) × 20 = floor(cbrt(4)) × 20 = 1 × 20 = 20
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(15); expect(result).toBe(20);
}); });
it("applies echo runestone multiplier", () => { it("applies echo runestone multiplier", () => {
// floor(cbrt(4)) × 15 = 15; × 2 = 30 // floor(cbrt(4)) × 20 = 20; × 2 = 40
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
expect(result).toBe(30); expect(result).toBe(40);
}); });
it("applies purchased runestone upgrade multiplier", () => { it("applies purchased runestone upgrade multiplier", () => {
// With "runestone_gain_1" purchased (multiplier 1.25): floor(15 × 1.25) = 18 // With "runestone_gain_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
expect(result).toBe(18); expect(result).toBe(25);
}); });
it("caps base runestones before multipliers", () => { it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 10 = 210, capped at 200 // cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200); expect(result).toBe(200);
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.4.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+5
View File
@@ -92,6 +92,11 @@ const fetchJson = async <T>(
= typeof errorBody.error === "string" = typeof errorBody.error === "string"
? errorBody.error ? errorBody.error
: "Unknown error"; : "Unknown error";
if (response.status === 401) {
globalThis.localStorage.removeItem("elysium_token");
globalThis.localStorage.removeItem("elysium_save_signature");
globalThis.location.href = "/";
}
if (response.status >= 400 && response.status < 500) { if (response.status >= 400 && response.status < 500) {
throw new ValidationError(message, response.status); throw new ValidationError(message, response.status);
} }
@@ -163,7 +163,8 @@ const CompanionPanel = (): JSX.Element => {
const progressByUnlockType: Record<string, number> = { const progressByUnlockType: Record<string, number> = {
apotheosis: state.apotheosis?.count ?? 0, apotheosis: state.apotheosis?.count ?? 0,
lifetimeBosses: state.player.lifetimeBossesDefeated, lifetimeBosses: state.player.lifetimeBossesDefeated,
lifetimeGold: state.player.lifetimeGoldEarned, // eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
lifetimeGold: state.player.lifetimeGoldEarned + state.player.totalGoldEarned,
lifetimeQuests: state.player.lifetimeQuestsCompleted, lifetimeQuests: state.player.lifetimeQuestsCompleted,
prestige: state.prestige.count, prestige: state.prestige.count,
transcendence: state.transcendence?.count ?? 0, transcendence: state.transcendence?.count ?? 0,
+3 -1
View File
@@ -114,6 +114,9 @@ const QuestCard = ({
} }
<div className="quest-rewards"> <div className="quest-rewards">
{quest.rewards.map((reward, rewardIndex) => { {quest.rewards.map((reward, rewardIndex) => {
if (reward.type === "crystals" && (reward.amount ?? 0) === 0) {
return null;
}
return ( return (
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}> <span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
{reward.type === "gold" {reward.type === "gold"
@@ -121,7 +124,6 @@ const QuestCard = ({
{reward.type === "essence" {reward.type === "essence"
&& `${formatNumber(reward.amount ?? 0)}`} && `${formatNumber(reward.amount ?? 0)}`}
{reward.type === "crystals" {reward.type === "crystals"
&& (reward.amount ?? 0) > 0
&& `💎 ${formatNumber(reward.amount ?? 0)}`} && `💎 ${formatNumber(reward.amount ?? 0)}`}
{reward.type === "upgrade" && "🔓 Upgrade"} {reward.type === "upgrade" && "🔓 Upgrade"}
{reward.type === "adventurer" && "👥 New Adventurer"} {reward.type === "adventurer" && "👥 New Adventurer"}
+1 -1
View File
@@ -218,7 +218,7 @@ const ResourceBar = ({
: ""}`}> : ""}`}>
<span className="resource-icon">{"💎"}</span> <span className="resource-icon">{"💎"}</span>
<span className="resource-value"> <span className="resource-value">
{formatInteger(crystals)} {formatNumber(crystals)}
</span> </span>
<span className="resource-label">{"Crystals"}</span> <span className="resource-label">{"Crystals"}</span>
{crystalsFull {crystalsFull
+29 -4
View File
@@ -1356,10 +1356,11 @@ export const GameProvider = ({
newlyFailedQuestsReference.current = []; newlyFailedQuestsReference.current = [];
} }
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions) // Auto-save every 30 seconds (skip if a force sync or auto-prestige is in-flight to avoid signature collisions)
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) { if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
lastSaveReference.current = Date.now(); lastSaveReference.current = Date.now();
if (stateReference.current !== null && !isSyncingReference.current) { // eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) {
void saveGame({ void saveGame({
state: stateReference.current, state: stateReference.current,
...signatureReference.current === null ...signatureReference.current === null
@@ -1496,11 +1497,20 @@ export const GameProvider = ({
}); });
/* /*
* Boss fight modifies server state; clear stale signature so * Boss fight modifies server state; update signature chain so
* the next pre-save or auto-save does not send a mismatched one. * the next pre-save or auto-save sends the correct token.
*/ */
if (result.signature === undefined) {
signatureReference.current = null; signatureReference.current = null;
localStorage.removeItem("elysium_save_signature"); localStorage.removeItem("elysium_save_signature");
} else {
signatureReference.current = result.signature;
localStorage.setItem(
"elysium_save_signature",
result.signature,
);
}
lastSaveReference.current = Date.now();
setAutoBossLastResult({ setAutoBossLastResult({
at: Date.now(), at: Date.now(),
bossName: bossName, bossName: bossName,
@@ -1847,6 +1857,13 @@ export const GameProvider = ({
}, },
}; };
}); });
/*
* Buying a prestige upgrade mutates DB state; clear the cached signature
* so the next auto-save doesn't collide with a stale one.
*/
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} catch (error_: unknown) { } catch (error_: unknown) {
logError("buy_prestige_upgrade", error_); logError("buy_prestige_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI // Silently ignore — server errors shouldn't crash the UI
@@ -2177,6 +2194,14 @@ export const GameProvider = ({
} }
return applyBossResult(previous, bossId, result); return applyBossResult(previous, bossId, result);
}); });
if (result.signature === undefined) {
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} else {
signatureReference.current = result.signature;
localStorage.setItem("elysium_save_signature", result.signature);
}
lastSaveReference.current = Date.now();
setBattleResult({ bossName: boss.name, result: result }); setBattleResult({ bossName: boss.name, result: result });
} catch (error_: unknown) { } catch (error_: unknown) {
const bossErrorMessage const bossErrorMessage
+20 -20
View File
@@ -95,29 +95,29 @@ export const PRESTIGE_COMBAT_BASE = 4;
export const RESOURCE_CAP = 1e300; export const RESOURCE_CAP = 1e300;
/** /**
* Probability of quest failure per zone scales from 10% (early game) to 40% (end game). * Probability of quest failure per zone scales from 4% (early game) to 15% (end game).
* On failure the quest resets to "available" with no rewards; the player must wait the * On failure the quest resets to "available" with no rewards; the player must wait the
* full duration again on their next attempt. * full duration again on their next attempt.
*/ */
export const zoneFailureChance: Record<string, number> = { export const zoneFailureChance: Record<string, number> = {
abyssal_trench: 0.24, abyssal_trench: 0.09,
astral_void: 0.2, astral_void: 0.08,
celestial_reaches: 0.22, celestial_reaches: 0.08,
cosmic_maelstrom: 0.4, cosmic_maelstrom: 0.15,
crystalline_spire: 0.28, crystalline_spire: 0.11,
eternal_throne: 0.32, eternal_throne: 0.12,
frozen_peaks: 0.14, frozen_peaks: 0.05,
infernal_court: 0.26, infernal_court: 0.1,
infinite_expanse: 0.36, infinite_expanse: 0.14,
primeval_sanctum: 0.4, primeval_sanctum: 0.15,
primordial_chaos: 0.34, primordial_chaos: 0.13,
reality_forge: 0.38, reality_forge: 0.14,
shadow_marshes: 0.16, shadow_marshes: 0.06,
shattered_ruins: 0.12, shattered_ruins: 0.05,
the_absolute: 0.4, the_absolute: 0.15,
verdant_vale: 0.1, verdant_vale: 0.04,
void_sanctum: 0.3, void_sanctum: 0.11,
volcanic_depths: 0.18, volcanic_depths: 0.07,
}; };
/** /**
@@ -451,7 +451,7 @@ export const computePartyCombatPower = (state: GameState): number => {
}; };
const basePrestigeThreshold = 1_000_000; const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 15; const runestonesPerPrestigeLevelClient = 20;
const maxBaseRunestones = 200; const maxBaseRunestones = 200;
/** /**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.4.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.4.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+5
View File
@@ -170,6 +170,11 @@ interface BossChallengeResponse {
adventurerId: string; adventurerId: string;
killed: number; killed: number;
}>; }>;
/**
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
*/
signature?: string;
} }
type PrestigeRequest = Record<string, never>; type PrestigeRequest = Record<string, never>;
@@ -8,6 +8,7 @@
type DailyChallengeType = type DailyChallengeType =
| "clicks" | "clicks"
| "bossesDefeated" | "bossesDefeated"
| "crafting"
| "questsCompleted" | "questsCompleted"
| "prestige"; | "prestige";