4 Commits

Author SHA1 Message Date
naomi e341db56af release: v0.5.0
CI / Lint, Build & Test (push) Successful in 1m15s
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 44s
2026-04-06 20:19:18 -07:00
hikari 2bc47b79aa fix: suppress expired-token log noise and redirect expired sessions to login (#241)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m11s
## Summary

- **Server**: `authMiddleware` no longer calls `logger.error` for expired tokens — expiry is expected behaviour, not an error. Only tampered signatures and malformed tokens (genuinely suspicious) still log.
- **Client**: `fetchJson` now handles 401 responses by clearing `elysium_token` and `elysium_save_signature` from localStorage and redirecting to `/`. Players whose 30-day token has expired will see the login page instead of a stuck "Invalid or expired token" error screen with no recovery path.

Closes #241

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #241
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 20:17:28 -07:00
hikari 3afe64e48a 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>
2026-04-06 19:33:05 -07:00
hikari e7164257c5 feat: comprehensive balance pass (#239)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m12s
## Summary

Working through all 15 open balance tickets in a coordinated multi-pass approach.

### Pass 1 — Quest failure rates (closes #172)
- Capped all zone quest failure chances at 15% (down from up to 40%)
- Proportional scaling preserved (harder zones still fail more than easier ones)

### Pass 2 — Crystal economy (closes #165, #173, #215)
- Added `crystal_pulse` (3,000 crystals), `crystal_surge` (20,000), `crystal_tempest` (150,000) upgrades to fill the dead zone between 600 and 2M crystal sinks
- Bumped `click_deity`, `prestige_master`, and `prestige_legend` achievement crystal rewards (5K→15K, 5K→15K, 25K→75K)
- Added crystal rewards to `first_steps` (+5) and `goblin_camp` (+10) early quests

### Pass 3 — Runestone/prestige loop (closes #166, #170)
- Bumped `runestonesPerPrestigeLevel` from 15 → 20 (~33% yield increase for mid-game runs)
- Reduced `income_10` cost from 22,500 → 15,000 and `income_11` from 60,000 → 35,000
- Kept client/server parity: `runestonesPerPrestigeLevelClient` in tick.ts updated to match

### Pass 4 — Quest content (#175, #178)
- Both already resolved in commit 666a5b2: quests now reach 5e141 CP across reality_forge, cosmic_maelstrom, primeval_sanctum, and the_absolute — fully covering P60–P212

### Pass 5 — Daily challenges (closes #167)
- Added `crafting` as a new `DailyChallengeType`
- Added 3 crafting challenge templates (craft 1/2/3 recipes)
- Changed generation to guarantee: 1 clicks + 1 crafting + 1 from progression pool
- Added crafting challenge tracking in `craft.ts` (awards crystals on recipe craft)
- Stuck players now have 2/3 daily challenges always completable

### Pass 6 — Transcendence costs (#179)
- Already resolved in commit 666a5b2: echo meta costs are 15/45/100 (was 25/75/200)

### Also closed as stale
- #171 (milestone bonus already quadratic)
- #174 (production multiplier already 1.3^n)
- #176 (expanse_sovereign HP already at 3e39)
- #177 (recipe costs already in expected range)
- #178 (post-absolute quests already present)
- #179 (echo meta costs already reduced)

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #239
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 18:58:04 -07:00
28 changed files with 299 additions and 67 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/api",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+3 -3
View File
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "☄️",
id: "click_deity",
name: "Click Deity",
reward: { crystals: 5000 },
reward: { crystals: 15_000 },
unlockedAt: null,
},
// Endgame gold milestones
@@ -405,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "💫",
id: "prestige_master",
name: "Master of Cycles",
reward: { crystals: 5000 },
reward: { crystals: 15_000 },
unlockedAt: null,
},
{
@@ -414,7 +414,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "🌠",
id: "prestige_legend",
name: "Legend of Eternity",
reward: { crystals: 25_000 },
reward: { crystals: 75_000 },
unlockedAt: null,
},
{
+1 -1
View File
@@ -21,7 +21,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: true,
},
{
baseCost: 100,
baseCost: 65,
class: "warrior",
combatPower: 3,
count: 0,
+14
View File
@@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
target: 5000,
type: "clicks",
},
// Crafting — requires materials but no zone/boss progression
{ label: "Craft 1 recipe", rewardCrystals: 75, target: 1, type: "crafting" },
{
label: "Craft 2 recipes",
rewardCrystals: 175,
target: 2,
type: "crafting",
},
{
label: "Craft 3 recipes",
rewardCrystals: 350,
target: 3,
type: "crafting",
},
// Boss defeats — requires active combat
{
label: "Defeat 1 boss",
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_10",
multiplier: 200,
name: "Eternal Rune I",
runestonesCost: 22_500,
runestonesCost: 15_000,
},
{
category: "income",
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_11",
multiplier: 500,
name: "Eternal Rune II",
runestonesCost: 60_000,
runestonesCost: 35_000,
},
// ── Click Power ───────────────────────────────────────────────────────────
{
+2
View File
@@ -19,6 +19,7 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [],
rewards: [
{ amount: 500, type: "gold" },
{ amount: 5, type: "crystals" },
{ targetId: "militia", type: "adventurer" },
],
status: "available",
@@ -33,6 +34,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 2000, type: "gold" },
{ amount: 5, type: "essence" },
{ amount: 10, type: "crystals" },
{ targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" },
+4 -4
View File
@@ -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",
+37
View File
@@ -496,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false,
},
// ── Purchasable essence/crystal sink upgrades ─────────────────────────────
{
costCrystals: 3000,
costEssence: 0,
costGold: 0,
description: "Crystalline energy pulses through your guild's operations. All income +50%.",
id: "crystal_pulse",
multiplier: 1.5,
name: "Crystal Pulse",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 20_000,
costEssence: 0,
costGold: 0,
description:
"Crystal resonance surges into every process your guild undertakes. All income doubled.",
id: "crystal_surge",
multiplier: 2,
name: "Crystal Surge",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 150_000,
costEssence: 0,
costGold: 0,
description: "Your guild's operations are saturated with crystalline power. All income x3.",
id: "crystal_tempest",
multiplier: 3,
name: "Crystal Tempest",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 0,
costEssence: 5_000_000,
+4
View File
@@ -35,12 +35,16 @@ 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)),
);
}
return context.json({ error: "Invalid or expired token" }, 401);
}
+20
View File
@@ -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) {
+11
View File
@@ -11,6 +11,7 @@ import { Hono } from "hono";
import { defaultRecipes } from "../data/recipes.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
@@ -138,6 +139,16 @@ craftRouter.post("/", async(context) => {
state.exploration.craftedCombatMultiplier
= updatedMultipliers.craftedCombatMultiplier;
if (state.dailyChallenges !== undefined) {
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
state.dailyChallenges,
"crafting",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: Date.now() },
+16 -3
View File
@@ -71,6 +71,10 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
return result;
};
const nonProgressionChallengeTypes: Array<DailyChallengeType> = [
"crafting",
];
const progressionChallengeTypes: Array<DailyChallengeType> = [
"bossesDefeated",
"questsCompleted",
@@ -79,8 +83,10 @@ const progressionChallengeTypes: Array<DailyChallengeType> = [
/**
* Generates 3 daily challenges for the given date string, deterministically.
* Always includes a "clicks" challenge (always completable regardless of
* progression), then picks 2 more from the remaining types.
* Always includes a "clicks" challenge and a "crafting" challenge (both
* completable regardless of zone/boss progression), then picks 1 more from
* the progression types. This ensures stuck players always have 2 completable
* challenges available.
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
* @returns An array of 3 DailyChallenge objects.
*/
@@ -90,7 +96,14 @@ const generateDailyChallenges = (
const seed = dateSeed(dateString);
const selectedTypes: Array<DailyChallengeType> = [
"clicks",
...shuffleWithSeed([ ...progressionChallengeTypes ], seed).slice(0, 2),
...shuffleWithSeed(
[ ...nonProgressionChallengeTypes ],
seed + 500,
).slice(0, 1),
...shuffleWithSeed(
[ ...progressionChallengeTypes ],
seed,
).slice(0, 1),
];
return selectedTypes.map((type, index) => {
+1 -1
View File
@@ -15,7 +15,7 @@ import type {
} from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000;
const runestonesPerPrestigeLevel = 15;
const runestonesPerPrestigeLevel = 20;
const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25;
+36 -5
View File
@@ -6,18 +6,26 @@ 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, verifyToken };
return { app, logger, verifyToken };
};
it("returns 401 when Authorization header is missing", async () => {
@@ -45,8 +53,8 @@ describe("authMiddleware", () => {
expect(body.discordId).toBe("user_123");
});
it("returns 401 when verifyToken throws", async () => {
const { app, verifyToken } = await makeApp();
it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw new Error("Invalid token");
});
@@ -54,10 +62,15 @@ 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 when verifyToken throws a non-Error value", async () => {
const { app, verifyToken } = await makeApp();
it("returns 401 and logs when verifyToken throws a non-Error value", async () => {
const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw "raw string error";
});
@@ -65,5 +78,23 @@ 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,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" });
+28
View File
@@ -144,6 +144,34 @@ describe("craft route", () => {
expect(body.bonusType).toBe("gold_income");
});
it("updates crafting challenge progress and awards crystals when dailyChallenges is defined", async () => {
const state = makeState({
dailyChallenges: {
date: "2024-01-15",
challenges: [
{
completed: false,
id: "2024-01-15_crafting",
label: "Craft 1 recipe",
progress: 0,
rewardCrystals: 75,
target: 1,
type: "crafting",
},
],
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: GameState };
};
expect(updateArg.data.state.dailyChallenges?.challenges[0]?.completed).toBe(true);
expect(updateArg.data.state.resources.crystals).toBe(75);
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID });
+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: 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);
+18 -5
View File
@@ -55,15 +55,28 @@ describe("generateDailyChallenges", () => {
expect(day2.some((c) => c.type === "clicks")).toBe(true);
});
it("generates different challenges for different dates", async () => {
it("always includes a crafting challenge regardless of date", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
const day1 = generateDailyChallenges("2024-01-15");
const day2 = generateDailyChallenges("2024-01-16");
// The 2 non-clicks types should vary by seed between dates
const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type);
const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type);
expect(day1NonClicks).not.toEqual(day2NonClicks);
expect(day1.some((c) => c.type === "crafting")).toBe(true);
expect(day2.some((c) => c.type === "crafting")).toBe(true);
});
it("progression challenge slot varies across different dates", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
// 2024-01-01 picks bossesDefeated, 2024-01-02 picks prestige (verified by seed)
const day1 = generateDailyChallenges("2024-01-01");
const day2 = generateDailyChallenges("2024-01-02");
const day1ProgressionType = day1.find((c) => {
return c.type !== "clicks" && c.type !== "crafting";
})?.type;
const day2ProgressionType = day2.find((c) => {
return c.type !== "clicks" && c.type !== "crafting";
})?.type;
expect(day1ProgressionType).not.toBe(day2ProgressionType);
});
});
+7 -7
View File
@@ -102,25 +102,25 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => {
// floor(cbrt(4_000_000 / 1_000_000)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15
// floor(cbrt(4_000_000 / 1_000_000)) × 20 = floor(cbrt(4)) × 20 = 1 × 20 = 20
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(15);
expect(result).toBe(20);
});
it("applies echo runestone multiplier", () => {
// floor(cbrt(4)) × 15 = 15; × 2 = 30
// floor(cbrt(4)) × 20 = 20; × 2 = 40
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
expect(result).toBe(30);
expect(result).toBe(40);
});
it("applies purchased runestone upgrade multiplier", () => {
// With "runestone_gain_1" purchased (multiplier 1.25): floor(15 × 1.25) = 18
// With "runestone_gain_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
expect(result).toBe(18);
expect(result).toBe(25);
});
it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 10 = 210, capped at 200
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/web",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
+5
View File
@@ -92,6 +92,11 @@ 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);
}
+19 -2
View File
@@ -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.
*/
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
+20 -20
View File
@@ -95,29 +95,29 @@ export const PRESTIGE_COMBAT_BASE = 4;
export const RESOURCE_CAP = 1e300;
/**
* Probability of quest failure per zone — scales from 10% (early game) to 40% (end game).
* Probability of quest failure per zone — scales from 4% (early game) to 15% (end game).
* On failure the quest resets to "available" with no rewards; the player must wait the
* full duration again on their next attempt.
*/
export const zoneFailureChance: Record<string, number> = {
abyssal_trench: 0.24,
astral_void: 0.2,
celestial_reaches: 0.22,
cosmic_maelstrom: 0.4,
crystalline_spire: 0.28,
eternal_throne: 0.32,
frozen_peaks: 0.14,
infernal_court: 0.26,
infinite_expanse: 0.36,
primeval_sanctum: 0.4,
primordial_chaos: 0.34,
reality_forge: 0.38,
shadow_marshes: 0.16,
shattered_ruins: 0.12,
the_absolute: 0.4,
verdant_vale: 0.1,
void_sanctum: 0.3,
volcanic_depths: 0.18,
abyssal_trench: 0.09,
astral_void: 0.08,
celestial_reaches: 0.08,
cosmic_maelstrom: 0.15,
crystalline_spire: 0.11,
eternal_throne: 0.12,
frozen_peaks: 0.05,
infernal_court: 0.1,
infinite_expanse: 0.14,
primeval_sanctum: 0.15,
primordial_chaos: 0.13,
reality_forge: 0.14,
shadow_marshes: 0.06,
shattered_ruins: 0.05,
the_absolute: 0.15,
verdant_vale: 0.04,
void_sanctum: 0.11,
volcanic_depths: 0.07,
};
/**
@@ -451,7 +451,7 @@ export const computePartyCombatPower = (state: GameState): number => {
};
const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 15;
const runestonesPerPrestigeLevelClient = 20;
const maxBaseRunestones = 200;
/**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "elysium",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/types",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+5
View File
@@ -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>;
@@ -8,6 +8,7 @@
type DailyChallengeType =
| "clicks"
| "bossesDefeated"
| "crafting"
| "questsCompleted"
| "prestige";