import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { JOHTO, MENU, RAM } from "../src/game/rom/profile"; import { initialMagikarpRecord, magikarpHouseSign } from "../src/game/scripts/lake-of-rage"; import { magikarpLength, magikarpLengthBytes } from "../src/game/battle/battle"; import { revisionAddresses, romRevision, useRevision } from "../src/game/rom/revision"; import { RomReader } from "../src/game/rom/reader"; import { RELEASES_IN_PROGRESS, validateRom } from "../src/rom/validate"; import { defaultTextContext, readString } from "../src/game/scripts/text"; import { textTiles } from "../src/game/rom/font"; import { dexHeight, dexWeight } from "../src/game/rom/pokedex"; import { Playthrough } from "./playthrough-driver"; /** The European releases at the repository root, each skipped when absent. */ const releases = [ { language: "fr", rom: "Pokemon - Version Cristal (France).gbc", release: RELEASES_IN_PROGRESS[0] }, { language: "de", rom: "Pokemon - Kristall-Edition (Germany).gbc", release: RELEASES_IN_PROGRESS[1] }, { language: "it", rom: "Pokemon - Versione Cristallo (Italy).gbc", release: RELEASES_IN_PROGRESS[2] }, { language: "es", rom: "Pokemon - Edicion Cristal (Spain).gbc", release: RELEASES_IN_PROGRESS[3] }, ] as const; const english = "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; for (const { language, rom, release } of releases) describe.skipIf(!existsSync(rom))(`the ${language} release`, () => { const bytes = existsSync(rom) ? new Uint8Array(readFileSync(rom)) : new Uint8Array(); it("is recognized from its header, then refused until its logic is ported", async () => { const assets = new CrystalAssets(bytes); expect(romRevision(assets.reader)).toBe(language); await expect(validateRom(new File([bytes], "crystal.gbc"))).rejects.toThrow( `This is the ${release.name.replace("Pokémon Crystal · ", "")} release`, ); }); it("points the profile at its addresses and back to English for another cartridge", () => { const overrides = revisionAddresses(language).JOHTO; const [key, value] = Object.entries(overrides).find(([, address]) => address > 0)!; new CrystalAssets(bytes); expect((JOHTO as Record)[key]).toBe(value); if (existsSync(english)) { new CrystalAssets(new Uint8Array(readFileSync(english))); expect((JOHTO as Record)[key]).not.toBe(value); new CrystalAssets(bytes); } }); it("decodes its location names and menu strings, and writes them back as tiles", () => { const assets = new CrystalAssets(bytes); const context = { player: "", rival: "", mom: "", strings: {}, weekday: "" }; for (let landmark = 1; landmark < 0x60; landmark++) { const name = assets.landmark(landmark); expect(name.length).toBeGreaterThan(0); // One tile per character, except the release's own word breaks. expect(textTiles(name.replace(/\n/g, " ")).length).toBeGreaterThan(0); } expect(readString(assets.reader, MENU["PlacePartyMonTMHMCompatibility.string_able"], context).length).toBeGreaterThan(0); }); it("plays the introduction and Mom's scene in its language, then steps outside", () => { const p = new Playthrough(new CrystalAssets(bytes)); p.start(); p.world.beginIntro(); p.play([0, "", 1, "", 1, 0, "KAWA"]); expect(p.world.intro).toBe(false); expect(p.world.name).toBe("KAWA"); // The release's own words: none of the English introduction's. const log = p.log.join(" "); expect(log.length).toBeGreaterThan(200); expect(log).not.toContain("Welcome to the world of POKéMON!"); p.reload(); p.walk(7, 0); expect(p.world.mapId).toBe("24:6"); p.walk(9, 3); expect(p.world.move("down")).toBe(true); p.play([1, 1, 1, 1, 1]); p.reload(); p.walk(6, 7); expect(p.world.move("down")).toBe(true); p.play(); expect(p.world.mapId).toBe("24:4"); expect(p.world.notice).toBe(""); }); }); describe("the European releases' word order", () => { it("places the enemy's nickname before EnemyText in French and Italian", () => { // on the enemy's turn ($5a), then the terminator. const reader = new RomReader(new Uint8Array([0x5a, 0x50])); const name = (revision: Parameters[0], prefix: string) => { useRevision(revision); return readString(reader, 0, { ...defaultTextContext("KAWA"), battle: { turn: 1, player: "CYNDAQUIL", enemy: "RATTATA", enemyPrefix: prefix }, }); }; expect(name("fr", " ennemi")).toBe("RATTATA ennemi"); expect(name("it", " nemico")).toBe("RATTATA nemico"); expect(name("de", "Gegn. ")).toBe("Gegn. RATTATA"); expect(name("1.1", "Enemy ")).toBe("Enemy RATTATA"); }); }); describe.skipIf(!existsSync(releases[0].rom))("the European releases' Magikarp", () => { it("measure in millimetres and print a tenth of them, from 105.3 by the release's own holder", () => { const bytes = new Uint8Array(readFileSync(releases[0].rom)); const assets = new CrystalAssets(bytes); const record = initialMagikarpRecord(assets.reader); // InitializeMagikarpHouse's bytes: 1053 mm. expect([record.feet, record.inches]).toEqual([0x04, 0x1d]); expect(record.holder).not.toBe("RALPH"); const strings: Record = {}; magikarpHouseSign({ strings, write: () => {} } as never, record); expect(strings[RAM.stringBuffer1]).toBe("105.3"); // CalcMagikarpLength stores the millimetres (English converts them). const [high, low] = magikarpLengthBytes(assets.reader, 0x1234, 0x5678); expect((high << 8) | low).toBe(magikarpLength(assets.reader, 0x1234, 0x5678).mm); }); }); describe.skipIf(!existsSync(releases[0].rom))("the European releases' Pokédex", () => { it("reads metric heights and weights and prints them in metres and kilograms", () => { for (const { rom } of releases) { if (!existsSync(rom)) continue; const dex = new CrystalAssets(new Uint8Array(readFileSync(rom))).pokedex; // PIKACHU: 0.4 m and 6.0 kg; its pages still follow. const entry = dex.entry(25); expect([entry.height, entry.weight]).toEqual([4, 60]); expect(entry.pages[0].length).toBeGreaterThan(10); const height = dexHeight(dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"), entry.height); const weight = dexWeight(dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight"), entry.weight); expect(height).toMatch(/^\S+ +0\.4m$/); expect(weight).toMatch(/^\S+ +6\.0kg$/); } }); });