509 lines
20 KiB
TypeScript
509 lines
20 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { resolve } from "node:path";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { GameWorld } from "../src/game/world/world";
|
|
import type { Direction } from "../src/game/world/types";
|
|
import { VECTORS } from "../src/game/world/types";
|
|
import { settle } from "./world-driver";
|
|
|
|
const romPath = resolve(
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
|
);
|
|
|
|
test("local maps follow a stairs transition and importing a previous position", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
|
|
// Find a walkable route in the local fixture, then drive the actual UI.
|
|
const world = new GameWorld(
|
|
new CrystalAssets(new Uint8Array(readFileSync(romPath))),
|
|
);
|
|
const target = world.map.warps[0];
|
|
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
|
|
const seen = new Set<string>();
|
|
let path: Direction[] | undefined;
|
|
const directions = [
|
|
["up", 0, -1],
|
|
["down", 0, 1],
|
|
["left", -1, 0],
|
|
["right", 1, 0],
|
|
] as const;
|
|
for (let i = 0; i < queue.length; i++) {
|
|
const node = queue[i];
|
|
const key = `${node.x},${node.y}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
if (node.x === target.x && node.y === target.y) {
|
|
path = node.path;
|
|
break;
|
|
}
|
|
for (const [direction, dx, dy] of directions) {
|
|
const x = node.x + dx,
|
|
y = node.y + dy;
|
|
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
|
|
continue;
|
|
if (
|
|
world.assets.permission(world.assets.collision(world.map, x, y)) !== 0
|
|
)
|
|
continue;
|
|
if (world.objects().some((object) => object.x === x && object.y === y))
|
|
continue;
|
|
queue.push({ x, y, path: [...node.path, direction] });
|
|
}
|
|
}
|
|
expect(path).toBeDefined();
|
|
await page.goto("/");
|
|
await page.locator("#rom-file").setInputFiles(romPath);
|
|
await expect(page.locator("#status")).toContainText("verified");
|
|
await page.locator("#tab-saves").click();
|
|
const downloaded = page.waitForEvent("download");
|
|
await page.locator("#backup").click();
|
|
const backup = await downloaded;
|
|
await page.locator("#play").click();
|
|
for (const direction of path!) {
|
|
const [dx, dy] = VECTORS[direction];
|
|
const stepX = world.x + dx,
|
|
stepY = world.y + dy;
|
|
expect(world.move(direction)).toBe(true);
|
|
settle(world);
|
|
await page.keyboard.down(
|
|
`Arrow${direction[0].toUpperCase()}${direction.slice(1)}`,
|
|
);
|
|
// Release as the step starts, before a warp and its forced exit complete.
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-x",
|
|
String(stepX),
|
|
);
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-y",
|
|
String(stepY),
|
|
);
|
|
await page.keyboard.up(
|
|
`Arrow${direction[0].toUpperCase()}${direction.slice(1)}`,
|
|
);
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-map",
|
|
world.mapId,
|
|
);
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-x",
|
|
String(world.x),
|
|
);
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-y",
|
|
String(world.y),
|
|
);
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-activity",
|
|
"idle",
|
|
);
|
|
}
|
|
await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:6");
|
|
await page.locator("#tab-map").click();
|
|
await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:6");
|
|
await expect(page.locator("#map-location")).toHaveText(
|
|
`${world.map.name} · ${world.map.location}`,
|
|
);
|
|
await page.locator("#tab-saves").click();
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await page.locator("#save-file").setInputFiles((await backup.path())!);
|
|
await expect(page.locator("#status")).toContainText("Backup restored");
|
|
await page.locator("#tab-map").click();
|
|
await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:7");
|
|
await expect(page.locator("#world-map")).toHaveAttribute("data-x", "3");
|
|
await expect(page.locator("#world-map")).toHaveAttribute("data-y", "3");
|
|
});
|
|
|
|
test("Mom's opening scene runs with native keyboard and pointer choices", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
await page.goto("/");
|
|
await page.locator("#rom-file").setInputFiles(romPath);
|
|
await expect(page.locator("#status")).toContainText("verified");
|
|
await page.locator("#play").click();
|
|
const screen = page.locator("#game-screen");
|
|
const step = async (key: string, map: string, x: number, y: number) => {
|
|
await page.keyboard.down(key);
|
|
await expect(screen).toHaveAttribute("data-map", map);
|
|
await expect(screen).toHaveAttribute("data-x", String(x));
|
|
await expect(screen).toHaveAttribute("data-y", String(y));
|
|
await page.keyboard.up(key);
|
|
};
|
|
// Bedroom (3,3) to the stairs, then down toward Mom.
|
|
await step("ArrowUp", "24:7", 3, 2);
|
|
for (const x of [4, 5, 6, 7]) await step("ArrowRight", "24:7", x, 2);
|
|
await step("ArrowUp", "24:7", 7, 1);
|
|
await step("ArrowUp", "24:7", 7, 0);
|
|
await expect(screen).toHaveAttribute("data-map", "24:6");
|
|
await expect(screen).toHaveAttribute("data-y", "1");
|
|
await expect(screen).toHaveAttribute("data-activity", "idle");
|
|
for (const y of [2, 3, 4]) await step("ArrowDown", "24:6", 9, y);
|
|
const dialogue = page.locator("#native-dialogue");
|
|
await expect(dialogue).toBeVisible();
|
|
const seen: string[] = [];
|
|
for (let guard = 0; guard < 60 && (await dialogue.isVisible()); guard++) {
|
|
const text = (await page.locator("#dialogue-text").textContent()) ?? "";
|
|
seen.push(text);
|
|
const choice = page.locator("#dialogue-choice");
|
|
if (await choice.isVisible()) {
|
|
if (await choice.locator("output").count()) {
|
|
// Weekday box: Up selects the next day, X confirms.
|
|
await expect(choice.locator("output")).toHaveText("SUNDAY");
|
|
await page.keyboard.press("ArrowUp");
|
|
await expect(choice.locator("output")).toHaveText("MONDAY");
|
|
await page.keyboard.press("KeyX");
|
|
} else {
|
|
await choice.getByRole("button", { name: "YES" }).click();
|
|
}
|
|
} else await page.keyboard.press("KeyX");
|
|
await expect
|
|
.poll(async () =>
|
|
(await dialogue.isVisible())
|
|
? await page.locator("#dialogue-text").textContent()
|
|
: "closed",
|
|
)
|
|
.not.toBe(text);
|
|
}
|
|
expect(seen).toContain("MONDAY, is it?");
|
|
expect(seen.some((text) => text.includes("received"))).toBe(true);
|
|
await expect(dialogue).toBeHidden();
|
|
// Mom walks back before the scene commits.
|
|
await expect(screen).toHaveAttribute("data-activity", "idle");
|
|
await expect(page.locator("#game-notice")).toHaveText("");
|
|
// The completed scene persists in the exported native save.
|
|
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({
|
|
version: 3,
|
|
map: "24:6",
|
|
scenes: { "24:6": 1 },
|
|
phone: [1],
|
|
});
|
|
expect(save.clock.dst).toBe(true);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("choosing a starter shows its picture, asks for a nickname and saves the party", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
// Start in Elm's lab after his introduction, facing Cyndaquil's ball.
|
|
const bytes = readFileSync(romPath);
|
|
const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes)));
|
|
const save = {
|
|
...world.save(),
|
|
map: "24:5",
|
|
x: 6,
|
|
y: 4,
|
|
direction: "up",
|
|
scenes: { "24:6": 1, "24:5": 1 },
|
|
};
|
|
const backup = testInfo.outputPath("lab.crystal-save.json");
|
|
writeFileSync(
|
|
backup,
|
|
JSON.stringify({
|
|
version: 1,
|
|
romHash: createHash("sha1").update(bytes).digest("hex"),
|
|
coreId: "crystal-native-world-v1",
|
|
savedAt: new Date().toISOString(),
|
|
state: Buffer.from(JSON.stringify(save)).toString("base64"),
|
|
}),
|
|
);
|
|
await page.goto("/");
|
|
await page.locator("#rom-file").setInputFiles(romPath);
|
|
await expect(page.locator("#status")).toContainText("verified");
|
|
await page.locator("#tab-saves").click();
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await page.locator("#save-file").setInputFiles(backup);
|
|
await expect(page.locator("#status")).toContainText("Backup restored");
|
|
await page.locator("#play").click();
|
|
await page.keyboard.press("KeyX");
|
|
const picture = page.locator("#pokepic");
|
|
await expect(picture).toBeVisible();
|
|
await expect(page.locator("#pokepic-canvas")).toHaveAttribute(
|
|
"aria-label",
|
|
"CYNDAQUIL",
|
|
);
|
|
// The picture is drawn from ROM tiles, not left blank.
|
|
expect(
|
|
await page.locator("#pokepic-canvas").evaluate((element) => {
|
|
const canvas = element as HTMLCanvasElement;
|
|
const pixels = canvas.getContext("2d")!.getImageData(0, 0, 56, 56).data;
|
|
const colors = new Set<number>();
|
|
for (let i = 0; i < pixels.length; i += 4)
|
|
colors.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]);
|
|
return colors.size;
|
|
}),
|
|
).toBeGreaterThanOrEqual(3);
|
|
await page.locator("#pokepic-next").click();
|
|
await expect(picture).toBeHidden();
|
|
await expect(page.locator("#dialogue-text")).toContainText("CYNDAQUIL");
|
|
await page.getByRole("button", { name: "YES" }).click();
|
|
const nickname = page.locator("#dialogue-choice input");
|
|
for (let guard = 0; guard < 12 && !(await nickname.count()); guard++) {
|
|
const choice = page.getByRole("button", { name: "YES" });
|
|
if (await choice.count()) await choice.click();
|
|
else await page.keyboard.press("KeyX");
|
|
await page.waitForTimeout(100);
|
|
}
|
|
await expect(page.locator("#dialogue-text")).toHaveText(
|
|
"CYNDAQUIL'S\nNICKNAME?",
|
|
);
|
|
await expect(nickname).toBeFocused();
|
|
await nickname.fill("BLAZE");
|
|
await nickname.press("Enter");
|
|
const screen = page.locator("#game-screen");
|
|
for (let guard = 0; guard < 30; guard++) {
|
|
if ((await screen.getAttribute("data-activity")) === "idle") break;
|
|
if (await page.locator("#native-dialogue").isVisible())
|
|
await page.keyboard.press("KeyX");
|
|
await page.waitForTimeout(120);
|
|
}
|
|
await expect(screen).toHaveAttribute("data-activity", "idle");
|
|
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 saved = JSON.parse(
|
|
Buffer.from(record.state, "base64").toString("utf8"),
|
|
);
|
|
expect(saved.party).toHaveLength(1);
|
|
expect(saved.party[0]).toMatchObject({
|
|
species: 155,
|
|
nickname: "BLAZE",
|
|
level: 5,
|
|
});
|
|
expect(saved.scenes["24:5"]).toBe(5);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("a home exit mat survives save/reload and waits for the exit direction", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
|
|
const bytes = readFileSync(romPath);
|
|
const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes)));
|
|
world.restore({
|
|
...world.save(),
|
|
map: "24:6",
|
|
x: 6,
|
|
y: 6,
|
|
scenes: { "24:6": 1 },
|
|
});
|
|
const backup = testInfo.outputPath("home.crystal-save.json");
|
|
writeFileSync(
|
|
backup,
|
|
JSON.stringify({
|
|
version: 1,
|
|
romHash: createHash("sha1").update(bytes).digest("hex"),
|
|
coreId: "crystal-native-world-v1",
|
|
savedAt: new Date().toISOString(),
|
|
state: Buffer.from(JSON.stringify(world.save())).toString("base64"),
|
|
}),
|
|
);
|
|
await page.goto("/");
|
|
await page.locator("#rom-file").setInputFiles(romPath);
|
|
await expect(page.locator("#status")).toContainText("verified");
|
|
await page.locator("#tab-saves").click();
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await page.locator("#save-file").setInputFiles(backup);
|
|
await expect(page.locator("#status")).toContainText("Backup restored");
|
|
await page.locator("#play").click();
|
|
const screen = page.locator("#game-screen");
|
|
await page.keyboard.down("ArrowDown");
|
|
await expect(screen).toHaveAttribute("data-y", "7");
|
|
await page.keyboard.up("ArrowDown");
|
|
await expect(screen).toHaveAttribute("data-activity", "idle");
|
|
await expect(screen).toHaveAttribute("data-map", "24:6");
|
|
await page.locator("#tab-saves").click();
|
|
await page.locator("#save").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 expect(screen).toHaveAttribute("data-map", "24:6");
|
|
await expect(screen).toHaveAttribute("data-y", "7");
|
|
await page.locator("#play").click();
|
|
// Walking back off the carpet is allowed and does not leave the house.
|
|
await page.keyboard.down("ArrowUp");
|
|
await expect(screen).toHaveAttribute("data-y", "6");
|
|
await page.keyboard.up("ArrowUp");
|
|
await expect(screen).toHaveAttribute("data-activity", "idle");
|
|
await expect(screen).toHaveAttribute("data-map", "24:6");
|
|
await page.keyboard.down("ArrowDown");
|
|
await expect(screen).toHaveAttribute("data-y", "7");
|
|
await page.keyboard.up("ArrowDown");
|
|
await expect(screen).toHaveAttribute("data-activity", "idle");
|
|
await page.keyboard.down("ArrowDown");
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector("#game-screen")?.getAttribute("data-map") ===
|
|
"24:4",
|
|
);
|
|
await page.keyboard.up("ArrowDown");
|
|
await expect(screen).toHaveAttribute("data-x", "13");
|
|
await expect(screen).toHaveAttribute("data-y", "6");
|
|
await expect(screen).toHaveAttribute("data-activity", "idle");
|
|
await expect(page.locator("#game-notice")).toHaveText("");
|
|
});
|
|
|
|
test("development server does not serve ROMs or the private reference checkout", async ({
|
|
request,
|
|
}) => {
|
|
for (const path of [
|
|
"/Pokemon%20-%20Crystal%20Version%20(UE)%20(V1.1)%20%5BC%5D%5B!%5D.gbc",
|
|
"/.reference/pokecrystal/README.md",
|
|
]) {
|
|
const response = await request.get(path);
|
|
expect([403, 404]).toContain(response.status());
|
|
}
|
|
});
|
|
|
|
test("native player loads the actual ROM without loading an emulator and persists movement", async ({
|
|
page,
|
|
context,
|
|
}, testInfo) => {
|
|
test.skip(
|
|
!existsSync(romPath),
|
|
"Requires a local user-supplied ROM; never downloaded or committed.",
|
|
);
|
|
const requests: string[] = [],
|
|
errors: string[] = [];
|
|
page.on("request", (request) => requests.push(request.url()));
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
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 expect(page.locator("#status")).toContainText("verified");
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-map",
|
|
"24:7",
|
|
);
|
|
await expect(page.locator("#minimap")).toBeVisible();
|
|
await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:7");
|
|
await page.locator("#play").click();
|
|
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3");
|
|
await page.keyboard.down("ArrowUp");
|
|
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
|
|
await page.keyboard.up("ArrowUp");
|
|
await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2");
|
|
// A is ignored mid-step, as in the original; wait for the step to end.
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-activity",
|
|
"idle",
|
|
);
|
|
await page.keyboard.press("KeyX");
|
|
// The radio's lines hold for `pause 45` each and advance by themselves.
|
|
await expect(page.locator("#native-dialogue")).toBeVisible();
|
|
await expect(page.locator("#dialogue-text")).toContainText("TALK!");
|
|
await expect(page.locator("#dialogue-next")).toBeHidden();
|
|
await expect(page.locator("#native-dialogue")).toBeHidden({
|
|
timeout: 10_000,
|
|
});
|
|
await page.locator("#play").click();
|
|
await expect(page.locator("#save-status")).toContainText("Session saved");
|
|
await page.locator("#tab-saves").click();
|
|
const exported = page.waitForEvent("download");
|
|
await page.locator("#backup").click();
|
|
const backup = await exported;
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await page.locator("#save-file").setInputFiles((await backup.path())!);
|
|
await expect(page.locator("#status")).toContainText("Backup restored");
|
|
await page.reload();
|
|
await page.locator("#rom-file").setInputFiles(romPath);
|
|
await expect(page.locator("#status")).toContainText("saved session");
|
|
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
|
|
await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2");
|
|
await page.locator("#play").click();
|
|
await page.keyboard.press("KeyX");
|
|
await expect(page.locator("#dialogue-text")).toContainText("POKéMON");
|
|
await expect(page.locator("#native-dialogue")).toBeHidden({ timeout: 5_000 });
|
|
// Opening any menu pauses and releases game input, even while a key is held.
|
|
await page.keyboard.down("ArrowUp");
|
|
await page.locator("#tab-map").click();
|
|
await expect(page.locator("#power-label")).toHaveText("PAUSED");
|
|
await expect(page.locator("#world-map")).toHaveAttribute("data-y", "2");
|
|
await expect(page.locator("#map-empty")).toBeHidden();
|
|
await expect(page.locator('[data-game-button="up"]')).toHaveAttribute(
|
|
"aria-pressed",
|
|
"false",
|
|
);
|
|
await page.keyboard.up("ArrowUp");
|
|
await page.locator("#tab-game").click();
|
|
await expect(page.locator("#pause-overlay")).toBeVisible();
|
|
for (const [width, height] of [
|
|
[1440, 1000],
|
|
[1024, 900],
|
|
[390, 844],
|
|
[320, 740],
|
|
[320, 568],
|
|
[844, 390],
|
|
]) {
|
|
await page.setViewportSize({ width, height });
|
|
await page.evaluate(() => scrollTo(0, 0));
|
|
expect(
|
|
await page.evaluate(
|
|
() => document.documentElement.scrollWidth <= innerWidth,
|
|
),
|
|
).toBe(true);
|
|
if (width <= 390 || height <= 390) {
|
|
const box = (await page.locator(".console").boundingBox())!;
|
|
expect(box.y + box.height).toBeLessThanOrEqual(height);
|
|
const toolbar = (await page.locator(".global-bar").boundingBox())!;
|
|
expect(toolbar.y + toolbar.height).toBeLessThanOrEqual(height);
|
|
}
|
|
// Responsive native rendering draws a different area, not a stretched GB frame.
|
|
await expect
|
|
.poll(async () =>
|
|
page.locator("#game-screen").evaluate((el) => {
|
|
const canvas = el as HTMLCanvasElement;
|
|
const bounds = canvas.getBoundingClientRect();
|
|
return Math.abs(
|
|
canvas.width / canvas.height - bounds.width / bounds.height,
|
|
);
|
|
}),
|
|
)
|
|
.toBeLessThan(0.03);
|
|
if (width >= 1024) {
|
|
expect(
|
|
await page
|
|
.locator("#game-screen")
|
|
.evaluate((el) => (el as HTMLCanvasElement).width),
|
|
).toBeGreaterThan(160);
|
|
}
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.screenshot({
|
|
path: testInfo.outputPath("native-local-rom.png"),
|
|
fullPage: true,
|
|
});
|
|
const other = await context.newPage();
|
|
await other.goto("/");
|
|
await other.locator("#rom-file").setInputFiles(romPath);
|
|
await expect(other.locator("#status")).toContainText("another tab");
|
|
await other.close();
|
|
expect(
|
|
requests.some((url) => /binjgb|\.wasm|\/src\/emulator\//.test(url)),
|
|
).toBe(false);
|
|
expect(requests.every((url) => new URL(url).hostname === "127.0.0.1")).toBe(
|
|
true,
|
|
);
|
|
expect(errors).toEqual([]);
|
|
});
|