Port the trainer card and the play time

The player's name in the start menu opens TrainerCard: page 1 with the
name, ID, money, Pokédex count and play time, page 2 with the Johto
leaders and the badges owned, turned and closed with the original
buttons. The card picture, leader faces and animated badges come from the
ROM with _CGB_TrainerCard's palettes. GameTimer now counts the play time
from the end of the introduction, capped at 999:59:59, and saves keep it.

Closes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-01 13:32:14 +02:00
1 parent 38b9562bd3
commit 47698565ab
24 files changed
+954 -30

No files matched your search

+2 -1
View File
@@ -4,6 +4,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture";
import type { GameWorld } from "../src/game/world/world";
import { generatePartyMon, calcStats } from "../src/game/party/pokemon";
import { withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = (scenario: BattleScenario = "win") =>
@@ -161,7 +162,7 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
if (world.battle?.message?.prompt) world.interact();
else world.tick();
}
expect(world.save()).toEqual(committed);
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
world.restore(committed);
expect(world.battle).toBeUndefined();
expect(world.save()).toEqual(committed);
+34
View File
@@ -354,6 +354,40 @@ test("the Pokégear shows its cards and calls Elm", async ({ page }) => {
await expect(page.locator("#game-notice")).toHaveText("");
});
test("the trainer card turns to the badges and back", async ({ page }) => {
await start(page, "pokegear");
const choices = page.locator("#dialogue-choice"),
card = page.locator("#trainer-card"),
info = page.locator("#trainer-card-info"),
body = page.locator("#trainer-card-page");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^CHRIS/ }).click();
await expect(card).toBeVisible();
await expect(page.locator("#native-dialogue")).toBeHidden();
await expect(info).toContainText("NAME/");
await expect(info).toContainText("CHRIS");
await expect(info).toContainText("ID NO");
await expect(info).toContainText("MONEY");
await expect(page.locator("#trainer-card-pic")).toHaveAttribute(
"aria-label",
"Chris",
);
await expect(body).toContainText("PLAY TIME");
await expect(body).toContainText("BADGES▶");
await page.keyboard.press("ArrowRight");
await expect(body.locator("canvas")).toHaveAttribute(
"aria-label",
"0 of 8 Johto badges",
);
await page.keyboard.press("ArrowLeft");
await expect(body).toContainText("PLAY TIME");
await page.keyboard.press("KeyZ");
await expect(card).toBeHidden();
await choices.getByRole("option", { name: /^EXIT/ }).click();
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
});
test("Mom keeps the player's money with the digit box", async ({ page }) => {
await start(page, "mom");
const text = page.locator("#dialogue-text"),
+4 -4
View File
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { C } from "../src/game/battle/constants";
import { martFixtureWorld, talkToClerk } from "./mart-fixture";
import { settle } from "./world-driver";
import { settle, withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = () => martFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))));
@@ -34,7 +34,7 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove Mart with the local ROM", ()
const shown = settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/enough money/i);
expect(world.save()).toEqual(committed);
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
});
it("refuses to buy into a full pocket, changing nothing", () => {
@@ -55,7 +55,7 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove Mart with the local ROM", ()
const shown = settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/can't carry/i);
expect(world.save()).toEqual(committed);
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
});
it("sells items at half the ROM price", () => {
@@ -116,7 +116,7 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove Mart with the local ROM", ()
talkToClerk(world);
settle(world, [2]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.save()).toEqual(before);
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
});
it("a mid-interaction save restores the committed state and replays it", () => {
+2 -2
View File
@@ -15,7 +15,7 @@ import { generatePartyMon } from "../src/game/party/pokemon";
import type { Evolution } from "../src/game/rom/pokemon";
import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo } from "./world-driver";
import { settle, walkTo, withoutPlayTime } from "./world-driver";
import { drawTile } from "../src/game/rendering/pixels";
import { CRYSTAL_ROM } from "../src/rom/validate";
import { VECTORS, type Direction } from "../src/game/world/types";
@@ -393,7 +393,7 @@ describe.skipIf(!existsSync(romPath))(
"ELM: Think it over\ncarefully.",
"Your partner is\nimportant.",
]);
expect(world.save()).toEqual(before);
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
});
it("jumps over representative Route 29 ledges from the actual collision data", () => {
const assets = load();
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { OW } from "../src/game/world/constants";
import {
advanceGameTime,
emptyGameTime,
type GameTime,
} from "../src/game/world/game-time";
import type { TrainerCardView } from "../src/game/scripts/trainer-card";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK_TOWN = "24:4",
CYNDAQUIL = 155;
describe("the game timer", () => {
const frames = (time: GameTime, count: number) => {
for (let i = 0; i < count; i++) advanceGameTime(time);
return time;
};
it("counts 60 frames to the second and carries into hours", () => {
expect(frames(emptyGameTime(), 59)).toEqual({ ...emptyGameTime(), frames: 59 });
expect(frames(emptyGameTime(), 60)).toEqual({ ...emptyGameTime(), seconds: 1 });
const time = { hours: 4, minutes: 59, seconds: 59, frames: 59, capped: false };
expect(frames(time, 1)).toEqual({ ...emptyGameTime(), hours: 5 });
});
it("caps at 999:59:59.00 when it would reach 1000 hours", () => {
const time = { hours: 999, minutes: 59, seconds: 59, frames: 58, capped: false };
frames(time, 1);
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 59, capped: false });
frames(time, 1);
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 0, capped: true });
frames(time, 5000);
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 0, capped: true });
});
});
describe.skipIf(!existsSync(romPath))("the trainer card", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
function fixture(avatar: "chris" | "kris" = "chris") {
const world = new GameWorld(
assets(),
() => new Date("2026-09-25T12:00:00"),
() => 0x42,
);
world.state.party = [
generatePartyMon(world.assets.pokemon, {
species: CYNDAQUIL,
level: 8,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
}),
];
world.state.money = 3456;
world.restore({
...world.save(),
map: NEW_BARK_TOWN,
x: 13,
y: 7,
direction: "down",
objects: [],
name: avatar === "kris" ? "KRIS" : "GOLD",
avatar,
playerId: 417,
});
return world;
}
function open(world: GameWorld) {
expect(world.openStartMenu()).toBe(true);
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error("Expected the start menu.");
// StartMenu.StatusString is <PLAYER>.
const index = choice.items.indexOf(world.name);
expect(index).toBeGreaterThan(-1);
world.choose(index);
return view(world);
}
function view(world: GameWorld): TrainerCardView {
const choice = world.dialogue?.choice;
if (choice?.kind !== "trainercard")
throw new Error(`Expected the trainer card, got ${choice?.kind}`);
expect(world.trainerCard).toBe(choice.view);
return choice.view;
}
function press(world: GameWorld, button: "a" | "b" | "up" | "down" | "left" | "right") {
if (button === "a") world.interact();
else if (button === "b") world.advanceDialogue();
else world.moveCursor(button);
}
it("shows the name, ID, money and play time with ROM labels", () => {
const world = fixture();
world.gameTime = { hours: 12, minutes: 7, seconds: 3, frames: 0, capped: false };
const card = open(world);
expect(card).toMatchObject({
page: 1,
female: false,
name: "GOLD",
id: "00417",
money: 3456,
time: { hours: 12, minutes: 7 },
colon: false,
badges: 0,
labels: {
name: "NAME/",
money: "MONEY",
pokedex: "POKéDEX",
playTime: "PLAY TIME",
badges: "BADGES▶",
},
});
// Without the Pokédex its line is cleared.
expect(card.pokedex).toBeUndefined();
});
it("counts caught Pokémon once the Pokédex is owned", () => {
const world = fixture("kris");
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.pokedex.seen = new Set([1, 4, 7, 155]);
world.state.pokedex.caught = new Set([155, 7]);
const card = open(world);
expect(card.pokedex).toBe(2);
expect(card.female).toBe(true);
expect(card.name).toBe("KRIS");
});
it("turns pages and leaves with the original buttons", () => {
const world = fixture();
open(world);
// Left, up and down do nothing on page 1; A or right turns the page.
for (const button of ["left", "up", "down"] as const) {
press(world, button);
expect(view(world).page).toBe(1);
}
press(world, "right");
expect(view(world).page).toBe(2);
// Right does nothing on page 2; left turns back.
press(world, "right");
expect(view(world).page).toBe(2);
press(world, "left");
expect(view(world).page).toBe(1);
press(world, "a");
expect(view(world).page).toBe(2);
// A on page 2 leaves, back to the start menu.
press(world, "a");
expect(world.trainerCard).toBeUndefined();
expect(world.dialogue?.choice?.kind).toBe("menu");
// B leaves from page 1.
world.choose((world.dialogue!.choice as { items: string[] }).items.indexOf("GOLD"));
expect(view(world).page).toBe(1);
press(world, "b");
expect(world.trainerCard).toBeUndefined();
expect(world.dialogue?.choice?.kind).toBe("menu");
settle(world, [-1]);
expect(world.busy).toBe(false);
expect(world.diagnostic).toBe("");
});
it("keeps play time running, blinks the colon and animates owned badges", () => {
const world = fixture();
world.state.flags.add(OW.ENGINE_ZEPHYRBADGE);
world.state.flags.add(OW.ENGINE_MINERALBADGE);
world.gameTime = { hours: 0, minutes: 0, seconds: 59, frames: 40, capped: false };
const card = open(world);
expect(card.badges).toBe(0b10001);
// The colon appears after 32 frames (16 ticks) and goes after 64.
for (let tick = 0; tick < 15; tick++) world.tick();
expect(view(world).colon).toBe(false);
world.tick();
expect(view(world)).toMatchObject({ colon: true, time: { hours: 0, minutes: 1 } });
for (let tick = 0; tick < 16; tick++) world.tick();
expect(view(world).colon).toBe(false);
// Page 2 restarts the timing; the badges step every eight frames.
press(world, "right");
expect(view(world).badgeFrame).toBe(0);
const steps = [];
for (let tick = 0; tick < 36; tick++) {
world.tick();
steps.push(view(world).badgeFrame);
}
expect(steps.filter((_, i) => i % 4 === 3)).toEqual([1, 2, 3, 4, 5, 6, 7, 0, 1]);
expect(view(world).colon).toBe(false);
// Two frames per tick were counted throughout: 68 ticks from 0:59.40.
expect(world.gameTime).toMatchObject({ minutes: 1, seconds: 1, frames: 56 });
});
it("shows no badge animation without badges", () => {
const world = fixture();
open(world);
press(world, "right");
for (let tick = 0; tick < 20; tick++) world.tick();
expect(view(world)).toMatchObject({ badges: 0, badgeFrame: 0 });
});
it("does not count during the introduction", () => {
const world = fixture();
world.gameTime = { hours: 3, minutes: 0, seconds: 0, frames: 0, capped: false };
world.beginIntro();
expect(world.gameTime).toEqual(emptyGameTime());
for (let tick = 0; tick < 100; tick++) world.tick();
expect(world.gameTime).toEqual(emptyGameTime());
});
it("saves the play time, including time spent in a scene", () => {
const world = fixture();
world.gameTime = { hours: 7, minutes: 30, seconds: 0, frames: 0, capped: false };
open(world);
for (let tick = 0; tick < 30; tick++) world.tick();
const save = world.save();
expect(save.gameTime).toEqual({ hours: 7, minutes: 30, seconds: 1, frames: 0, capped: false });
const other = fixture();
other.restore(save);
expect(other.gameTime).toEqual(save.gameTime);
// Older saves start from zero; invalid times are refused.
const { gameTime: _, ...old } = save;
expect(migrateSave(old, () => 0)?.gameTime).toEqual(emptyGameTime());
expect(() =>
other.restore({ ...save, gameTime: { ...save.gameTime, minutes: 60 } }),
).toThrow(/Invalid native save/);
});
});
+3 -2
View File
@@ -6,6 +6,7 @@ import { OW } from "../src/game/world/constants";
import { trainerFixtureWorld } from "./trainer-fixture";
import { migrateSave, type GameWorld } from "../src/game/world/world";
import { DecorationData } from "../src/game/world/decorations";
import { withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
@@ -208,7 +209,7 @@ describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", ()
world.interact();
finish(world);
expect(world.notice).toContain("not been ported");
expect(world.save()).toEqual(before);
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
});
it("restores a midbattle save without partially paying the reward", () => {
@@ -219,7 +220,7 @@ describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", ()
if (world.battle?.message?.prompt) world.interact();
else world.tick();
}
expect(world.save()).toEqual(before);
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
world.restore(before);
expect(world.battle).toBeUndefined();
expect(world.save()).toEqual(before);
+7
View File
@@ -132,3 +132,10 @@ export function walkTo(
}
throw new Error(`Could not reach ${targetX},${targetY}`);
}
/** A save without the play time, which keeps counting while refused or
* rolled-back actions leave everything else unchanged. */
export function withoutPlayTime<T extends { gameTime?: unknown }>(save: T) {
const { gameTime: _, ...rest } = save;
return rest;
}