generated from nhcarrigan/template
chore: more feedback fixes (#129)
## 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:
@@ -148,11 +148,22 @@ craftRouter.post("/", async(context) => {
|
||||
|
||||
const bonusType = recipe.bonus.type;
|
||||
const bonusValue = recipe.bonus.value;
|
||||
const { materials } = state.exploration;
|
||||
const {
|
||||
craftedGoldMultiplier,
|
||||
craftedEssenceMultiplier,
|
||||
craftedClickMultiplier,
|
||||
craftedCombatMultiplier,
|
||||
} = updatedMultipliers;
|
||||
const response: CraftRecipeResponse = {
|
||||
bonusType,
|
||||
bonusValue,
|
||||
craftedClickMultiplier,
|
||||
craftedCombatMultiplier,
|
||||
craftedEssenceMultiplier,
|
||||
craftedGoldMultiplier,
|
||||
materials,
|
||||
recipeId,
|
||||
...updatedMultipliers,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -586,6 +586,8 @@ const patchQuestRewards = (state: GameState): number => {
|
||||
return `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
|
||||
}));
|
||||
for (const reward of defaultQuest.rewards) {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const key = `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
|
||||
if (!existingKeys.has(key)) {
|
||||
savedQuest.rewards.push(structuredClone(reward));
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable max-lines -- Route file requires multiple handlers */
|
||||
import { Hono } from "hono";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialExploration } from "../data/initialState.js";
|
||||
@@ -15,6 +16,7 @@ import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectEventResult,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
@@ -49,6 +51,64 @@ const pickNothingMessage = (): string => {
|
||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||
};
|
||||
|
||||
exploreRouter.get("/claimable", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const areaId = context.req.query("areaId");
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultExplorations.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!explorationArea) {
|
||||
return context.json({ error: "Unknown exploration area" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.exploration) {
|
||||
const response: ExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
const area = state.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
|
||||
if (!area || area.status !== "in_progress") {
|
||||
const response: ExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
const claimable = Date.now() >= expiresAt;
|
||||
const response: ExploreClaimableResponse = { claimable };
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"explore_claimable",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
exploreRouter.post("/start", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({});
|
||||
|
||||
Reference in New Issue
Block a user