chore: more feedback fixes (#129)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
CI / Lint, Build & Test (push) Successful in 1m9s

## Summary

- Fix `NaN` displayed in Sync New Content / Force Unlock notifications by guarding against undefined counts
- Poll server for exploration claimability before showing Collect button to prevent client/server desync
- Return authoritative materials list from craft API to prevent client desync causing false affordability
- Add test coverage for `sync-new-content` and `explore/claimable` endpoints

Closes #125
Closes #127
Closes #128

## Test plan

- [ ] Trigger a sync with new content and verify the notification shows a real count instead of `NaN`
- [ ] Start an exploration, wait for it to complete, and verify the Collect button only appears after the server confirms claimable
- [ ] Attempt to craft a recipe and verify the material counts in the UI update to match the server's authoritative values

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #129
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #129.
This commit is contained in:
2026-03-24 13:20:37 -07:00
committed by Naomi Carrigan
parent 790d35420f
commit 6e573bea14
11 changed files with 484 additions and 49 deletions
+186
View File
@@ -557,6 +557,192 @@ describe("debug route", () => {
});
});
const syncNewContent = () =>
app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" }));
describe("POST /sync-new-content", () => {
it("returns 404 when no game state found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await syncNewContent();
expect(res.status).toBe(404);
});
it("returns 200 with zero counts when state already has all content", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("injects missing entries when arrays are empty", async () => {
const state = makeState({ adventurers: [], bosses: [], quests: [], upgrades: [], achievements: [], equipment: [], zones: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurersAdded: number; bossesAdded: number };
expect(body.adventurersAdded).toBeGreaterThan(0);
expect(body.bossesAdded).toBeGreaterThan(0);
});
it("injects missing exploration areas when exploration has no areas", async () => {
const state = makeState({ exploration: makeExploration([]) });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
expect(body.explorationAreasAdded).toBeGreaterThan(0);
});
it("skips existing exploration areas when building the id set", async () => {
const state = makeState({ exploration: makeExploration([
{ id: "verdant_meadow", status: "available", completedOnce: false } as GameState["exploration"]["areas"][0],
]) });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
// One area already existed so total injected is one less than full count
expect(body.explorationAreasAdded).toBeGreaterThan(0);
});
it("returns explorationAreasAdded=0 when exploration state is undefined", async () => {
const state = makeState({ exploration: undefined as unknown as GameState["exploration"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
expect(body.explorationAreasAdded).toBe(0);
});
it("patches quest rewards when saved quest has fewer rewards than default", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "first_steps");
expect(quest?.rewards.length).toBeGreaterThan(0);
});
it("skips quest reward patching for quests not in defaults", async () => {
const state = makeState({
quests: [{ id: "nonexistent_quest", status: "available", rewards: [] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "nonexistent_quest");
expect(quest?.rewards).toStrictEqual([]);
});
it("does not re-add rewards that are already present in the saved quest", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "adventurer", targetId: "militia" }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "first_steps");
// Reward already present so count stays the same
expect(quest?.rewards.filter((r) => r.targetId === "militia").length).toBe(1);
});
it("patches boss upgrade rewards when saved boss has fewer rewards than default", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", upgradeRewards: [] }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.upgradeRewards.length).toBeGreaterThan(0);
});
it("skips boss reward patching for bosses not in defaults", async () => {
const state = makeState({
bosses: [{ id: "nonexistent_boss", status: "available", upgradeRewards: [] }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const boss = body.state.bosses.find((b) => b.id === "nonexistent_boss");
expect(boss?.upgradeRewards).toStrictEqual([]);
});
it("sorts multiple legacy items to the end when their ids are not in the defaults", async () => {
const state = makeState({
achievements: [
{ id: "legacy_achievement_a", status: "locked" },
{ id: "legacy_achievement_b", status: "locked" },
] as GameState["achievements"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("uses amount field when building the reward key for quests with amount-based rewards", async () => {
const state = makeState({
quests: [{ id: "dragon_lair", status: "available", rewards: [{ type: "gold", amount: 500 }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("falls back to empty string when reward has neither targetId nor amount", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "unknown" }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
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("returns 500 when DB throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await syncNewContent();
expect(res.status).toBe(500);
});
it("returns 500 when DB throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
const res = await syncNewContent();
expect(res.status).toBe(500);
});
});
describe("POST /hard-reset", () => {
it("returns 404 when no player found", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
+93
View File
@@ -77,6 +77,99 @@ describe("explore route", () => {
body: JSON.stringify(body),
}));
const getClaimable = (areaId?: string) => {
const url = areaId === undefined
? "http://localhost/explore/claimable"
: `http://localhost/explore/claimable?areaId=${areaId}`;
return app.fetch(new Request(url));
};
describe("GET /claimable", () => {
it("returns 400 when areaId is missing", async () => {
const res = await getClaimable();
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await getClaimable("nonexistent_area");
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(404);
});
it("returns claimable=false when no exploration state exists", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when area is not in_progress", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when exploration is still in progress", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now(), completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=true when exploration is complete", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(true);
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
});
describe("POST /start", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postStart({});