POKéGEAR in the start menu runs the clock, Town Map and phone cards with the original joypad rules. Calls to Elm and Mom run their callee scripts, with the out-of-area and out-of-hours cases and HangUp's corrected timing. Mom saves a quarter of trainer prizes once asked, talking to her at home opens BankOfMom (GET, SAVE, CHANGE and the DST checks) with its digit amount box, and MomTriesToBuySomething buys items and dolls with her savings. Saves gain Mom's money, her next item and its trigger balance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3456aa4e11
commit
209778cabe
29 files changed
+2726
-47
No files matched your search
@@ -11,7 +11,7 @@ test.beforeEach(() =>
|
||||
|
||||
async function start(
|
||||
page: Page,
|
||||
room: boolean | "poster" | "center" | "candy" = false,
|
||||
room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom" = false,
|
||||
) {
|
||||
await page.route("**/src/game/runtime.ts", async (route) => {
|
||||
const response = await route.fetch(),
|
||||
@@ -295,3 +295,95 @@ test("a Rare Candy levels up and evolves a Pokémon through the pack", async ({
|
||||
expect([state.party[0].species, state.party[0].level]).toEqual([156, 14]);
|
||||
expect(state.bag.items).toEqual([{ item: 18, quantity: 5 }]);
|
||||
});
|
||||
|
||||
test("the Pokégear shows its cards and calls Elm", async ({ page }) => {
|
||||
await start(page, "pokegear");
|
||||
const text = page.locator("#dialogue-text"),
|
||||
choices = page.locator("#dialogue-choice"),
|
||||
gear = page.locator("#pokegear"),
|
||||
card = page.locator("#pokegear-card");
|
||||
await page.keyboard.press("Enter");
|
||||
await choices.getByRole("option", { name: /^POKéGEAR/ }).click();
|
||||
await expect(gear).toBeVisible();
|
||||
await expect(gear.locator('[aria-current="true"]')).toHaveText("CLOCK");
|
||||
await expect(card).toContainText("TUESDAY");
|
||||
await expect(card).toContainText("12:00 PM");
|
||||
await expect(text).toHaveText("Press any button to exit.");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(card).toContainText("▶ NEW BARK TOWN");
|
||||
await expect(card.locator("canvas")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Town Map: NEW BARK TOWN, you are here",
|
||||
);
|
||||
await expect(page.locator("#native-dialogue")).toBeHidden();
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(card).toContainText("▶ ROUTE 29");
|
||||
// The on-screen controls drive the Pokégear too.
|
||||
const right = page.locator('[data-game-button="right"]');
|
||||
await right.scrollIntoViewIfNeeded();
|
||||
const bounds = (await right.boundingBox())!;
|
||||
await page.mouse.move(
|
||||
bounds.x + bounds.width / 2,
|
||||
bounds.y + bounds.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
await page.locator("#game-screen").focus();
|
||||
await expect(card.getByRole("list", { name: "Phone numbers" })).toContainText(
|
||||
"PROF.ELM:",
|
||||
);
|
||||
await expect(text).toHaveText(/Whom do you want\s+to call\?/);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(card).toContainText("CALL");
|
||||
await expect(card).toContainText("CANCEL");
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(text).toHaveText("……");
|
||||
await page.clock.runFor(1000);
|
||||
await expect(text).toHaveText("Hello, CHRIS?");
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(text).toHaveText("Click!");
|
||||
await page.clock.runFor(3000);
|
||||
await expect(text).toHaveText(/Whom do you want\s+to call\?/);
|
||||
await page.keyboard.press("KeyZ");
|
||||
await expect(gear).toBeHidden();
|
||||
await choices.getByRole("option", { name: /^EXIT/ }).click();
|
||||
await page.clock.runFor(500);
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
});
|
||||
|
||||
test("Mom keeps the player's money with the digit box", async ({ page }) => {
|
||||
await start(page, "mom");
|
||||
const text = page.locator("#dialogue-text"),
|
||||
choices = page.locator("#dialogue-choice"),
|
||||
box = choices.locator(".mom-bank");
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(text).toHaveText(/Hi! Welcome home!/);
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(text).toHaveText(/Or is this about\s+your money\?/);
|
||||
await choices.getByRole("button", { name: "YES" }).click();
|
||||
await expect(text).toHaveText(/What do you want\s+to do\?/);
|
||||
await choices.getByRole("option", { name: "SAVE" }).click();
|
||||
await expect(text).toHaveText(/How much do you\s+want to save\?/);
|
||||
await expect(box).toContainText("SAVED¥2300");
|
||||
await expect(box).toContainText("HELD¥3000");
|
||||
await expect(box).toContainText("DEPOSIT¥000000");
|
||||
await expect(box.locator(".mom-bank-cursor")).toHaveCount(1);
|
||||
// The keyboard moves to the thousands and raises it; the on-screen
|
||||
// buttons raise it again.
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowLeft");
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(box).toContainText("DEPOSIT¥001000");
|
||||
await choices.getByRole("button", { name: "Increase digit" }).click();
|
||||
await expect(box).toContainText("DEPOSIT¥002000");
|
||||
await page.locator("#game-screen").focus();
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(text).toHaveText(/Your money's safe\s+here! Get going!/);
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(page.locator("#native-dialogue")).toBeHidden();
|
||||
const { state } = await backup(page);
|
||||
expect([state.money, state.momsMoney]).toEqual([1000, 4300]);
|
||||
});
|
||||
@@ -5,10 +5,13 @@ import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
|
||||
/** `room` places the player at the bedroom PC or poster, "center" at
|
||||
* Cherrygrove's Pokémon Center PC with two Pokémon ("candy": the first at
|
||||
* level 13, with a Rare Candy); otherwise outdoors. */
|
||||
* level 13, with a Rare Candy); otherwise outdoors ("pokegear": with the
|
||||
* Pokégear, its map and phone cards, and Mom's and Elm's numbers). "mom"
|
||||
* faces Mom in the living room after the Mystery Egg, with her bank open
|
||||
* and ¥2300 saved. */
|
||||
export function foundationFixture(
|
||||
assets: CrystalAssets,
|
||||
room: boolean | "poster" | "center" | "candy",
|
||||
room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom",
|
||||
) {
|
||||
const world = new GameWorld(
|
||||
assets,
|
||||
@@ -32,21 +35,50 @@ export function foundationFixture(
|
||||
world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION
|
||||
if (room === "candy") world.state.bag.items.push({ item: 0x20, quantity: 1 });
|
||||
}
|
||||
if (room === "pokegear" || room === "mom") {
|
||||
world.state.party = [
|
||||
generatePartyMon(assets.pokemon, {
|
||||
species: 155,
|
||||
level: 8,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x9abc,
|
||||
caughtData: 0,
|
||||
}),
|
||||
];
|
||||
for (const flag of [OW.ENGINE_MAP_CARD, OW.ENGINE_PHONE_CARD, 4])
|
||||
world.state.flags.add(flag); // 4: ENGINE_POKEGEAR
|
||||
world.state.phone = [OW.PHONECONTACT_MOM, OW.PHONECONTACT_ELM];
|
||||
world.state.events.add(26); // EVENT_GOT_A_POKEMON_FROM_ELM
|
||||
// Walking outside clears the bedroom's temporary events.
|
||||
for (let event = 0; event < 8; event++) world.state.events.delete(event);
|
||||
}
|
||||
if (room === "mom") {
|
||||
// EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON, EVENT_GAVE_MYSTERY_EGG_TO_ELM
|
||||
// and EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST.
|
||||
for (const event of [30, 31, 64]) world.state.events.add(event);
|
||||
world.state.flags.add(OW.ENGINE_MOM_ACTIVE);
|
||||
world.state.scenes["24:6"] = 1;
|
||||
world.state.momsMoney = 2300;
|
||||
}
|
||||
const save = world.save();
|
||||
const [map, x, y] =
|
||||
room === "center" || room === "candy"
|
||||
? ["26:5", 9, 2]
|
||||
: room === "poster"
|
||||
? ["24:7", 6, 1]
|
||||
: room
|
||||
? ["24:7", 2, 2]
|
||||
: ["24:4", 9, 6];
|
||||
: room === "mom"
|
||||
? ["24:6", 7, 3]
|
||||
: room && room !== "pokegear"
|
||||
? ["24:7", 2, 2]
|
||||
: ["24:4", 9, 6];
|
||||
world.restore({
|
||||
...save,
|
||||
map,
|
||||
x,
|
||||
y,
|
||||
direction: "up",
|
||||
direction: room === "mom" ? "down" : "up",
|
||||
objects: [],
|
||||
});
|
||||
return world;
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { ENGINE_FLAGS } from "../src/game/rom/profile";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
import {
|
||||
shiftClockHour,
|
||||
type MomBankButton,
|
||||
type MomBankView,
|
||||
} from "../src/game/scripts/mom";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const PLAYERS_HOUSE_1F = "24:6",
|
||||
CYNDAQUIL = 155;
|
||||
const EVENT_GOT_A_POKEMON_FROM_ELM = 26,
|
||||
EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30,
|
||||
EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31,
|
||||
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64,
|
||||
EVENT_FIRST_TIME_BANKING_WITH_MOM = 118;
|
||||
const GET = 0,
|
||||
SAVE = 1,
|
||||
CHANGE = 2;
|
||||
|
||||
describe("DSTChecks' clock shift", () => {
|
||||
it("moves the clock an hour, carrying across the half-day offset limit", () => {
|
||||
const clock = { minuteOffset: 30, weekdayOffset: 2, dst: false };
|
||||
shiftClockHour(clock, 1);
|
||||
expect(clock).toEqual({ minuteOffset: 90, weekdayOffset: 2, dst: false });
|
||||
clock.minuteOffset = 700;
|
||||
shiftClockHour(clock, 1);
|
||||
// 760 minutes ahead is a day ahead less 680 minutes.
|
||||
expect(clock).toMatchObject({ minuteOffset: -680, weekdayOffset: 3 });
|
||||
shiftClockHour(clock, -1);
|
||||
expect(clock).toMatchObject({
|
||||
minuteOffset: -740 + 1440,
|
||||
weekdayOffset: 2,
|
||||
});
|
||||
clock.minuteOffset = -700;
|
||||
clock.weekdayOffset = 0;
|
||||
shiftClockHour(clock, -1);
|
||||
expect(clock).toMatchObject({ minuteOffset: 680, weekdayOffset: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("BankOfMom", () => {
|
||||
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
/** The player faces Mom in the living room on a Friday, after the
|
||||
* Mystery Egg was delivered. */
|
||||
function fixture(time = "2026-09-25T12:00:00") {
|
||||
const world = new GameWorld(
|
||||
assets(),
|
||||
() => new Date(time),
|
||||
() => 0x42,
|
||||
);
|
||||
world.state.party = [
|
||||
generatePartyMon(world.assets.pokemon, {
|
||||
species: CYNDAQUIL,
|
||||
level: 8,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x9abc,
|
||||
caughtData: 0,
|
||||
}),
|
||||
];
|
||||
world.state.flags.add(ENGINE_FLAGS.pokegear);
|
||||
for (const event of [
|
||||
EVENT_GOT_A_POKEMON_FROM_ELM,
|
||||
EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON,
|
||||
EVENT_GAVE_MYSTERY_EGG_TO_ELM,
|
||||
])
|
||||
world.state.events.add(event);
|
||||
world.state.scenes[PLAYERS_HOUSE_1F] = 1;
|
||||
world.state.money = 3000;
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: PLAYERS_HOUSE_1F,
|
||||
x: 7,
|
||||
y: 3,
|
||||
direction: "down",
|
||||
objects: [],
|
||||
});
|
||||
return world;
|
||||
}
|
||||
/** A world whose bank is already open, as after the first visit. */
|
||||
function banking(time?: string) {
|
||||
const world = fixture(time);
|
||||
world.state.flags.add(OW.ENGINE_MOM_ACTIVE);
|
||||
world.state.events.add(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST);
|
||||
world.state.momsMoney = 2300;
|
||||
return world;
|
||||
}
|
||||
/** Acknowledge pages up to the next choice; returns the pages seen. */
|
||||
function until(world: GameWorld) {
|
||||
const pages: string[] = [];
|
||||
for (let tick = 0; tick < 2000; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (!dialogue) {
|
||||
if (!world.busy) return pages;
|
||||
world.tick();
|
||||
continue;
|
||||
}
|
||||
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||
if (page && pages.at(-1) !== page) pages.push(page);
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice) return pages;
|
||||
if (last && dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
}
|
||||
throw new Error("No choice appeared.");
|
||||
}
|
||||
function talk(world: GameWorld) {
|
||||
world.interact();
|
||||
return until(world);
|
||||
}
|
||||
function answer(world: GameWorld, value: number) {
|
||||
world.choose(value);
|
||||
return until(world);
|
||||
}
|
||||
function view(world: GameWorld): MomBankView {
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "mombank")
|
||||
throw new Error(`Expected Mom's amount box, got ${choice?.kind}`);
|
||||
return choice.view;
|
||||
}
|
||||
function press(world: GameWorld, ...buttons: MomBankButton[]) {
|
||||
let pages: string[] = [];
|
||||
for (const button of buttons) {
|
||||
if (button === "a") world.interact();
|
||||
else if (button === "b") world.advanceDialogue();
|
||||
else world.moveCursor(button);
|
||||
pages = until(world);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
/** Open the menu and choose GET, SAVE or CHANGE. */
|
||||
function menu(world: GameWorld, item: number) {
|
||||
expect(talk(world)).toEqual([
|
||||
"Hi! Welcome home! You're trying very hard, I see.",
|
||||
"I've kept your room tidy.",
|
||||
"Or is this about your money?",
|
||||
]);
|
||||
const pages = answer(world, 1);
|
||||
expect(pages).toEqual(["What do you want to do?"]);
|
||||
const choice = world.dialogue?.choice;
|
||||
expect(choice).toMatchObject({
|
||||
kind: "menu",
|
||||
items: ["GET", "SAVE", "CHANGE", "CANCEL"],
|
||||
selected: 0,
|
||||
});
|
||||
return answer(world, item);
|
||||
}
|
||||
/** Close the last text and check the interaction ended cleanly. */
|
||||
function finish(world: GameWorld) {
|
||||
expect(until(world)).toEqual([]);
|
||||
settle(world);
|
||||
expect(world.busy).toBe(false);
|
||||
expect(world.diagnostic).toBe("");
|
||||
}
|
||||
/** Key in an amount digit by digit, from the leftmost of six digits. */
|
||||
function enter(world: GameWorld, amount: number) {
|
||||
const digits = String(amount).padStart(6, "0");
|
||||
press(world, "left", "left", "left", "left", "left");
|
||||
for (const digit of digits) {
|
||||
for (let i = 0; i < Number(digit); i++) press(world, "up");
|
||||
press(world, "right");
|
||||
}
|
||||
expect(view(world).amount).toBe(amount);
|
||||
}
|
||||
|
||||
it("offers to save money the first time, then only cheers the player on", () => {
|
||||
const world = fixture();
|
||||
const offer = talk(world);
|
||||
expect(offer[0]).toBe("Wow, that's a cute POKéMON.");
|
||||
expect(offer.at(-1)).toBe("Do you want me to save your money?");
|
||||
expect(world.dialogue?.choice?.kind).toBe("yesno");
|
||||
// Saves made during the visit keep the state from before it.
|
||||
expect(world.save().events).not.toContain(
|
||||
EVENT_FIRST_TIME_BANKING_WITH_MOM,
|
||||
);
|
||||
world.choose(1);
|
||||
const pages = [...until(world)];
|
||||
expect(pages.slice(0, 3)).toEqual([
|
||||
"OK, I'll take care of your money.",
|
||||
"………………",
|
||||
"Be careful.",
|
||||
]);
|
||||
expect(pages.at(-1)).toBe("Now, go on!");
|
||||
finish(world);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
|
||||
expect(world.state.events.has(EVENT_FIRST_TIME_BANKING_WITH_MOM)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST),
|
||||
).toBe(true);
|
||||
|
||||
// Until New Bark Town's callback clears it, Mom only cheers.
|
||||
const cheer = talk(world);
|
||||
expect(cheer.join(" ")).toMatch(/behind you/i);
|
||||
finish(world);
|
||||
});
|
||||
|
||||
it("does not start saving when the player declines", () => {
|
||||
const world = fixture();
|
||||
talk(world);
|
||||
const pages = answer(world, 0);
|
||||
expect(pages[0]).toBe("Be careful.");
|
||||
finish(world);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("deposits money, refusing more than the player holds", () => {
|
||||
const world = banking();
|
||||
expect(menu(world, SAVE)).toEqual(["How much do you want to save?"]);
|
||||
expect(view(world)).toEqual({
|
||||
labels: ["SAVED", "HELD", "DEPOSIT"],
|
||||
saved: 2300,
|
||||
held: 3000,
|
||||
amount: 0,
|
||||
cursor: 5,
|
||||
});
|
||||
enter(world, 5000);
|
||||
expect(press(world, "a")).toEqual([
|
||||
"You don't have that much.",
|
||||
"How much do you want to save?",
|
||||
]);
|
||||
// .StoreMoney starts again from zero on the last digit.
|
||||
expect(view(world)).toMatchObject({ amount: 0, cursor: 5 });
|
||||
enter(world, 1000);
|
||||
expect(press(world, "a")).toEqual(["Your money's safe here! Get going!"]);
|
||||
finish(world);
|
||||
expect(world.state.money).toBe(2000);
|
||||
expect(world.state.momsMoney).toBe(3300);
|
||||
});
|
||||
|
||||
it("withdraws money, refusing more than Mom saved", () => {
|
||||
const world = banking();
|
||||
expect(menu(world, GET)).toEqual(["How much do you want to take?"]);
|
||||
expect(view(world).labels[2]).toBe("WITHDRAW");
|
||||
enter(world, 2301);
|
||||
expect(press(world, "a")).toEqual([
|
||||
"You haven't saved that much.",
|
||||
"How much do you want to take?",
|
||||
]);
|
||||
enter(world, 2300);
|
||||
expect(press(world, "a")).toEqual(["CHRIS, don't give up!"]);
|
||||
finish(world);
|
||||
expect(world.state.money).toBe(5300);
|
||||
expect(world.state.momsMoney).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses amounts that would pass MAX_MONEY on either side", () => {
|
||||
const world = banking();
|
||||
world.state.money = 999_000;
|
||||
menu(world, GET);
|
||||
enter(world, 1000);
|
||||
expect(press(world, "a")).toEqual([
|
||||
"You can't take that much.",
|
||||
"How much do you want to take?",
|
||||
]);
|
||||
enter(world, 999);
|
||||
press(world, "a");
|
||||
finish(world);
|
||||
expect(world.state.money).toBe(999_999);
|
||||
expect(world.state.momsMoney).toBe(1301);
|
||||
|
||||
world.state.momsMoney = 999_000;
|
||||
menu(world, SAVE);
|
||||
enter(world, 1000);
|
||||
expect(press(world, "a")).toEqual([
|
||||
"You can't save that much.",
|
||||
"How much do you want to save?",
|
||||
]);
|
||||
press(world, "b");
|
||||
finish(world);
|
||||
expect(world.state.momsMoney).toBe(999_000);
|
||||
});
|
||||
|
||||
it("steps digits through GiveMoney and TakeMoney's limits", () => {
|
||||
const world = banking();
|
||||
menu(world, SAVE);
|
||||
// The cursor stops at both ends.
|
||||
press(world, "right");
|
||||
expect(view(world).cursor).toBe(5);
|
||||
press(world, "down");
|
||||
expect(view(world).amount).toBe(0);
|
||||
press(world, "up", "up", "left", "up");
|
||||
expect(view(world)).toMatchObject({ amount: 12, cursor: 4 });
|
||||
for (let i = 0; i < 6; i++) press(world, "left");
|
||||
expect(view(world).cursor).toBe(0);
|
||||
for (let i = 0; i < 10; i++) press(world, "up");
|
||||
expect(view(world).amount).toBe(999_999);
|
||||
press(world, "down");
|
||||
expect(view(world).amount).toBe(899_999);
|
||||
for (let i = 0; i < 9; i++) press(world, "down");
|
||||
expect(view(world).amount).toBe(0);
|
||||
// Start and Select are not read.
|
||||
world.choose("start");
|
||||
expect(view(world).amount).toBe(0);
|
||||
// A with nothing entered cancels.
|
||||
expect(press(world, "a")).toEqual(["Just do what you can."]);
|
||||
finish(world);
|
||||
expect(world.state.momsMoney).toBe(2300);
|
||||
});
|
||||
|
||||
it("cancels with B, from the box and from the menu", () => {
|
||||
const world = banking();
|
||||
menu(world, SAVE);
|
||||
press(world, "up");
|
||||
expect(press(world, "b")).toEqual(["Just do what you can."]);
|
||||
finish(world);
|
||||
expect(world.state.money).toBe(3000);
|
||||
menu(world, -1);
|
||||
finish(world);
|
||||
});
|
||||
|
||||
it("starts or stops her savings with CHANGE", () => {
|
||||
const world = banking();
|
||||
expect(menu(world, CHANGE)).toEqual(["Do you want to save some money?"]);
|
||||
expect(answer(world, 1)).toEqual([
|
||||
"OK, I'll save your money. Trust me!",
|
||||
"CHRIS, stick with it!",
|
||||
]);
|
||||
finish(world);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
|
||||
menu(world, CHANGE);
|
||||
expect(answer(world, 0)).toEqual(["Just do what you can."]);
|
||||
finish(world);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
|
||||
});
|
||||
|
||||
it("asks about Daylight Saving Time when it is not about money", () => {
|
||||
const world = banking();
|
||||
talk(world);
|
||||
expect(answer(world, 0)).toEqual([
|
||||
"Do you want to switch to Daylight Saving Time?",
|
||||
]);
|
||||
expect(answer(world, 1)).toEqual([
|
||||
"I set the clock forward by one hour.",
|
||||
"Just do what you can.",
|
||||
]);
|
||||
finish(world);
|
||||
expect(world.state.clock.dst).toBe(true);
|
||||
expect(world.clock().hours).toBe(13);
|
||||
|
||||
talk(world);
|
||||
expect(answer(world, 0)).toEqual(["Is Daylight Saving Time over?"]);
|
||||
expect(answer(world, 1)).toEqual([
|
||||
"I put the clock back one hour.",
|
||||
"Just do what you can.",
|
||||
]);
|
||||
finish(world);
|
||||
expect(world.state.clock.dst).toBe(false);
|
||||
expect(world.clock().hours).toBe(12);
|
||||
|
||||
// Declining leaves the clock alone.
|
||||
talk(world);
|
||||
answer(world, 0);
|
||||
expect(answer(world, 0)).toEqual(["Just do what you can."]);
|
||||
finish(world);
|
||||
expect(world.state.clock).toMatchObject({ dst: false, minuteOffset: 0 });
|
||||
});
|
||||
|
||||
it("will not change the clock within an hour of midnight", () => {
|
||||
const world = banking("2026-09-25T23:30:00");
|
||||
talk(world);
|
||||
expect(answer(world, 0)).toEqual([
|
||||
"Do you want to adjust your clock",
|
||||
"for Daylight Saving Time?",
|
||||
]);
|
||||
expect(answer(world, 1)).toEqual([
|
||||
"I lost the in- struction booklet for the POKéGEAR.",
|
||||
"Come back again in a while.",
|
||||
"Just do what you can.",
|
||||
]);
|
||||
finish(world);
|
||||
expect(world.state.clock).toMatchObject({ dst: false, minuteOffset: 0 });
|
||||
|
||||
const dst = banking("2026-09-26T00:30:00");
|
||||
dst.state.clock.dst = true;
|
||||
talk(dst);
|
||||
expect(answer(dst, 0)).toEqual([
|
||||
"Do you want to adjust your clock",
|
||||
"for Daylight Saving Time?",
|
||||
]);
|
||||
expect(answer(dst, 0)).toEqual(["Just do what you can."]);
|
||||
finish(dst);
|
||||
expect(dst.state.clock.dst).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export class MovementAssets extends CrystalAssets {
|
||||
tileset: 1,
|
||||
environment: 1,
|
||||
paletteMode: 1,
|
||||
phoneService: 0,
|
||||
blocks: new Uint8Array(16),
|
||||
warps: [],
|
||||
backgroundEvents: [],
|
||||
|
||||
@@ -205,7 +205,8 @@ describe.skipIf(!existsSync(romPath))("Elm's robbed call with the local ROM", ()
|
||||
const pages = settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(pages[0]).toContain("It's a disaster!");
|
||||
expect(pages.at(-1)).toBe("Click!");
|
||||
// HangUp: "Click!", then Phone_CallEnd's boops.
|
||||
expect(pages.slice(-2)).toEqual(["Click!", "……"]);
|
||||
expect(world.state.specialPhoneCall).toBe(0);
|
||||
expect(world.state.events.has(ELM_CALLED)).toBe(true);
|
||||
world.restore(world.save());
|
||||
@@ -225,7 +226,7 @@ describe.skipIf(!existsSync(romPath))("Elm's robbed call with the local ROM", ()
|
||||
expect(saved).toMatchObject({ x: 17, y: 8 });
|
||||
world.restore(saved);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(settle(world).at(-1)).toBe("Click!");
|
||||
expect(settle(world).slice(-2)).toEqual(["Click!", "……"]);
|
||||
expect(world.state.specialPhoneCall).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld, migrateSave } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { ENGINE_FLAGS } from "../src/game/rom/profile";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
import { Pokegear, type PokegearView } from "../src/game/scripts/pokegear";
|
||||
import {
|
||||
emptyScriptState,
|
||||
type ScriptHost,
|
||||
} from "../src/game/scripts/interpreter";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const NEW_BARK_TOWN = "24:4",
|
||||
PLAYERS_HOUSE_1F = "24:6",
|
||||
CYNDAQUIL = 155;
|
||||
const EVENT_GOT_A_POKEMON_FROM_ELM = 26,
|
||||
EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30,
|
||||
EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31,
|
||||
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64,
|
||||
EVENT_DUDE_TALKED_TO_YOU = 65;
|
||||
const LANDMARK_ROUTE_29 = 2;
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the Pokégear", () => {
|
||||
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
/** Outdoors in New Bark Town on a Friday at noon, with Mom's and Elm's
|
||||
* numbers, the Pokégear and its map and phone cards. */
|
||||
function fixture(map = NEW_BARK_TOWN, x = 13, y = 7) {
|
||||
const world = new GameWorld(
|
||||
assets(),
|
||||
() => new Date("2026-09-25T12:00:00"),
|
||||
() => 0x42,
|
||||
);
|
||||
world.state.party = [
|
||||
generatePartyMon(world.assets.pokemon, {
|
||||
species: CYNDAQUIL,
|
||||
level: 8,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x9abc,
|
||||
caughtData: 0,
|
||||
}),
|
||||
];
|
||||
world.state.flags.add(ENGINE_FLAGS.pokegear);
|
||||
world.state.flags.add(OW.ENGINE_PHONE_CARD);
|
||||
world.state.flags.add(OW.ENGINE_MAP_CARD);
|
||||
world.state.phone = [OW.PHONECONTACT_MOM, OW.PHONECONTACT_ELM];
|
||||
world.state.events.add(EVENT_GOT_A_POKEMON_FROM_ELM);
|
||||
world.state.scenes[NEW_BARK_TOWN] = 1;
|
||||
// Arriving through a warp clears the map's temporary events, such as
|
||||
// the bedroom's EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8 that Mom checks.
|
||||
for (let event = 0; event < 8; event++) world.state.events.delete(event);
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map,
|
||||
x,
|
||||
y,
|
||||
direction: "down",
|
||||
objects: [],
|
||||
});
|
||||
return world;
|
||||
}
|
||||
/** Acknowledge pages up to the next choice; returns the pages seen. */
|
||||
function until(world: GameWorld) {
|
||||
const pages: string[] = [];
|
||||
for (let tick = 0; tick < 2000; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (!dialogue) {
|
||||
if (!world.busy) return pages;
|
||||
world.tick();
|
||||
continue;
|
||||
}
|
||||
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||
if (page && pages.at(-1) !== page) pages.push(page);
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice) return pages;
|
||||
if (last && dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
}
|
||||
throw new Error("No choice appeared.");
|
||||
}
|
||||
/** Open the Pokégear from the start menu. */
|
||||
function openGear(world: GameWorld) {
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
until(world);
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "menu") throw new Error("Expected the start menu.");
|
||||
world.choose(choice.items.indexOf("POKéGEAR"));
|
||||
return until(world);
|
||||
}
|
||||
function view(world: GameWorld): PokegearView {
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "pokegear") throw new Error(`Expected the Pokégear, got ${choice?.kind}`);
|
||||
return choice.view;
|
||||
}
|
||||
function press(world: GameWorld, button: "a" | "b" | "up" | "down" | "left" | "right") {
|
||||
if (button === "a") world.interact();
|
||||
else if (button === "b") world.advanceDialogue();
|
||||
else world.moveCursor(button);
|
||||
return until(world);
|
||||
}
|
||||
/** B out of the Pokégear and the start menu. */
|
||||
function close(world: GameWorld) {
|
||||
for (let i = 0; i < 10 && world.busy; i++) {
|
||||
if (world.dialogue?.choice) world.advanceDialogue();
|
||||
until(world);
|
||||
}
|
||||
settle(world);
|
||||
expect(world.diagnostic).toBe("");
|
||||
expect(world.pokegear).toBeUndefined();
|
||||
}
|
||||
|
||||
it("opens on the clock card and switches cards with left and right", () => {
|
||||
const world = fixture();
|
||||
expect(openGear(world)).toEqual(["Press any button to exit."]);
|
||||
expect(view(world)).toMatchObject({
|
||||
card: "clock",
|
||||
cards: ["clock", "map", "phone"],
|
||||
clock: { day: "FRIDAY", time: "12:00 PM" },
|
||||
});
|
||||
expect(world.pokegear).toBe(view(world));
|
||||
// Left does nothing on the clock card.
|
||||
press(world, "left");
|
||||
expect(view(world).card).toBe("clock");
|
||||
press(world, "right");
|
||||
expect(view(world)).toMatchObject({
|
||||
card: "map",
|
||||
map: { kanto: false, cursor: 1, player: 1, name: "NEW BARK TOWN" },
|
||||
});
|
||||
press(world, "up");
|
||||
expect(view(world).map).toMatchObject({ cursor: LANDMARK_ROUTE_29, name: "ROUTE 29" });
|
||||
press(world, "down");
|
||||
press(world, "down");
|
||||
// The cursor wraps to the last Johto landmark.
|
||||
expect(view(world).map?.cursor).toBe(OW.JOHTO_LANDMARK_LAST);
|
||||
press(world, "right");
|
||||
expect(view(world).phone).toEqual({
|
||||
rows: ["MOM:", "PROF.ELM:", "----------", "----------"],
|
||||
cursor: 0,
|
||||
signal: true,
|
||||
});
|
||||
expect(world.dialogue?.pages).toEqual(["Whom do you want\nto call?"]);
|
||||
// There is no radio card; right stays on the phone.
|
||||
press(world, "right");
|
||||
expect(view(world).card).toBe("phone");
|
||||
press(world, "left");
|
||||
// The map card keeps its cursor while the Pokégear is open.
|
||||
expect(view(world).map?.cursor).toBe(OW.JOHTO_LANDMARK_LAST);
|
||||
press(world, "left");
|
||||
expect(view(world).card).toBe("clock");
|
||||
// Any button leaves the clock card for the start menu.
|
||||
press(world, "a");
|
||||
expect(world.dialogue?.choice?.kind).toBe("menu");
|
||||
expect(world.pokegear).toBeUndefined();
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("follows the game clock while the clock card is open", () => {
|
||||
let now = new Date("2026-09-25T00:05:00");
|
||||
const world = new GameWorld(assets(), () => now, () => 0x42);
|
||||
world.state.flags.add(ENGINE_FLAGS.pokegear);
|
||||
openGear(world);
|
||||
expect(view(world).clock).toEqual({ day: "FRIDAY", time: "12:05 AM" });
|
||||
now = new Date("2026-09-25T09:41:00");
|
||||
world.tick();
|
||||
expect(world.pokegear?.clock).toEqual({ day: "FRIDAY", time: " 9:41 AM" });
|
||||
// Without the map or phone card, right does nothing.
|
||||
press(world, "right");
|
||||
expect(view(world)).toMatchObject({ card: "clock", cards: ["clock"] });
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("scrolls the phone list through all ten slots", () => {
|
||||
const world = fixture();
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
for (let i = 0; i < 3; i++) press(world, "down");
|
||||
expect(view(world).phone).toMatchObject({ cursor: 3 });
|
||||
for (let i = 0; i < 10; i++) press(world, "down");
|
||||
// The list scrolls until its last four slots show.
|
||||
expect(view(world).phone).toMatchObject({
|
||||
cursor: 3,
|
||||
rows: ["----------", "----------", "----------", "----------"],
|
||||
});
|
||||
// A on an empty slot does nothing.
|
||||
press(world, "a");
|
||||
expect(view(world).phone?.submenu).toBeUndefined();
|
||||
for (let i = 0; i < 9; i++) press(world, "up");
|
||||
expect(view(world).phone).toMatchObject({ cursor: 0, rows: ["MOM:", "PROF.ELM:", "----------", "----------"] });
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("calls Elm with his callee script", () => {
|
||||
const world = fixture();
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
press(world, "down");
|
||||
press(world, "a");
|
||||
// Mom and Elm cannot be deleted.
|
||||
expect(view(world).phone).toMatchObject({
|
||||
chosen: 1,
|
||||
submenu: { items: ["CALL", "CANCEL"], selected: 0 },
|
||||
});
|
||||
// The submenu stops at its ends.
|
||||
press(world, "down");
|
||||
press(world, "down");
|
||||
expect(view(world).phone?.submenu?.selected).toBe(1);
|
||||
press(world, "a");
|
||||
expect(view(world).phone?.submenu).toBeUndefined();
|
||||
press(world, "a");
|
||||
press(world, "up");
|
||||
const pages = press(world, "a");
|
||||
expect(pages[0]).toBe("……");
|
||||
expect(pages.slice(1, -3)).toEqual([
|
||||
"Hello, CHRIS?",
|
||||
"Try not to overdo it.",
|
||||
"Be sure to heal your POKéMON if they are hurt.",
|
||||
]);
|
||||
expect(pages.slice(-3)).toEqual(["Click!", "……", "Whom do you want to call?"]);
|
||||
expect(view(world).phone?.chosen).toBeUndefined();
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("tells the player to talk to a contact on the same map", () => {
|
||||
const world = fixture(PLAYERS_HOUSE_1F, 7, 3);
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
press(world, "a");
|
||||
const pages = press(world, "a");
|
||||
expect(pages).toContain("Just go talk to that person!");
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("calls Mom, whose lecture after the catching lesson starts her savings", () => {
|
||||
const world = fixture();
|
||||
world.state.events.add(EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON);
|
||||
world.state.events.add(EVENT_GAVE_MYSTERY_EGG_TO_ELM);
|
||||
world.state.events.add(EVENT_DUDE_TALKED_TO_YOU);
|
||||
world.state.specialPhoneCall = 1;
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
press(world, "a");
|
||||
const lecture = press(world, "a");
|
||||
expect(lecture).toContain("Hello?");
|
||||
expect(world.dialogue?.choice?.kind).toBe("yesno");
|
||||
// Saves made during the call keep the state from before the menu.
|
||||
expect(world.save().events).not.toContain(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST);
|
||||
world.choose(1);
|
||||
const answer = until(world);
|
||||
expect(answer.slice(0, 2)).toEqual([
|
||||
"OK. I'll save your money.",
|
||||
"CHRIS, keep it up! I'm rooting for you, baby!",
|
||||
]);
|
||||
expect(answer.slice(-3)).toEqual(["Click!", "……", "Whom do you want to call?"]);
|
||||
close(world);
|
||||
expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST)).toBe(true);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
|
||||
expect(world.state.specialPhoneCall).toBe(0);
|
||||
|
||||
// The next call asks about her savings from where the player is.
|
||||
world.state.momsMoney = 1234;
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
press(world, "a");
|
||||
const greeting = press(world, "a");
|
||||
expect(greeting).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Hello?",
|
||||
"Oh, hi, CHRIS! Working hard?",
|
||||
"What? You're in NEW BARK TOWN?",
|
||||
"Come see your MOM sometime!",
|
||||
]),
|
||||
);
|
||||
expect(greeting.slice(-2).join(" ")).toMatch(
|
||||
/¥1234.*Do you want to keep on saving\?$/,
|
||||
);
|
||||
world.choose(0);
|
||||
expect(until(world)).toContain("OK. I won't save your money.");
|
||||
close(world);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no service where the map has none", () => {
|
||||
const world = fixture();
|
||||
const state = emptyScriptState();
|
||||
state.flags.add(OW.ENGINE_PHONE_CARD);
|
||||
state.phone = [OW.PHONECONTACT_ELM];
|
||||
const scriptHost: ScriptHost = {
|
||||
...(world as unknown as { host(id: number): ScriptHost }).host(0),
|
||||
phoneService: () => 1,
|
||||
};
|
||||
const gear = new Pokegear(state, {
|
||||
reader: world.assets.reader,
|
||||
data: world.assets.pokemon,
|
||||
text: () => scriptHost.text(),
|
||||
scriptHost: () => scriptHost,
|
||||
landmarkOf: () => 1,
|
||||
}).run();
|
||||
const effects: unknown[] = [];
|
||||
let effect = gear.next();
|
||||
const answers = ["right", "a", "a", undefined, undefined, "b", "b"];
|
||||
while (!effect.done) {
|
||||
effects.push(effect.value);
|
||||
effect = gear.next(answers.shift());
|
||||
}
|
||||
const phone = effects.find(
|
||||
(e) => (e as { view?: PokegearView }).view?.card === "phone",
|
||||
) as { view: PokegearView };
|
||||
expect(phone.view.phone?.signal).toBe(false);
|
||||
expect(effects).toContainEqual({ kind: "text", pages: ["……"], holdTicks: 60 });
|
||||
expect(effects).toContainEqual({
|
||||
kind: "text",
|
||||
pages: ["You're out of the\nservice area."],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Mom's savings in saves", () => {
|
||||
const world = fixture();
|
||||
world.state.momsMoney = 4321;
|
||||
const save = world.save();
|
||||
expect(save.momsMoney).toBe(4321);
|
||||
const { momsMoney: _, ...old } = save;
|
||||
const migrated = migrateSave(old, () => 0)!;
|
||||
expect(migrated.momsMoney).toBe(0);
|
||||
world.restore(migrated);
|
||||
expect(world.state.momsMoney).toBe(0);
|
||||
expect(() => world.restore({ ...save, momsMoney: -1 })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -168,9 +168,12 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
|
||||
"OPTION",
|
||||
"EXIT",
|
||||
]);
|
||||
// The cursor stays on the last choice.
|
||||
pick(world, "POKéGEAR");
|
||||
expect(world.notice).toBe("POKéGEAR is not ported yet.");
|
||||
// The cursor stays on the last choice; B leaves the Pokégear's clock.
|
||||
expect(pick(world, "POKéGEAR")).toEqual(["Press any button to exit."]);
|
||||
expect(world.dialogue?.choice?.kind).toBe("pokegear");
|
||||
world.advanceDialogue();
|
||||
until(world);
|
||||
expect(world.dialogue?.choice?.kind).toBe("menu");
|
||||
close(world);
|
||||
open(world);
|
||||
const reopened = world.dialogue?.choice;
|
||||
|
||||
@@ -105,6 +105,23 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
|
||||
expect(result.prizeMoney).toBe(128);
|
||||
expect(result.texts).toContain("CHRIS got ¥128");
|
||||
});
|
||||
it("sends a quarter of the prize to Mom while she saves some", () => {
|
||||
const { mon, options } = setup();
|
||||
mon.item = 0x5b;
|
||||
const result = finish(new Battle({ ...options, mom: { saving: 1, money: 10 } }));
|
||||
expect(result.prizeMoney).toBe(96);
|
||||
expect(result.momMoney).toBe(32);
|
||||
expect(result.texts).toContain("CHRIS got ¥128 for winning! Sent some to MOM!");
|
||||
// A full account keeps the whole prize in the wallet.
|
||||
const full = setup();
|
||||
const kept = finish(
|
||||
new Battle({ ...full.options, mom: { saving: 1, money: 999999 } }),
|
||||
);
|
||||
expect(kept.prizeMoney).toBe(64);
|
||||
expect(kept.momMoney).toBeUndefined();
|
||||
expect(kept.texts).toContain("CHRIS got ¥64 for winning!");
|
||||
expect(kept.texts).not.toContain("MOM");
|
||||
});
|
||||
it("scores exhausted trainer moves before the menu, then uses Struggle", () => {
|
||||
const { options } = setup();
|
||||
options.trainer!.party[0].pp.fill(0);
|
||||
|
||||
+138
-1
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
import { trainerFixtureWorld } from "./trainer-fixture";
|
||||
import type { GameWorld } from "../src/game/world/world";
|
||||
import { migrateSave, type GameWorld } from "../src/game/world/world";
|
||||
import { DecorationData } from "../src/game/world/decorations";
|
||||
|
||||
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
@@ -65,6 +67,141 @@ describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", ()
|
||||
expect(world.state.money).toBe(C.MAX_MONEY);
|
||||
});
|
||||
|
||||
it("sends some of the reward to Mom", () => {
|
||||
const world = fixture();
|
||||
world.state.flags.add(OW.ENGINE_MOM_SAVING_MONEY);
|
||||
world.state.momsMoney = 100;
|
||||
const before = world.save();
|
||||
world.interact();
|
||||
const messages = finish(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(messages).toContain("Sent some to MOM!");
|
||||
expect(world.state.money).toBe(before.money + 48);
|
||||
expect(world.state.momsMoney).toBe(116);
|
||||
expect(world.state.momItemTriggerBalance).toBe(OW.MOM_MONEY);
|
||||
});
|
||||
|
||||
describe("Mom's purchases (MomTriesToBuySomething)", () => {
|
||||
const SUPER_POTION = 0x11,
|
||||
MOM_ITEMS_1 = [
|
||||
[SUPER_POTION, 600],
|
||||
[0x09, 90], // ANTIDOTE
|
||||
[0x05, 180], // POKE_BALL
|
||||
[0x13, 450], // ESCAPE_ROPE
|
||||
[0x04, 500], // GREAT_BALL
|
||||
];
|
||||
/** Beat Joey while Mom saves a quarter (¥16) of his ¥64; returns the
|
||||
* pages of the call that follows, if any. */
|
||||
function win(world: GameWorld, momsMoney: number) {
|
||||
world.state.flags.add(OW.ENGINE_MOM_SAVING_MONEY);
|
||||
world.state.momsMoney = momsMoney - 16;
|
||||
world.interact();
|
||||
const pages: string[] = [];
|
||||
// The queued call starts at the next PlayerEvents.
|
||||
let idle = 0;
|
||||
for (let tick = 0; tick < 10000; tick++) {
|
||||
const view = world.battle;
|
||||
if (view) {
|
||||
if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
|
||||
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
continue;
|
||||
}
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||
if (pages.at(-1) !== page) pages.push(page);
|
||||
if (dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
} else if (world.busy || idle++ < 2) world.tick();
|
||||
else {
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
throw new Error(`Trainer battle did not settle: ${world.diagnostic}`);
|
||||
}
|
||||
|
||||
it("buys MomItems_2's next item once her savings reach its trigger", () => {
|
||||
const world = fixture();
|
||||
const pcItems = world.state.pcItems.map((slot) => ({ ...slot }));
|
||||
const pages = win(world, 900);
|
||||
expect(pages).toEqual([
|
||||
"Hi, CHRIS! How are you?",
|
||||
"I found a useful item shopping, so",
|
||||
"I bought it with your money. Sorry!",
|
||||
"It's in your PC. You'll like it!",
|
||||
"Click!",
|
||||
"……",
|
||||
]);
|
||||
expect(world.state.momsMoney).toBe(300);
|
||||
expect(world.state.momItem).toBe(1);
|
||||
expect(world.state.pcItems).toEqual([...pcItems, { item: SUPER_POTION, quantity: 1 }]);
|
||||
// MomItems_2's next trigger is ¥4000.
|
||||
expect(win(world, 3999)).toEqual([]);
|
||||
expect(world.state.momItem).toBe(1);
|
||||
const saved = world.save();
|
||||
expect([saved.momItem, saved.momItemTriggerBalance]).toEqual([1, 4600]);
|
||||
});
|
||||
|
||||
it("buys a random MomItems_1 item when her savings land on a ¥2300 step", () => {
|
||||
const world = fixture();
|
||||
world.state.momItem = 1;
|
||||
const pages = win(world, 2300);
|
||||
expect(pages[1]).toBe("I found a useful item shopping, so");
|
||||
const bought = world.state.pcItems.at(-1)!;
|
||||
const entry = MOM_ITEMS_1.find(([item]) => item === bought.item);
|
||||
expect(entry).toBeDefined();
|
||||
expect(world.state.momsMoney).toBe(2300 - entry![1]);
|
||||
expect(world.state.momItem).toBe(1);
|
||||
expect(world.state.momItemTriggerBalance).toBe(4600);
|
||||
});
|
||||
|
||||
it("raises the ¥2300 step past her savings without buying anything", () => {
|
||||
const world = fixture();
|
||||
world.state.momItem = 1;
|
||||
expect(win(world, 2400)).toEqual([]);
|
||||
expect(world.state.momsMoney).toBe(2400);
|
||||
expect(world.state.momItemTriggerBalance).toBe(4600);
|
||||
});
|
||||
|
||||
it("sends dolls to the player's room", () => {
|
||||
const world = fixture();
|
||||
world.state.momItem = 3; // ¥10000: the Charmander doll for ¥1800.
|
||||
const pages = win(world, 10000);
|
||||
expect(pages.slice(0, 4)).toEqual([
|
||||
"Hi, CHRIS! How are you?",
|
||||
"While shopping today, I saw this adorable doll, so",
|
||||
"I bought it with your money. Sorry!",
|
||||
"It's in your room. You'll love it!",
|
||||
]);
|
||||
expect(world.state.momsMoney).toBe(8200);
|
||||
expect(world.state.momItem).toBe(4);
|
||||
const doll = new DecorationData(world.assets.reader, world.assets.pokemon);
|
||||
const charmander = Array.from({ length: 52 }, (_, i) => i + 1).find(
|
||||
(id) => doll.name(id) === "CHARMANDER DOLL",
|
||||
)!;
|
||||
expect(world.state.events.has(doll.attribute(charmander).event)).toBe(true);
|
||||
});
|
||||
|
||||
it("buys nothing while the PC is full", () => {
|
||||
const world = fixture();
|
||||
world.state.pcItems = Array.from({ length: 50 }, (_, i) => ({ item: 0x20 + i, quantity: 1 }));
|
||||
expect(win(world, 900)).toEqual([]);
|
||||
expect(world.state.momsMoney).toBe(900);
|
||||
expect(world.state.momItem).toBe(0);
|
||||
});
|
||||
|
||||
it("migrates saves made before her purchases", () => {
|
||||
const world = fixture();
|
||||
const { momItem: _, momItemTriggerBalance: __, ...old } = world.save();
|
||||
const migrated = migrateSave(old, () => 0)!;
|
||||
expect([migrated.momItem, migrated.momItemTriggerBalance]).toEqual([0, OW.MOM_MONEY]);
|
||||
world.restore(migrated);
|
||||
expect(() => world.restore({ ...migrated, momItem: 11 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back reward, experience and sightings if the following script is unsupported", () => {
|
||||
const world = fixture([0xff]);
|
||||
const before = world.save();
|
||||
|
||||
Reference in new issue
Block a user