generated from nhcarrigan/template
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)
This commit is contained in:
@@ -21,7 +21,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
baseCost: 100,
|
||||
baseCost: 65,
|
||||
class: "warrior",
|
||||
combatPower: 3,
|
||||
count: 0,
|
||||
|
||||
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
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,
|
||||
|
||||
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
|
||||
// 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<CraftingRecipe> = [
|
||||
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<CraftingRecipe> = [
|
||||
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<CraftingRecipe> = [
|
||||
|
||||
// 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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, never>;
|
||||
|
||||
Reference in New Issue
Block a user