4 Commits

Author SHA1 Message Date
hikari bd8ae930a5 balance: add crafting daily challenge type to unblock progression-stuck players (closes #167)
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m1s
CI / Lint, Build & Test (pull_request) Successful in 1m10s
2026-04-06 18:54:39 -07:00
hikari 55a521a759 balance: increase runestone yield and reduce income_10/11 costs (closes #166, #170) 2026-04-06 18:44:44 -07:00
hikari 4e2bc2cb98 balance: crystal economy improvements (closes #165, #173, #215)
Early game: first_steps and goblin_camp quests now award a small
crystal bonus so the crystal economy is visible from turn one.

Mid-game income: click_deity (1M clicks) 5k→15k, prestige_master
(P10) 5k→15k, prestige_legend (P25) 25k→75k to close the gap
before the first large crystal-cost upgrade.

Crystal sinks: add crystal_pulse (3k→×1.5), crystal_surge
(20k→×2), and crystal_tempest (150k→×3) global upgrades to fill
the dead zone between crystal_mastery (600 crystals) and the
existing 2M+ adventurer upgrades.

Closes #165
Closes #173
Closes #215
2026-04-06 18:38:30 -07:00
hikari b5eff7de31 balance: cap quest failure rates at 15% (closes #172)
Proportionally scaled all zoneFailureChance values from the old
4%-40% range down to 4%-15%, preserving the relative gradient
across zones. A 7-hour quest failing 40% of the time was too
punishing; 15% max keeps risk meaningful without being cruel.

Closes #172
2026-04-06 18:28:05 -07:00
19 changed files with 32 additions and 197 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/api",
"version": "0.5.0",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+1 -1
View File
@@ -21,7 +21,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: true,
},
{
baseCost: 65,
baseCost: 100,
class: "warrior",
combatPower: 3,
count: 0,
+1 -1
View File
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 },
bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 },
description:
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
equipped: false,
+4 -4
View File
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 13: primordial_chaos
{
bonus: { type: "click_power", value: 1.22 },
bonus: { type: "click_power", value: 1.2 },
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.25 },
bonus: { type: "click_power", value: 1.22 },
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.28 },
bonus: { type: "click_power", value: 1.25 },
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.3 },
bonus: { type: "click_power", value: 1.28 },
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",
+6 -10
View File
@@ -35,16 +35,12 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
const payload = verifyToken(token);
context.set("discordId", payload.discordId);
} catch (error) {
const isExpiredToken
= error instanceof Error && error.message === "Token has expired";
if (!isExpiredToken) {
void logger.error(
"auth_middleware",
error instanceof Error
? error
: new Error(String(error)),
);
}
void logger.error(
"auth_middleware",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Invalid or expired token" }, 401);
}
-20
View File
@@ -9,7 +9,6 @@
/* 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,
@@ -26,17 +25,6 @@ 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.
@@ -391,11 +379,6 @@ 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 });
@@ -418,9 +401,6 @@ bossRouter.post("/challenge", async(context) => {
if (casualties !== undefined) {
response.casualties = casualties;
}
if (updatedSignature !== undefined) {
response.signature = updatedSignature;
}
return context.json(response);
} catch (error) {
+1 -42
View File
@@ -681,45 +681,6 @@ const validateAndSanitize = (
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 {
...incoming,
achievements,
@@ -732,7 +693,6 @@ const validateAndSanitize = (
...apotheosisSpread,
...explorationSpread,
...storySpread,
...dailyChallengesSpread,
};
};
@@ -1064,8 +1024,7 @@ gameRouter.post("/save", async(context) => {
const companionUnlocks = computeUnlockedCompanionIds({
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
// eslint-disable-next-line stylistic/max-len -- Long property; splitting would reduce readability
lifetimeGoldEarned: (playerRecord?.lifetimeGoldEarned ?? 0) + stateToSave.player.totalGoldEarned,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
prestigeCount: stateToSave.prestige.count,
transcendenceCount: stateToSave.transcendence?.count ?? 0,
+5 -36
View File
@@ -6,26 +6,18 @@ vi.mock("../../src/services/jwt.js", () => ({
verifyToken: vi.fn(),
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
},
}));
describe("authMiddleware", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
const makeApp = async () => {
const { authMiddleware } = await import("../../src/middleware/auth.js");
const { verifyToken } = await import("../../src/services/jwt.js");
const { logger } = await import("../../src/services/logger.js");
const app = new Hono<{ Variables: { discordId: string } }>();
app.use("*", authMiddleware);
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
return { app, logger, verifyToken };
return { app, verifyToken };
};
it("returns 401 when Authorization header is missing", async () => {
@@ -53,8 +45,8 @@ describe("authMiddleware", () => {
expect(body.discordId).toBe("user_123");
});
it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
const { app, logger, verifyToken } = await makeApp();
it("returns 401 when verifyToken throws", async () => {
const { app, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw new Error("Invalid token");
});
@@ -62,15 +54,10 @@ describe("authMiddleware", () => {
headers: { Authorization: "Bearer bad_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>)).toHaveBeenCalledWith(
"auth_middleware",
expect.any(Error),
);
});
it("returns 401 and logs when verifyToken throws a non-Error value", async () => {
const { app, logger, verifyToken } = await makeApp();
it("returns 401 when verifyToken throws a non-Error value", async () => {
const { app, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw "raw string error";
});
@@ -78,23 +65,5 @@ describe("authMiddleware", () => {
headers: { Authorization: "Bearer bad_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>)).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,37 +340,6 @@ 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" });
+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 () => {
const state = makeState({
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"],
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"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/web",
"version": "0.5.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
-5
View File
@@ -92,11 +92,6 @@ const fetchJson = async <T>(
= typeof errorBody.error === "string"
? errorBody.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) {
throw new ValidationError(message, response.status);
}
@@ -163,8 +163,7 @@ const CompanionPanel = (): JSX.Element => {
const progressByUnlockType: Record<string, number> = {
apotheosis: state.apotheosis?.count ?? 0,
lifetimeBosses: state.player.lifetimeBossesDefeated,
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
lifetimeGold: state.player.lifetimeGoldEarned + state.player.totalGoldEarned,
lifetimeGold: state.player.lifetimeGoldEarned,
lifetimeQuests: state.player.lifetimeQuestsCompleted,
prestige: state.prestige.count,
transcendence: state.transcendence?.count ?? 0,
+1 -3
View File
@@ -114,9 +114,6 @@ const QuestCard = ({
}
<div className="quest-rewards">
{quest.rewards.map((reward, rewardIndex) => {
if (reward.type === "crystals" && (reward.amount ?? 0) === 0) {
return null;
}
return (
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
{reward.type === "gold"
@@ -124,6 +121,7 @@ const QuestCard = ({
{reward.type === "essence"
&& `${formatNumber(reward.amount ?? 0)}`}
{reward.type === "crystals"
&& (reward.amount ?? 0) > 0
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
{reward.type === "upgrade" && "🔓 Upgrade"}
{reward.type === "adventurer" && "👥 New Adventurer"}
+1 -1
View File
@@ -218,7 +218,7 @@ const ResourceBar = ({
: ""}`}>
<span className="resource-icon">{"💎"}</span>
<span className="resource-value">
{formatNumber(crystals)}
{formatInteger(crystals)}
</span>
<span className="resource-label">{"Crystals"}</span>
{crystalsFull
+6 -31
View File
@@ -1356,11 +1356,10 @@ export const GameProvider = ({
newlyFailedQuestsReference.current = [];
}
// Auto-save every 30 seconds (skip if a force sync or auto-prestige is in-flight to avoid signature collisions)
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
lastSaveReference.current = Date.now();
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) {
if (stateReference.current !== null && !isSyncingReference.current) {
void saveGame({
state: stateReference.current,
...signatureReference.current === null
@@ -1497,20 +1496,11 @@ export const GameProvider = ({
});
/*
* Boss fight modifies server state; update signature chain so
* the next pre-save or auto-save sends the correct token.
* Boss fight modifies server state; clear stale signature so
* the next pre-save or auto-save does not send a mismatched one.
*/
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();
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
setAutoBossLastResult({
at: Date.now(),
bossName: bossName,
@@ -1857,13 +1847,6 @@ 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) {
logError("buy_prestige_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI
@@ -2194,14 +2177,6 @@ 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "elysium",
"version": "0.5.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/types",
"version": "0.5.0",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
-5
View File
@@ -170,11 +170,6 @@ 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>;