diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 334a37a..3aa2e5e 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -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. diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index 2fec074..a57acea 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -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, diff --git a/apps/api/test/routes/game.spec.ts b/apps/api/test/routes/game.spec.ts index 79e0177..fac2962 100644 --- a/apps/api/test/routes/game.spec.ts +++ b/apps/api/test/routes/game.spec.ts @@ -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 } }); diff --git a/apps/api/test/routes/profile.spec.ts b/apps/api/test/routes/profile.spec.ts index d642820..0cb3fea 100644 --- a/apps/api/test/routes/profile.spec.ts +++ b/apps/api/test/routes/profile.spec.ts @@ -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 /", () => { diff --git a/apps/web/src/components/game/characterSheetPanel.tsx b/apps/web/src/components/game/characterSheetPanel.tsx index 836d21f..34de5ff 100644 --- a/apps/web/src/components/game/characterSheetPanel.tsx +++ b/apps/web/src/components/game/characterSheetPanel.tsx @@ -657,6 +657,15 @@ const CharacterSheetPanel = (): JSX.Element => { if (choice === undefined) { return null; } + const characterName + = player?.characterName === "" + || player?.characterName === undefined + ? "the guild leader" + : player.characterName; + const outcome = choice.outcome.replaceAll( + "{characterName}", + characterName, + ); return (
{outcome}