Port the introduction's naming and clock screens
Checks / web (push) Canceled after 0s

A new game now runs NewGame's InitGender and OakSpeech natively before
the bedroom: Boy or Girl, InitClock's hour and minute steppers with
their YES/NO confirmations, Oak's speech with Wooper, and NamePlayer's
preset names or naming field (a blank name takes CHRIS or KRIS). Texts,
menus and time strings come from a new INTRO profile table verified by
check:reference; Oak's and the player's pictures decode from
TrainerPicPointers, ChrisPic/KrisPic and TrainerPalettes.

The chosen time is kept as a minute offset from the device clock, which
time of day, the weekday, NPC hours and the daily reset now read. The
intro runs only when nothing is saved; a save made during it starts it
again. Saves gain intro and clock.minuteOffset, migrated by absence. The
shell's name and character fields are gone. Intentional change: the
steppers start at the device time rather than 10:00.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 09:53:52 +02:00
1 parent 5a0fe1e313
commit b13fd20be4
28 files changed
+975 -101

No files matched your search

+2 -2
View File
@@ -27,11 +27,11 @@ npm ci
npm run dev npm run dev
``` ```
Open the printed local URL, choose a name/character and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. Open the printed local URL and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. A new game starts with Professor Oak's introduction, where you choose your character, set the clock and pick a name.
Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data. Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data.
Native saves are separate from emulator snapshots and cartridge `.sav` files. Saving during a scene keeps the progress from before it, so the scene replays after loading. Time of day follows the device clock; the weekday you give Mom is kept as an offset from the device's weekday. The tile counter measures in-game movement, not real-world steps. Native saves are separate from emulator snapshots and cartridge `.sav` files. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps.
## Reference emulator ## Reference emulator
+5 -1
View File
@@ -34,7 +34,7 @@ flowchart LR
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build | | `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
| `reference/public` | Licensed emulator files for the reference build only | | `reference/public` | Licensed emulator files for the reference build only |
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the weekday offset and DST flag; positions of objects that scripts moved; and the party, bag, Pokédex sets, money and room decoration slots. Older saves without decorations migrate to the original bed and Town Map poster. Decoration imports validate slot categories, ownership and unique doll placement. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID. `GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the game clock's minute offset, the weekday offset and DST flag, whether the introduction is still pending; positions of objects that scripts moved; and the party, bag, Pokédex sets, money and room decoration slots. Older saves without decorations migrate to the original bed and Town Map poster. Decoration imports validate slot categories, ownership and unique doll placement. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival. Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
@@ -59,6 +59,10 @@ copy and a revision counter, so rollback and the minimap see the same terrain.
Surf, Cut and Strength use the ROM scripts. Start/Select opens the Key Items Surf, Cut and Strength use the ROM scripts. Start/Select opens the Key Items
menu, pausing overworld ticks while choosing the Bicycle. The bedroom PC edits menu, pausing overworld ticks while choosing the Bicycle. The bedroom PC edits
decorations on the script draft and reloads the room when the menu commits. decorations on the script draft and reloads the room when the menu commits.
`scripts/intro.ts` runs a new game's introduction as a native generator on a
script draft; the session starts it only when nothing is saved, and the name,
gender and clock offset commit when it ends. `GameWorld` reads a game clock:
the device time plus that offset.
`scripts/pc.ts` runs the Pokémon Center and house PCs on the same draft, so a `scripts/pc.ts` runs the Pokémon Center and house PCs on the same draft, so a
refused step leaves boxes and items unchanged. `party/storage.ts` holds the refused step leaves boxes and items unchanged. `party/storage.ts` holds the
boxes and the PC's items. A `memscript` effect queues Bill's call (like boxes and the PC's items. A `memscript` effect queues Bill's call (like
+16 -3
View File
@@ -34,7 +34,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Native canvas rendering, camera scrolling, walking, solid-tile/NPC collision, doors and stairs within the supported area. - Native canvas rendering, camera scrolling, walking, solid-tile/NPC collision, doors and stairs within the supported area.
- A bounded interpreter for a subset of the game's own event bytecode, plus text decoding. This does not execute GB instructions. - A bounded interpreter for a subset of the game's own event bytecode, plus text decoding. This does not execute GB instructions.
- Original initial event flags, readable signs, bedroom radio, bookshelves and supported conversations. Unknown interactions abort without committing partial changes. - Original initial event flags, readable signs, bedroom radio, bookshelves and supported conversations. Unknown interactions abort without committing partial changes.
- Native DOM dialogue, name/avatar choice, touch/keyboard controls and pause/resume. - Native DOM dialogue, name/avatar choice, touch/keyboard controls and pause/resume. (The name and character are now chosen in the introduction; see below.)
- A minimal Crystal-style shell with Game, Savegames, Map, Pokédex, Link and Friends tabs across the top. The game uses the available native canvas, and leaving Game releases input and pauses play. - A minimal Crystal-style shell with Game, Savegames, Map, Pokédex, Link and Friends tabs across the top. The game uses the available native canvas, and leaving Game releases input and pauses play.
- Existing save, backup export and backup import actions in Savegames. Map shares a live local minimap with the game and shows the current supported area, player position and coordinates. Pokédex, Link and Friends are clearly marked as planned rather than populated with placeholder data. - Existing save, backup export and backup import actions in Savegames. Map shares a live local minimap with the game and shows the current supported area, player position and coordinates. Pokédex, Link and Friends are clearly marked as planned rather than populated with placeholder data.
- Native JSON state inside the backup envelope, separate IndexedDB storage and a separate tab lock. No emulator snapshot or cartridge save migration is implied. - Native JSON state inside the backup envelope, separate IndexedDB storage and a separate tab lock. No emulator snapshot or cartridge save migration is implied.
@@ -133,6 +133,19 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Not ported: MOVE PKMN W/O MAIL and the stats screen (both show a notice and return to the menu), printing, a `givepoke` with a full party (refused; no open map reaches it) and the tutorial with a full party and box (the original repeats the unusable ball). Changing boxes does not show the save messages; the native session saves continuously. - Not ported: MOVE PKMN W/O MAIL and the stats screen (both show a notice and return to the menu), printing, a `givepoke` with a full party (refused; no open map reaches it) and the tutorial with a full party and box (the original repeats the unusable ball). Changing boxes does not show the save messages; the native session saves continuously.
- Next: the introduction's naming and clock screens, player switching and other items in battle, then party and bag menus. - Next: the introduction's naming and clock screens, player switching and other items in battle, then party and bag menus.
## Implemented: the introduction's naming and clock screens
- A new game runs `NewGame`'s `InitGender` and `OakSpeech` natively before the bedroom: "Boy"/"Girl" (B disabled), `InitClock`, Oak's speech with Wooper, then `NamePlayer`. Texts, menu items, the preset names, the naming title and the time strings come from the ROM (`INTRO` table in the profile, verified by `check:reference`).
- `InitClock`: the hour and minute steppers wrap as `SetHour` and `SetMinutes` do, each confirmed with YES/NO; NO asks again. Labels follow `PrintHour` ("DAY 5 o'clock"), and `OakText_ResponseToSetTime` keeps its `DAY_HOUR + 1` comparison, so 10 AM still "overslept".
- The chosen time is stored as a minute offset from the device clock (the shortest one, within ±12 hours). Time of day, the weekday, NPC hours and the daily reset all read this game clock. Mom's `SetDayOfWeek` offset is taken from the game clock's day.
- `NamePlayer`: NEW NAME opens the naming field; a blank name takes CHRIS or KRIS, as `InitName` does. The other items are the gender's preset names.
- Oak's picture decodes `TrainerPicPointers` through `FixPicBank`; the player's is the uncompressed `ChrisPic`/`KrisPic`. Both use `TrainerPalettes` (Chris class 0, Kris class 1), as `DrawIntroPlayerPic` sets them.
- The intro covers the world and its minimap until it ends. It runs only when no session is saved. A save made during it keeps `intro: true`, and loading that save starts it again. The Game tab's name and character fields are gone.
- Save format 3 gained `intro` and `clock.minuteOffset`, migrated by absence to a finished intro on the device clock.
- Intentional change: the steppers start at the device's current time rather than 10:00, so confirming keeps the device clock.
- Not presented: the palette fades, Wooper's cry, the picture slide during name selection, `ShrinkPlayer` and the Route 30 music (no audio yet).
- Next: player switching and other items in battle; MOVE PKMN W/O MAIL and the stats screen with the party menu; a continuous opening playthrough.
## Native battles: current state ## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching and items other than balls in battle remain unfinished. The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching and items other than balls in battle remain unfinished.
@@ -205,7 +218,7 @@ their scripts and story progression are supported.
- [x] Battles: stats, damage, types, accuracy, criticals, status, turn order, fainting, experience, move learning, trainer AI, trainer send-outs and prize money - [x] Battles: stats, damage, types, accuracy, criticals, status, turn order, fainting, experience, move learning, trainer AI, trainer send-outs and prize money
- [ ] Player switching, and items other than balls, in battle - [ ] Player switching, and items other than balls, in battle
- [x] The PC and boxes (catching with a full party) - [x] The PC and boxes (catching with a full party)
- [ ] The introduction's naming and clock screens - [x] The introduction's naming and clock screens
- [ ] Controlled-seed comparisons with the original beyond damage and type effectiveness - [ ] Controlled-seed comparisons with the original beyond damage and type effectiveness
- [ ] Acceptance: one continuous playthrough of the opening quest with save/reload. The scenes are tested individually from fixtures so far. - [ ] Acceptance: one continuous playthrough of the opening quest with save/reload. The scenes are tested individually from fixtures so far.
@@ -213,7 +226,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game. Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial and the PC run now. Next: the introduction's naming and clock screens. Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC and the introduction's naming and clock screens run now. Next: player switching and other items in battle.
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
+37 -1
View File
@@ -1,6 +1,6 @@
# Recovered Claude session # Recovered Claude session
Latest continuation: see **The PC and boxes: 2026-09-25** at the end of Latest continuation: see **The introduction's naming and clock: 2026-09-25** at the end of
this file. Earlier sections are historical snapshots. this file. Earlier sections are historical snapshots.
Recovered on 2026-09-23 from the local Claude Code transcript Recovered on 2026-09-23 from the local Claude Code transcript
@@ -475,3 +475,39 @@ Not committed.
Next (PLAN section 2): the introduction's naming and clock screens; player Next (PLAN section 2): the introduction's naming and clock screens; player
switching and non-ball items in battle; MOVE PKMN W/O MAIL and the stats switching and non-ball items in battle; MOVE PKMN W/O MAIL and the stats
screen with the party menu; a continuous opening playthrough. screen with the party menu; a continuous opening playthrough.
## The introduction's naming and clock: 2026-09-25
The PC increment above was committed as `3bdca00`. This increment finishes
the next open item of PLAN section 2.
- `scripts/intro.ts` (`IntroRun`): `InitGender`, `OakSpeech` with
`InitClock` and `NamePlayer`, as a native generator the world runs like a
script (`GameWorld.beginIntro`). Texts, menus, preset names and time
strings come from the ROM through a new `INTRO` profile table verified by
`check:reference`.
- New effects: `portrait` (Oak's trainer picture or the player's, decoded
by `PokemonData.trainerPic`, `playerPic` and `trainerPalette`) and a
`clock` stepper choice.
- Game clock: `clock.minuteOffset` is added to the device time for time of
day, weekday, NPC hours and the daily reset. The host's `deviceWeekday`
became `clockDay`.
- `GameSession.load` calls the runtime's optional `newGame()` when nothing
is saved. Saves gained `intro` (true while pending; a save made then
restarts the introduction). The shell's name/character fields are gone.
- Browser fixture specs strip `this.world?.beginIntro();` from the served
runtime, since their worlds stand in for saved sessions. Fresh-load tests
play the intro with `tests/intro-driver.ts`.
Not presented: palette fades, Wooper's cry, the picture slide while
naming, `ShrinkPlayer` and music. Intentional change: the steppers start at
the device time instead of 10:00.
Checks: 298 unit tests with the local ROM; native and reference builds;
reference, vendor and release checks; `git diff --check`; 87
Chromium/WebKit browser tests passed, 19 skipped intentionally (including
the new intro flow and a reload during it, in both browsers). Not committed.
Next (PLAN section 2): player switching and non-ball items in battle; MOVE
PKMN W/O MAIL and the stats screen with the party menu; a continuous opening
playthrough.
+2 -2
View File
@@ -201,8 +201,8 @@ for (const [key, symbol] of Object.entries(ramNames)) {
if (!match || Number(match[1]) !== ram.get(symbol)) if (!match || Number(match[1]) !== ram.get(symbol))
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`); throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
} }
// BATTLE, OVERWORLD and PC entries are keyed by their PRET labels. // BATTLE, OVERWORLD, PC and INTRO entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC"]) { for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1]; const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
if (!block) throw new Error(`${table} symbol table is missing.`); if (!block) throw new Error(`${table} symbol table is missing.`);
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) { for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
+3
View File
@@ -4,6 +4,9 @@ import type { Button } from "../input/types";
export interface GameRuntime { export interface GameRuntime {
readonly id: string; readonly id: string;
load(rom: Uint8Array, canvas: HTMLCanvasElement): Promise<void>; load(rom: Uint8Array, canvas: HTMLCanvasElement): Promise<void>;
/** Start a new game in the loaded session, when nothing was saved; the
* emulator reference starts its cartridge instead. */
newGame?(): void;
play(): void; play(): void;
pause(): void; pause(): void;
setButton(button: Button, pressed: boolean): void; setButton(button: Button, pressed: boolean): void;
+1
View File
@@ -30,6 +30,7 @@ export class GameSession {
} }
await this.runtime.load(rom.bytes, canvas); await this.runtime.load(rom.bytes, canvas);
if (saved) this.runtime.restoreState(saved.state); if (saved) this.runtime.restoreState(saved.state);
else this.runtime.newGame?.();
// Never retain ROM bytes in storage or the session after loading. // Never retain ROM bytes in storage or the session after loading.
this.rom = { ...rom, bytes: new Uint8Array() }; this.rom = { ...rom, bytes: new Uint8Array() };
this.state = "paused"; this.state = "paused";
+44 -2
View File
@@ -1,6 +1,12 @@
import { RomReader } from "./reader"; import { RomReader } from "./reader";
import { decompress } from "./lz"; import { decompress } from "./lz";
import { NUM_ITEMS, NUM_POKEMON, SYMBOLS } from "./profile"; import {
INTRO,
NUM_ITEMS,
NUM_POKEMON,
NUM_TRAINER_CLASSES,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text"; import { defaultTextContext, readString } from "../scripts/text";
import type { Palette } from "../world/types"; import type { Palette } from "../world/types";
@@ -254,8 +260,44 @@ export class PokemonData {
/** Normal colors from PokemonPalettes, between white and black. */ /** Normal colors from PokemonPalettes, between white and black. */
palette(species: number, shiny = false): Palette { palette(species: number, shiny = false): Palette {
this.checkSpecies(species); this.checkSpecies(species);
return this.middleColors(
SYMBOLS.pokemonPalettes + species * 8 + (shiny ? 4 : 0),
);
}
/** A trainer class's front picture (GetTrainerPic): 7x7 tiles, column
* by column. */
trainerPic(classId: number): Uint8Array {
if (
!Number.isInteger(classId) ||
classId < 1 ||
classId > NUM_TRAINER_CLASSES
)
throw new Error("Unknown trainer class.");
const r = this.reader, const r = this.reader,
p = SYMBOLS.pokemonPalettes + species * 8 + (shiny ? 4 : 0); entry = INTRO.TrainerPicPointers + (classId - 1) * 3;
const pics1 = r.byte(SYMBOLS.picsBanks);
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
const tiles = decompress(r, r.address(bank, r.word(entry + 1)), 0x1000);
if (tiles.length < 49 * 16) throw new Error("Picture data is truncated.");
return tiles.slice(0, 49 * 16);
}
/** DrawIntroPlayerPic's uncompressed ChrisPic or KrisPic. */
playerPic(female: boolean): Uint8Array {
return this.reader.slice(female ? INTRO.KrisPic : INTRO.ChrisPic, 49 * 16);
}
/** TrainerPalettes, by class: CHRIS is 0 and KRIS shares class 1. */
trainerPalette(classId: number): Palette {
if (
!Number.isInteger(classId) ||
classId < 0 ||
classId > NUM_TRAINER_CLASSES
)
throw new Error("Unknown trainer class.");
return this.middleColors(INTRO.TrainerPalettes + classId * 4);
}
/** Two stored colors between white and black. */
private middleColors(p: number): Palette {
const r = this.reader;
const color = (value: number) => const color = (value: number) =>
[ [
Math.round(((value & 31) * 255) / 31), Math.round(((value & 31) * 255) / 31),
+38
View File
@@ -281,6 +281,44 @@ export const OVERWORLD = {
MansionPalette2: 0x496fe, MansionPalette2: 0x496fe,
} as const; } as const;
/** A new game's introduction (engine/menus/intro_menu.asm NewGame):
* InitGender, OakSpeech with InitClock and NamePlayer, and the pictures
* they show, keyed by their PRET labels for \`npm run check:reference\`. */
export const INTRO = {
TrainerPicPointers: 0x128000,
TrainerPalettes: 0xb0ce,
ChrisPic: 0x888a9,
KrisPic: 0x88bb9,
_AreYouABoyOrAreYouAGirlText: 0x1c0ca3,
"InitGender.MenuData": 0x48e04,
_OakText1: 0x1c1d35,
_OakText2: 0x1c1da4,
_OakText4: 0x1c1de5,
_OakText5: 0x1c1e51,
_OakText6: 0x1c4000,
_OakText7: 0x1c4026,
_OakTimeWokeUpText: 0x1bc29c,
_OakTimeWhatTimeIsItText: 0x1bc2eb,
_OakTimeWhatHoursText: 0x1bc2fd,
_OakTimeHoursQuestionMarkText: 0x1bc305,
_OakTimeHowManyMinutesText: 0x1bc308,
_OakTimeWhoaMinutesText: 0x1bc31b,
_OakTimeMinutesQuestionMarkText: 0x1bc323,
_OakTimeOversleptText: 0x1bc326,
_OakTimeYikesText: 0x1bc336,
_OakTimeSoDarkText: 0x1bc34f,
"ChrisNameMenuHeader.MaleNames": 0x882be,
"KrisNameMenuHeader.FemaleNames": 0x882ee,
"NamingScreenJumptable.PlayerNameString": 0x117a3,
"NamePlayer.Chris": 0x60d3,
"NamePlayer.Kris": 0x60de,
String_oclock: 0x9087e,
String_min: 0x9089f,
"GetTimeOfDayString.nite_string": 0x90b71,
"GetTimeOfDayString.morn_string": 0x90b76,
"GetTimeOfDayString.day_string": 0x90b7b,
} as const;
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu /** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
* data, strings and texts, keyed by their PRET labels so that * data, strings and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */ * \`npm run check:reference\` can verify each address by name. */
+7 -4
View File
@@ -21,7 +21,6 @@ export class NativeRuntime implements GameRuntime {
constructor( constructor(
private onUpdate: (world: GameWorld) => void, private onUpdate: (world: GameWorld) => void,
private onError: (error: unknown) => void, private onError: (error: unknown) => void,
private player: () => { name: string; avatar: "chris" | "kris" },
/** Presentation handles menu navigation; battle rules stay in the world. */ /** Presentation handles menu navigation; battle rules stay in the world. */
private battleInput?: (button: Button) => boolean, private battleInput?: (button: Button) => boolean,
) {} ) {}
@@ -30,14 +29,16 @@ export class NativeRuntime implements GameRuntime {
this.fieldMenuOpen = false; this.fieldMenuOpen = false;
const assets = new CrystalAssets(bytes); const assets = new CrystalAssets(bytes);
const world = new GameWorld(assets); const world = new GameWorld(assets);
const player = this.player();
world.name = player.name;
world.avatar = player.avatar;
this.world = world; this.world = world;
this.renderer = new WorldRenderer(assets, canvas); this.renderer = new WorldRenderer(assets, canvas);
this.observeCanvas(canvas); this.observeCanvas(canvas);
this.publish(); this.publish();
} }
/** A new game starts with its introduction (NewGame's OakSpeech). */
newGame() {
this.world?.beginIntro();
this.publish();
}
play() { play() {
if (!this.world || this.raf) return; if (!this.world || this.raf) return;
this.lastTime = 0; this.lastTime = 0;
@@ -144,6 +145,8 @@ export class NativeRuntime implements GameRuntime {
world.notice, world.notice,
world.activity, world.activity,
world.picture, world.picture,
world.portrait,
world.intro,
world.battle && [ world.battle && [
world.battle.message, world.battle.message,
world.battle.prompt?.kind, world.battle.prompt?.kind,
+14 -6
View File
@@ -55,10 +55,14 @@ import {
} from "./text"; } from "./text";
export interface ClockSettings { export interface ClockSettings {
/** Days added to the device weekday, chosen through SetDayOfWeek. */ /** Minutes added to the device time, chosen through InitClock. */
minuteOffset: number;
/** Days added to the game clock's weekday, chosen through SetDayOfWeek. */
weekdayOffset: number; weekdayOffset: number;
dst: boolean; dst: boolean;
} }
/** A trainer class's front picture, or the player's (DrawIntroPlayerPic). */
export type Portrait = { trainer: number } | { player: boolean };
export interface ScriptState { export interface ScriptState {
decorations: Decorations; decorations: Decorations;
events: Set<number>; events: Set<number>;
@@ -106,7 +110,7 @@ export const emptyScriptState = (): ScriptState => ({
variables: {}, variables: {},
scenes: {}, scenes: {},
phone: [], phone: [],
clock: { weekdayOffset: 0, dst: false }, clock: { minuteOffset: 0, weekdayOffset: 0, dst: false },
party: [], party: [],
bag: emptyBag(), bag: emptyBag(),
storage: emptyStorage(), storage: emptyStorage(),
@@ -186,6 +190,10 @@ export type ScriptEffect =
| { kind: "pause"; ticks: number } | { kind: "pause"; ticks: number }
/** A front picture in a box (pokepic); species 0 closes it. */ /** A front picture in a box (pokepic); species 0 closes it. */
| { kind: "picture"; species: number } | { kind: "picture"; species: number }
/** The introduction's trainer or player picture; none closes it. */
| { kind: "portrait"; portrait?: Portrait }
/** InitClock's hour or minute stepper; answered with the chosen index. */
| { kind: "clock"; pages: string[]; labels: string[]; selected: number }
/** The naming screen; answered with the name, or "" to keep `current`. */ /** The naming screen; answered with the name, or "" to keep `current`. */
| { kind: "name"; pages: string[]; current: string; max: number } | { kind: "name"; pages: string[]; current: string; max: number }
/** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */ /** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */
@@ -262,7 +270,8 @@ export interface ScriptHost {
/** wTimeOfDay: morning 0, day 1, night 2. */ /** wTimeOfDay: morning 0, day 1, night 2. */
timeOfDay(): number; timeOfDay(): number;
clock(): { weekday: number; hours: number; minutes: number }; clock(): { weekday: number; hours: number; minutes: number };
deviceWeekday(): number; /** The game clock's weekday before SetDayOfWeek's offset. */
clockDay(): number;
text(): TextContext; text(): TextContext;
data: PokemonData; data: PokemonData;
/** wPlayerID and wPlayerGender (1 for Kris). */ /** wPlayerID and wPlayerGender (1 for Kris). */
@@ -1967,8 +1976,7 @@ export class ScriptRun {
pages: [names[day] + readText(r, SYMBOLS.isItDayText, this.text())], pages: [names[day] + readText(r, SYMBOLS.isItDayText, this.text())],
}; };
if (!confirmed) continue; if (!confirmed) continue;
this.state.clock.weekdayOffset = this.state.clock.weekdayOffset = (day - this.host.clockDay() + 7) % 7;
(day - this.host.deviceWeekday() + 7) % 7;
return value; return value;
} }
} }
@@ -2075,7 +2083,7 @@ export function runScript(
sceneOwner: () => undefined, sceneOwner: () => undefined,
timeOfDay: () => 1, timeOfDay: () => 1,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }), clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0, clockDay: () => 0,
text: () => defaultTextContext(name), text: () => defaultTextContext(name),
data: new PokemonData(reader), data: new PokemonData(reader),
player: () => ({ id: 0, gender: 0 }), player: () => ({ id: 0, gender: 0 }),
+257
View File
@@ -0,0 +1,257 @@
import type { RomReader } from "../rom/reader";
import { INTRO } from "../rom/profile";
import {
copyScriptState,
type ScriptEffect,
type ScriptState,
} from "./interpreter";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** POKEMON_PROF's trainer class, and WOOPER. */
const POKEMON_PROF = 10,
WOOPER = 194;
/** MORN_HOUR, DAY_HOUR and NITE_HOUR (constants/misc_constants.asm). */
const MORN_HOUR = 4,
DAY_HOUR = 10,
NITE_HOUR = 18;
/** PLAYER_NAME_LENGTH - 1. */
const NAME_MAX = 7;
/** What the introduction needs from the world. */
export interface IntroHost {
reader: RomReader;
/** The device's current time, which the game clock is offset from. */
now(): Date;
/** Text values, with `<PLAYER>` read as `player`. */
text(player: string): TextContext;
}
/** A new game's introduction: NewGame's PlayerProfileSetup (InitGender)
* and OakSpeech, with InitClock and NamePlayer
* (engine/menus/intro_menu.asm, engine/menus/init_gender.asm and
* engine/rtc/timeset.asm). The original draws its own screens; here the
* pictures, texts, menus and steppers are presented natively. */
export class IntroRun {
readonly state: ScriptState;
/** Intro scripts never defer another script. */
readonly deferred = undefined;
/** wPlayerGender and wPlayerName, applied when the introduction ends. */
avatar: "chris" | "kris" = "chris";
name = "";
private iterator: Gen;
private done = false;
constructor(
state: ScriptState,
private host: IntroHost,
) {
this.state = copyScriptState(state);
this.iterator = this.run();
}
/** Advance until the next effect, or undefined when the intro ended. */
resume(input?: Input): ScriptEffect | undefined {
if (this.done) return undefined;
const next = this.iterator.next(input);
if (next.done) {
this.done = true;
return undefined;
}
return next.value;
}
private string(address: number) {
return readString(this.host.reader, address, this.host.text(this.name));
}
private text(address: number) {
return readText(this.host.reader, address, this.host.text(this.name));
}
private pages(address: number) {
return this.text(address).split("\n\n");
}
/** Consecutive "@"-terminated strings, as static menus store them. */
private strings(address: number, count: number) {
const r = this.host.reader,
result: string[] = [];
let p = address;
for (let i = 0; i < count; i++) {
result.push(this.string(p));
while (r.byte(p++) !== 0x50);
}
return result;
}
/** A static menu with STATICMENU_DISABLE_B: only a listed item answers. */
private *menu(pages: string[], items: string[]): Gen<number> {
for (;;) {
const choice = yield { kind: "menu", pages, items, selected: 0 };
if (typeof choice === "number" && choice >= 0 && choice < items.length)
return choice;
}
}
private *run(): Gen {
// InitGender: "Boy" or "Girl"; B is disabled.
const gender = yield* this.menu(
this.pages(INTRO._AreYouABoyOrAreYouAGirlText),
this.strings(INTRO["InitGender.MenuData"] + 2, 2),
);
this.avatar = gender === 1 ? "kris" : "chris";
yield* this.initClock();
yield { kind: "portrait", portrait: { trainer: POKEMON_PROF } };
yield { kind: "text", pages: this.pages(INTRO._OakText1) };
// OakText2's text_asm plays Wooper's cry; OakText3 is a button wait.
yield { kind: "portrait" };
yield { kind: "picture", species: WOOPER };
yield { kind: "text", pages: this.pages(INTRO._OakText2) };
yield { kind: "text", pages: this.pages(INTRO._OakText4) };
yield { kind: "picture", species: 0 };
yield { kind: "portrait", portrait: { trainer: POKEMON_PROF } };
yield { kind: "text", pages: this.pages(INTRO._OakText5) };
yield {
kind: "portrait",
portrait: { player: this.avatar === "kris" },
};
yield { kind: "text", pages: this.pages(INTRO._OakText6) };
yield* this.namePlayer();
yield { kind: "text", pages: this.pages(INTRO._OakText7) };
yield { kind: "portrait" };
}
/** NamePlayer: NEW NAME opens the naming screen; the others are the
* gender's preset names (ShowPlayerNamingChoices). */
private *namePlayer(): Gen {
const female = this.avatar === "kris";
const items = this.strings(
female
? INTRO["KrisNameMenuHeader.FemaleNames"] + 2
: INTRO["ChrisNameMenuHeader.MaleNames"] + 2,
5,
);
const choice = yield* this.menu([""], items);
if (choice > 0) {
this.name = items[choice];
return;
}
const fallback = this.string(
female ? INTRO["NamePlayer.Kris"] : INTRO["NamePlayer.Chris"],
);
for (;;) {
const name = yield {
kind: "name",
pages: [this.string(INTRO["NamingScreenJumptable.PlayerNameString"])],
current: fallback,
max: NAME_MAX,
};
if (typeof name !== "string") continue;
// InitName: a blank name takes the gender's default.
this.name = name.trim() || fallback;
return;
}
}
/** GetTimeOfDayString and AdjustHourForAMorPM, as PrintHour prints. */
private hourString(hour: number) {
const period =
hour < MORN_HOUR
? INTRO["GetTimeOfDayString.nite_string"]
: hour < DAY_HOUR
? INTRO["GetTimeOfDayString.morn_string"]
: hour < NITE_HOUR
? INTRO["GetTimeOfDayString.day_string"]
: INTRO["GetTimeOfDayString.nite_string"];
return `${this.string(period)} ${hour % 12 || 12}`;
}
/** InitClock: Oak asks for the hour, then the minutes, each confirmed
* with YES/NO. The chosen time becomes an offset from the device clock.
* Intentional change: the steppers start at the device's time rather
* than 10:00, so confirming keeps the device time. */
private *initClock(): Gen {
const start = this.host.now();
let hour = start.getHours(),
minutes = start.getMinutes();
const oclock = this.string(INTRO.String_oclock),
min = this.string(INTRO.String_min);
const hourLabels = Array.from(
{ length: 24 },
(_, h) => `${this.hourString(h)} ${oclock}`,
);
const minuteLabels = Array.from({ length: 60 }, (_, m) => `${m} ${min}`);
yield { kind: "text", pages: this.pages(INTRO._OakTimeWokeUpText) };
for (;;) {
hour = yield* this.stepper(
this.pages(INTRO._OakTimeWhatTimeIsItText),
hourLabels,
hour,
);
// "What?" with DisplayHourOClock on the second line, then "?".
const hours = yield {
kind: "yesno",
pages: [
`${this.text(INTRO._OakTimeWhatHoursText)}\n${hourLabels[hour]}${this.text(INTRO._OakTimeHoursQuestionMarkText)}`,
],
};
if (!hours) continue;
let confirmed = false;
while (!confirmed) {
minutes = yield* this.stepper(
this.pages(INTRO._OakTimeHowManyMinutesText),
minuteLabels,
minutes,
);
confirmed = Boolean(
yield {
kind: "yesno",
pages: [
`${this.text(INTRO._OakTimeWhoaMinutesText)} ${minuteLabels[minutes]}${this.text(INTRO._OakTimeMinutesQuestionMarkText)}`,
],
},
);
}
break;
}
// InitTimeOfDay: the device clock plus this offset reads the chosen
// time now. The shortest offset keeps the device's date where it can.
const device = start.getHours() * 60 + start.getMinutes();
this.state.clock.minuteOffset =
((((hour * 60 + minutes - device) % 1440) + 1440 + 720) % 1440) - 720;
// OakText_ResponseToSetTime compares against DAY_HOUR + 1, so 10 AM
// still counts as oversleeping in the morning.
const response =
hour < MORN_HOUR
? INTRO._OakTimeSoDarkText
: hour < DAY_HOUR + 1
? INTRO._OakTimeOversleptText
: hour < NITE_HOUR
? INTRO._OakTimeYikesText
: INTRO._OakTimeSoDarkText;
yield {
kind: "text",
pages: [
`${this.hourString(hour)}:${String(minutes).padStart(2, "0")}${this.text(response)}`,
],
};
}
/** SetHour and SetMinutes: Up and Down wrap through the labels; A
* answers with the shown value. */
private *stepper(
pages: string[],
labels: string[],
value: number,
): Gen<number> {
for (;;) {
const answer = yield { kind: "clock", pages, labels, selected: value };
if (
typeof answer === "number" &&
Number.isInteger(answer) &&
answer >= 0 &&
answer < labels.length
)
return answer;
}
}
}
+97 -8
View File
@@ -23,8 +23,10 @@ import {
type MartMode, type MartMode,
type ScriptEffect, type ScriptEffect,
type ScriptHost, type ScriptHost,
type Portrait,
type ScriptState, type ScriptState,
} from "../scripts/interpreter"; } from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import type { MovementPose, MovementStep } from "../scripts/movement"; import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag"; import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import { import {
@@ -77,7 +79,9 @@ export interface NativeSave {
/** Queued special call, persisted until its script completes. */ /** Queued special call, persisted until its script completes. */
specialPhoneCall: number; specialPhoneCall: number;
rivalName: string; rivalName: string;
clock: { weekdayOffset: number; dst: boolean }; clock: { minuteOffset: number; weekdayOffset: number; dst: boolean };
/** The introduction has not finished: it starts again on load. */
intro: boolean;
/** Current-map objects moved or turned by scripts, as saved object structs. */ /** Current-map objects moved or turned by scripts, as saved object structs. */
objects: { id: number; x: number; y: number; direction: Direction }[]; objects: { id: number; x: number; y: number; direction: Direction }[];
/** wPlayerID, drawn at a new game. */ /** wPlayerID, drawn at a new game. */
@@ -194,6 +198,8 @@ export interface Pose {
export type Choice = export type Choice =
| { kind: "yesno"; selected: 0 | 1 } | { kind: "yesno"; selected: 0 | 1 }
| { kind: "weekday"; names: string[]; selected: number } | { kind: "weekday"; names: string[]; selected: number }
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
| { kind: "clock"; labels: string[]; selected: number }
/** The naming screen: an empty answer keeps `current`. */ /** The naming screen: an empty answer keeps `current`. */
| { kind: "name"; current: string; max: number; selected?: undefined } | { kind: "name"; current: string; max: number; selected?: undefined }
/** A vertical menu; `selected` answers with that item's index. */ /** A vertical menu; `selected` answers with that item's index. */
@@ -244,7 +250,7 @@ export interface BattleView {
const BATTLE_MESSAGE_TICKS = 30; const BATTLE_MESSAGE_TICKS = 30;
interface ActiveScript { interface ActiveScript {
run: ScriptRun; run: ScriptRun | IntroRun;
effect?: ScriptEffect; effect?: ScriptEffect;
/** Committed save data for the whole interaction, used for mid-script saves. */ /** Committed save data for the whole interaction, used for mid-script saves. */
committed: NativeSave; committed: NativeSave;
@@ -463,6 +469,10 @@ export class GameWorld {
emote: { object: number; emote: number } | undefined; emote: { object: number; emote: number } | undefined;
/** Species whose front picture is shown (pokepic), if any. */ /** Species whose front picture is shown (pokepic), if any. */
picture: number | undefined; picture: number | undefined;
/** The introduction's trainer or player picture, if any. */
portrait: Portrait | undefined;
/** True from a new game until the introduction finishes. */
private introPending = false;
/** The battle in progress, if any. */ /** The battle in progress, if any. */
battle: BattleView | undefined; battle: BattleView | undefined;
private battleRun?: Generator<BattleEffect, BattleOutcome, BattleInput>; private battleRun?: Generator<BattleEffect, BattleOutcome, BattleInput>;
@@ -593,13 +603,21 @@ export class GameWorld {
if (mode === 4) return this.liveState.flash ? 2 : 3; if (mode === 4) return this.liveState.flash ? 2 : 3;
return this.clockTimeOfDay; return this.clockTimeOfDay;
} }
/** The introduction is running, or has yet to run after a load. */
get intro() {
return this.introPending;
}
/** The game clock: the device time plus the offset InitClock chose. */
private gameNow(clock = this.liveState.clock) {
return new Date(this.now().getTime() + clock.minuteOffset * 60_000);
}
/** wTimeOfDay from the game clock: morning 4-10, day 10-18, night. */ /** wTimeOfDay from the game clock: morning 4-10, day 10-18, night. */
get clockTimeOfDay(): number { get clockTimeOfDay(): number {
const hour = this.now().getHours(); const hour = this.gameNow().getHours();
return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2; return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2;
} }
clock() { clock() {
const now = this.now(); const now = this.gameNow();
return { return {
weekday: (now.getDay() + this.liveState.clock.weekdayOffset) % 7, weekday: (now.getDay() + this.liveState.clock.weekdayOffset) % 7,
hours: now.getHours(), hours: now.getHours(),
@@ -624,7 +642,7 @@ export class GameWorld {
if (object.eventFlag !== 0xffff && state.events.has(object.eventFlag)) if (object.eventFlag !== 0xffff && state.events.has(object.eventFlag))
return false; return false;
if (!this.assets.sprite(this.spriteOf(object, state))) return false; if (!this.assets.sprite(this.spriteOf(object, state))) return false;
const hour = this.now().getHours(), const hour = this.gameNow().getHours(),
timeMask = 1 << this.clockTimeOfDay; timeMask = 1 << this.clockTimeOfDay;
if (object.hour1 === 0xff) if (object.hour1 === 0xff)
return object.hour2 === 0xff || Boolean(object.hour2 & timeMask); return object.hour2 === 0xff || Boolean(object.hour2 & timeMask);
@@ -1470,7 +1488,7 @@ export class GameWorld {
sceneOwner: (id) => this.assets.sceneOwner(id), sceneOwner: (id) => this.assets.sceneOwner(id),
timeOfDay: () => this.clockTimeOfDay, timeOfDay: () => this.clockTimeOfDay,
clock: () => this.clock(), clock: () => this.clock(),
deviceWeekday: () => this.now().getDay(), clockDay: () => this.gameNow().getDay(),
text: () => ({ text: () => ({
...defaultTextContext(this.name), ...defaultTextContext(this.name),
rival: this.state.rivalName, rival: this.state.rivalName,
@@ -1581,6 +1599,35 @@ export class GameWorld {
this.pump(); this.pump();
} }
/** NewGame's PlayerProfileSetup and OakSpeech, before the player
* appears in the bedroom. Saves made meanwhile start it again. */
beginIntro() {
if (this.script) throw new Error("The introduction needs an idle world.");
this.introPending = true;
this.script = {
run: new IntroRun(this.state, {
reader: this.assets.reader,
now: () => this.now(),
text: (player) => ({ ...this.host(PLAYER).text(), player }),
}),
committed: this.save(),
rollback: {
state: this.state,
mapId: this.mapId,
player: copyActor(this.player),
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene: false,
};
this.pump();
}
/** Run the active script until it needs presentation time or input. */ /** Run the active script until it needs presentation time or input. */
private pump(input?: number | string) { private pump(input?: number | string) {
const script = this.script; const script = this.script;
@@ -1717,6 +1764,20 @@ export class GameWorld {
case "picture": case "picture":
this.picture = effect.species || undefined; this.picture = effect.species || undefined;
return false; return false;
case "portrait":
this.portrait = effect.portrait;
return false;
case "clock":
this.dialogue = {
pages: effect.pages,
index: 0,
choice: {
kind: "clock",
labels: effect.labels,
selected: effect.selected,
},
};
return true;
case "battle": { case "battle": {
const draft = script.run.state; const draft = script.run.state;
const trainers = new TrainerData(this.assets.reader); const trainers = new TrainerData(this.assets.reader);
@@ -2190,6 +2251,12 @@ export class GameWorld {
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
this.portrait = undefined;
if (script.run instanceof IntroRun) {
this.name = script.run.name;
this.avatar = script.run.avatar;
this.introPending = false;
}
if (!script.scene || script.run.deferred !== undefined) if (!script.scene || script.run.deferred !== undefined)
this.sceneCheck = true; this.sceneCheck = true;
if (script.run.deferred !== undefined) if (script.run.deferred !== undefined)
@@ -2211,6 +2278,7 @@ export class GameWorld {
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
this.portrait = undefined;
if (script.sighted !== undefined) if (script.sighted !== undefined)
this.refusedSighting = { this.refusedSighting = {
map: this.mapId, map: this.mapId,
@@ -2305,7 +2373,7 @@ export class GameWorld {
/** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the /** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the
* first idle check on a new game day clears the daily engine flags. */ * first idle check on a new game day clears the daily engine flags. */
private checkDailyReset() { private checkDailyReset() {
const now = this.now(); const now = this.gameNow(this.state.clock);
const day = const day =
Math.floor( Math.floor(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000, Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
@@ -3098,6 +3166,11 @@ export class GameWorld {
choice.items.length - 1, choice.items.length - 1,
choice.selected + 1, choice.selected + 1,
); );
} else if (choice.kind === "clock") {
const length = choice.labels.length;
if (direction === "up") choice.selected = (choice.selected + 1) % length;
if (direction === "down")
choice.selected = (choice.selected + length - 1) % length;
} else if (choice.kind === "martlist") { } else if (choice.kind === "martlist") {
const length = choice.entries.length; const length = choice.entries.length;
if (!length) return; if (!length) return;
@@ -3165,6 +3238,7 @@ export class GameWorld {
specialPhoneCall: state.specialPhoneCall, specialPhoneCall: state.specialPhoneCall,
rivalName: state.rivalName, rivalName: state.rivalName,
clock: { ...state.clock }, clock: { ...state.clock },
intro: this.introPending,
objects: [...this.actors] objects: [...this.actors]
.filter(([, actor]) => actor.moved) .filter(([, actor]) => actor.moved)
.map(([id, actor]) => ({ .map(([id, actor]) => ({
@@ -3249,7 +3323,9 @@ export class GameWorld {
!save.phone.every((id) => integer(id, 0, 255)) || !save.phone.every((id) => integer(id, 0, 255)) ||
new Set(save.phone).size !== save.phone.length || new Set(save.phone).size !== save.phone.length ||
!save.clock || !save.clock ||
!integer(save.clock.minuteOffset, -720, 719) ||
!integer(save.clock.weekdayOffset, 0, 6) || !integer(save.clock.weekdayOffset, 0, 6) ||
typeof save.intro !== "boolean" ||
typeof save.clock.dst !== "boolean" || typeof save.clock.dst !== "boolean" ||
!Array.isArray(save.objects) || !Array.isArray(save.objects) ||
save.objects.length > 16 || save.objects.length > 16 ||
@@ -3396,10 +3472,14 @@ export class GameWorld {
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
this.portrait = undefined;
this.battle = undefined; this.battle = undefined;
this.battleRun = undefined; this.battleRun = undefined;
this.memScript = undefined; this.memScript = undefined;
this.clearNotice(); this.clearNotice();
this.introPending = false;
// A save made during the introduction starts it again.
if (save.intro) this.beginIntro();
} }
} }
@@ -3437,6 +3517,13 @@ export function migrateSave(
...(!Object.hasOwn(save, "noWildEncounters") && { ...(!Object.hasOwn(save, "noWildEncounters") && {
noWildEncounters: false, noWildEncounters: false,
}), }),
...(!Object.hasOwn(save, "intro") && { intro: false }),
// Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null &&
typeof save.clock === "object" &&
!Object.hasOwn(save.clock, "minuteOffset") && {
clock: { minuteOffset: 0, ...(save.clock as object) },
}),
} as unknown as NativeSave; } as unknown as NativeSave;
} }
if (save.version !== 1 && save.version !== 2) return undefined; if (save.version !== 1 && save.version !== 2) return undefined;
@@ -3446,13 +3533,14 @@ export function migrateSave(
...save, ...save,
scenes: {}, scenes: {},
phone: [], phone: [],
clock: { weekdayOffset: 0, dst: false }, clock: { minuteOffset: 0, weekdayOffset: 0, dst: false },
objects: [], objects: [],
} }
: save; : save;
return { return {
...previous, ...previous,
version: 3, version: 3,
clock: { minuteOffset: 0, ...(previous.clock as Record<string, unknown>) },
decorations: initialDecorations(), decorations: initialDecorations(),
playerId: newPlayerId(), playerId: newPlayerId(),
party: [], party: [],
@@ -3470,5 +3558,6 @@ export function migrateSave(
backupWarp: null, backupWarp: null,
flash: false, flash: false,
noWildEncounters: false, noWildEncounters: false,
intro: false,
} as unknown as NativeSave; } as unknown as NativeSave;
} }
+46 -23
View File
@@ -42,8 +42,7 @@ function showWorld(world: GameWorld) {
`You are at ${world.x}, ${world.y}. Red marks your position.`; `You are at ${world.x}, ${world.y}. Red marks your position.`;
get("minimap-position").textContent = `${world.x}, ${world.y}`; get("minimap-position").textContent = `${world.x}, ${world.y}`;
get("map-empty").hidden = true; get("map-empty").hidden = true;
get<HTMLInputElement>("player-name").value = world.name; get("intro-screen").hidden = !world.intro;
get<HTMLSelectElement>("player-avatar").value = world.avatar;
get("location").textContent = world.map.name; get("location").textContent = world.map.name;
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`; get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice; get("game-notice").textContent = world.notice;
@@ -81,6 +80,7 @@ function showWorld(world: GameWorld) {
canvas.dataset.x = String(world.x); canvas.dataset.x = String(world.x);
canvas.dataset.y = String(world.y); canvas.dataset.y = String(world.y);
canvas.dataset.activity = world.activity; canvas.dataset.activity = world.activity;
canvas.dataset.intro = String(world.intro);
canvas.setAttribute( canvas.setAttribute(
"aria-label", "aria-label",
`${world.map.name}. Position ${world.x}, ${world.y}. Arrow keys move. X interacts. Z continues dialogue.`, `${world.map.name}. Position ${world.x}, ${world.y}. Arrow keys move. X interacts. Z continues dialogue.`,
@@ -134,6 +134,25 @@ function showChoice(choice: Choice | undefined) {
); );
container.firstElementChild?.setAttribute("aria-label", "Previous day"); container.firstElementChild?.setAttribute("aria-label", "Previous day");
container.children[2]?.setAttribute("aria-label", "Next day"); container.children[2]?.setAttribute("aria-label", "Next day");
} else if (choice.kind === "clock") {
// InitClock's hour or minutes: Up for later, Down for earlier.
const value = document.createElement("output");
value.textContent = choice.labels[choice.selected];
value.setAttribute("aria-live", "polite");
container.replaceChildren(
button("▼", undefined, () => {
runtime.world?.moveCursor("down");
if (runtime.world) showWorld(runtime.world);
}),
value,
button("▲", undefined, () => {
runtime.world?.moveCursor("up");
if (runtime.world) showWorld(runtime.world);
}),
button("OK", true, () => runtime.choose(choice.selected)),
);
container.firstElementChild?.setAttribute("aria-label", "Earlier");
container.children[2]?.setAttribute("aria-label", "Later");
} else if (choice.kind === "name") } else if (choice.kind === "name")
container.replaceChildren( container.replaceChildren(
...nameField(choice.current, choice.max, (name) => runtime.choose(name)), ...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
@@ -687,20 +706,37 @@ function setHp(bar: HTMLElement, hp: number, max: number) {
bar.classList.toggle("low", fraction <= 0.5 && fraction > 0.2); bar.classList.toggle("low", fraction <= 0.5 && fraction > 0.2);
bar.classList.toggle("critical", fraction <= 0.2); bar.classList.toggle("critical", fraction <= 0.2);
} }
let shownPicture: number | undefined; let shownPicture = "";
/** Draw a pokepic from the ROM's front picture tiles and palette. */ /** Draw a pokepic, or the introduction's trainer or player picture, from
* the ROM's picture tiles and palette. */
function showPicture(world: GameWorld) { function showPicture(world: GameWorld) {
const box = get("pokepic"); const box = get("pokepic");
box.hidden = world.picture === undefined; const portrait = world.portrait;
if (world.picture === shownPicture) return; box.hidden = world.picture === undefined && !portrait;
shownPicture = world.picture; const key = JSON.stringify([world.picture, portrait]);
if (world.picture === undefined) return; if (key === shownPicture) return;
shownPicture = key;
if (box.hidden) return;
const data = world.assets.pokemon; const data = world.assets.pokemon;
const picture = get<HTMLCanvasElement>("pokepic-canvas"); const picture = get<HTMLCanvasElement>("pokepic-canvas");
const context = picture.getContext("2d")!; const context = picture.getContext("2d")!;
const image = context.createImageData(56, 56); const image = context.createImageData(56, 56);
const tiles = data.frontPic(world.picture), let tiles: Uint8Array, palette: Palette, label: string;
if (world.picture !== undefined) {
tiles = data.frontPic(world.picture);
palette = data.palette(world.picture); palette = data.palette(world.picture);
label = data.speciesName(world.picture);
} else if (portrait && "player" in portrait) {
// DrawIntroPlayerPic: CHRIS's palette, or KRIS's (class 1).
tiles = data.playerPic(portrait.player);
palette = data.trainerPalette(portrait.player ? 1 : 0);
label = portrait.player ? "Kris" : "Chris";
} else {
const trainer = (portrait as { trainer: number }).trainer;
tiles = data.trainerPic(trainer);
palette = data.trainerPalette(trainer);
label = "Professor Oak";
}
// Picture tiles are stored column by column. // Picture tiles are stored column by column.
for (let tile = 0; tile < 49; tile++) for (let tile = 0; tile < 49; tile++)
drawTile( drawTile(
@@ -714,7 +750,7 @@ function showPicture(world: GameWorld) {
palette, palette,
); );
context.putImageData(image, 0, 0); context.putImageData(image, 0, 0);
picture.setAttribute("aria-label", data.speciesName(world.picture)); picture.setAttribute("aria-label", label);
} }
const runtime = new NativeRuntime( const runtime = new NativeRuntime(
showWorld, showWorld,
@@ -724,17 +760,6 @@ const runtime = new NativeRuntime(
showError(error); showError(error);
update(); update();
}, },
() => ({
name:
get<HTMLInputElement>("player-name")
.value.trim()
.replace(/[^A-Za-z0-9 -]/g, "")
.slice(0, 7) || "CHRIS",
avatar:
get<HTMLSelectElement>("player-avatar").value === "kris"
? "kris"
: "chris",
}),
handleBattleInput, handleBattleInput,
); );
const session = new GameSession( const session = new GameSession(
@@ -793,8 +818,6 @@ function update() {
get("pause-overlay").hidden = session.state !== "paused"; get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy; get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy); get("rom-label").classList.toggle("disabled", busy);
get<HTMLInputElement>("player-name").disabled = busy || ready();
get<HTMLSelectElement>("player-avatar").disabled = busy || ready();
get<HTMLButtonElement>("dialogue-next").disabled = get<HTMLButtonElement>("dialogue-next").disabled =
busy || session.state !== "playing"; busy || session.state !== "playing";
} }
+1 -4
View File
@@ -22,10 +22,6 @@ export function renderShell(root: HTMLElement) {
<div id="screen-empty" class="screen-empty"> <div id="screen-empty" class="screen-empty">
<form class="load-card" onsubmit="return false"> <form class="load-card" onsubmit="return false">
<p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English US/Europe · Revision 1.1</p> <p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English US/Europe · Revision 1.1</p>
<div class="player-setup">
<label>Name <input id="player-name" value="CHRIS" maxlength="7" pattern="[A-Za-z0-9 -]{1,7}" autocomplete="off"></label>
<label>Character <select id="player-avatar"><option value="chris">Chris</option><option value="kris">Kris</option></select></label>
</div>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">▶</span> Choose ROM</label> <label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">▶</span> Choose ROM</label>
<p class="privacy-note">Opened locally. Never uploaded.</p> <p class="privacy-note">Opened locally. Never uploaded.</p>
</form> </form>
@@ -41,6 +37,7 @@ export function renderShell(root: HTMLElement) {
<p id="mart-money" class="mart-money"></p> <p id="mart-money" class="mart-money"></p>
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-menu" class="mart-menu"></div></div> <div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-menu" class="mart-menu"></div></div>
</div> </div>
<div id="intro-screen" class="intro-screen" hidden></div>
<div id="pokepic" class="pokepic" hidden><canvas id="pokepic-canvas" width="56" height="56" role="img"></canvas><button id="pokepic-next" hidden>Continue <span aria-hidden="true">▶</span></button></div> <div id="pokepic" class="pokepic" hidden><canvas id="pokepic-canvas" width="56" height="56" role="img"></canvas><button id="pokepic-next" hidden>Continue <span aria-hidden="true">▶</span></button></div>
<div id="native-dialogue" class="native-dialogue" aria-label="Dialogue" hidden><p id="dialogue-text" aria-live="polite"></p><div id="dialogue-choice" class="dialogue-choice" hidden></div><div><span id="dialogue-page"></span><button id="dialogue-next">Continue <span aria-hidden="true">▶</span></button></div></div> <div id="native-dialogue" class="native-dialogue" aria-label="Dialogue" hidden><p id="dialogue-text" aria-live="polite"></p><div id="dialogue-choice" class="dialogue-choice" hidden></div><div><span id="dialogue-page"></span><button id="dialogue-next">Continue <span aria-hidden="true">▶</span></button></div></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing</button></div> <div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing</button></div>
+14 -29
View File
@@ -255,28 +255,6 @@ dialog h2 {
margin: 7px 0 12px; margin: 7px 0 12px;
font-size: 11px; font-size: 11px;
} }
.player-setup {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 12px 0;
}
.player-setup label {
font-size: 10px;
font-weight: 700;
}
.player-setup input,
.player-setup select {
display: block;
width: 100%;
min-width: 0;
min-height: 44px;
margin-top: 3px;
padding: 7px;
background: #fffef5;
border: 3px double var(--ink);
border-radius: 0;
}
.primary-button, .primary-button,
.menu-button { .menu-button {
min-height: 44px; min-height: 44px;
@@ -466,6 +444,20 @@ dialog h2 {
.battle-menu .wide { .battle-menu .wide {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* NewGame's screens: a plain background over the world until the
introduction ends. */
.intro-screen {
position: absolute;
inset: 0;
z-index: 2;
background: #f8f8f0;
}
.intro-screen[hidden] {
display: none;
}
.intro-screen:not([hidden]) ~ .minimap-overlay {
display: none;
}
.pokepic { .pokepic {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
@@ -922,9 +914,6 @@ dialog dd {
.load-card h1 { .load-card h1 {
font-size: 16px; font-size: 16px;
} }
.player-setup {
margin: 7px 0;
}
.minimap-overlay { .minimap-overlay {
top: 4px; top: 4px;
right: 4px; right: 4px;
@@ -982,10 +971,6 @@ dialog dd {
grid-column: 1; grid-column: 1;
margin: 0; margin: 0;
} }
.screen-empty .player-setup {
grid-column: 1;
margin: 0;
}
.screen-empty .primary-button { .screen-empty .primary-button {
grid-column: 2; grid-column: 2;
grid-row: 1 / 3; grid-row: 1 / 3;
+3 -1
View File
@@ -13,7 +13,9 @@ async function start(page: Page, scenario: BattleScenario = "win") {
const source = await response.text(); const source = await response.text();
const needle = "const world = new GameWorld(assets);"; const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle); expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle, // Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { battleFixtureWorld } = await import('/tests/battle-fixture.ts'); `const { battleFixtureWorld } = await import('/tests/battle-fixture.ts');
const world = battleFixtureWorld(assets, ${JSON.stringify(scenario)});`) }); const world = battleFixtureWorld(assets, ${JSON.stringify(scenario)});`) });
}); });
-2
View File
@@ -45,8 +45,6 @@ test("ROM gate, help and responsive layouts", async ({ page }) => {
expect(button.width).toBeGreaterThanOrEqual(44); expect(button.width).toBeGreaterThanOrEqual(44);
expect(button.height).toBeGreaterThanOrEqual(44); expect(button.height).toBeGreaterThanOrEqual(44);
} }
await page.locator("#player-name").scrollIntoViewIfNeeded();
await expect(page.locator("#player-name")).toBeInViewport({ ratio: 0.9 });
await page.locator("#rom-label").scrollIntoViewIfNeeded(); await page.locator("#rom-label").scrollIntoViewIfNeeded();
await expect(page.locator("#rom-label")).toBeInViewport({ ratio: 0.9 }); await expect(page.locator("#rom-label")).toBeInViewport({ ratio: 0.9 });
} }
+3 -1
View File
@@ -15,9 +15,11 @@ async function start(page: Page, room: boolean | "poster" | "center" = false) {
source = await response.text(); source = await response.text();
const needle = "const world = new GameWorld(assets);"; const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle); expect(source).toContain(needle);
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ await route.fulfill({
response, response,
body: source.replace( body: source.replace("this.world?.beginIntro();", "").replace(
needle, needle,
`const { foundationFixture } = await import('/tests/foundation-fixture.ts'); const world = foundationFixture(assets, ${JSON.stringify(room)});`, `const { foundationFixture } = await import('/tests/foundation-fixture.ts'); const world = foundationFixture(assets, ${JSON.stringify(room)});`,
), ),
+131
View File
@@ -0,0 +1,131 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test.beforeEach(() =>
test.skip(!existsSync(romPath), "Requires the local user ROM."),
);
async function start(page: Page) {
await page.clock.install({ time: new Date("2026-09-25T14:37:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-25T14:37:05"));
await page.locator("#play").click();
}
/** Press A until the dialogue shows `text`. */
async function advanceTo(page: Page, text: string) {
const dialogue = page.locator("#dialogue-text");
for (let guard = 0; guard < 40; guard++) {
if ((await dialogue.textContent())?.includes(text)) return;
await page.keyboard.press("KeyX");
}
await expect(dialogue).toContainText(text);
}
test("a new game asks for gender, the clock and a name before the bedroom", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await start(page);
const screen = page.locator("#game-screen"),
choice = page.locator("#dialogue-choice"),
text = page.locator("#dialogue-text"),
picture = page.locator("#pokepic-canvas");
await expect(screen).toHaveAttribute("data-intro", "true");
await expect(page.locator("#intro-screen")).toBeVisible();
await expect(text).toHaveText("Are you a boy?\nOr are you a girl?");
// Down, then A chooses "Girl"; B is ignored.
await page.keyboard.press("KeyZ");
await page.keyboard.press("ArrowDown");
await expect(choice.getByRole("option", { name: "Girl" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.keyboard.press("KeyX");
await advanceTo(page, "What time is it?");
// The hour starts at the device time; Up three times is 5 PM.
const value = choice.locator("output");
await expect(value).toHaveText("DAY 2 o'clock");
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowUp");
await expect(value).toHaveText("DAY 5 o'clock");
await page.keyboard.press("KeyX");
await expect(text).toHaveText("What?\nDAY 5 o'clock?");
await choice.getByRole("button", { name: "YES" }).click();
await expect(text).toHaveText("How many minutes?");
await expect(value).toHaveText("37 min.");
await choice.getByRole("button", { name: "Earlier" }).click();
await expect(value).toHaveText("36 min.");
await choice.getByRole("button", { name: "OK" }).click();
await expect(text).toHaveText("Whoa! 36 min.?");
await page.keyboard.press("KeyX");
await expect(text).toHaveText("DAY 5:36!\nYikes! I over-\nslept!");
await page.keyboard.press("KeyX");
await expect(picture).toHaveAttribute("aria-label", "Professor Oak");
await advanceTo(page, "This world is in-");
await expect(picture).toHaveAttribute("aria-label", "WOOPER");
await advanceTo(page, "Now, what did you");
await expect(picture).toHaveAttribute("aria-label", "Kris");
await page.keyboard.press("KeyX");
await expect(choice.getByRole("option")).toHaveText([
"NEW NAME",
"KRIS",
"AMANDA",
"JUANA",
"JODI",
]);
await page.keyboard.press("KeyX");
await expect(text).toHaveText("YOUR NAME?");
await choice.locator("input").fill("LYRA");
await choice.locator("input").press("Enter");
await expect(text).toHaveText("LYRA, are you\nready?");
await advanceTo(page, "I'll be seeing you");
await page.keyboard.press("KeyX");
await expect(screen).toHaveAttribute("data-intro", "false");
await expect(page.locator("#intro-screen")).toBeHidden();
await expect(page.locator("#pokepic")).toBeHidden();
await expect(screen).toHaveAttribute("data-map", "24:7");
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const record = JSON.parse(
readFileSync((await (await download).path())!, "utf8"),
);
const save = JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
expect(save).toMatchObject({
intro: false,
name: "LYRA",
avatar: "kris",
clock: { minuteOffset: 17 * 60 + 36 - (14 * 60 + 37) },
});
expect(errors).toEqual([]);
});
test("a session saved during the introduction starts it again", async ({
page,
}) => {
await start(page);
await page.keyboard.press("KeyX");
await advanceTo(page, "What time is it?");
// Leaving the game tab pauses and saves.
await page.locator("#tab-saves").click();
await expect(page.locator("#save-status")).toContainText("Session saved");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
await page.locator("#play").click();
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-intro",
"true",
);
await expect(page.locator("#dialogue-text")).toHaveText(
"Are you a boy?\nOr are you a girl?",
);
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, type Page } from "@playwright/test";
/** Play a new game's introduction through the real controls: the gender
* menu, the clock (kept at the device time) and a name. An empty name
* picks the gender's first preset (CHRIS or KRIS). */
export async function completeIntro(
page: Page,
{ girl = false, name = "" } = {},
) {
const screen = page.locator("#game-screen"),
choice = page.locator("#dialogue-choice");
await expect(screen).toHaveAttribute("data-intro", "true");
await choice.getByRole("option", { name: girl ? "Girl" : "Boy" }).click();
for (
let guard = 0;
guard < 80 && (await screen.getAttribute("data-intro")) === "true";
guard++
) {
if (!(await choice.isVisible())) {
await page.locator("#dialogue-next").click();
continue;
}
const menu = choice.getByRole("option");
if (await choice.locator("input").count()) {
await choice.locator("input").fill(name);
await choice.getByRole("button", { name: "OK" }).click();
} else if (await menu.count()) await menu.nth(name ? 0 : 1).click();
else if (await choice.getByRole("button", { name: "YES" }).count())
await choice.getByRole("button", { name: "YES" }).click();
else await choice.getByRole("button", { name: "OK" }).click();
}
await expect(screen).toHaveAttribute("data-intro", "false");
}
+192
View File
@@ -0,0 +1,192 @@
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 { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
/** InitGender's menu, then NamePlayer's first item. */
const BOY = 0,
GIRL = 1,
NEW_NAME = 0,
YES = 1,
NO = 0;
describe.skipIf(!existsSync(romPath))("the new game introduction", () => {
const bytes = existsSync(romPath)
? new Uint8Array(readFileSync(romPath))
: new Uint8Array();
function intro(time = "2026-09-25T14:37:00") {
let now = new Date(time);
const world = new GameWorld(new CrystalAssets(bytes), () => now);
world.beginIntro();
return { world, setNow: (value: string) => (now = new Date(value)) };
}
it("asks for gender, the clock and a name, then starts in the bedroom", () => {
const { world } = intro();
expect(world.intro).toBe(true);
expect(world.dialogue?.choice).toEqual({
kind: "menu",
items: ["Boy", "Girl"],
selected: 0,
});
// The steppers start at the device time; 5:05 PM is chosen instead.
const shown = settle(world, [GIRL, 17, YES, 5, YES, NEW_NAME]);
expect(shown).toContain("What?\nDAY 5 o'clock?");
expect(shown).toContain("Whoa! 5 min.?");
expect(shown).toContain("DAY 5:05!\nYikes! I over-\nslept!");
expect(shown).toContain("My name is OAK.");
expect(shown.at(-1)).toBe("YOUR NAME?");
expect(world.portrait).toEqual({ player: true });
expect(world.dialogue?.choice).toEqual({
kind: "name",
current: "KRIS",
max: 7,
});
const rest = settle(world, ["KAWA"]);
expect(rest[1]).toBe("KAWA, are you\nready?");
expect(world.intro).toBe(false);
expect(world.busy).toBe(false);
expect(world.portrait).toBeUndefined();
expect(world.name).toBe("KAWA");
expect(world.avatar).toBe("kris");
expect(world.state.clock.minuteOffset).toBe(17 * 60 + 5 - (14 * 60 + 37));
expect(world.clock()).toMatchObject({ hours: 17, minutes: 5 });
expect(world.clockTimeOfDay).toBe(1);
expect(world.mapId).toBe("24:7");
expect(world.save()).toMatchObject({ intro: false, name: "KAWA" });
});
it("offers the gender's preset names and a blank name's default", () => {
const { world } = intro();
const shown = settle(world, [BOY, 14, YES, 37, YES, 2]);
expect(shown).toContain("Zzz… Hm? Wha…?\nYou woke me up!");
expect(world.name).toBe("MAT");
expect(world.avatar).toBe("chris");
expect(world.state.clock.minuteOffset).toBe(0);
const blank = intro().world;
settle(blank, [GIRL, 14, YES, 37, YES, NEW_NAME, ""]);
expect(blank.name).toBe("KRIS");
});
it("repeats the hour or minutes after NO and ignores B in its menus", () => {
const { world } = intro();
world.choose(-1);
expect(world.dialogue?.choice?.kind).toBe("menu");
const shown = settle(world, [BOY, 3, NO, 22, YES, 59, NO, 0, YES, 1]);
expect(shown).toContain("What?\nNITE 3 o'clock?");
expect(shown).toContain("What?\nNITE 10 o'clock?");
expect(shown).toContain("NITE 10:00!\nNo wonder it's so\ndark!");
expect(world.name).toBe("CHRIS");
});
it("answers the chosen hour as OakText_ResponseToSetTime does", () => {
const response = (hour: number) => {
const { world } = intro();
const shown = settle(world, [BOY, hour, YES, 0, YES, 1]);
return shown.find((page) => page.includes(":00"));
};
expect(response(3)).toBe("NITE 3:00!\nNo wonder it's so\ndark!");
expect(response(4)).toBe("MORN 4:00!\nI overslept!");
// DAY_HOUR + 1: ten o'clock prints DAY but still oversleeps.
expect(response(10)).toBe("DAY 10:00!\nI overslept!");
expect(response(11)).toBe("DAY 11:00!\nYikes! I over-\nslept!");
expect(response(18)).toBe("NITE 6:00!\nNo wonder it's so\ndark!");
expect(response(0)).toBe("NITE 12:00!\nNo wonder it's so\ndark!");
});
it("wraps the stepper and keeps the shortest offset across midnight", () => {
const { world, setNow } = intro("2026-09-25T23:30:00");
world.choose(BOY);
world.advanceDialogue();
world.advanceDialogue();
world.advanceDialogue();
expect(world.dialogue?.choice).toMatchObject({
kind: "clock",
selected: 23,
});
world.moveCursor("up");
expect(world.dialogue?.choice).toMatchObject({ selected: 0 });
world.moveCursor("down");
world.moveCursor("down");
expect(world.dialogue?.choice).toMatchObject({ selected: 22 });
world.moveCursor("up");
world.moveCursor("up");
settle(world, [0, YES, 10, YES, 1]);
expect(world.state.clock.minuteOffset).toBe(40);
// The game clock passes midnight 40 minutes before the device does.
expect(world.clock()).toMatchObject({ hours: 0, minutes: 10 });
setNow("2026-09-26T08:00:00");
expect(world.clock()).toMatchObject({ hours: 8, minutes: 40 });
const early = intro("2026-09-25T00:10:00").world;
settle(early, [BOY, 23, YES, 30, YES, 1]);
expect(early.state.clock.minuteOffset).toBe(-40);
});
it("starts again after a save made during it", () => {
const { world } = intro();
settle(world, [GIRL, 14, YES, 37, YES, NEW_NAME]);
const mid = world.save();
expect(mid.intro).toBe(true);
expect(mid.name).toBe("CHRIS");
const restored = new GameWorld(
new CrystalAssets(bytes),
() => new Date("2026-09-25T14:37:00"),
);
restored.restore(mid);
expect(restored.intro).toBe(true);
expect(restored.dialogue?.pages[0]).toBe(
"Are you a boy?\nOr are you a girl?",
);
settle(restored, [BOY, 14, YES, 37, YES, 3]);
expect(restored.name).toBe("ALLAN");
const done = restored.save();
expect(done.intro).toBe(false);
// Loading a finished save leaves no introduction behind.
const loaded = intro().world;
loaded.restore(done);
expect(loaded.intro).toBe(false);
expect(loaded.busy).toBe(false);
expect(loaded.name).toBe("ALLAN");
});
it("migrates earlier saves as past the introduction on the device clock", () => {
const world = new GameWorld(new CrystalAssets(bytes));
const { intro: _, ...old } = world.save();
const migrated = migrateSave(
{ ...old, clock: { weekdayOffset: 3, dst: true } },
() => 0,
)!;
expect(migrated.intro).toBe(false);
expect(migrated.clock).toEqual({
minuteOffset: 0,
weekdayOffset: 3,
dst: true,
});
world.restore(migrated);
expect(world.intro).toBe(false);
expect(() =>
world.restore({
...world.save(),
clock: { minuteOffset: 720, weekdayOffset: 0, dst: false },
}),
).toThrow();
});
it("reads Oak's, Chris's and Kris's pictures and palettes", () => {
const data = new CrystalAssets(bytes).pokemon;
expect(data.trainerPic(10)).toHaveLength(49 * 16);
expect(data.trainerPic(10).some((byte) => byte !== 0)).toBe(true);
expect(data.playerPic(false)).not.toEqual(data.playerPic(true));
expect(data.trainerPalette(0)).not.toEqual(data.trainerPalette(1));
expect(data.trainerPalette(10)[0]).toEqual([255, 255, 255]);
expect(() => data.trainerPic(0)).toThrow();
});
});
+3 -1
View File
@@ -13,7 +13,9 @@ async function start(page: Page) {
const source = await response.text(); const source = await response.text();
const needle = "const world = new GameWorld(assets);"; const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle); expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle, // Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { martFixtureWorld } = await import('/tests/mart-fixture.ts'); `const { martFixtureWorld } = await import('/tests/mart-fixture.ts');
const world = martFixtureWorld(assets);`) }); const world = martFixtureWorld(assets);`) });
}); });
+9 -3
View File
@@ -8,6 +8,7 @@ import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types"; import type { Direction } from "../src/game/world/types";
import { VECTORS } from "../src/game/world/types"; import { VECTORS } from "../src/game/world/types";
import { settle } from "./world-driver"; import { settle } from "./world-driver";
import { completeIntro } from "./intro-driver";
const romPath = resolve( const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ?? process.env.CRYSTAL_ROM_PATH ??
@@ -59,10 +60,13 @@ test("local maps follow a stairs transition and importing a previous position",
await page.goto("/"); await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath); await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified"); await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click();
await completeIntro(page);
await page.locator("#tab-saves").click(); await page.locator("#tab-saves").click();
const downloaded = page.waitForEvent("download"); const downloaded = page.waitForEvent("download");
await page.locator("#backup").click(); await page.locator("#backup").click();
const backup = await downloaded; const backup = await downloaded;
await page.locator("#tab-game").click();
await page.locator("#play").click(); await page.locator("#play").click();
for (const direction of path!) { for (const direction of path!) {
const [dx, dy] = VECTORS[direction]; const [dx, dy] = VECTORS[direction];
@@ -128,6 +132,7 @@ test("Mom's opening scene runs with native keyboard and pointer choices", async
await page.locator("#rom-file").setInputFiles(romPath); await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified"); await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click(); await page.locator("#play").click();
await completeIntro(page);
const screen = page.locator("#game-screen"); const screen = page.locator("#game-screen");
const step = async (key: string, map: string, x: number, y: number) => { const step = async (key: string, map: string, x: number, y: number) => {
await page.keyboard.down(key); await page.keyboard.down(key);
@@ -437,17 +442,18 @@ test("native player loads the actual ROM without loading an emulator and persist
page.on("request", (request) => requests.push(request.url())); page.on("request", (request) => requests.push(request.url()));
page.on("pageerror", (error) => errors.push(error.message)); page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/"); await page.goto("/");
await page.locator("#player-name").fill("KAWA");
await page.locator("#player-avatar").selectOption("kris");
await page.locator("#rom-file").setInputFiles(romPath); await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified"); await expect(page.locator("#status")).toContainText("verified");
await expect(page.locator("#game-screen")).toHaveAttribute( await expect(page.locator("#game-screen")).toHaveAttribute(
"data-map", "data-map",
"24:7", "24:7",
); );
await expect(page.locator("#minimap")).toBeVisible();
await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:7"); await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:7");
await page.locator("#play").click(); await page.locator("#play").click();
// The introduction covers the world and its minimap until it ends.
await expect(page.locator("#minimap")).toBeHidden();
await completeIntro(page, { girl: true, name: "KAWA" });
await expect(page.locator("#minimap")).toBeVisible();
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3"); await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3");
await page.keyboard.down("ArrowUp"); await page.keyboard.down("ArrowUp");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2"); await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
+3 -3
View File
@@ -90,7 +90,7 @@ describe("native ROM formats", () => {
sceneOwner: () => undefined, sceneOwner: () => undefined,
timeMask: () => 2, timeMask: () => 2,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }), clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0, clockDay: () => 0,
text: () => defaultTextContext("KAWA"), text: () => defaultTextContext("KAWA"),
}; };
const yes = new ScriptRun(new RomReader(bytes), 0, state, host); const yes = new ScriptRun(new RomReader(bytes), 0, state, host);
@@ -260,7 +260,7 @@ describe.skipIf(!existsSync(romPath))(
expect(save.scenes).toEqual({ "24:6": 1 }); expect(save.scenes).toEqual({ "24:6": 1 });
expect(save.phone).toEqual([1]); expect(save.phone).toEqual([1]);
// 2026-09-22 is a Tuesday; the chosen Monday is six days ahead. // 2026-09-22 is a Tuesday; the chosen Monday is six days ahead.
expect(save.clock).toEqual({ weekdayOffset: 6, dst: true }); expect(save.clock).toEqual({ minuteOffset: 0, weekdayOffset: 6, dst: true });
expect(world.clock().weekday).toBe(1); expect(world.clock().weekday).toBe(1);
expect(world.state.flags.size).toBe(flags + 2); expect(world.state.flags.size).toBe(flags + 2);
expect(world.state.events.has(1735)).toBe(true); expect(world.state.events.has(1735)).toBe(true);
@@ -839,7 +839,7 @@ describe.skipIf(!existsSync(romPath))(
sceneOwner: () => undefined, sceneOwner: () => undefined,
timeOfDay: () => 1, timeOfDay: () => 1,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }), clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0, clockDay: () => 0,
text: () => defaultTextContext("CHRIS"), text: () => defaultTextContext("CHRIS"),
data: assets.pokemon, data: assets.pokemon,
player: () => ({ id: 0, gender: 0 as const }), player: () => ({ id: 0, gender: 0 as const }),
+3 -1
View File
@@ -14,7 +14,9 @@ async function start(
const source = await response.text(); const source = await response.text();
const needle = "const world = new GameWorld(assets);"; const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle); expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle, // Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { ${fixture} } = await import('/tests/quest-fixture.ts'); `const { ${fixture} } = await import('/tests/quest-fixture.ts');
const world = ${fixture}(assets);`) }); const world = ${fixture}(assets);`) });
}); });
+6 -2
View File
@@ -12,7 +12,9 @@ test("trainer UI refuses RUN, wins against Joey and saves the reward", async ({
const source = await response.text(); const source = await response.text();
const needle = "const world = new GameWorld(assets);"; const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle); expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle, // Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { trainerFixtureWorld } = await import('/tests/trainer-fixture.ts'); `const { trainerFixtureWorld } = await import('/tests/trainer-fixture.ts');
const world = trainerFixtureWorld(assets, undefined, false, true);`) }); const world = trainerFixtureWorld(assets, undefined, false, true);`) });
}); });
@@ -68,7 +70,9 @@ test("Route 30: Bug Catcher Don spots the player, walks over and sends out both
const source = await response.text(); const source = await response.text();
const needle = "const world = new GameWorld(assets);"; const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle); expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle, // Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { route30FixtureWorld } = await import('/tests/trainer-fixture.ts'); `const { route30FixtureWorld } = await import('/tests/trainer-fixture.ts');
const world = route30FixtureWorld(assets);`) }); const world = route30FixtureWorld(assets);`) });
}); });
+5 -2
View File
@@ -2,7 +2,8 @@ import type { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types"; import type { Direction } from "../src/game/world/types";
/** Drive a world through scenes the way a player would: acknowledge pages, /** Drive a world through scenes the way a player would: acknowledge pages,
* answer choices in order (default YES / first weekday) and wait out timed * answer choices in order (default YES, the first weekday or menu item, or
* the clock's shown value) and wait out timed
* effects. Returns every page that was shown. */ * effects. Returns every page that was shown. */
export function settle( export function settle(
world: GameWorld, world: GameWorld,
@@ -29,7 +30,9 @@ export function settle(
? answer ? answer
: dialogue.choice.kind === "yesno" : dialogue.choice.kind === "yesno"
? 1 ? 1
: 0, : dialogue.choice.kind === "clock"
? dialogue.choice.selected
: 0,
); );
} else if (last && dialogue.holdTicks !== undefined) { } else if (last && dialogue.holdTicks !== undefined) {
if (page !== lastHeld) shown.push(page); if (page !== lastHeld) shown.push(page);