Enable Route 35, Route 36, Route 37, the National Park and its gates, Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's room and both floors of the Burned Tower. The story runs from Floria, the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the rival in the Burned Tower and the beasts' release to Ecruteak Gym, Morty, the FOGBADGE and TM30. Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons, CheckEncounterRoamMon) with their state in the save; only Continue moves them on restore, so a failed import's rollback keeps them in place. Port the Bug-Catching Contest: registration on contest days, Park Balls, contestants, the timer, QUIT, the catch-and-swap prompt, judging from the ROM's contestant table, prizes and the saved contest state. Port the Game Corner's SlotMachine and CardFlip on a new realtime tile screen that advances two frames per tick with buffered buttons: reel strips and scrolling, bias tables, the seven streak, Golem and Chansey, payouts, the Card Flip board, cursor and navigation. Their tilemaps are checked against the ROM's own data. Also port getnum, a plain warp that continues its script on the new map, and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the Burned Tower rival) is no longer sighted once its own flag is set, as the original's read of bit 1 of wNumKeyItems gives with the usual key items. The playthrough walks through the National Park and continues to Morty; its driver chooses moves by type matchup, buys at Marts and uses Super Potions and Awakenings in trainer battles. New tests cover roaming, the contest, the Game Corner and Ecruteak's side content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
576 lines
21 KiB
TypeScript
576 lines
21 KiB
TypeScript
import { expect } from "vitest";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { encodeSave } from "../src/storage/saves";
|
|
import { CRYSTAL_ROM } from "../src/rom/validate";
|
|
import type { CrystalAssets } from "../src/game/rom/assets";
|
|
import { GameWorld, type NativeSave } from "../src/game/world/world";
|
|
import type { Direction } from "../src/game/world/types";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { applyTypeMatchups } from "../src/game/battle/battle";
|
|
import { BattleData } from "../src/game/battle/data";
|
|
import { afterRestore, findPath } from "./world-driver";
|
|
|
|
const STEP: Record<Direction, [number, number]> = {
|
|
up: [0, -1],
|
|
down: [0, 1],
|
|
left: [-1, 0],
|
|
right: [1, 0],
|
|
};
|
|
|
|
/** Warp mats and edges that need their direction pressed (.CheckWarp). */
|
|
const WARP_EXITS: Partial<Record<number, Direction>> = {
|
|
0x70: "down",
|
|
0x76: "left",
|
|
0x78: "up",
|
|
0x7e: "right",
|
|
};
|
|
|
|
/** Plays the game through the world's own inputs, as tests/playthrough
|
|
* does: scripts, battles and walking, with a seeded Random. A stage's save
|
|
* can be dumped (PLAYTHROUGH_SAVES) and resumed with `resume`. */
|
|
export class Playthrough {
|
|
world!: GameWorld;
|
|
readonly log: string[] = [];
|
|
/** A wild species to throw Poké Balls at instead of fighting. */
|
|
wanted = 0;
|
|
seed = 0x2a;
|
|
private stage = 0;
|
|
readonly now = () => new Date("2026-09-22T12:00:00");
|
|
readonly random = () => {
|
|
this.seed = (this.seed * 1103515245 + 12345) >>> 0;
|
|
return (this.seed >>> 16) & 255;
|
|
};
|
|
constructor(readonly assets: CrystalAssets) {}
|
|
|
|
/** A new world for the introduction. */
|
|
start() {
|
|
this.world = new GameWorld(this.assets, this.now, this.random);
|
|
}
|
|
/** Continue from a dumped stage. */
|
|
resume(dump: { save: NativeSave; seed: number }) {
|
|
this.seed = dump.seed;
|
|
this.world = new GameWorld(this.assets, this.now, this.random);
|
|
this.world.restore(dump.save);
|
|
}
|
|
|
|
/** The usable move that hits hardest for its power, STAB and type
|
|
* matchups against `targetTypes`, or the first with PP; a move Disable
|
|
* blocks (its slot in the side's disable count) is skipped. */
|
|
strongest = (
|
|
mon: {
|
|
moves: readonly number[];
|
|
pp: readonly number[];
|
|
types: readonly number[];
|
|
},
|
|
targetTypes: readonly number[],
|
|
disableCount = 0,
|
|
) => {
|
|
const disabled = disableCount ? ((disableCount >> 4) & 0xf) - 1 : -1;
|
|
this.matchups ??= new BattleData(this.assets.reader).typeMatchups();
|
|
const score = (move: number) =>
|
|
applyTypeMatchups(this.matchups!, {
|
|
damage: this.assets.pokemon.move(move).power * 10,
|
|
modifier: 0,
|
|
moveType: this.assets.pokemon.move(move).type,
|
|
userTypes: mon.types,
|
|
targetTypes,
|
|
identified: false,
|
|
}).damage;
|
|
let best = -1;
|
|
mon.moves.forEach((move, i) => {
|
|
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
|
|
if (best < 0 || score(move) > score(mon.moves[best])) best = i;
|
|
});
|
|
return Math.max(0, best);
|
|
};
|
|
private matchups?: ReturnType<BattleData["typeMatchups"]>;
|
|
/** Run scripts and battles until the player is free again. Battles use
|
|
* the strongest move with PP; a wild battle below half HP is run from,
|
|
* and a trainer battle (which cannot be run from) instead reaches for
|
|
* the bag through the battle's own item action (BattleMenu_Pack), as a
|
|
* cautious player who restocked before a hard fight would: a carried
|
|
* Awakening wakes the active Pokémon before Sleep/Dream Eater can lock
|
|
* it out of a whole fight, and a carried Super Potion heals it below
|
|
* half HP. New moves are learned when there is room, and other
|
|
* questions take the queued answers or their defaults. */
|
|
play = (answers: (number | string)[] = []) => {
|
|
const world = this.world,
|
|
log = this.log;
|
|
for (let tick = 0; tick < 100_000; tick++) {
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
const battle = world.battle;
|
|
if (battle) {
|
|
if (battle.message && log.at(-1) !== battle.message.text)
|
|
log.push(battle.message.text);
|
|
const prompt = battle.prompt;
|
|
if (prompt?.kind === "action") {
|
|
const { engine } = battle,
|
|
mon = engine.player.mon;
|
|
const ball = world.liveState.bag.balls.findIndex(
|
|
(slot) => slot.item === C.POKE_BALL,
|
|
);
|
|
const potion = world.liveState.bag.items.findIndex(
|
|
(slot) => slot.item === C.SUPER_POTION,
|
|
);
|
|
const awakening = world.liveState.bag.items.findIndex(
|
|
(slot) => slot.item === C.AWAKENING,
|
|
);
|
|
if (
|
|
engine.battleMode === C.WILD_BATTLE &&
|
|
engine.enemy.mon.species === this.wanted &&
|
|
!world.liveState.party.some((member) => member.species === this.wanted) &&
|
|
ball >= 0
|
|
)
|
|
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
|
|
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
|
world.battleAction({ action: "run" });
|
|
else if (
|
|
engine.battleMode !== C.WILD_BATTLE &&
|
|
(mon.status & C.SLP_MASK) !== 0 &&
|
|
awakening >= 0
|
|
)
|
|
world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening });
|
|
else if (
|
|
engine.battleMode !== C.WILD_BATTLE &&
|
|
mon.hp * 2 < mon.maxHp &&
|
|
potion >= 0
|
|
)
|
|
world.battleAction({ action: "item", item: C.SUPER_POTION, slot: potion });
|
|
else
|
|
world.battleAction({
|
|
action: "fight",
|
|
move: this.strongest(
|
|
mon,
|
|
engine.enemy.mon.types,
|
|
engine.player.disableCount,
|
|
),
|
|
});
|
|
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
|
// SHIFT's party menu: keep the Pokémon out (or send the first
|
|
// that can fight when one must go). UseItem_SelectMon shows the
|
|
// same prompt kind for the item action just above, distinguished
|
|
// by its own text (BattlePack's RestoreHPEffect heals whichever
|
|
// slot is chosen; the active Pokémon is the one that asked for it).
|
|
else if (prompt?.kind === "party")
|
|
world.battleChoose(
|
|
prompt.text === "Use on which POKéMON?"
|
|
? battle.engine.curBattleMon
|
|
: prompt.cancel
|
|
? -1
|
|
: world.liveState.party.findIndex(
|
|
(mon, i) =>
|
|
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
|
|
),
|
|
);
|
|
else if (prompt?.kind === "forget") world.battleChoose(4);
|
|
else if (prompt?.kind === "name") world.battleName("");
|
|
else if (prompt?.kind === "stats" || battle.message?.prompt)
|
|
world.interact();
|
|
else world.tick();
|
|
continue;
|
|
}
|
|
const dialogue = world.dialogue;
|
|
if (dialogue) {
|
|
const page = dialogue.pages[dialogue.index];
|
|
const last = dialogue.index === dialogue.pages.length - 1;
|
|
if (log.at(-1) !== page) log.push(page);
|
|
if (last && dialogue.choice) {
|
|
const answer = answers.shift();
|
|
if (dialogue.choice.kind === "name")
|
|
world.choose(typeof answer === "string" ? answer : "");
|
|
else
|
|
world.choose(
|
|
typeof answer === "number"
|
|
? answer
|
|
: dialogue.choice.kind === "yesno"
|
|
? 1
|
|
: dialogue.choice.kind === "clock"
|
|
? dialogue.choice.selected
|
|
: 0,
|
|
);
|
|
} else if (last && dialogue.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
continue;
|
|
}
|
|
if (!world.busy) {
|
|
world.tick(); // scene scripts and phone calls start on idle ticks
|
|
if (!world.busy) return;
|
|
continue;
|
|
}
|
|
world.tick();
|
|
}
|
|
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
|
};
|
|
|
|
/** Walk to a tile on the current map, replanning after ledges, roaming
|
|
* NPCs and scenes that move the player. A warp target leaves the map. */
|
|
walk = (x: number, y: number) => {
|
|
const world = this.world,
|
|
map = world.mapId,
|
|
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
|
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
this.play();
|
|
if (warp && world.mapId !== map) return;
|
|
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
|
if (world.x === x && world.y === y) return;
|
|
for (const direction of findPath(world, x, y)) {
|
|
const [dx, dy] = STEP[direction];
|
|
const from = [world.x, world.y];
|
|
if (!world.move(direction)) {
|
|
for (let tick = 0; tick < 64; tick++) world.tick();
|
|
break;
|
|
}
|
|
this.play();
|
|
if (
|
|
world.mapId !== map ||
|
|
world.x !== from[0] + dx ||
|
|
world.y !== from[1] + dy
|
|
)
|
|
break;
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}: ${world.notice}`,
|
|
);
|
|
};
|
|
/** Step across a map connection from the nearest usable edge tile. */
|
|
cross = (direction: Direction, target: string) => {
|
|
const world = this.world,
|
|
map = world.mapId;
|
|
const [dx, dy] = STEP[direction];
|
|
const edge = Array.from(
|
|
{ length: dx ? world.map.height : world.map.width },
|
|
(_, i) =>
|
|
dx
|
|
? [dx < 0 ? 0 : world.map.width - 1, i]
|
|
: [i, dy < 0 ? 0 : world.map.height - 1],
|
|
).flatMap(([x, y]) => {
|
|
try {
|
|
return [{ x, y, length: findPath(world, x, y).length }];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
edge.sort((a, b) => a.length - b.length);
|
|
for (const { x, y } of edge) {
|
|
this.walk(x, y);
|
|
if (world.move(direction)) {
|
|
this.play();
|
|
if (world.mapId === target) return;
|
|
}
|
|
if (world.mapId !== map) break;
|
|
}
|
|
throw new Error(
|
|
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
|
|
);
|
|
};
|
|
/** Stand in front of a warp leading to `target` and take it. */
|
|
enter = (target: string, direction: Direction) => {
|
|
const world = this.world;
|
|
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
|
expect(warp, `a warp to ${target}`).toBeDefined();
|
|
const [dx, dy] = STEP[direction];
|
|
this.walk(warp.x - dx, warp.y - dy);
|
|
expect(world.move(direction)).toBe(true);
|
|
this.play();
|
|
expect(world.mapId).toBe(target);
|
|
};
|
|
/** Leave through an exit mat, which needs the exit direction pressed. */
|
|
exit = (target: string, direction: Direction = "down") => {
|
|
const world = this.world;
|
|
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
|
this.walk(warp.x, warp.y);
|
|
if (world.mapId !== target) {
|
|
expect(world.move(direction)).toBe(true);
|
|
this.play();
|
|
}
|
|
expect(world.mapId).toBe(target);
|
|
};
|
|
/** Serialize the save to JSON and load it into a fresh world. */
|
|
reload = () => {
|
|
const saved = JSON.parse(JSON.stringify(this.world.save()));
|
|
// PLAYTHROUGH_SAVES=dir keeps every stage's save, for debugging a
|
|
// later stage from where the run reached.
|
|
if (process.env.PLAYTHROUGH_SAVES) {
|
|
mkdirSync(process.env.PLAYTHROUGH_SAVES, { recursive: true });
|
|
writeFileSync(
|
|
`${process.env.PLAYTHROUGH_SAVES}/${String(++this.stage).padStart(3, "0")}.json`,
|
|
JSON.stringify({ save: saved, seed: this.seed }),
|
|
);
|
|
}
|
|
this.world = new GameWorld(this.assets, this.now, this.random);
|
|
this.world.restore(saved);
|
|
const reloaded = this.world.save();
|
|
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
|
};
|
|
lead = () => this.world.state.party[0];
|
|
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
|
|
* tests/human-test-saves.test.ts writes them (kept out of git). */
|
|
writeTestSave = (name: string) => {
|
|
if (!process.env.MAKE_TEST_SAVES) return;
|
|
mkdirSync("test-saves", { recursive: true });
|
|
writeFileSync(
|
|
`test-saves/${name}.crystal-save.json`,
|
|
encodeSave({
|
|
version: 1,
|
|
romHash: CRYSTAL_ROM.sha1,
|
|
coreId: "crystal-native-world-v1",
|
|
savedAt: new Date().toISOString(),
|
|
state: new TextEncoder().encode(JSON.stringify(this.world.save())),
|
|
}),
|
|
);
|
|
};
|
|
/** PokecenterNurseScript from in front of the counter, then back out. */
|
|
heal = (center: string, city: string) => {
|
|
this.enter(center, "up");
|
|
this.walk(3, 3);
|
|
this.world.move("up"); // the counter: this only turns the player
|
|
this.world.interact();
|
|
this.play();
|
|
expect(
|
|
this.world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0]),
|
|
).toBe(true);
|
|
this.exit(city);
|
|
};
|
|
/** Pace between two neighbouring grass tiles (the nearest pair) until
|
|
* the lead reaches `level`. `rest` heals and comes back here. */
|
|
train = (level: number, rest: () => void) => {
|
|
const world = this.world,
|
|
map = world.mapId,
|
|
grass = world.assets.grassCollisions();
|
|
const isGrass = (x: number, y: number) =>
|
|
x >= 0 &&
|
|
y >= 0 &&
|
|
x < world.map.width &&
|
|
y < world.map.height &&
|
|
grass.includes(world.assets.collision(world.map, x, y));
|
|
for (let round = 0; round < 2000 && this.lead().level < level; round++) {
|
|
if (this.lead().hp * 2 < this.lead().stats[0]) {
|
|
rest();
|
|
expect(this.world.mapId).toBe(map);
|
|
}
|
|
const pairs: { x: number; y: number; length: number }[] = [];
|
|
for (let y = 0; y < world.map.height; y++)
|
|
for (let x = 0; x < world.map.width; x++)
|
|
if (isGrass(x, y) && isGrass(x + 1, y))
|
|
try {
|
|
pairs.push({ x, y, length: findPath(world, x, y).length });
|
|
} catch {
|
|
/* unreachable */
|
|
}
|
|
pairs.sort((a, b) => a.length - b.length);
|
|
const { x, y } = pairs[0];
|
|
this.walk(x, y);
|
|
this.walk(x + 1, y);
|
|
}
|
|
expect(this.lead().level).toBeGreaterThanOrEqual(level);
|
|
};
|
|
|
|
/** Take the stairs (or another warp) to `target`, trying each. A warp
|
|
* mat or cave edge needs its exit direction pressed (.CheckWarp). */
|
|
stairs = (target: string) => {
|
|
const world = this.world;
|
|
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
|
|
try {
|
|
findPath(world, warp.x, warp.y);
|
|
} catch {
|
|
continue;
|
|
}
|
|
this.walk(warp.x, warp.y);
|
|
if (world.mapId === target) return;
|
|
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
|
if (exit && world.move(exit)) {
|
|
this.play();
|
|
if (world.mapId === target) return;
|
|
}
|
|
}
|
|
throw new Error(`No way from ${world.mapId} to ${target}`);
|
|
};
|
|
/** Find a way through warps among `maps` to `target`, as a player
|
|
* trying staircases would: depth first, backing out of dead ends. */
|
|
route = (
|
|
target: string,
|
|
maps: string[],
|
|
depth = 6,
|
|
goal?: { x: number; y: number },
|
|
): boolean => {
|
|
const world = this.world;
|
|
const reachable = () => {
|
|
if (!goal) return true;
|
|
try {
|
|
findPath(world, goal.x, goal.y);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
if (world.mapId === target && reachable()) return true;
|
|
if (!depth) return false;
|
|
const here = world.save();
|
|
for (const warp of world.map.warps) {
|
|
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
|
try {
|
|
findPath(world, warp.x, warp.y);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const from = world.mapId;
|
|
this.walk(warp.x, warp.y);
|
|
if (world.mapId !== from && this.route(target, maps, depth - 1, goal))
|
|
return true;
|
|
world.restore(here);
|
|
}
|
|
return false;
|
|
};
|
|
/** Advance to the next menu choice, keeping its pages in the log. */
|
|
toChoice = () => {
|
|
const world = this.world,
|
|
log = this.log;
|
|
for (let tick = 0; tick < 2000; tick++) {
|
|
const dialogue = world.dialogue;
|
|
if (dialogue) {
|
|
const page = dialogue.pages[dialogue.index];
|
|
if (log.at(-1) !== page) log.push(page);
|
|
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
|
|
return dialogue.choice;
|
|
if (dialogue.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
} else world.tick();
|
|
}
|
|
throw new Error(`No choice appeared: ${world.diagnostic}`);
|
|
};
|
|
/** Choose a menu item by its label (or a pattern). */
|
|
choose = (label: string | RegExp) => {
|
|
const choice = this.toChoice();
|
|
if (choice.kind !== "menu")
|
|
throw new Error(`Expected a menu, got ${choice.kind}`);
|
|
const index = choice.items.findIndex((item) =>
|
|
typeof label === "string" ? item === label : label.test(item),
|
|
);
|
|
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
|
this.world.choose(index);
|
|
};
|
|
/** Choose an item in the pack. */
|
|
choosePack = (item: number) => {
|
|
const choice = this.toChoice();
|
|
if (choice.kind !== "pack")
|
|
throw new Error(`Expected the pack, got ${choice.kind}`);
|
|
for (const [pocket, { entries }] of choice.pockets.entries()) {
|
|
const index = entries.findIndex((entry) => entry.item === item);
|
|
if (index >= 0) return this.world.choosePackEntry(pocket, index);
|
|
}
|
|
throw new Error(`Item ${item} is not in the pack.`);
|
|
};
|
|
/** Talk to an object wherever it stands. A trainer who spots the player
|
|
* on the way battles and moves, so the approach is planned again. */
|
|
talkTo = (id: number) => {
|
|
const world = this.world;
|
|
const COUNTER = 0x90;
|
|
for (let attempt = 0; attempt < 4; attempt++)
|
|
for (const [dx, dy, face] of [
|
|
[-1, 0, "right"],
|
|
[1, 0, "left"],
|
|
[0, 1, "up"],
|
|
[0, -1, "down"],
|
|
] as const) {
|
|
const target = world.objects().find((object) => object.id === id)!;
|
|
// Across a counter the player stands one tile further away.
|
|
const across =
|
|
world.assets.collision(world.map, target.x + dx, target.y + dy) === COUNTER;
|
|
const x = target.x + dx * (across ? 2 : 1),
|
|
y = target.y + dy * (across ? 2 : 1);
|
|
try {
|
|
if (!(world.x === x && world.y === y)) this.walk(x, y);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const now = world.objects().find((object) => object.id === id)!;
|
|
if (now.x !== target.x || now.y !== target.y) break;
|
|
world.move(face);
|
|
world.interact();
|
|
return;
|
|
}
|
|
throw new Error(`No way to talk to object ${id}`);
|
|
};
|
|
/** Talk to the clerk at `clerk` and buy `quantity` of `item` through
|
|
* the world's own BuyMenu (BuyMenuLoop, SelectQuantityToBuy): BUY, the
|
|
* item in the stock list, the quantity, YES, back out of the list,
|
|
* then QUIT, as tests/mart.test.ts and tests/goldenrod.test.ts drive the
|
|
* martmenu/martlist/martquantity choices directly. */
|
|
buy = (clerk: number, item: number, quantity: number) => {
|
|
const world = this.world;
|
|
this.talkTo(clerk);
|
|
expect(this.toChoice().kind).toBe("martmenu");
|
|
world.choose(0); // BUY
|
|
const list = this.toChoice();
|
|
if (list.kind !== "martlist")
|
|
throw new Error(`Expected a mart list, got ${list.kind}`);
|
|
const index = list.entries.findIndex((entry) => entry.item === item);
|
|
expect(index, `item ${item} in the mart's stock`).toBeGreaterThanOrEqual(0);
|
|
world.choose(index);
|
|
expect(this.toChoice().kind).toBe("martquantity");
|
|
world.choose(quantity);
|
|
expect(this.toChoice().kind).toBe("yesno");
|
|
world.choose(1);
|
|
// "Here you are!", then the stock list again (BuyMenuLoop repeats).
|
|
expect(this.toChoice().kind).toBe("martlist");
|
|
world.choose(-1); // back to the mart's own menu
|
|
expect(this.toChoice().kind).toBe("martmenu");
|
|
world.choose(2); // QUIT
|
|
this.play();
|
|
};
|
|
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
|
|
* a full move list forgets the first move. */
|
|
teach = (item: number, slot: number) => {
|
|
const world = this.world;
|
|
expect(world.openStartMenu()).toBe(true);
|
|
this.choose("PACK");
|
|
this.choosePack(item);
|
|
this.choose("USE");
|
|
expect(this.toChoice().kind).toBe("yesno");
|
|
world.choose(1);
|
|
const party = this.toChoice();
|
|
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
|
|
world.choose(slot);
|
|
if (world.liveState.party[slot].moves.every(Boolean)) {
|
|
expect(this.toChoice().kind).toBe("yesno");
|
|
world.choose(1);
|
|
this.toChoice();
|
|
world.choose(0);
|
|
}
|
|
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++)
|
|
this.toChoice();
|
|
// The menus work on a draft that is kept once they close.
|
|
for (let i = 0; i < 10 && world.busy; i++) {
|
|
if (world.dialogue?.choice) world.advanceDialogue();
|
|
else world.tick();
|
|
}
|
|
this.play();
|
|
};
|
|
/** Cut the tree at (x, y) from a walkable side (TryCutOW, then YES). */
|
|
cut = (x: number, y: number) => {
|
|
const world = this.world;
|
|
for (const [face, [dx, dy]] of Object.entries(STEP) as [Direction, [number, number]][]) {
|
|
try {
|
|
this.walk(x - dx, y - dy);
|
|
} catch {
|
|
continue;
|
|
}
|
|
world.move(face);
|
|
world.interact();
|
|
this.play([1]);
|
|
return;
|
|
}
|
|
throw new Error(`No side to cut ${x},${y} from`);
|
|
};
|
|
/** The current location and party, then the last pages, for a failure. */
|
|
trace = () =>
|
|
`${this.world.mapId} ${this.world.x},${this.world.y}; party ${JSON.stringify(
|
|
this.world.state.party.map(({ species, level, hp }) => ({
|
|
species,
|
|
level,
|
|
hp,
|
|
})),
|
|
)}\n${this.log.slice(-30).join("\n---\n")}`;
|
|
}
|