From e742c3a6ef22f09ee4dc0b4a8d31e36bc028333f Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 6 Apr 2026 19:15:48 -0700 Subject: [PATCH] feat: comprehensive balance pass (#239) - fix: boss signature chain maintained through fight results (#148) - fix: militia cost curve smoothed (100g -> 65g) (#145) - fix: crystal_shard buffed to epic tier (1.65x/1.2x -> 1.9x/1.3x) (#144) - fix: click_power recipe ceiling raised and z13-18 progression smoothed (#142) - close: elder_bark_shield, void_fragment_amulet, soul_bound_catalyst already at target values (#143) --- apps/api/src/data/adventurers.ts | 2 +- apps/api/src/data/equipment.ts | 2 +- apps/api/src/data/recipes.ts | 8 +++---- apps/api/src/routes/boss.ts | 20 ++++++++++++++++++ apps/api/test/routes/boss.spec.ts | 31 ++++++++++++++++++++++++++++ apps/api/test/routes/debug.spec.ts | 2 +- apps/web/src/context/gameContext.tsx | 25 ++++++++++++++++++---- packages/types/src/interfaces/api.ts | 5 +++++ 8 files changed, 84 insertions(+), 11 deletions(-) diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index 7294344..23dda09 100644 --- a/apps/api/src/data/adventurers.ts +++ b/apps/api/src/data/adventurers.ts @@ -21,7 +21,7 @@ export const defaultAdventurers: Array = [ unlocked: true, }, { - baseCost: 100, + baseCost: 65, class: "warrior", combatPower: 3, count: 0, diff --git a/apps/api/src/data/equipment.ts b/apps/api/src/data/equipment.ts index abbe4c0..7469ab2 100644 --- a/apps/api/src/data/equipment.ts +++ b/apps/api/src/data/equipment.ts @@ -269,7 +269,7 @@ export const defaultEquipment: Array = [ type: "trinket", }, { - bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 }, + bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 }, description: "A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.", equipped: false, diff --git a/apps/api/src/data/recipes.ts b/apps/api/src/data/recipes.ts index 28ab3be..26561c1 100644 --- a/apps/api/src/data/recipes.ts +++ b/apps/api/src/data/recipes.ts @@ -323,7 +323,7 @@ export const defaultRecipes: Array = [ // Zone 13: primordial_chaos { - bonus: { type: "click_power", value: 1.2 }, + bonus: { type: "click_power", value: 1.22 }, 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.", id: "chaos_lens", @@ -387,7 +387,7 @@ export const defaultRecipes: Array = [ zoneId: "reality_forge", }, { - bonus: { type: "click_power", value: 1.22 }, + bonus: { type: "click_power", value: 1.25 }, description: "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", @@ -439,7 +439,7 @@ export const defaultRecipes: Array = [ zoneId: "primeval_sanctum", }, { - bonus: { type: "click_power", value: 1.25 }, + bonus: { type: "click_power", value: 1.28 }, 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.", id: "first_artefact", @@ -522,7 +522,7 @@ export const defaultRecipes: Array = [ // Zone 18: the_absolute { - bonus: { type: "click_power", value: 1.28 }, + bonus: { type: "click_power", value: 1.3 }, 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.", id: "absolute_focus", diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index bbf026a..5b9f276 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -9,6 +9,7 @@ /* eslint-disable complexity -- Boss handler has inherent complexity */ /* 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 */ +import { createHmac } from "node:crypto"; import { computeSetBonuses, getActiveCompanionBonus, @@ -25,6 +26,17 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { logger } from "../services/logger.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). * 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 }, }); + const secret = process.env.ANTI_CHEAT_SECRET; + const updatedSignature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(state), secret); + const { bossId } = body; void logger.metric("boss_challenge", 1, { bossId, discordId, won }); @@ -401,6 +418,9 @@ bossRouter.post("/challenge", async(context) => { if (casualties !== undefined) { response.casualties = casualties; } + if (updatedSignature !== undefined) { + response.signature = updatedSignature; + } return context.json(response); } catch (error) { diff --git a/apps/api/test/routes/boss.spec.ts b/apps/api/test/routes/boss.spec.ts index b3f9e6d..aa7f96e 100644 --- a/apps/api/test/routes/boss.spec.ts +++ b/apps/api/test/routes/boss.spec.ts @@ -340,6 +340,37 @@ describe("boss route", () => { 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 () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); const res = await challenge({ bossId: "test_boss" }); diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index dcdcdfe..517c5aa 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -597,7 +597,7 @@ describe("debug route", () => { it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => { 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.update).mockResolvedValueOnce({} as never); diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index e54bd4e..39bc7ad 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -1496,11 +1496,20 @@ export const GameProvider = ({ }); /* - * Boss fight modifies server state; clear stale signature so - * the next pre-save or auto-save does not send a mismatched one. + * Boss fight modifies server state; update signature chain so + * the next pre-save or auto-save sends the correct token. */ - signatureReference.current = null; - localStorage.removeItem("elysium_save_signature"); + 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(); setAutoBossLastResult({ at: Date.now(), bossName: bossName, @@ -2177,6 +2186,14 @@ export const GameProvider = ({ } 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 }); } catch (error_: unknown) { const bossErrorMessage diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 976a301..0f94b5c 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -170,6 +170,11 @@ interface BossChallengeResponse { adventurerId: string; killed: number; }>; + + /** + * HMAC-SHA256 signature of the updated state for anti-cheat chain continuity. + */ + signature?: string; } type PrestigeRequest = Record;