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 */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
const state = rawState as 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 now = Date.now();
|
||||||
|
|
||||||
const { offlineGold, offlineEssence, offlineSeconds }
|
const { offlineGold, offlineEssence, offlineSeconds }
|
||||||
@@ -933,6 +941,19 @@ gameRouter.post("/save", async(context) => {
|
|||||||
player: { ...stateToSave.player, lastSavedAt: now },
|
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.
|
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||||
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* 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 complexity -- Route handlers have inherent complexity */
|
||||||
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
|
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
|
||||||
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
|
/* 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({
|
return context.json({
|
||||||
achievementsUnlocked: achievementsUnlocked,
|
achievementsUnlocked: achievementsUnlocked,
|
||||||
activeTitle: player.activeTitle,
|
activeTitle: player.activeTitle,
|
||||||
@@ -153,6 +156,7 @@ profileRouter.get("/:discordId", async(context) => {
|
|||||||
characterClass: player.characterClass,
|
characterClass: player.characterClass,
|
||||||
characterName: player.characterName,
|
characterName: player.characterName,
|
||||||
characterRace: player.characterRace ?? "",
|
characterRace: player.characterRace ?? "",
|
||||||
|
completedChapters: completedChapters,
|
||||||
createdAt: player.createdAt,
|
createdAt: player.createdAt,
|
||||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||||
|
|||||||
@@ -233,6 +233,16 @@ describe("game route", () => {
|
|||||||
expect(body.savedAt).toBeGreaterThan(0);
|
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 () => {
|
it("validates and sanitizes state when previous record exists", async () => {
|
||||||
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
||||||
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
|
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");
|
const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id");
|
||||||
expect(unknown?.name).toBe("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 /", () => {
|
describe("PUT /", () => {
|
||||||
|
|||||||
@@ -657,6 +657,15 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
if (choice === undefined) {
|
if (choice === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const characterName
|
||||||
|
= player?.characterName === ""
|
||||||
|
|| player?.characterName === undefined
|
||||||
|
? "the guild leader"
|
||||||
|
: player.characterName;
|
||||||
|
const outcome = choice.outcome.replaceAll(
|
||||||
|
"{characterName}",
|
||||||
|
characterName,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="character-sheet-story-entry"
|
className="character-sheet-story-entry"
|
||||||
@@ -668,6 +677,7 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
<span className="character-sheet-story-choice">
|
<span className="character-sheet-story-choice">
|
||||||
{choice.label}
|
{choice.label}
|
||||||
</span>
|
</span>
|
||||||
|
<p className="character-sheet-story-outcome">{outcome}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4386,3 +4386,10 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-sheet-story-outcome {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--colour-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
import type { GameState } from "./gameState.js";
|
import type { GameState } from "./gameState.js";
|
||||||
import type { Player } from "./player.js";
|
import type { Player } from "./player.js";
|
||||||
import type { ProfileSettings } from "./profileSettings.js";
|
import type { ProfileSettings } from "./profileSettings.js";
|
||||||
|
import type { CompletedChapter } from "./story.js";
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -247,6 +248,11 @@ interface PublicProfileResponse {
|
|||||||
rarity: EquipmentRarity;
|
rarity: EquipmentRarity;
|
||||||
bonus: EquipmentBonus;
|
bonus: EquipmentBonus;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Story chapters the player has completed and their chosen outcomes.
|
||||||
|
*/
|
||||||
|
completedChapters: Array<CompletedChapter>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateProfileRequest {
|
interface UpdateProfileRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user