From 6e573bea14d2611f755488b1c89cc3b1ff66e9bf Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 13:20:37 -0700 Subject: [PATCH] chore: more feedback fixes (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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: https://git.nhcarrigan.com/nhcarrigan/elysium/pulls/129 Co-authored-by: Hikari Co-committed-by: Hikari --- apps/api/src/routes/craft.ts | 13 +- apps/api/src/routes/debug.ts | 2 + apps/api/src/routes/explore.ts | 60 ++++++ apps/api/test/routes/debug.spec.ts | 186 ++++++++++++++++++ apps/api/test/routes/explore.spec.ts | 93 +++++++++ apps/web/src/api/client.ts | 15 ++ apps/web/src/components/game/debugPanel.tsx | 76 +++---- .../src/components/game/explorationPanel.tsx | 71 ++++++- apps/web/src/context/gameContext.tsx | 10 +- packages/types/src/index.ts | 1 + packages/types/src/interfaces/api.ts | 6 + 11 files changed, 484 insertions(+), 49 deletions(-) diff --git a/apps/api/src/routes/craft.ts b/apps/api/src/routes/craft.ts index 62b8491..88f44ae 100644 --- a/apps/api/src/routes/craft.ts +++ b/apps/api/src/routes/craft.ts @@ -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) { diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index e2e4ab5..fb65e35 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -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)); diff --git a/apps/api/src/routes/explore.ts b/apps/api/src/routes/explore.ts index 8120f59..a7b80f1 100644 --- a/apps/api/src/routes/explore.ts +++ b/apps/api/src/routes/explore.ts @@ -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"); diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index f0dab75..1765f59 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -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); diff --git a/apps/api/test/routes/explore.spec.ts b/apps/api/test/routes/explore.spec.ts index 396cda3..848be98 100644 --- a/apps/api/test/routes/explore.spec.ts +++ b/apps/api/test/routes/explore.spec.ts @@ -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({}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c5b28ff..b4bc783 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -17,6 +17,7 @@ import type { BuyPrestigeUpgradeResponse, CraftRecipeRequest, CraftRecipeResponse, + ExploreClaimableResponse, ExploreCollectRequest, ExploreCollectResponse, ExploreStartRequest, @@ -244,6 +245,19 @@ const collectExploration = async( }); }; +/** + * Checks whether a given exploration area is ready to claim on the server. + * @param areaId - The area ID to check. + * @returns Whether the exploration is claimable. + */ +const checkExplorationClaimable = async( + areaId: string, +): Promise => { + return await fetchJson( + `/explore/claimable?areaId=${encodeURIComponent(areaId)}`, + ); +}; + /** * Crafts a recipe on the server. * @param body - The craft recipe request payload. @@ -316,6 +330,7 @@ export { buyEchoUpgrade, buyPrestigeUpgrade, challengeBoss, + checkExplorationClaimable, collectExploration, craftRecipe, debugHardReset, diff --git a/apps/web/src/components/game/debugPanel.tsx b/apps/web/src/components/game/debugPanel.tsx index 32f04bd..6953a2f 100644 --- a/apps/web/src/components/game/debugPanel.tsx +++ b/apps/web/src/components/game/debugPanel.tsx @@ -13,18 +13,22 @@ import { ConfirmationModal } from "../ui/confirmationModal.js"; type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null; interface SyncNewContentResult { - achievementsAdded: number; - adventurersAdded: number; - bossesAdded: number; - bossRewardsPatched: number; - equipmentAdded: number; - explorationAreasAdded: number; - questRewardsPatched: number; - questsAdded: number; - upgradesAdded: number; - zonesAdded: number; + achievementsAdded: number | undefined; + adventurersAdded: number | undefined; + bossesAdded: number | undefined; + bossRewardsPatched: number | undefined; + equipmentAdded: number | undefined; + explorationAreasAdded: number | undefined; + questRewardsPatched: number | undefined; + questsAdded: number | undefined; + upgradesAdded: number | undefined; + zonesAdded: number | undefined; } +const safeNumber = (value: number | undefined): number => { + return value ?? 0; +}; + /** * Builds a human-readable summary of what the sync-new-content operation added. * @param result - The counts returned by the operation. @@ -32,16 +36,16 @@ interface SyncNewContentResult { */ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => { const entries: Array<[ number, string ]> = [ - [ result.zonesAdded, "zone(s)" ], - [ result.questsAdded, "quest(s)" ], - [ result.questRewardsPatched, "quest reward(s) patched" ], - [ result.bossesAdded, "boss(es)" ], - [ result.bossRewardsPatched, "boss reward(s) patched" ], - [ result.explorationAreasAdded, "exploration area(s)" ], - [ result.adventurersAdded, "adventurer tier(s)" ], - [ result.upgradesAdded, "upgrade(s)" ], - [ result.equipmentAdded, "equipment item(s)" ], - [ result.achievementsAdded, "achievement(s)" ], + [ safeNumber(result.zonesAdded), "zone(s)" ], + [ safeNumber(result.questsAdded), "quest(s)" ], + [ safeNumber(result.questRewardsPatched), "quest reward(s) patched" ], + [ safeNumber(result.bossesAdded), "boss(es)" ], + [ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ], + [ safeNumber(result.explorationAreasAdded), "exploration area(s)" ], + [ safeNumber(result.adventurersAdded), "adventurer tier(s)" ], + [ safeNumber(result.upgradesAdded), "upgrade(s)" ], + [ safeNumber(result.equipmentAdded), "equipment item(s)" ], + [ safeNumber(result.achievementsAdded), "achievement(s)" ], ]; const parts = entries. filter(([ count ]) => { @@ -60,14 +64,14 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => { }; interface ForceUnlocksResult { - adventurersUnlocked: number; - bossesUnlocked: number; - equipmentUnlocked: number; - explorationUnlocked: number; - questsUnlocked: number; - storyUnlocked: number; - upgradesUnlocked: number; - zonesUnlocked: number; + adventurersUnlocked: number | undefined; + bossesUnlocked: number | undefined; + equipmentUnlocked: number | undefined; + explorationUnlocked: number | undefined; + questsUnlocked: number | undefined; + storyUnlocked: number | undefined; + upgradesUnlocked: number | undefined; + zonesUnlocked: number | undefined; } /** @@ -77,14 +81,14 @@ interface ForceUnlocksResult { */ const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => { const entries: Array<[ number, string ]> = [ - [ result.zonesUnlocked, "zone(s)" ], - [ result.questsUnlocked, "quest(s)" ], - [ result.bossesUnlocked, "boss(es)" ], - [ result.explorationUnlocked, "exploration area(s)" ], - [ result.adventurersUnlocked, "adventurer tier(s)" ], - [ result.upgradesUnlocked, "upgrade(s)" ], - [ result.equipmentUnlocked, "equipment item(s)" ], - [ result.storyUnlocked, "story chapter(s)" ], + [ safeNumber(result.zonesUnlocked), "zone(s)" ], + [ safeNumber(result.questsUnlocked), "quest(s)" ], + [ safeNumber(result.bossesUnlocked), "boss(es)" ], + [ safeNumber(result.explorationUnlocked), "exploration area(s)" ], + [ safeNumber(result.adventurersUnlocked), "adventurer tier(s)" ], + [ safeNumber(result.upgradesUnlocked), "upgrade(s)" ], + [ safeNumber(result.equipmentUnlocked), "equipment item(s)" ], + [ safeNumber(result.storyUnlocked), "story chapter(s)" ], ]; const parts = entries. filter(([ count ]) => { diff --git a/apps/web/src/components/game/explorationPanel.tsx b/apps/web/src/components/game/explorationPanel.tsx index 2ba693e..228fa14 100644 --- a/apps/web/src/components/game/explorationPanel.tsx +++ b/apps/web/src/components/game/explorationPanel.tsx @@ -7,12 +7,17 @@ /* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable complexity -- Complex component with many conditional render paths */ /* eslint-disable max-lines -- Exploration panel requires many render paths and result display */ -import { type JSX, useState } from "react"; +/* eslint-disable max-statements -- Component function requires many state declarations and handlers */ +import { type JSX, useEffect, useRef, useState } from "react"; +import { checkExplorationClaimable } from "../../api/client.js"; import { useGame } from "../../context/gameContext.js"; import { EXPLORATION_AREAS } from "../../data/explorations.js"; import { cdnImage } from "../../utils/cdn.js"; import { ZoneSelector } from "./zoneSelector.js"; -import type { ExploreCollectResponse } from "@elysium/types"; +import type { + ExploreClaimableResponse, + ExploreCollectResponse, +} from "@elysium/types"; /** * Formats a duration in seconds to a human-readable string. @@ -83,6 +88,61 @@ const ExplorationPanel = (): JSX.Element => { }); const [ pendingAreaId, setPendingAreaId ] = useState(null); const [ lastResult, setLastResult ] = useState(null); + const [ claimableAreaIds, setClaimableAreaIds ] + = useState>(new Set()); + + const stateReference = useRef(state); + stateReference.current = state; + + const claimableReference = useRef(claimableAreaIds); + claimableReference.current = claimableAreaIds; + + useEffect(() => { + const pollClaimable = async(): Promise => { + const currentState = stateReference.current; + if (currentState === null) { + return; + } + const inProgressArea = currentState.exploration?.areas.find((a) => { + return a.status === "in_progress"; + }); + if (inProgressArea === undefined) { + return; + } + if (claimableReference.current.has(inProgressArea.id)) { + return; + } + const areaData = EXPLORATION_AREAS.find((a) => { + return a.id === inProgressArea.id; + }); + if (areaData === undefined) { + return; + } + const remaining = timeRemaining( + inProgressArea.endsAt, + inProgressArea.startedAt ?? 0, + areaData.durationSeconds, + ); + if (remaining > 0) { + return; + } + const result: ExploreClaimableResponse + = await checkExplorationClaimable(inProgressArea.id); + if (result.claimable) { + setClaimableAreaIds((previous) => { + return new Set([ ...previous, inProgressArea.id ]); + }); + } + }; + + const intervalId = setInterval(() => { + void pollClaimable(); + }, 1000); + + return (): void => { + clearInterval(intervalId); + }; + }, []); if (state === null) { return ( @@ -134,6 +194,11 @@ const ExplorationPanel = (): JSX.Element => { try { const result = await collectExploration(areaId); setLastResult({ areaId: areaId, response: result }); + setClaimableAreaIds((previous) => { + const next = new Set(previous); + next.delete(areaId); + return next; + }); } finally { setPendingAreaId(null); } @@ -269,7 +334,7 @@ const ExplorationPanel = (): JSX.Element => { const endsAt = areaState?.endsAt; const isReady = status === "in_progress" - && timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0; + && claimableAreaIds.has(area.id); const isPending = pendingAreaId === area.id; function handleStartClick(): void { diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 5887e28..786bf69 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -1864,14 +1864,6 @@ export const GameProvider = ({ if (previous?.exploration === undefined) { return previous; } - let materials = [ ...previous.exploration.materials ]; - for (const request of recipe.requiredMaterials) { - materials = materials.map((mat) => { - return mat.materialId === request.materialId - ? { ...mat, quantity: mat.quantity - request.quantity } - : mat; - }); - } return { ...previous, exploration: { @@ -1884,7 +1876,7 @@ export const GameProvider = ({ ...previous.exploration.craftedRecipeIds, recipeId, ], - materials: materials, + materials: result.materials, }, }; }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3687c06..64464a1 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -55,6 +55,7 @@ export type { BuyPrestigeUpgradeResponse, CraftRecipeRequest, CraftRecipeResponse, + ExploreClaimableResponse, ExploreCollectEventResult, ExploreCollectRequest, ExploreCollectResponse, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 70fd596..c099839 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -384,6 +384,10 @@ interface ExploreCollectResponse { event: ExploreCollectEventResult | null; } +interface ExploreClaimableResponse { + claimable: boolean; +} + interface CraftRecipeRequest { recipeId: string; } @@ -396,6 +400,7 @@ interface CraftRecipeResponse { craftedEssenceMultiplier: number; craftedClickMultiplier: number; craftedCombatMultiplier: number; + materials: Array<{ materialId: string; quantity: number }>; } interface ForceUnlocksResponse { @@ -528,6 +533,7 @@ export type { BuyPrestigeUpgradeResponse, CraftRecipeRequest, CraftRecipeResponse, + ExploreClaimableResponse, ExploreCollectEventResult, ExploreCollectRequest, ExploreCollectResponse,