generated from nhcarrigan/template
fix: save character name correctly and show story on character sheet
- Load route syncs characterName from Player record so profile updates are reflected immediately on next load - Save route preserves Player record's characterName so auto-saves cannot overwrite profile updates - Public profile response now includes completedChapters - Character sheet panel displays completed story chapters with outcome - Removed stale CSS for old achievement/codex toast classes
This commit is contained in:
@@ -747,6 +747,14 @@ gameRouter.get("/load", async(context) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
/*
|
||||
* Always sync character name from the Player record — the profile update route
|
||||
* writes to Player.characterName directly, bypassing the game state blob.
|
||||
*/
|
||||
if (playerRecord !== null) {
|
||||
state.player.characterName = playerRecord.characterName;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const { offlineGold, offlineEssence, offlineSeconds }
|
||||
@@ -933,6 +941,19 @@ gameRouter.post("/save", async(context) => {
|
||||
player: { ...stateToSave.player, lastSavedAt: now },
|
||||
};
|
||||
|
||||
/*
|
||||
* Preserve the Player record's character name so that profile updates are not
|
||||
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: {
|
||||
...stateToSave.player,
|
||||
characterName:
|
||||
playerRecord?.characterName ?? stateToSave.player.characterName,
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many steps */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
|
||||
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
|
||||
@@ -142,6 +143,8 @@ profileRouter.get("/:discordId", async(context) => {
|
||||
};
|
||||
});
|
||||
|
||||
const completedChapters = state?.story?.completedChapters ?? [];
|
||||
|
||||
return context.json({
|
||||
achievementsUnlocked: achievementsUnlocked,
|
||||
activeTitle: player.activeTitle,
|
||||
@@ -153,6 +156,7 @@ profileRouter.get("/:discordId", async(context) => {
|
||||
characterClass: player.characterClass,
|
||||
characterName: player.characterName,
|
||||
characterRace: player.characterRace ?? "",
|
||||
completedChapters: completedChapters,
|
||||
createdAt: player.createdAt,
|
||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||
|
||||
@@ -233,6 +233,16 @@ describe("game route", () => {
|
||||
expect(body.savedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to state characterName when playerRecord is null", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const state = makeState();
|
||||
const res = await save({ state });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("validates and sanitizes state when previous record exists", async () => {
|
||||
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
||||
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
|
||||
|
||||
@@ -181,6 +181,24 @@ describe("profile route", () => {
|
||||
const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id");
|
||||
expect(unknown?.name).toBe("unknown_title_id");
|
||||
});
|
||||
|
||||
it("includes completed story chapters in profile response", async () => {
|
||||
const state = makeState({
|
||||
story: {
|
||||
unlockedChapterIds: [ "boss_troll_king" ],
|
||||
completedChapters: [ { chapterId: "boss_troll_king", choiceId: "fight" } ],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
completedChapters: Array<{ chapterId: string; choiceId: string }>;
|
||||
};
|
||||
expect(body.completedChapters).toHaveLength(1);
|
||||
expect(body.completedChapters[0]).toMatchObject({ chapterId: "boss_troll_king", choiceId: "fight" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /", () => {
|
||||
|
||||
Reference in New Issue
Block a user