Open Route 32 to Route 34, the Ruins of Alph and Azalea Town (#32)
Enable Route 32 and its Pokémon Center, the Ruins of Alph with the four chambers, item and word rooms, inner chamber and research center, Union Cave, Route 33, Azalea Town with Kurt's house, the kiln and Bugsy's gym, the Slowpoke Well, Ilex Forest and Route 34. Port what their scripts need: saved WRAM bytes by label (readmem, writemem, loadmem: wFarfetchdPosition, wKurtApricornQuantity), VAR_UNOWNCOUNT and VAR_KURT_APRICORNS, SelectApricornForKurt and verbosegiveitemvar, Unown letters (GetUnownLetter, per-letter pictures, CheckUnownLetter's redraw, UpdateUnownDex, wFirstUnownSeen), the chamber puzzles (_UnownPuzzle), DisplayUnownWords, HoOhChamber and OmanyteChamber. Minigames and specials that draw a whole screen in tiles now share a tile screen effect with ROM graphics, palettes and sprites, answered with buttons; the standard font and textbox frame load for them. The playthrough continues through Kurt's Slowpoke Well, Bugsy, the rival and the Farfetch'd to the Route 34 gate; its helpers move to tests/playthrough-driver.ts. New tests solve the Kabuto puzzle through the world, catch Unown and make Kurt's balls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
15a31f95e4
commit
f4ef244fc7
31 files changed
+2334
-528
No files matched your search
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { PokemonData } from "../src/game/rom/pokemon";
|
||||
import { JOHTO } from "../src/game/rom/profile";
|
||||
import { RomReader } from "../src/game/rom/reader";
|
||||
import { generatePartyMon, updateUnownDex } from "../src/game/party/pokemon";
|
||||
import { unownUnlocked } from "../src/game/battle/battle";
|
||||
import {
|
||||
initialPieces,
|
||||
moveCursor,
|
||||
solved,
|
||||
} from "../src/game/scripts/unown-puzzle";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
import { battleFixtureWorld } from "./battle-fixture";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const UNOWN = 201;
|
||||
|
||||
describe("GetUnownLetter", () => {
|
||||
it("takes the middle two bits of each DV, divided by 10, plus one", () => {
|
||||
expect(PokemonData.unownLetter(0x0000)).toBe(1);
|
||||
// Every middle bit set: $ff / 10 = 25, letter Z.
|
||||
expect(PokemonData.unownLetter(0x6666)).toBe(26);
|
||||
// Attack %0110 0000 alone: $c0 / 10 = 19, letter T.
|
||||
expect(PokemonData.unownLetter(0x6000)).toBe(20);
|
||||
// Special's bits are the lowest: 3 / 10 = 0.
|
||||
expect(PokemonData.unownLetter(0x0006)).toBe(1);
|
||||
for (let dvs = 0; dvs < 0x10000; dvs += 37) {
|
||||
const letter = PokemonData.unownLetter(dvs);
|
||||
expect(letter).toBeGreaterThanOrEqual(1);
|
||||
expect(letter).toBeLessThanOrEqual(26);
|
||||
}
|
||||
});
|
||||
it("records each new letter once, and the first in the party", () => {
|
||||
const record = { unownDex: [] as number[], firstUnownSeen: 0 };
|
||||
updateUnownDex(record, { species: UNOWN, dvs: 0x6666 }, false);
|
||||
expect(record).toEqual({ unownDex: [26], firstUnownSeen: 0 });
|
||||
updateUnownDex(record, { species: UNOWN, dvs: 0x0000 });
|
||||
updateUnownDex(record, { species: UNOWN, dvs: 0x6666 });
|
||||
updateUnownDex(record, { species: 16, dvs: 0x1234 });
|
||||
expect(record).toEqual({ unownDex: [26, 1], firstUnownSeen: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Unown puzzle's rules", () => {
|
||||
it("moves the cursor around START and CANCEL", () => {
|
||||
expect(moveCursor(0, "up")).toBeUndefined();
|
||||
expect(moveCursor(0, "left")).toBeUndefined();
|
||||
expect(moveCursor(5, "right")).toBeUndefined();
|
||||
expect(moveCursor(7, "down")).toBe(13);
|
||||
// Below the inner pieces' last row lies START/CANCEL.
|
||||
expect(moveCursor(25, "down")).toBeUndefined();
|
||||
expect(moveCursor(24, "down")).toBe(30);
|
||||
expect(moveCursor(30, "right")).toBe(35);
|
||||
expect(moveCursor(35, "left")).toBe(30);
|
||||
expect(moveCursor(30, "down")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the Ruins of Alph with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const assets = () => new CrystalAssets(bytes);
|
||||
const KABUTO_CHAMBER = "3:24",
|
||||
INNER_CHAMBER = "3:27";
|
||||
const EVENT_SOLVED_KABUTO_PUZZLE = 0x2a1;
|
||||
|
||||
it("places the sixteen pieces on the border, and knows the solution", () => {
|
||||
const reader = new RomReader(bytes);
|
||||
let seed = 3;
|
||||
const pieces = initialPieces({
|
||||
reader,
|
||||
random: () => (seed = (seed * 75 + 74) % 65537) & 0xff,
|
||||
});
|
||||
expect(pieces.filter(Boolean).sort((a, b) => a - b)).toEqual(
|
||||
Array.from({ length: 16 }, (_, i) => i + 1),
|
||||
);
|
||||
// Only the outer ring and the corners of the bottom row are used.
|
||||
pieces.forEach((piece, place) => {
|
||||
const row = Math.floor(place / 6),
|
||||
column = place % 6;
|
||||
if (piece) expect(row === 0 || row === 5 || column === 0 || column === 5).toBe(true);
|
||||
});
|
||||
const done = Array<number>(36).fill(0);
|
||||
for (let piece = 1; piece <= 16; piece++)
|
||||
done[7 + ((piece - 1) >> 2) * 6 + ((piece - 1) & 3)] = piece;
|
||||
expect(solved(reader, done)).toBe(true);
|
||||
expect(solved(reader, pieces)).toBe(false);
|
||||
});
|
||||
|
||||
it("only lets wild Unown appear with letters from the unlocked sets", () => {
|
||||
const data = new PokemonData(new RomReader(bytes));
|
||||
for (let dvs = 0; dvs < 0x10000; dvs += 7) {
|
||||
const letter = PokemonData.unownLetter(dvs);
|
||||
expect(unownUnlocked(data, 0, dvs)).toBe(false);
|
||||
expect(unownUnlocked(data, 1, dvs)).toBe(letter <= 11);
|
||||
expect(unownUnlocked(data, 1 << 3, dvs)).toBe(letter >= 24);
|
||||
expect(unownUnlocked(data, 0x0f, dvs)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/** Solve the puzzle on screen as a player would: read the pieces from
|
||||
* the tilemap, then carry each to its place. */
|
||||
const solvePuzzle = (world: GameWorld) => {
|
||||
const r = world.assets.reader;
|
||||
const corners = Array.from({ length: 17 }, (_, piece) =>
|
||||
r.byte(JOHTO["GetCurrentPuzzlePieceVTileCorner.Corners"] + piece),
|
||||
);
|
||||
const placeCell = (place: number) => {
|
||||
const cell = r.word(JOHTO.UnownPuzzleCoordData + place * 6 + 2) - 0xc4a0;
|
||||
return cell;
|
||||
};
|
||||
const screen = () => {
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "screen") throw new Error(`No puzzle on screen: ${world.diagnostic}`);
|
||||
return choice.screen;
|
||||
};
|
||||
const pieces = () =>
|
||||
Array.from({ length: 36 }, (_, place) => {
|
||||
const tile = screen().map[placeCell(place)];
|
||||
const piece = corners.indexOf(tile, 1);
|
||||
return piece > 0 ? piece : 0;
|
||||
});
|
||||
let cursor = 0;
|
||||
const goTo = (target: number) => {
|
||||
// Breadth first over the cursor's moves.
|
||||
const previous = new Map<number, [number, string]>([[cursor, [-1, ""]]]);
|
||||
const queue = [cursor];
|
||||
while (queue.length) {
|
||||
const at = queue.shift()!;
|
||||
for (const direction of ["up", "down", "left", "right"]) {
|
||||
const next = moveCursor(at, direction);
|
||||
if (next === undefined || previous.has(next)) continue;
|
||||
previous.set(next, [at, direction]);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
const path: string[] = [];
|
||||
for (let at = target; at !== cursor; at = previous.get(at)![0])
|
||||
path.unshift(previous.get(at)![1]);
|
||||
for (const direction of path) world.choose(direction);
|
||||
cursor = target;
|
||||
};
|
||||
for (let piece = 1; piece <= 16; piece++) {
|
||||
const from = pieces().indexOf(piece);
|
||||
const to = 7 + ((piece - 1) >> 2) * 6 + ((piece - 1) & 3);
|
||||
goTo(from);
|
||||
world.choose("a");
|
||||
goTo(to);
|
||||
world.choose("a");
|
||||
}
|
||||
};
|
||||
|
||||
it("solves the Kabuto puzzle, unlocks A to K and drops into the inner chamber", () => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
world.restore({ ...world.save(), map: KABUTO_CHAMBER, x: 3, y: 3, direction: "up" });
|
||||
settle(world);
|
||||
// The floor is closed until the puzzle is solved.
|
||||
expect(world.assets.permission(world.assets.collision(world.map, 3, 3))).toBe(0);
|
||||
world.interact();
|
||||
for (let i = 0; i < 20 && world.dialogue?.choice?.kind !== "screen"; i++) world.tick();
|
||||
// START quits without solving.
|
||||
world.openStartMenu();
|
||||
settle(world);
|
||||
expect(world.state.events.has(EVENT_SOLVED_KABUTO_PUZZLE)).toBe(false);
|
||||
world.interact();
|
||||
for (let i = 0; i < 20 && world.dialogue?.choice?.kind !== "screen"; i++) world.tick();
|
||||
solvePuzzle(world);
|
||||
// The finished picture waits for A or B.
|
||||
expect(world.dialogue?.choice?.kind).toBe("screen");
|
||||
world.choose("a");
|
||||
settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.mapId).toBe(INNER_CHAMBER);
|
||||
expect(world.state.events.has(EVENT_SOLVED_KABUTO_PUZZLE)).toBe(true);
|
||||
expect(world.state.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the wall's word in Unown letters", () => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
world.restore({ ...world.save(), map: KABUTO_CHAMBER, x: 3, y: 1, direction: "up" });
|
||||
settle(world);
|
||||
world.interact();
|
||||
for (let i = 0; i < 40 && world.dialogue?.choice?.kind !== "screen"; i++) {
|
||||
if (world.dialogue && !world.dialogue.choice) world.advanceDialogue();
|
||||
else world.tick();
|
||||
}
|
||||
const choice = world.dialogue?.choice;
|
||||
expect(choice?.kind).toBe("screen");
|
||||
if (choice?.kind !== "screen") return;
|
||||
expect(choice.screen.overlay).toBe(true);
|
||||
// UNOWNWORDS_ESCAPE: six letters, two tiles wide, in bank 1.
|
||||
const drawn = choice.screen.map.filter((tile) => tile >= 0x100);
|
||||
expect(drawn.length).toBe(6 * 4);
|
||||
world.choose("b");
|
||||
settle(world);
|
||||
expect(world.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("meets Unown in the inner chamber once a set is unlocked, and records them", () => {
|
||||
// The battle fixture's encounter tables would replace the chamber's.
|
||||
let seed = 5;
|
||||
const world = new GameWorld(assets(), () => new Date("2026-09-22T12:00:00"), () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
});
|
||||
world.state.party.push(
|
||||
generatePartyMon(world.assets.pokemon, {
|
||||
species: 157, level: 50, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0,
|
||||
}),
|
||||
);
|
||||
world.state.flags.add(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K);
|
||||
world.state.bag.balls = [{ item: 1, quantity: 30 }]; // MASTER BALL
|
||||
world.restore({ ...world.save(), map: INNER_CHAMBER, x: 10, y: 8 });
|
||||
settle(world);
|
||||
let caught = false;
|
||||
for (let step = 0; step < 400 && !caught; step++) {
|
||||
world.move(step & 1 ? "up" : "down");
|
||||
for (let tick = 0; tick < 4000; tick++) {
|
||||
const battle = world.battle;
|
||||
if (battle) {
|
||||
if (battle.prompt?.kind === "action") {
|
||||
const ball = world.liveState.bag.balls.findIndex((slot) => slot.item === 1);
|
||||
if (battle.engine.enemy.mon.species === UNOWN && ball >= 0)
|
||||
world.battleAction({ action: "item", item: 1, slot: ball });
|
||||
else world.battleAction({ action: "run" });
|
||||
} else if (battle.prompt?.kind === "name") world.battleName("");
|
||||
else if (battle.prompt?.kind === "yesno") world.battleChoose(0);
|
||||
else if (battle.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
continue;
|
||||
}
|
||||
if (world.dialogue) {
|
||||
if (world.dialogue.choice) world.choose(0);
|
||||
else world.advanceDialogue();
|
||||
continue;
|
||||
}
|
||||
if (!world.busy) break;
|
||||
world.tick();
|
||||
}
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
caught = world.state.party.some((member) => member.species === UNOWN);
|
||||
}
|
||||
expect(caught).toBe(true);
|
||||
const unown = world.state.party.find((member) => member.species === UNOWN)!;
|
||||
const letter = PokemonData.unownLetter(unown.dvs);
|
||||
expect(letter).toBeLessThanOrEqual(11);
|
||||
expect(world.state.unownDex).toEqual([letter]);
|
||||
expect(world.state.firstUnownSeen).toBeGreaterThanOrEqual(1);
|
||||
// VAR_UNOWNCOUNT counts the letters.
|
||||
expect(world.state.unownDex.length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user