feat: comprehensive balance pass (#239)
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m10s
CI / Lint, Build & Test (pull_request) Successful in 1m15s

- 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)
This commit is contained in:
2026-04-06 19:15:48 -07:00
committed by Naomi Carrigan
parent e7164257c5
commit e742c3a6ef
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);
+19 -2
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.
*/ */
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,
@@ -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>;