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
+12 -1
View File
@@ -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) {
+2
View File
@@ -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));
+60
View File
@@ -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");