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>
This commit was merged in pull request #240.
This commit is contained in:
2026-04-06 19:33:05 -07:00
committed by Naomi Carrigan
parent e7164257c5
commit 3afe64e48a
8 changed files with 84 additions and 11 deletions
+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",
+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) {
+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);
+21 -4
View File
@@ -1496,11 +1496,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,
@@ -2177,6 +2186,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
+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>;