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
19 changed files with 197 additions and 32 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",
+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,
+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,
+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",
+10 -6
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) {
void logger.error( const isExpiredToken
"auth_middleware", = error instanceof Error && error.message === "Token has expired";
error instanceof Error if (!isExpiredToken) {
? error void logger.error(
: new Error(String(error)), "auth_middleware",
); error instanceof Error
? 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) {
+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,
+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" });
+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);
+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
+31 -6
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.
*/ */
signatureReference.current = null; if (result.signature === undefined) {
localStorage.removeItem("elysium_save_signature"); signatureReference.current = null;
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
+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>;