Integrate battles with the overworld UI and validate browser flows
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
- Drive wild battles through the real UI in Chromium and WebKit using a test-only encounter fixture: victory with level-up, escape, whiteout, mid-battle backup restore and unsupported-effect rollback. - Add keyboard and on-screen D-pad navigation for battle menus. - Accept pending stat experience in saves (awarded without CalcMonStats). - Commit whiteout healing, money loss and destination callbacks together; aborted scripts restore state and map. - Give long notices their own status-bar row on narrow screens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c08d250cf6
commit
11d1153802
11 files changed
+462
-13
No files matched your search
@@ -48,4 +48,4 @@ Asset caches and the ROM reader remain in memory for lazy extraction. Neither RO
|
||||
|
||||
The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling.
|
||||
|
||||
`game/battle` now holds the native battle core and ROM-backed battle tables. Its unit and damage/type reference tests do not yet establish the full overworld-to-battle browser flow. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
|
||||
`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; no production map has encounters enabled yet. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
|
||||
+1
-1
@@ -49,7 +49,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
||||
|
||||
## In progress: native battles
|
||||
|
||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience and move learning; reference tests compare damage and type effectiveness. Full browser battle flows, capture, Route 29 scripts and the opening quest remain unverified or unfinished.
|
||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience and move learning; 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, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Capture, switching, items in battle, Route 29 scripts and the opening quest remain unfinished.
|
||||
|
||||
This is still not a complete new game. Playable battles, party/bag/Pokégear menus, story progression beyond the lab, NPC roaming, decorations, animated terrain, surfing, entering routes and audio remain unfinished. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
|
||||
|
||||
|
||||
@@ -100,3 +100,33 @@ opening scenes. Audit Route 29 callbacks, scripts and unsupported destinations
|
||||
before enabling it. Then test wild encounters and battle controls through the
|
||||
browser, including win, escape, loss and save/reload, and reconcile the roadmap
|
||||
with the verified behavior.
|
||||
|
||||
## Battle integration increment: 2026-09-23 (resumed from Codex)
|
||||
|
||||
Codex committed and pushed `c08d250`, then stopped mid-increment with the
|
||||
keyboard/D-pad battle test failing. Claude Code completed it:
|
||||
|
||||
- Browser battle flows run through the real UI via `tests/battle-fixture.ts`
|
||||
(encounter selection only; production maps stay gated): win with level-up and
|
||||
Smokescreen, escape, whiteout, keyboard/D-pad menus, mid-battle backup restore
|
||||
and unsupported-effect rollback. `tests/battle-world.test.ts` covers the same
|
||||
at world level, plus pending stat experience and whiteout callback rollback.
|
||||
- Saves accept stored stats between the no-stat-exp and full-stat-exp values,
|
||||
since `GiveExperiencePoints` awards stat experience without `CalcMonStats`.
|
||||
- A script warp (e.g. whiteout) validates its destination and commits healing,
|
||||
money loss and destination callbacks together; aborts restore state and map.
|
||||
- Keyboard and on-screen D-pad navigate battle menus (`battle-selected`); A
|
||||
continues messages, B leaves the move list.
|
||||
- Fixed a phone-width layout bug: a long notice sat in the Play button's auto
|
||||
grid column and crushed the status text, pushing the game panel off-screen.
|
||||
Below 720px the notice now has its own row.
|
||||
- Test fixes: the navigation test now presses A through the intro message, and
|
||||
uses a real pointer click on the D-pad (synthetic pointer events cannot be
|
||||
captured).
|
||||
|
||||
Final checks: 82 unit tests with the local ROM (55 passed, 27 skipped without
|
||||
it); 36 Chromium/WebKit browser tests; native and reference builds; release,
|
||||
vendor and reference checks; `git diff --check`. Not committed.
|
||||
|
||||
Next: Route 29 (`24:3`) scripts, catching tutorial and items before enabling it;
|
||||
then capture, switching and PACK in battle.
|
||||
@@ -181,9 +181,14 @@ export function validPartyMon(
|
||||
!integer(mon.hp, 0, mon.stats[0])
|
||||
)
|
||||
return false;
|
||||
// Stored stats must be the ones the game would calculate.
|
||||
const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
|
||||
return stats.every((stat, index) => stat === mon.stats[index]);
|
||||
// GiveExperiencePoints awards stat experience after every victory, but
|
||||
// CalcMonStats only runs on a level-up (or a later PC recalculation).
|
||||
// Preserve legitimate pending gains instead of silently applying them on
|
||||
// load. Stored stats must stay inside the ROM-derived possible range.
|
||||
const minimum = calcStats(data, mon.species, mon.dvs, mon.level);
|
||||
const maximum = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
|
||||
return mon.stats.every((stat, index) =>
|
||||
stat >= minimum[index] && stat <= maximum[index]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
+8
-1
@@ -21,6 +21,8 @@ export class NativeRuntime implements GameRuntime {
|
||||
private onUpdate: (world: GameWorld) => void,
|
||||
private onError: (error: unknown) => void,
|
||||
private player: () => { name: string; avatar: "chris" | "kris" },
|
||||
/** Presentation handles menu navigation; battle rules stay in the world. */
|
||||
private battleInput?: (button: Button) => boolean,
|
||||
) {}
|
||||
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
|
||||
this.dispose();
|
||||
@@ -55,9 +57,14 @@ export class NativeRuntime implements GameRuntime {
|
||||
if (this.pressed.has(button) || !this.world) return;
|
||||
this.pressed.add(button);
|
||||
const world = this.world;
|
||||
if (world.battle && this.battleInput?.(button)) {
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
if (button === "a") world.interact();
|
||||
if (button === "b") world.advanceDialogue();
|
||||
if ((button === "up" || button === "down") && world.dialogue?.choice)
|
||||
if ((button === "up" || button === "down") &&
|
||||
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno"))
|
||||
world.moveCursor(button);
|
||||
if ((button === "start" || button === "select") && !world.busy)
|
||||
world.notice =
|
||||
|
||||
+22
-6
@@ -143,7 +143,13 @@ interface ActiveScript {
|
||||
effect?: ScriptEffect;
|
||||
/** Committed save data for the whole interaction, used for mid-script saves. */
|
||||
committed: NativeSave;
|
||||
rollback: { player: Actor; actors: Map<number, Actor>; spawned: Set<number> };
|
||||
rollback: {
|
||||
player: Actor;
|
||||
actors: Map<number, Actor>;
|
||||
spawned: Set<number>;
|
||||
state: ScriptState;
|
||||
mapId: string;
|
||||
};
|
||||
waitTicks: number;
|
||||
moving: Map<number, { steps: MovementStep[]; index: number; wait: number }>;
|
||||
follow?: { leader: number; follower: number };
|
||||
@@ -644,9 +650,13 @@ export class GameWorld {
|
||||
|
||||
/** Evaluate map callbacks on a copy, so an unsupported callback leaves the
|
||||
* current map untouched. Methods follow the original map setup scripts. */
|
||||
private prepareMap(id: string, method: "warp" | "connection" | "continue") {
|
||||
private prepareMap(
|
||||
id: string,
|
||||
method: "warp" | "connection" | "continue",
|
||||
sourceState = this.state,
|
||||
) {
|
||||
const map = this.assets.map(id);
|
||||
const state = copyScriptState(this.state);
|
||||
const state = copyScriptState(sourceState);
|
||||
const actors = new Map<number, Actor>();
|
||||
if (method !== "continue")
|
||||
for (let event = 0; event < 8; event++) state.events.delete(event);
|
||||
@@ -779,6 +789,8 @@ export class GameWorld {
|
||||
),
|
||||
committed,
|
||||
rollback: {
|
||||
state: this.state,
|
||||
mapId: this.mapId,
|
||||
player: copyActor(this.player),
|
||||
actors: new Map(
|
||||
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
|
||||
@@ -1045,10 +1057,12 @@ export class GameWorld {
|
||||
|
||||
private finishScript() {
|
||||
const script = this.script!;
|
||||
this.state = script.run.state;
|
||||
if (script.warp) {
|
||||
const { map, x, y } = script.warp;
|
||||
const entry = this.prepareMap(map, "warp");
|
||||
if (!this.assets.isPorted(map) || !this.walkable(this.assets.map(map), x, y))
|
||||
throw new Error("Script warp destination is not supported.");
|
||||
// Whiteout healing/money and destination callbacks commit together.
|
||||
const entry = this.prepareMap(map, "warp", script.run.state);
|
||||
this.player = {
|
||||
...this.player,
|
||||
x,
|
||||
@@ -1057,7 +1071,7 @@ export class GameWorld {
|
||||
motion: undefined,
|
||||
};
|
||||
this.applyMap(entry);
|
||||
}
|
||||
} else this.state = script.run.state;
|
||||
this.script = undefined;
|
||||
this.dialogue = undefined;
|
||||
this.emote = undefined;
|
||||
@@ -1071,6 +1085,8 @@ export class GameWorld {
|
||||
const script = this.script!;
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
this.state = script.rollback.state;
|
||||
this.mapId = script.rollback.mapId;
|
||||
this.player = script.rollback.player;
|
||||
this.actors = script.rollback.actors;
|
||||
this.spawned = script.rollback.spawned;
|
||||
|
||||
+47
@@ -7,6 +7,7 @@ import type { Choice, GameWorld } from "./game/world/world";
|
||||
import { drawTile } from "./game/rendering/pixels";
|
||||
import { PokemonData } from "./game/rom/pokemon";
|
||||
import type { Palette } from "./game/world/types";
|
||||
import type { Button } from "./input/types";
|
||||
import { NICKNAME_PATTERN } from "./game/party/pokemon";
|
||||
import { GameSession } from "./app/session";
|
||||
import { InputController } from "./input/controller";
|
||||
@@ -179,6 +180,41 @@ function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, pal
|
||||
const battlePics = { foe: "", player: "" };
|
||||
let battleMenuKey = "";
|
||||
let showMoves = false;
|
||||
let battleCursor = 0;
|
||||
let battleSelectionKey = "";
|
||||
|
||||
function battleButtons() {
|
||||
return [...get("battle-menu").querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
|
||||
}
|
||||
function highlightBattleChoice() {
|
||||
battleButtons().forEach((button, index) => {
|
||||
button.classList.toggle("battle-selected", index === battleCursor);
|
||||
});
|
||||
}
|
||||
/** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */
|
||||
function handleBattleInput(button: Button): boolean {
|
||||
const world = runtime.world;
|
||||
if (!world?.battle) return false;
|
||||
if (button === "b" && showMoves) {
|
||||
showMoves = false;
|
||||
return true;
|
||||
}
|
||||
const buttons = battleButtons();
|
||||
if (!buttons.length) return false;
|
||||
if (button === "a") {
|
||||
buttons[battleCursor]?.click();
|
||||
return true;
|
||||
}
|
||||
if (["up", "down", "left", "right"].includes(button)) {
|
||||
const delta = button === "up" || button === "left" ? -1 : 1;
|
||||
battleCursor = (battleCursor + delta + buttons.length) % buttons.length;
|
||||
if (world.battle.prompt?.kind === "yesno")
|
||||
world.battle.selected = battleCursor === 0 ? 1 : 0;
|
||||
highlightBattleChoice();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** The battle screen: pictures, HUDs, messages and the current choice. */
|
||||
function showBattle(world: GameWorld) {
|
||||
const view = world.battle;
|
||||
@@ -187,6 +223,8 @@ function showBattle(world: GameWorld) {
|
||||
if (!view) {
|
||||
battlePics.foe = battlePics.player = battleMenuKey = "";
|
||||
showMoves = false;
|
||||
battleCursor = 0;
|
||||
battleSelectionKey = "";
|
||||
return;
|
||||
}
|
||||
const data = world.assets.pokemon;
|
||||
@@ -221,6 +259,12 @@ function showBattle(world: GameWorld) {
|
||||
}
|
||||
get("battle-text").textContent = view.message?.text ?? "";
|
||||
const prompt = view.prompt;
|
||||
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}`;
|
||||
if (selectionKey !== battleSelectionKey) {
|
||||
battleCursor = 0;
|
||||
battleSelectionKey = selectionKey;
|
||||
}
|
||||
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
|
||||
const key = JSON.stringify([prompt, view.selected, showMoves, own?.pp, view.message?.prompt]);
|
||||
if (key === battleMenuKey) return;
|
||||
battleMenuKey = key;
|
||||
@@ -293,6 +337,8 @@ function showBattle(world: GameWorld) {
|
||||
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
|
||||
}
|
||||
menu.replaceChildren(...buttons);
|
||||
battleCursor = Math.min(battleCursor, Math.max(0, battleButtons().length - 1));
|
||||
highlightBattleChoice();
|
||||
}
|
||||
function setHp(bar: HTMLElement, hp: number, max: number) {
|
||||
const fraction = max ? hp / max : 0;
|
||||
@@ -348,6 +394,7 @@ const runtime = new NativeRuntime(
|
||||
? "kris"
|
||||
: "chris",
|
||||
}),
|
||||
handleBattleInput,
|
||||
);
|
||||
const session = new GameSession(
|
||||
runtime,
|
||||
|
||||
+7
-1
@@ -448,6 +448,10 @@ dialog h2 {
|
||||
.battle-menu button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.battle-menu button.battle-selected {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: -4px;
|
||||
}
|
||||
.battle-menu button small {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
@@ -806,7 +810,9 @@ dialog dd {
|
||||
.global-bar {
|
||||
min-height: 76px;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas: "play status" "notice save";
|
||||
/* The notice gets its own row: in the button's auto column, a long
|
||||
notice would widen it and crush the status text. */
|
||||
grid-template-areas: "play status" "notice notice" "save save";
|
||||
gap: 3px 9px;
|
||||
}
|
||||
.save-status {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture";
|
||||
|
||||
const romPath = resolve(process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc");
|
||||
|
||||
async function start(page: Page, scenario: BattleScenario = "win") {
|
||||
// Replace only this test's runtime module response. No fixture import or
|
||||
// encounter override exists in the application or production build.
|
||||
await page.route("**/src/game/runtime.ts", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const source = await response.text();
|
||||
const needle = "const world = new GameWorld(assets);";
|
||||
expect(source).toContain(needle);
|
||||
await route.fulfill({ response, body: source.replace(needle,
|
||||
`const { battleFixtureWorld } = await import('/tests/battle-fixture.ts');
|
||||
const world = battleFixtureWorld(assets, ${JSON.stringify(scenario)});`) });
|
||||
});
|
||||
await page.clock.install({ time: new Date("2026-09-22T12:00: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-22T12:05:00"));
|
||||
await page.locator("#play").click();
|
||||
for (const direction of ENCOUNTER_WALK) {
|
||||
const key = `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`;
|
||||
await page.keyboard.down(key);
|
||||
await page.clock.runFor(70);
|
||||
await page.keyboard.up(key);
|
||||
await page.clock.runFor(300);
|
||||
}
|
||||
await expect(page.locator("#battle")).toBeVisible();
|
||||
await expect(page.locator("#battle-foe-name")).toHaveText("PIDGEY");
|
||||
}
|
||||
|
||||
async function exportState(page: Page) {
|
||||
await page.locator("#tab-saves").click();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.locator("#backup").click();
|
||||
const path = (await (await download).path())!;
|
||||
const record = JSON.parse(readFileSync(path, "utf8"));
|
||||
return { path, state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")) };
|
||||
}
|
||||
|
||||
async function finish(page: Page, action: "fight" | "run" = "fight") {
|
||||
const messages = new Set<string>();
|
||||
for (let guard = 0; guard < 100; guard++) {
|
||||
if (await page.locator("#battle").isVisible()) {
|
||||
messages.add(await page.locator("#battle-text").innerText());
|
||||
const menu = page.locator("#battle-menu");
|
||||
const fight = menu.getByRole("button", { name: "FIGHT", exact: true });
|
||||
if (await fight.count()) {
|
||||
if (action === "run") await menu.getByRole("button", { name: "RUN", exact: true }).click();
|
||||
else {
|
||||
await fight.click();
|
||||
await menu.getByRole("button", { name: /TACKLE|TRANSFORM/ }).click();
|
||||
}
|
||||
} else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) {
|
||||
await menu.getByRole("button", { name: "YES", exact: true }).click();
|
||||
} else if (await menu.getByRole("button", { name: /Continue/ }).count()) {
|
||||
await menu.getByRole("button", { name: /Continue/ }).click();
|
||||
} else await page.clock.runFor(1200);
|
||||
} else if (await page.locator("#native-dialogue").isVisible()) {
|
||||
messages.add(await page.locator("#dialogue-text").innerText());
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.clock.runFor(1500);
|
||||
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
|
||||
await page.clock.runFor(1500);
|
||||
} else return [...messages].join("\n");
|
||||
}
|
||||
throw new Error(`Battle did not finish: ${[...messages].join(" | ")}`);
|
||||
}
|
||||
|
||||
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM; only encounter selection is a test fixture."));
|
||||
|
||||
test("battle UI wins, acknowledges level-up stats and saves learned moves", async ({ page }) => {
|
||||
await start(page);
|
||||
const messages = await finish(page);
|
||||
expect(messages).toContain("SMOKESCREEN");
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
const { state } = await exportState(page);
|
||||
expect(state.party[0]).toMatchObject({ species: 155, level: 6 });
|
||||
expect(state.party[0].moves).toContain(108);
|
||||
expect(state.pokedex.seen).toContain(16);
|
||||
expect(state.pokedex.caught).not.toContain(16);
|
||||
});
|
||||
|
||||
test("battle UI escapes without experience and saves a seen species", async ({ page }) => {
|
||||
await start(page);
|
||||
expect(await finish(page, "run")).toContain("Got away safely");
|
||||
const { state } = await exportState(page);
|
||||
expect(state.party[0].exp).toBe(178);
|
||||
expect(state.pokedex.seen).toContain(16);
|
||||
});
|
||||
|
||||
test("keyboard and touch D-pad navigate battle moves, cancel and run", async ({ page }) => {
|
||||
await start(page);
|
||||
const menu = page.locator("#battle-menu");
|
||||
// A continues the intro message through the same keyboard path as menus.
|
||||
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "FIGHT", exact: true }).count()); tick++) {
|
||||
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
|
||||
await page.clock.runFor(1200);
|
||||
}
|
||||
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toHaveClass(/battle-selected/);
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(menu.getByRole("button", { name: /TACKLE/ })).toBeVisible();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(menu.getByRole("button", { name: /LEER/ })).toHaveClass(/battle-selected/);
|
||||
await page.keyboard.press("KeyZ");
|
||||
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible();
|
||||
// A real pointer press on the on-screen D-pad (synthetic events cannot be captured).
|
||||
await page.locator('[data-game-button="down"]').click();
|
||||
await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/);
|
||||
await page.keyboard.press("KeyX");
|
||||
expect(await finish(page, "run")).toContain("Got away safely");
|
||||
});
|
||||
|
||||
test("battle UI whiteout heals the party and halves money at home", async ({ page }) => {
|
||||
await start(page, "loss");
|
||||
expect(await finish(page)).toMatch(/whited\s+out/i);
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
const { state } = await exportState(page);
|
||||
expect(state).toMatchObject({ map: "24:7", x: 3, y: 3, money: 1500 });
|
||||
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
|
||||
expect(state.party[0].pp[0]).toBe(35);
|
||||
});
|
||||
|
||||
test("pausing battle freezes messages and importing its backup restores committed state", async ({ page }) => {
|
||||
await start(page);
|
||||
const { path, state } = await exportState(page);
|
||||
expect(state).toMatchObject({ map: "24:7", x: 6, y: 2, money: 3001 });
|
||||
expect(state.party[0].level).toBe(5);
|
||||
expect(state.pokedex.seen).not.toContain(16);
|
||||
const text = await page.locator("#battle-text").textContent();
|
||||
await page.clock.runFor(5000);
|
||||
expect(await page.locator("#battle-text").textContent()).toBe(text);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.locator("#save-file").setInputFiles(path);
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await expect(page.locator("#battle")).toBeHidden();
|
||||
await page.locator("#play").click();
|
||||
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "6");
|
||||
const restored = await exportState(page);
|
||||
expect(restored.state).toEqual(state);
|
||||
});
|
||||
|
||||
test("an unsupported battle effect closes the UI without persisting partial changes", async ({ page }) => {
|
||||
await start(page, "unsupported");
|
||||
await finish(page);
|
||||
await expect(page.locator("#game-notice")).toContainText("not been ported");
|
||||
const { state } = await exportState(page);
|
||||
expect(state.party[0].pp[0]).toBe(10);
|
||||
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
|
||||
expect(state.pokedex.seen).not.toContain(16);
|
||||
expect(state.money).toBe(3001);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
|
||||
export type BattleScenario = "win" | "loss" | "unsupported";
|
||||
|
||||
/** Test-only encounters on an existing floor: production maps stay gated.
|
||||
* Everything after selection runs through the ROM's real wild-battle script. */
|
||||
export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenario = "win") {
|
||||
assets.grassCollisions = () => [assets.collision(assets.map("24:7"), 3, 2)];
|
||||
assets.grassWildMons = (id) => id === "24:7" ? {
|
||||
rates: [256, 256, 256],
|
||||
slots: [0, 1, 2].map(() => Array.from({ length: 7 }, () => ({ species: 16, level: 3 }))),
|
||||
} : undefined;
|
||||
let seed = 1;
|
||||
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
});
|
||||
const mon = generatePartyMon(assets.pokemon, {
|
||||
species: 155, level: 5, item: 0, otId: world.playerId,
|
||||
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
||||
});
|
||||
mon.exp = 178; // One battle away from level 6 and Smokescreen.
|
||||
if (scenario === "loss") mon.hp = 1;
|
||||
if (scenario === "unsupported") {
|
||||
mon.moves = [144, 0, 0, 0]; // Transform is explicitly refused.
|
||||
mon.pp = [10, 0, 0, 0];
|
||||
}
|
||||
world.state.party.push(mon);
|
||||
world.state.pokedex.seen.add(155);
|
||||
world.state.pokedex.caught.add(155);
|
||||
world.state.money = 3001;
|
||||
return world;
|
||||
}
|
||||
|
||||
export const ENCOUNTER_WALK = ["up", "right", "right", "right", "right"] as const;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
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";
|
||||
|
||||
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const fixture = (scenario: BattleScenario = "win") =>
|
||||
battleFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), scenario);
|
||||
|
||||
function encounter(world: GameWorld) {
|
||||
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
||||
expect(world.battle).toBeDefined();
|
||||
}
|
||||
|
||||
function finish(world: GameWorld, action: "fight" | "run" = "fight") {
|
||||
const messages = new Set<string>();
|
||||
for (let tick = 0; tick < 10000; tick++) {
|
||||
if (world.battle) {
|
||||
const view = world.battle;
|
||||
if (view.message) messages.add(view.message.text);
|
||||
if (view.prompt?.kind === "action") world.battleAction({ action, move: 0 });
|
||||
else if (view.prompt?.kind === "yesno") world.battleChoose(1);
|
||||
else if (view.prompt?.kind === "forget") world.battleChoose(3);
|
||||
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
} else if (world.dialogue) {
|
||||
messages.add(world.dialogue.pages[world.dialogue.index]);
|
||||
world.advanceDialogue();
|
||||
world.tick();
|
||||
} else if (world.busy) world.tick();
|
||||
else return [...messages].join("\n");
|
||||
}
|
||||
throw new Error(`Battle did not settle: ${world.diagnostic}`);
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("world battle integration with the local ROM", () => {
|
||||
it("commits a victory, level-up, move learning and Pokédex sighting", () => {
|
||||
const world = fixture();
|
||||
encounter(world);
|
||||
const before = world.save();
|
||||
expect(before.party[0].level).toBe(5);
|
||||
expect(before.pokedex.seen).toEqual([155]);
|
||||
const messages = finish(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(messages).toContain("SMOKESCREEN");
|
||||
expect(world.state.party[0].level).toBe(6);
|
||||
expect(world.state.party[0].moves).toContain(108);
|
||||
expect(world.state.pokedex.seen.has(16)).toBe(true);
|
||||
expect(world.state.pokedex.caught.has(16)).toBe(false);
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
});
|
||||
|
||||
it("escapes without awarding experience and returns to walking", () => {
|
||||
const world = fixture();
|
||||
encounter(world);
|
||||
expect(finish(world, "run")).toMatch(/Got away safely/);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.party[0].exp).toBe(178);
|
||||
expect(world.state.pokedex.seen.has(16)).toBe(true);
|
||||
expect(world.move("down")).toBe(true);
|
||||
});
|
||||
|
||||
it("restores earned stat experience before the next stat recalculation", () => {
|
||||
const world = fixture();
|
||||
const mon = generatePartyMon(world.assets.pokemon, {
|
||||
species: 155, level: 100, item: 0, otId: world.playerId,
|
||||
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
||||
});
|
||||
mon.moves = [33, 0, 0, 0];
|
||||
mon.pp = [35, 0, 0, 0];
|
||||
world.state.party = [mon];
|
||||
encounter(world);
|
||||
finish(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
const saved = world.save();
|
||||
expect(saved.party[0].statExp.some((value) => value > 0)).toBe(true);
|
||||
expect(saved.party[0].stats).not.toEqual(calcStats(world.assets.pokemon, 155, mon.dvs, 100, saved.party[0].statExp));
|
||||
expect(() => world.restore(saved)).not.toThrow();
|
||||
expect(world.save()).toEqual(saved);
|
||||
const corrupt = structuredClone(saved);
|
||||
corrupt.party[0].stats[1] = 999;
|
||||
expect(() => world.restore(corrupt)).toThrow("Invalid native save");
|
||||
});
|
||||
|
||||
it("whiteout heals the party, halves money and returns home", () => {
|
||||
const world = fixture("loss");
|
||||
encounter(world);
|
||||
const messages = finish(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(messages).toMatch(/whited\s+out/i);
|
||||
expect(world.save()).toMatchObject({ map: "24:7", x: 3, y: 3, money: 1500 });
|
||||
const mon = world.state.party[0];
|
||||
expect(mon.hp).toBe(mon.stats[0]);
|
||||
expect(mon.pp[0]).toBe(35);
|
||||
expect(mon.status).toBe(0);
|
||||
});
|
||||
|
||||
it("a mid-battle save restores the committed party and position", () => {
|
||||
const world = fixture();
|
||||
encounter(world);
|
||||
const committed = world.save();
|
||||
for (let tick = 0; tick < 5000; tick++) {
|
||||
if (world.battle?.prompt?.kind === "action") {
|
||||
world.battleAction({ action: "fight", move: 0 });
|
||||
break;
|
||||
}
|
||||
if (world.battle?.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
}
|
||||
expect(world.save()).toEqual(committed);
|
||||
world.restore(committed);
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(world.save()).toEqual(committed);
|
||||
expect(world.move("right")).toBe(true);
|
||||
});
|
||||
|
||||
it("a failed whiteout map callback cannot commit healing or money loss", () => {
|
||||
const world = fixture("loss");
|
||||
encounter(world);
|
||||
const before = world.save();
|
||||
// A malformed callback models a destination whose logic is not supported.
|
||||
world.assets.map("24:7").callbacks.push({ kind: 5, script: world.assets.reader.bytes.length });
|
||||
finish(world);
|
||||
expect(world.notice).toContain("not been ported");
|
||||
expect(world.save().money).toBe(before.money);
|
||||
expect(world.save().party).toEqual(before.party);
|
||||
expect(world.save().pokedex).toEqual(before.pokedex);
|
||||
});
|
||||
|
||||
it("unsupported effects discard all battle party and Pokédex changes", () => {
|
||||
const world = fixture("unsupported");
|
||||
encounter(world);
|
||||
const before = world.save();
|
||||
finish(world);
|
||||
expect(world.notice).toContain("not been ported");
|
||||
expect(world.save().party).toEqual(before.party);
|
||||
expect(world.save().pokedex).toEqual(before.pokedex);
|
||||
expect(world.save().money).toBe(before.money);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user