generated from nhcarrigan/template
Compare commits
4 Commits
v0.5.0
..
bd8ae930a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
bd8ae930a5
|
|||
|
55a521a759
|
|||
|
4e2bc2cb98
|
|||
|
b5eff7de31
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/api",
|
"name": "@elysium/api",
|
||||||
"version": "0.5.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
unlocked: true,
|
unlocked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 65,
|
baseCost: 100,
|
||||||
class: "warrior",
|
class: "warrior",
|
||||||
combatPower: 3,
|
combatPower: 3,
|
||||||
count: 0,
|
count: 0,
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 },
|
bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 },
|
||||||
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,
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 13: primordial_chaos
|
// Zone 13: primordial_chaos
|
||||||
{
|
{
|
||||||
bonus: { type: "click_power", value: 1.22 },
|
bonus: { type: "click_power", value: 1.2 },
|
||||||
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.25 },
|
bonus: { type: "click_power", value: 1.22 },
|
||||||
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.28 },
|
bonus: { type: "click_power", value: 1.25 },
|
||||||
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.3 },
|
bonus: { type: "click_power", value: 1.28 },
|
||||||
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",
|
||||||
|
|||||||
@@ -35,16 +35,12 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
|||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
context.set("discordId", payload.discordId);
|
context.set("discordId", payload.discordId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const isExpiredToken
|
void logger.error(
|
||||||
= error instanceof Error && error.message === "Token has expired";
|
"auth_middleware",
|
||||||
if (!isExpiredToken) {
|
error instanceof Error
|
||||||
void logger.error(
|
? error
|
||||||
"auth_middleware",
|
: new Error(String(error)),
|
||||||
error instanceof Error
|
);
|
||||||
? error
|
|
||||||
: new Error(String(error)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context.json({ error: "Invalid or expired token" }, 401);
|
return context.json({ error: "Invalid or expired token" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
/* 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,
|
||||||
@@ -26,17 +25,6 @@ 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.
|
||||||
@@ -391,11 +379,6 @@ 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 });
|
||||||
|
|
||||||
@@ -418,9 +401,6 @@ 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) {
|
||||||
|
|||||||
@@ -6,26 +6,18 @@ vi.mock("../../src/services/jwt.js", () => ({
|
|||||||
verifyToken: vi.fn(),
|
verifyToken: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../src/services/logger.js", () => ({
|
|
||||||
logger: {
|
|
||||||
error: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("authMiddleware", () => {
|
describe("authMiddleware", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeApp = async () => {
|
const makeApp = async () => {
|
||||||
const { authMiddleware } = await import("../../src/middleware/auth.js");
|
const { authMiddleware } = await import("../../src/middleware/auth.js");
|
||||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||||
const { logger } = await import("../../src/services/logger.js");
|
|
||||||
const app = new Hono<{ Variables: { discordId: string } }>();
|
const app = new Hono<{ Variables: { discordId: string } }>();
|
||||||
app.use("*", authMiddleware);
|
app.use("*", authMiddleware);
|
||||||
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
|
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 () => {
|
it("returns 401 when Authorization header is missing", async () => {
|
||||||
@@ -53,8 +45,8 @@ describe("authMiddleware", () => {
|
|||||||
expect(body.discordId).toBe("user_123");
|
expect(body.discordId).toBe("user_123");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
|
it("returns 401 when verifyToken throws", async () => {
|
||||||
const { app, logger, verifyToken } = await makeApp();
|
const { app, verifyToken } = await makeApp();
|
||||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
throw new Error("Invalid token");
|
throw new Error("Invalid token");
|
||||||
});
|
});
|
||||||
@@ -62,15 +54,10 @@ describe("authMiddleware", () => {
|
|||||||
headers: { Authorization: "Bearer bad_token" },
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
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 () => {
|
it("returns 401 when verifyToken throws a non-Error value", async () => {
|
||||||
const { app, logger, verifyToken } = await makeApp();
|
const { app, verifyToken } = await makeApp();
|
||||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
throw "raw string error";
|
throw "raw string error";
|
||||||
});
|
});
|
||||||
@@ -78,23 +65,5 @@ describe("authMiddleware", () => {
|
|||||||
headers: { Authorization: "Bearer bad_token" },
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -340,37 +340,6 @@ 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" });
|
||||||
|
|||||||
@@ -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: 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.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.5.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -92,11 +92,6 @@ const fetchJson = async <T>(
|
|||||||
= typeof errorBody.error === "string"
|
= typeof errorBody.error === "string"
|
||||||
? errorBody.error
|
? errorBody.error
|
||||||
: "Unknown 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) {
|
if (response.status >= 400 && response.status < 500) {
|
||||||
throw new ValidationError(message, response.status);
|
throw new ValidationError(message, response.status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1496,20 +1496,11 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Boss fight modifies server state; update signature chain so
|
* Boss fight modifies server state; clear stale signature so
|
||||||
* the next pre-save or auto-save sends the correct token.
|
* the next pre-save or auto-save does not send a mismatched one.
|
||||||
*/
|
*/
|
||||||
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,
|
||||||
@@ -2186,14 +2177,6 @@ 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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "elysium",
|
"name": "elysium",
|
||||||
"version": "0.5.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/types",
|
"name": "@elysium/types",
|
||||||
"version": "0.5.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -170,11 +170,6 @@ 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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user