From 3e92936cb94021090ef49b65ad3d42328e701efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= Date: Thu, 24 Sep 2026 09:33:33 +0200 Subject: [PATCH] Add scripted trainer battle foundation and native move AI --- docs/ARCHITECTURE.md | 2 +- docs/NATIVE_PORT.md | 10 +- docs/PLAN.md | 8 ++ scripts/check-reference.mjs | 15 +- src/game/battle/ai.ts | 244 ++++++++++++++++++++++++++++++++ src/game/battle/battle.ts | 175 +++++++++++++++++++---- src/game/battle/constants.ts | 37 +++++ src/game/rom/assets.ts | 1 + src/game/rom/profile.ts | 13 ++ src/game/rom/trainers.ts | 197 ++++++++++++++++++++++++++ src/game/scripts/interpreter.ts | 46 +++++- src/game/scripts/text.ts | 2 + src/game/world/types.ts | 2 + src/game/world/world.ts | 19 +++ tests/battle-ai.test.ts | 207 +++++++++++++++++++++++++++ tests/trainer-battle.test.ts | 151 ++++++++++++++++++++ tests/trainer-browser.spec.ts | 63 +++++++++ tests/trainer-fixture.ts | 30 ++++ tests/trainer-world.test.ts | 107 ++++++++++++++ tests/trainers.test.ts | 120 ++++++++++++++++ 20 files changed, 1417 insertions(+), 32 deletions(-) create mode 100644 src/game/battle/ai.ts create mode 100644 src/game/rom/trainers.ts create mode 100644 tests/battle-ai.test.ts create mode 100644 tests/trainer-battle.test.ts create mode 100644 tests/trainer-browser.spec.ts create mode 100644 tests/trainer-fixture.ts create mode 100644 tests/trainer-world.test.ts create mode 100644 tests/trainers.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 182cce0..0dbf1f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,4 +48,4 @@ Asset caches and the ROM reader remain in memory for lazy extraction. Neither RO The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling. -`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; no production map has encounters enabled yet. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state. +`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Scripted single-Pokémon trainer battles share that core, with ROM trainer decoders and explicit AI support checks; test fixtures exercise these without opening new production maps. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state. diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index adf7e1c..70cad6e 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -24,7 +24,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta | Native module | PRET reference | Evidence and limits | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `game/rom/lz.ts` | `home/decompress.asm` | All LZ command types have unit vectors; six tilesets match original ASM execution byte-for-byte. | -| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only the six ported maps can be entered. Location names, scene-variable owners and connection offsets come from the ROM. | +| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only maps explicitly listed in NATIVE_MAPS can be entered. Location names, scene-variable owners and connection offsets come from the ROM. | | Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Terrain animation and other special palettes pending. | | Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Dynamic Pokémon icons and room decorations pending. | | `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Surfing remains absent; turn timing still needs comparison. | @@ -67,6 +67,14 @@ Native production is `dist/`; the optional reference output is `dist-reference/` Native saves are version 3. Version 2 added scene values by owning map, phone contacts, the weekday offset and DST flag, and positions of objects that scripts moved on the current map. Version 3 adds the player ID, party, bag, Pokédex seen/caught sets and money. Early version-3 backups without money receive the original starting balance; explicitly invalid balances are rejected. Restores recompute each Pokémon's stats from its DVs and reject any mismatch, and check every item against its ROM pocket. Older saves migrate with new-game defaults; a missing player ID is drawn as a new game draws it. Objects are restored from the save on continue, and only the TILES callback runs, as in `MapSetupScript_Continue`. +## Scripted trainer battle foundation + +`game/rom/trainers.ts` decodes `TrainerGroups`, `TrainerClassAttributes`, `TrainerClassNames`, `TrainerClassDVs` and map trainer headers. Tests cover all 542 ROM records across 67 classes; CAL2 construction refuses its SRAM-dependent Mystery Gift party. + +`game/battle/ai.ts` translates `AIChooseMove`, `AI_Redundant` and the Basic, Setup, Types, Offensive, Cautious and Status layers. It preserves the original random tie selection and known redundancy quirks. Smart, Opportunist, Aggressive and Risky remain refused, including the rival's AI. Synthetic scoring vectors establish the tested branches; original-engine AI execution parity is still pending. + +`loadtrainer`, `winlosstext` and `startbattle` now run single-Pokémon trainer battles through the existing battle UI. Tests cover Joey's ROM party/text/reward, RUN refusal, trainer experience, Amulet Coin, CANLOSE behavior, money limits, transactional rollback and midbattle restore. Chromium and WebKit exercise the UI with a test-only script. Production map gates are unchanged. Trainer items, party switching, sight lines, trainer-object interaction and the rival round trip remain unported. + ## Next slice Route 29 and Cherrygrove are enabled with item balls, fruit trees and the daily reset, the Pokémon Center nurse and the recorded whiteout spawn. NPCs roam. Next: trainer sight lines and trainer battles, capture with the catching tutorial, and party, bag and Pokégear menus over the native models. Other specials stay refused until translated. diff --git a/docs/PLAN.md b/docs/PLAN.md index e57198e..e07491f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -59,6 +59,14 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati - NPCs roam as `HandleNPCStep` does: random walks (Y, X, XY) and slow/fast spins from `SpriteMovementData`, `CanObjectMoveInDirection` (side walls, land, objects' current and previous tiles, radius stored plus one, screen edge), random sleeps, freezing during text and `applymovement`, and deletion/respawn at home beyond the object window. Wandering positions are not saved; objects start from home on reload. - Still refused: the catching tutorial (`catchtutorial`) and Cherrygrove's rival scene, both reachable only after the Mystery Egg errand. +## Implemented: first scripted trainer battle slice + +- ROM decoder for 542 trainer records across 67 classes, all four party formats, class names, DVs, rewards and map trainer headers. SRAM-dependent Mystery Gift parties remain refused. +- Native move scoring for Basic, Setup, Types, Offensive, Cautious and Status, with deterministic tie selection. Other scoring layers remain refused. +- Scripted single-Pokémon trainer battles support the challenge, sending out, RUN refusal, experience, win/loss text and prize money, including Amulet Coin. Script results, party changes and rewards commit together; interrupted saves replay from committed state. +- Local-ROM tests and Chromium/WebKit tests exercise Youngster Joey through a test-only script. No new maps or trainer encounters are enabled in normal play. +- Next: advanced AI, trainer party switching/items, trainer sight lines and normal trainer-object scripts, then the rival and Route 30 story. The rival remains refused because its AI is not yet translated. + ## In progress: native battles The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience and move learning; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Capture, switching, items in battle, trainer battles and the rest of the opening quest remain unfinished. diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs index 8a2b366..a50cf5f 100644 --- a/scripts/check-reference.mjs +++ b/scripts/check-reference.mjs @@ -38,6 +38,12 @@ const symbols = new Map( }), ); const names = { + trainerGroups: "TrainerGroups", + trainerClassNames: "TrainerClassNames", + trainerClassAttributes: "TrainerClassAttributes", + trainerClassDVs: "TrainerClassDVs", + statusOnlyEffects: "StatusOnlyEffects", + residualMoves: "ResidualMoves", mapGroups: "MapGroupPointers", tilesets: "Tilesets", collisions: "CollisionPermissionTable", @@ -136,6 +142,7 @@ const ram = new Map( }), ); const ramNames = { + battleReward: "wBattleReward", stringBuffer1: "wStringBuffer1", stringBuffer2: "wStringBuffer2", stringBuffer3: "wStringBuffer3", @@ -156,12 +163,12 @@ const ramNames = { }; const profile = readFileSync("src/game/rom/profile.ts", "utf8"); for (const [key, symbol] of Object.entries(names)) { - const match = new RegExp(`${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile); + const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile); if (!match || Number(match[1]) !== symbols.get(symbol)) throw new Error(`Profile address mismatch: ${key} / ${symbol}`); } for (const [key, symbol] of Object.entries(ramNames)) { - const match = new RegExp(`${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile); + const match = new RegExp(`\\b${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile); if (!match || Number(match[1]) !== ram.get(symbol)) throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`); } @@ -175,6 +182,8 @@ for (const [key, symbol] of Object.entries(ramNames)) { throw new Error(`Battle symbol mismatch: ${label}`); } } +if (!profile.includes(`NUM_TRAINER_CLASSES = ${(symbols.get("Trainers") - symbols.get("TrainerGroups")) / 2};`)) + throw new Error("Trainer class count does not match the reference."); const eventIds = new Map(); { let next = 0; @@ -201,7 +210,7 @@ const eventNames = { playersHouseBigDoll: "EVENT_PLAYERS_HOUSE_2F_BIG_DOLL", }; for (const [key, name] of Object.entries(eventNames)) { - const match = new RegExp(`${key}:\\s*(\\d+)`).exec(profile); + const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile); if (!match || Number(match[1]) !== eventIds.get(name)) throw new Error(`Profile event mismatch: ${key} / ${name}`); } diff --git a/src/game/battle/ai.ts b/src/game/battle/ai.ts new file mode 100644 index 0000000..aed8ca6 --- /dev/null +++ b/src/game/battle/ai.ts @@ -0,0 +1,244 @@ +import type { PokemonData } from "../rom/pokemon"; +import { SYMBOLS } from "../rom/profile"; +import { applyTypeMatchups, UnsupportedBattle, type BattleMon } from "./battle"; +import type { BattleData } from "./data"; +import { C } from "./constants"; + +/** Structural view of Side. The caller skips AI for lock-in. Struggle is + * decided later by ParseEnemyAction, after AI has consumed its random bytes. */ +export interface TrainerAISide { + mon: Pick< + BattleMon, + "moves" | "pp" | "status" | "hp" | "maxHp" | "types" | "species" | "dvs" + >; + sub: readonly number[]; + screens: number; + turnsTaken: number; + disableCount: number; + disabledMove: number; +} +export interface TrainerAIInput { + weights: number; + player: TrainerAISide; + enemy: TrainerAISide; + data: Pick; + battleData: Pick; + weather: number; + random: () => number; +} + +// AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist, +// Aggressive, Cautious, Status, Risky, then six actual AI_None entries. +const SUPPORTED = 0xfd8f; +export function validateTrainerAI(weights: number): void { + if ( + !Number.isInteger(weights) || + weights < 0 || + weights > 0xffff || + weights & ~SUPPORTED + ) + throw new UnsupportedBattle( + `Trainer AI layers 0x${weights.toString(16)} are not translated.`, + ); +} +const has = (side: TrainerAISide, byte: number, bit: number) => + Boolean(side.sub[byte] & (1 << bit)); +const screen = (side: TrainerAISide, bit: number) => + Boolean(side.screens & (1 << bit)); + +/** AI_Redundant, including the original Nightmare and Future Sight bugs. */ +function redundant(effect: number, input: TrainerAIInput): boolean { + const { player: p, enemy: e, weather } = input; + switch (effect) { + case C.EFFECT_DREAM_EATER: + return !(p.mon.status & 7); + case C.EFFECT_HEAL: + case C.EFFECT_MORNING_SUN: + case C.EFFECT_SYNTHESIS: + case C.EFFECT_MOONLIGHT: + return e.mon.hp === e.mon.maxHp; + case C.EFFECT_LIGHT_SCREEN: + return screen(e, C.SCREENS_LIGHT_SCREEN); + case C.EFFECT_MIST: + return has(e, 4, C.SUBSTATUS_MIST); + case C.EFFECT_FOCUS_ENERGY: + return has(e, 4, C.SUBSTATUS_FOCUS_ENERGY); + case C.EFFECT_CONFUSE: + return has(p, 3, C.SUBSTATUS_CONFUSED) || screen(p, C.SCREENS_SAFEGUARD); + case C.EFFECT_TRANSFORM: + return has(e, 5, C.SUBSTATUS_TRANSFORMED); + case C.EFFECT_REFLECT: + return screen(e, C.SCREENS_REFLECT); + case C.EFFECT_SUBSTITUTE: + return has(e, 4, C.SUBSTATUS_SUBSTITUTE); + case C.EFFECT_LEECH_SEED: + return has(p, 4, C.SUBSTATUS_LEECH_SEED); + case C.EFFECT_DISABLE: + return p.disableCount !== 0; + case C.EFFECT_ENCORE: + return has(p, 5, C.SUBSTATUS_ENCORED); + case C.EFFECT_SNORE: + case C.EFFECT_SLEEP_TALK: + return !(e.mon.status & 7); + case C.EFFECT_MEAN_LOOK: + return has(e, 5, C.SUBSTATUS_CANT_RUN); + case C.EFFECT_NIGHTMARE: + return !p.mon.status || has(p, 1, C.SUBSTATUS_NIGHTMARE); + case C.EFFECT_SPIKES: + return screen(p, C.SCREENS_SPIKES); + case C.EFFECT_FORESIGHT: + return has(p, 1, C.SUBSTATUS_IDENTIFIED); + case C.EFFECT_PERISH_SONG: + return has(p, 1, C.SUBSTATUS_PERISH); + case C.EFFECT_SANDSTORM: + return weather === C.WEATHER_SANDSTORM; + case C.EFFECT_SAFEGUARD: + return screen(e, C.SCREENS_SAFEGUARD); + case C.EFFECT_RAIN_DANCE: + return weather === C.WEATHER_RAIN; + case C.EFFECT_SUNNY_DAY: + return weather === C.WEATHER_SUN; + case C.EFFECT_TELEPORT: + return true; + case C.EFFECT_SWAGGER: + return has(p, 3, C.SUBSTATUS_CONFUSED); + case C.EFFECT_FUTURE_SIGHT: + return screen(e, C.SCREENS_UNUSED); + case C.EFFECT_ATTRACT: { + // CheckOppositeGender uses the original party species and backup DVs. + // These are not exposed by this view for transformed combatants. + if ( + has(p, 5, C.SUBSTATUS_TRANSFORMED) || + has(e, 5, C.SUBSTATUS_TRANSFORMED) + ) + throw new UnsupportedBattle( + "Trainer Attract AI for transformed combatants is not translated.", + ); + const gender = (side: TrainerAISide) => { + const ratio = input.data.baseData(side.mon.species).genderRatio; + if (ratio === 255) return -1; + if (!ratio) return 1; + if (ratio === 254) return 0; + const value = + ((side.mon.dvs >> 8) & 0xf0) | ((side.mon.dvs >> 4) & 0xf); + return value > ratio ? 1 : 0; + }; + const pg = gender(p), + eg = gender(e); + return pg < 0 || eg < 0 || pg === eg || has(p, 1, C.SUBSTATUS_IN_LOVE); + } + default: + return false; + } +} + +/** AIChooseMove and its implemented scoring layers. Scores are the raw + * pre-normalization bytes. Ties use Random & 3 rejection sampling, preserving + * both slot fairness and the original number of random calls. */ +export function chooseTrainerMove(input: TrainerAIInput): { + move: number; + index: number; + scores: number[]; +} { + validateTrainerAI(input.weights); + const { player, enemy, data, battleData, weights, random } = input; + const moves = enemy.mon.moves.slice(0, 4); + const end = moves.indexOf(0); + if (end >= 0) moves.length = end; + if (!moves.length) + throw new UnsupportedBattle( + "Trainer move AI requires at least one nonzero move.", + ); + const attributes = moves.map((move) => data.move(move)); + const scores: number[] = Array.from({ length: 4 }, (_, i) => + moves[i] === enemy.disabledMove || !(enemy.mon.pp[i] & 0x3f) ? 80 : 20, + ); + const add = (i: number, value: number) => { + scores[i] = (scores[i] + value) & 255; + }; + const matchups = battleData.typeMatchups(); + const matchup = (i: number) => + applyTypeMatchups(matchups, { + damage: 0, + modifier: 0, + moveType: attributes[i].type, + userTypes: enemy.mon.types, + targetTypes: player.mon.types, + identified: has(player, 1, C.SUBSTATUS_IDENTIFIED), + }).matchup; + if (weights & 1) { + const statusOnly = battleData.byteList(SYMBOLS.statusOnlyEffects); + attributes.forEach((move, i) => { + if ( + redundant(move.effect, input) || + (statusOnly.includes(move.effect) && + (player.mon.status !== 0 || screen(player, C.SCREENS_SAFEGUARD))) + ) + add(i, 10); + }); + } + if (weights & 2) + attributes.forEach((move, i) => { + const effect = move.effect; + const up = + (effect >= C.EFFECT_ATTACK_UP && effect <= C.EFFECT_EVASION_UP) || + (effect >= C.EFFECT_ATTACK_UP_2 && effect <= C.EFFECT_EVASION_UP_2); + const down = + (effect >= C.EFFECT_ATTACK_DOWN && effect <= C.EFFECT_EVASION_DOWN) || + (effect >= C.EFFECT_ATTACK_DOWN_2 && effect <= C.EFFECT_EVASION_DOWN_2); + if (!up && !down) return; + const turns = up ? enemy.turnsTaken : player.turnsTaken; + if (!turns) { + if (random() >= 128) add(i, -2); + } else if (random() >= 30) add(i, 2); + }); + if (weights & 4) + attributes.forEach((move, i) => { + const value = matchup(i); + if (!value) add(i, 10); + else if (value > 10 && move.power) add(i, -1); + else if ( + value < 10 && + attributes.some((other) => other.type !== move.type && other.power) + ) + add(i, 1); + }); + if (weights & 8) + attributes.forEach((move, i) => { + if (!move.power) add(i, 2); + }); + if (weights & 128 && enemy.turnsTaken) { + const residual = battleData.byteList(SYMBOLS.residualMoves); + for (let i = 0; i < moves.length; i++) { + if (!residual.includes(moves[i])) continue; + // Original ret nc exits the entire layer, not just this move. + if (random() >= 230) break; + add(i, 1); + } + } + if (weights & 256) + attributes.forEach((move, i) => { + const poison = + move.effect === C.EFFECT_POISON || move.effect === C.EFFECT_TOXIC; + if ( + poison || + move.effect === C.EFFECT_SLEEP || + move.effect === C.EFFECT_PARALYZE || + move.power + ) { + if ((poison && player.mon.types.includes(C.POISON)) || !matchup(i)) + add(i, 10); + } + }); + // A zero score would wrap to 255 on the first decrement in the ROM. + const effective = scores.slice(0, moves.length).map((score) => score || 256); + const minimum = Math.min(...effective); + for (let attempt = 0; attempt < 4096; attempt++) { + const index = random() & 3; + if (index < moves.length && effective[index] === minimum) + return { move: moves[index], index, scores }; + } + throw new UnsupportedBattle( + "Trainer move selection random source did not produce a candidate.", + ); +} diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index c37aa99..52b45bc 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -5,6 +5,7 @@ import { calcStats, type PartyMon } from "../party/pokemon"; import { readString, readTextEnd, type TextContext } from "../scripts/text"; import { BattleData } from "./data"; import { C, COMMAND } from "./constants"; +import { chooseTrainerMove, validateTrainerAI } from "./ai"; /** Raised for battle rules that have not been translated. The battle is * discarded, as if it never started. */ @@ -91,7 +92,17 @@ export type BattleEffect = | { kind: "stats"; labels: string[]; values: number[] }; export type BattleAction = { action: "fight"; move: number } | { action: "run" }; export type BattleInput = number | BattleAction | undefined; -export type BattleOutcome = { result: number }; +export type BattleOutcome = { result: number; prizeMoney?: number }; + +export interface BattleTrainer { + name: string; + className: string; + party: PartyMon[]; + aiFlags: number; + reward: number; + winText?: number; + lossText?: number; +} type Gen = Generator; @@ -104,7 +115,9 @@ export interface BattleOptions { /** wTimeOfDay: morning 0, day 1, night 2. */ timeOfDay: number; random: () => number; - wild: { species: number; level: number }; + wild?: { species: number; level: number }; + trainer?: BattleTrainer; + canLose?: boolean; text: () => TextContext; } @@ -214,9 +227,9 @@ export function applyTypeMatchups( return { damage, modifier: (modifier & C.STAB_DAMAGE) | matchup, matchup, missed }; } -/** A native translation of Crystal's battle engine for wild battles. +/** A native translation of Crystal's battle engine. * The move effect scripts are the ROM's own; each battle command is a - * native translation. Link, trainer, contest and item paths are refused. */ + * native translation. Unsupported AI, switching, contest and item paths are refused. */ export class Battle { readonly data: BattleData; readonly player = new Side(); @@ -255,6 +268,7 @@ export class Battle { constructor(private options: BattleOptions) { this.data = new BattleData(options.data.reader); this.matchups = this.data.typeMatchups(); + if (options.trainer) this.battleMode = C.TRAINER_BATTLE; } private get reader(): RomReader { return this.options.data.reader; @@ -278,6 +292,9 @@ export class Battle { const base = this.options.text(); return { ...base, + trainer: this.options.trainer + ? `${this.options.trainer.className} ${this.options.trainer.name}`.trim() + : base.trainer, strings: { ...base.strings, [RAM.battleMonNickname]: this.player.mon?.nickname ?? "", @@ -358,6 +375,36 @@ export class Battle { /** LoadEnemyMon for a wild Pokémon from a grass or water table. */ private loadEnemyMon() { + const trainer = this.options.trainer; + if (trainer) { + const mon = trainer.party[0], + base = this.options.data.baseData(mon.species); + this.enemy.mon = { + species: mon.species, + item: mon.item, + moves: [...mon.moves], + dvs: mon.dvs, + pp: [...mon.pp], + happiness: mon.happiness, + level: mon.level, + status: mon.status, + hp: mon.hp, + maxHp: mon.stats[0], + stats: mon.stats.slice(1), + types: [...base.types], + nickname: mon.nickname, + }; + this.enemyBase = { + stats: base.stats.slice(0, 5), + catchRate: base.catchRate, + exp: base.baseExp, + }; + this.enemy.stats = [...this.enemy.mon.stats]; + this.applyStatusEffects(this.enemy); + this.options.pokedex.seen.add(mon.species); + return; + } + if (!this.options.wild) throw new UnsupportedBattle("Missing encounter."); const { species, level } = this.options.wild; if (species === C.UNOWN || species === C.MAGIKARP) throw new UnsupportedBattle("Unown and Magikarp encounters are not ported yet."); @@ -452,19 +499,65 @@ export class Battle { // ---------------------------------------------------------------- flow - /** StartBattle through ExitBattle for a wild encounter. */ + /** StartBattle through ExitBattle. */ *run(): Gen { + const trainer = this.options.trainer; + if (trainer) { + validateTrainerAI(trainer.aiFlags); + if (trainer.party.length !== 1) + throw new UnsupportedBattle( + "Trainer party switching is not ported yet.", + ); + if ( + !Number.isInteger(trainer.reward) || + trainer.reward < 0 || + trainer.reward > C.MAX_MONEY + ) + throw new UnsupportedBattle("Invalid trainer reward."); + } this.loadEnemyMon(); this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0); - if (this.curBattleMon < 0) throw new UnsupportedBattle("No Pokémon can fight."); - yield* this.say(BATTLE.WildPokemonAppearedText); + if (this.curBattleMon < 0) + throw new UnsupportedBattle("No Pokémon can fight."); + if (trainer) { + yield* this.say(BATTLE.WantsToBattleText); + yield* this.say(BATTLE.BattleText_EnemySentOut); + } else yield* this.say(BATTLE.WildPokemonAppearedText); this.participants = 1 << this.curBattleMon; this.initBattleMon(); + const amuletCoin = + this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN; this.newStatus(this.player); this.newStatus(this.enemy); yield* this.say(BATTLE.GoMonText); yield this.hud(); yield* this.battleTurn(); + if (trainer && (this.battleResult & 0x3f) === C.WIN) { + yield* this.say(BATTLE.BattleText_EnemyWasDefeated); + if (this.options.canLose) { + for (const mon of this.options.party) { + mon.hp = mon.stats[0]; + mon.status = 0; + mon.pp = mon.moves.map((move, slot) => { + if (!move) return 0; + const ups = mon.pp[slot] >> 6, + base = this.options.data.move(move).pp; + return ( + (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups) + ); + }) as PartyMon["pp"]; + } + this.initBattleMon(); + yield this.hud(); + } + if (trainer.winText) yield* this.say(trainer.winText); + const prizeMoney = trainer.reward * (amuletCoin ? 2 : 1); + this.numbers[RAM.battleReward] = prizeMoney; + yield* this.say(BATTLE.GotMoneyForWinningText); + return { result: this.battleResult, prizeMoney }; + } + if (trainer && this.options.canLose && trainer.lossText) + yield* this.say(trainer.lossText); return { result: this.battleResult }; } @@ -474,6 +567,17 @@ export class Battle { this.player.justGotFrozen = 0; this.enemy.justGotFrozen = 0; this.updateBattleMonInParty(); + // AIChooseMove runs once before BattleMenu, even with exhausted PP. + if (this.options.trainer && !this.checkEnemyLockedIn()) { + const choice = chooseTrainerMove({ + weights: this.options.trainer.aiFlags, + player: this.player, enemy: this.enemy, + data: this.options.data, battleData: this.data, weather: this.weather, + random: () => this.random(), + }); + this.enemy.moveNum = choice.index; + this.enemy.curMove = choice.move; + } if (!this.checkPlayerLockedIn()) { for (;;) { const escaped = yield* this.battleMenu(); @@ -574,27 +678,39 @@ export class Battle { this.parseEnemyAction(); return true; } - /** ParseEnemyAction for a wild Pokémon: a random usable move. */ + /** ParseEnemyAction: use the trainer AI selection or a random wild move. */ private parseEnemyAction() { const enemy = this.enemy, mon = enemy.mon; if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn()) - throw new UnsupportedBattle("Locked-in and encored wild moves are not ported yet."); + throw new UnsupportedBattle( + "Locked-in and encored enemy moves are not ported yet.", + ); let move: number = C.STRUGGLE; - for (let slot = 0; slot < 4; slot++) { - const candidate = mon.moves[slot]; - if (!candidate) break; - if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK)) continue; - for (let guard = 0; guard < 4096; guard++) { - const choice = this.random() & 3; - if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue; - if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue; - enemy.moveNum = choice; - move = mon.moves[choice]; + const usable = mon.moves.some( + (candidate, slot) => + candidate && + candidate !== enemy.disabledMove && + mon.pp[slot] & C.PP_MASK, + ); + if (this.options.trainer && usable) { + move = enemy.curMove; + } else + for (let slot = 0; slot < 4; slot++) { + const candidate = mon.moves[slot]; + if (!candidate) break; + if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK)) + continue; + for (let guard = 0; guard < 4096; guard++) { + const choice = this.random() & 3; + if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue; + if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue; + enemy.moveNum = choice; + move = mon.moves[choice]; + break; + } break; } - break; - } enemy.curMove = move; this.turn = 1; this.updateMoveData(); @@ -640,9 +756,16 @@ export class Battle { /** DetermineMoveOrder: true if the player moves first. */ private determineMoveOrder(): boolean { if (this.battlePlayerAction) return true; - const player = this.data.priority(this.options.data.move(this.player.curMove).effect, C.BASE_PRIORITY); - const enemy = this.data.priority(this.options.data.move(this.enemy.curMove).effect, C.BASE_PRIORITY); - const priority = (move: number, value: number) => (move === C.VITAL_THROW ? 0 : value); + const player = this.data.priority( + this.options.data.move(this.player.curMove).effect, + C.BASE_PRIORITY, + ); + const enemy = this.data.priority( + this.options.data.move(this.enemy.curMove).effect, + C.BASE_PRIORITY, + ); + const priority = (move: number, value: number) => + move === C.VITAL_THROW ? 0 : value; const playerPriority = priority(this.player.curMove, player), enemyPriority = priority(this.enemy.curMove, enemy); if (playerPriority !== enemyPriority) return playerPriority > enemyPriority; @@ -732,6 +855,10 @@ export class Battle { /** TryToRunAwayFromBattle for a wild battle. Returns true on escape. */ private *tryToRunAway(): Gen { + if (this.options.trainer) { + yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle); + return false; + } if (this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.player.wrapCount) { yield* this.say(BATTLE.BattleText_CantEscape); return false; diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts index 89ebd10..eac212c 100644 --- a/src/game/battle/constants.ts +++ b/src/game/battle/constants.ts @@ -43,6 +43,40 @@ export const C = { EFFECT_BEAT_UP: 154, EFFECT_FLY: 155, + EFFECT_SLEEP: 1, + EFFECT_HEAL: 32, + EFFECT_LIGHT_SCREEN: 35, + EFFECT_MIST: 46, + EFFECT_FOCUS_ENERGY: 47, + EFFECT_CONFUSE: 49, + EFFECT_TRANSFORM: 57, + EFFECT_REFLECT: 65, + EFFECT_SUBSTITUTE: 79, + EFFECT_LEECH_SEED: 84, + EFFECT_DISABLE: 86, + EFFECT_ENCORE: 90, + EFFECT_SLEEP_TALK: 97, + EFFECT_MEAN_LOOK: 106, + EFFECT_NIGHTMARE: 107, + EFFECT_SPIKES: 112, + EFFECT_FORESIGHT: 113, + EFFECT_PERISH_SONG: 114, + EFFECT_SANDSTORM: 115, + EFFECT_ATTRACT: 120, + EFFECT_SAFEGUARD: 124, + EFFECT_RAIN_DANCE: 136, + EFFECT_SUNNY_DAY: 137, + EFFECT_TELEPORT: 153, + EFFECT_MORNING_SUN: 132, + EFFECT_SYNTHESIS: 133, + EFFECT_MOONLIGHT: 134, + EFFECT_POISON: 66, + EFFECT_PARALYZE: 67, + EFFECT_ATTACK_UP: 10, + EFFECT_EVASION_UP: 16, + EFFECT_ATTACK_UP_2: 50, + EFFECT_EVASION_UP_2: 56, + // Moves (constants/move_constants.asm) GUST: 16, WHIRLWIND: 18, @@ -175,6 +209,8 @@ export const C = { SCREENS_SAFEGUARD: 2, SCREENS_LIGHT_SCREEN: 3, SCREENS_REFLECT: 4, + SCREENS_UNUSED: 5, + WEATHER_SANDSTORM: 3, WEATHER_NONE: 0, WEATHER_RAIN: 1, WEATHER_SUN: 2, @@ -189,6 +225,7 @@ export const C = { // Battle modes, types and results WILD_BATTLE: 1, TRAINER_BATTLE: 2, + HELD_AMULET_COIN: 76, BATTLETYPE_NORMAL: 0, BATTLETYPE_DEBUG: 2, BATTLETYPE_TUTORIAL: 3, diff --git a/src/game/rom/assets.ts b/src/game/rom/assets.ts index 1e4fbd5..7316e25 100644 --- a/src/game/rom/assets.ts +++ b/src/game/rom/assets.ts @@ -167,6 +167,7 @@ export class CrystalAssets { x: r.byte(p + 2) - 4, movement: r.byte(p + 3), radius: r.byte(p + 4), + sightRange: r.byte(p + 8), hour1: r.byte(p + 5), hour2: r.byte(p + 6), palette: r.byte(p + 7) >> 4, diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index 94096b9..cf4991a 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -3,6 +3,12 @@ * No original graphics, maps, dialogue, music or ROM instructions live here. * See docs/NATIVE_PORT.md for source references and verification procedure. */ export const SYMBOLS = { + trainerGroups: 0x39999, + trainerClassNames: 0x2c1ef, + trainerClassAttributes: 0x3959c, + trainerClassDVs: 0x270d6, + statusOnlyEffects: 0x385db, + residualMoves: 0x39446, mapGroups: 0x94000, tilesets: 0x4d596, collisions: 0x4ce1f, @@ -104,6 +110,7 @@ export const SPECIAL_ROUTINES = { /** Work RAM addresses that the event and text formats name as operands. */ export const RAM = { + battleReward: 0xc686, stringBuffer1: 0xd073, stringBuffer2: 0xd086, stringBuffer3: 0xd099, @@ -141,6 +148,11 @@ export const ENGINE_FLAGS = { /** Battle engine tables and texts, keyed by their PRET labels so that * \`npm run check:reference\` can verify each address by name. */ export const BATTLE = { + WantsToBattleText: 0x807a9, + BattleText_EnemyWasDefeated: 0x809da, + GotMoneyForWinningText: 0x809be, + BattleText_EnemySentOut: 0x80af8, + BattleText_TheresNoEscapeFromTrainerBattle: 0x80b49, AccuracyLevelMultipliers: 0x34eb2, AlreadyAsleepText: 0x811c1, AlreadyConfusedText: 0x80dcc, @@ -254,6 +266,7 @@ export const BATTLE = { export const MAP_GROUP_COUNT = 26; export const NUM_POKEMON = 251; +export const NUM_TRAINER_CLASSES = 67; export const NUM_ITEMS = 190; export const NUM_ENGINE_FLAGS = 162; export const NUM_FRUIT_TREES = 30; diff --git a/src/game/rom/trainers.ts b/src/game/rom/trainers.ts new file mode 100644 index 0000000..8963083 --- /dev/null +++ b/src/game/rom/trainers.ts @@ -0,0 +1,197 @@ +import { RomReader } from "./reader"; +import { PokemonData } from "./pokemon"; +import { NUM_TRAINER_CLASSES, SYMBOLS } from "./profile"; +import { defaultTextContext, readString } from "../scripts/text"; +import { generatePartyMon, type PartyMon } from "../party/pokemon"; + +export interface TrainerAttributes { + items: [number, number]; + baseReward: number; + aiMoveFlags: number; + aiItemSwitchFlags: number; +} +export interface TrainerPartyEntry { + level: number; + species: number; + item: number; + moves?: PartyMon["moves"]; +} +export interface TrainerDefinition { + classId: number; + id: number; + name: string; + className: string; + partyType: 0 | 1 | 2 | 3; + party: TrainerPartyEntry[]; + attributes: TrainerAttributes; + dvs: number; + reward: number; +} +export interface TrainerHeader { + eventFlag: number; + classId: number; + id: number; + seenText: number; + beatenText: number; + lostScript: number; + afterScript: number; +} + +/** ReadTrainerParty, GetTrainerDVs and TrainerClassAttributes. Only the + * cartridge records are decoded; CAL2's SRAM mystery-gift party is unsupported. */ +export class TrainerData { + private pokemon: PokemonData; + constructor(readonly reader: RomReader) { + this.pokemon = new PokemonData(reader); + } + private checkClass(classId: number) { + if ( + !Number.isInteger(classId) || + classId < 1 || + classId > NUM_TRAINER_CLASSES + ) + throw new Error("Unknown trainer class."); + } + className(classId: number): string { + this.checkClass(classId); + let p: number = SYMBOLS.trainerClassNames; + for (let i = 1; i < classId; i++) p = this.nameEnd(p); + return readString(this.reader, p, defaultTextContext("")); + } + attributes(classId: number): TrainerAttributes { + this.checkClass(classId); + const r = this.reader, + p = SYMBOLS.trainerClassAttributes + (classId - 1) * 7; + return { + items: [r.byte(p), r.byte(p + 1)], + baseReward: r.byte(p + 2), + aiMoveFlags: r.word(p + 3), + aiItemSwitchFlags: r.word(p + 5), + }; + } + dvs(classId: number): number { + this.checkClass(classId); + const p = SYMBOLS.trainerClassDVs + (classId - 1) * 2; + return (this.reader.byte(p) << 8) | this.reader.byte(p + 1); + } + private nameEnd(offset: number): number { + for (let i = 0; i < 32; i++) + if (this.reader.byte(offset + i) === 0x50) return offset + i + 1; + throw new Error("Invalid trainer name."); + } + private group(classId: number): [number, number] { + this.checkClass(classId); + const r = this.reader, + bank = r.bank(SYMBOLS.trainerGroups); + const start = r.near(SYMBOLS.trainerGroups + (classId - 1) * 2, bank); + // The last class contains one trainer and has no following group pointer. + const end = + classId === NUM_TRAINER_CLASSES + ? (bank + 1) * 0x4000 + : r.near(SYMBOLS.trainerGroups + classId * 2, bank); + return [start, end]; + } + trainer(classId: number, id: number): TrainerDefinition { + const [start, end] = this.group(classId), + r = this.reader; + if ( + !Number.isInteger(id) || + id < 1 || + id > 255 || + (classId === NUM_TRAINER_CLASSES && id !== 1) + ) + throw new Error("Unknown trainer ID."); + let p = start; + for (let skipped = 1; skipped < id; skipped++) { + while (p < end && r.byte(p++) !== 0xff) { + /* record terminator */ + } + } + if (p >= end) throw new Error("Unknown trainer ID."); + const name = readString(r, p, defaultTextContext("")); + p = this.nameEnd(p); + const partyType = r.byte(p++); + if (partyType > 3) throw new Error("Unknown trainer party format."); + const party: TrainerPartyEntry[] = []; + while (p < end && r.byte(p) !== 0xff) { + if (party.length === 6) + throw new Error("Trainer party exceeds six Pokémon."); + const level = r.byte(p++), + species = r.byte(p++); + if (level < 1 || level > 100 || species < 1 || species > 251) + throw new Error("Invalid trainer Pokémon."); + const item = partyType & 2 ? r.byte(p++) : 0; + const moves = + partyType & 1 + ? (Array.from(r.slice(p, 4)) as PartyMon["moves"]) + : undefined; + if (moves) { + p += 4; + if (moves.some((move) => move > 251)) + throw new Error("Invalid trainer move."); + } + party.push({ level, species, item, ...(moves ? { moves } : {}) }); + } + if (p >= end || !party.length) + throw new Error("Invalid trainer party terminator."); + const attributes = this.attributes(classId); + return { + classId, + id, + name, + className: this.className(classId), + partyType: partyType as TrainerDefinition["partyType"], + party, + attributes, + dvs: this.dvs(classId), + // ComputeTrainerReward uses the last party member, not the highest level. + // WinTrainerBattle pays four shares when no money is sent to Mom. + reward: attributes.baseReward * party[party.length - 1].level * 4, + }; + } + createParty( + trainer: TrainerDefinition, + owner = { otId: 0, ot: "TRAINER" }, + ): PartyMon[] { + if (trainer.classId === 12 && trainer.id === 2) + throw new Error("CAL2 requires an imported Mystery Gift trainer party."); + return trainer.party.map((entry) => { + const mon = generatePartyMon(this.pokemon, { + species: entry.species, + level: entry.level, + item: entry.item, + dvs: trainer.dvs, + caughtData: 0, + ...owner, + }); + if (entry.moves) { + mon.moves = [...entry.moves]; + // TrainerType2/4 overwrite PP only up to the first empty move. Preserve + // the original unused-slot bytes initialized by FillPP. + for (let i = 0; i < 4 && mon.moves[i]; i++) + mon.pp[i] = this.pokemon.move(mon.moves[i]).pp; + } + return mon; + }); + } + /** Map trainer macro: event, class, ID and four same-bank pointers. */ + header(offset: number): TrainerHeader { + const r = this.reader, + bank = r.bank(offset); + const pointer = (delta: number) => + r.word(offset + delta) === 0 ? 0 : r.near(offset + delta, bank); + const classId = r.byte(offset + 2), + id = r.byte(offset + 3); + this.checkClass(classId); + if (!id) throw new Error("Unknown trainer ID."); + return { + eventFlag: r.word(offset), + classId, + id, + seenText: pointer(4), + beatenText: pointer(6), + lostScript: pointer(8), + afterScript: pointer(10), + }; + } +} diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index f2d374c..d5f0f34 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -154,7 +154,12 @@ export type ScriptEffect = selected: number; } /** startbattle: answered with wBattleResult. */ - | { kind: "battle"; wild: { species: number; level: number } } + | { + kind: "battle"; + wild?: { species: number; level: number }; + trainer?: { classId: number; id: number; winText?: number; lossText?: number }; + canLose?: boolean; + } /** newloadmap: the world enters this map after the script ends. */ | { kind: "warp"; map: string; x: number; y: number }; @@ -266,6 +271,10 @@ export class ScriptRun { value = 0, battleResult = 0, spawn = 0; + let wild = this.options.wild; + let trainer: { classId: number; id: number } | undefined; + let winText: number | undefined, lossText: number | undefined; + let canLose = false; // One array for the whole run: specials append to and clear it in place. const pages: string[] = []; const byte = () => r.byte(p++); @@ -463,18 +472,49 @@ export class ScriptRun { p = SYMBOLS.fruitTreeScript; break; } + case 0x1e: { + // Only VAR_BATTLETYPE's normal and CANLOSE modes are translated. + const variable = byte(), setting = byte(); + if (variable !== 3 || (setting !== 0 && setting !== 1)) + throw new UnsupportedScript(offset, opcode, " (battle type or variable)"); + canLose = setting === 1; + break; + } case 0x5b: + trainer = undefined; + wild = this.options.wild; break; // randomwildmon clears wBattleScriptFlags. + case 0x5d: + wild = { species: byte(), level: byte() }; + trainer = undefined; + break; + case 0x5e: + trainer = { classId: byte(), id: byte() }; + wild = undefined; + break; + case 0x64: { + // Null pointers request the battle engine's default end text. + const bank = r.bank(p), won = word(), lost = word(); + winText = won ? r.address(bank, won) : undefined; + lossText = lost ? r.address(bank, lost) : undefined; + break; + } case 0x5f: { - if (!this.options.wild) throw new UnsupportedScript(offset, opcode, " (trainer battle)"); - const result = yield synchronous({ kind: "battle", wild: this.options.wild }); + if (!wild && !trainer) throw new UnsupportedScript(offset, opcode, " (no opponent)"); + yield* flush(); + const result = yield synchronous({ + kind: "battle", wild, + trainer: trainer && { ...trainer, winText, lossText }, canLose, + }); if (typeof result !== "number") throw new Error("Invalid battle result."); battleResult = result; + value = result & 0x3f; // Script_startbattle also updates wScriptVar. break; } case 0x60: // Script_reloadmapafterbattle: a loss continues with the whiteout. if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript; + canLose = false; break; case 0x8a: { byte(); // newloadmap MAPSETUP_WARP diff --git a/src/game/scripts/text.ts b/src/game/scripts/text.ts index f519057..da9f5c2 100644 --- a/src/game/scripts/text.ts +++ b/src/game/scripts/text.ts @@ -71,6 +71,7 @@ const CHARACTERS: Record = { export interface TextContext { player: string; rival: string; + trainer?: string; mom: string; /** Native strings stored at the RAM addresses named by text_ram. */ strings: Readonly>; @@ -126,6 +127,7 @@ function character(code: number, context: TextContext): string | undefined { if (code === 0x5a) return battler(context, context.battle?.turn === 1); if (code === 0x52 || code === 0x14) return context.player; if (code === 0x53) return context.rival; + if (code === 0x3f) return context.trainer; if (code === 0x49) return context.mom; if (code >= 0x80 && code <= 0x99) return String.fromCharCode(65 + code - 0x80); diff --git a/src/game/world/types.ts b/src/game/world/types.ts index d9d1027..5fadf7a 100644 --- a/src/game/world/types.ts +++ b/src/game/world/types.ts @@ -26,6 +26,8 @@ export interface MapObject { movement: number; /** Movement radius as stored: high nibble y, low nibble x. */ radius: number; + /** Trainer vision distance; absent on older synthetic map fixtures. */ + sightRange?: number; palette: number; kind: number; script: number; diff --git a/src/game/world/world.ts b/src/game/world/world.ts index 7e0889f..a46b605 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -1,4 +1,5 @@ import { CrystalAssets } from "../rom/assets"; +import { TrainerData } from "../rom/trainers"; import { INITIAL_POSITION, NUM_FRUIT_TREES, SYMBOLS } from "../rom/profile"; import { PLAYER, @@ -1006,6 +1007,19 @@ export class GameWorld { return false; case "battle": { const draft = script.run.state; + const trainers = new TrainerData(this.assets.reader); + const opponent = effect.trainer && trainers.trainer(effect.trainer.classId, effect.trainer.id); + if (opponent?.attributes.items.some((item) => item !== 0)) + throw new Error("Trainer item AI is not ported."); + const trainer = opponent && { + name: opponent.name, + className: opponent.className, + party: trainers.createParty(opponent, { otId: this.playerId, ot: this.name }), + aiFlags: opponent.attributes.aiMoveFlags, + reward: opponent.reward, + winText: effect.trainer?.winText, + lossText: effect.trainer?.lossText, + }; const engine = new Battle({ data: this.assets.pokemon, party: draft.party, @@ -1015,6 +1029,8 @@ export class GameWorld { timeOfDay: this.clockTimeOfDay, random: () => this.random() & 0xff, wild: effect.wild, + trainer, + canLose: effect.canLose, text: () => this.host(PLAYER).text(), }); this.battle = { engine, selected: 1 }; @@ -1226,6 +1242,9 @@ export class GameWorld { input = undefined; view.prompt = undefined; if (next.done) { + if (this.script && next.value.prizeMoney) + this.script.run.state.money = Math.min(C.MAX_MONEY, + this.script.run.state.money + next.value.prizeMoney); this.battle = undefined; this.battleRun = undefined; this.pump(next.value.result); diff --git a/tests/battle-ai.test.ts b/tests/battle-ai.test.ts new file mode 100644 index 0000000..60ce79e --- /dev/null +++ b/tests/battle-ai.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { + chooseTrainerMove, + validateTrainerAI, + type TrainerAIInput, + type TrainerAISide, +} from "../src/game/battle/ai"; +import { C } from "../src/game/battle/constants"; +import { SYMBOLS } from "../src/game/rom/profile"; +import type { MoveData } from "../src/game/rom/pokemon"; + +const move = ( + id: number, + effect = C.EFFECT_NORMAL_HIT, + power = 40, + type = 0, +): MoveData => ({ id, effect, power, type, accuracy: 255, pp: 30, chance: 0 }); +const side = (): TrainerAISide => ({ + mon: { + moves: [1, 2, 0, 0], + pp: [30, 30, 0, 0], + status: 0, + hp: 20, + maxHp: 20, + types: [0, 0], + species: 1, + dvs: 0x8888, + }, + sub: [0, 0, 0, 0, 0, 0], + screens: 0, + turnsTaken: 0, + disableCount: 0, + disabledMove: 0, +}); +function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput { + let cursor = 0; + return { + weights: 0, + player: side(), + enemy: side(), + weather: 0, + random: () => { + if (cursor >= bytes.length) + throw new Error("Unexpected extra random call"); + return bytes[cursor++]; + }, + data: { + move: (id) => moves.find((m) => m.id === id)!, + baseData: () => { + throw new Error("Unexpected base data lookup"); + }, + }, + battleData: { + byteList: (address) => + address === SYMBOLS.statusOnlyEffects + ? [C.EFFECT_SLEEP, C.EFFECT_TOXIC, C.EFFECT_POISON, C.EFFECT_PARALYZE] + : [1, 2], + typeMatchups: () => [], + }, + }; +} + +describe("trainer AI native scoring", () => { + it("rejects untranslated layers, including Rival1, before consuming randomness", () => { + for (const weights of [16, 32, 64, 512, 0x3d3, -1, 65536]) + expect(() => validateTrainerAI(weights)).toThrow(/not translated/); + expect(() => validateTrainerAI(0xfd8f)).not.toThrow(); + }); + it("uses masked random slots and retries blanks without first-slot tie bias", () => { + const input = fixture(undefined, [3, 6, 253]); + expect(chooseTrainerMove(input)).toEqual({ + move: 2, + index: 1, + scores: [20, 20, 80, 80], + }); + }); + it("refuses a random source that never produces a candidate", () => { + const input = fixture(); + input.random = () => 3; + expect(() => chooseTrainerMove(input)).toThrow(/random source/); + }); + it("masks PP Ups and discourages disabled/exhausted moves", () => { + const input = fixture([move(1), move(2), move(3)], [0, 1, 2]); + input.enemy.mon.moves = [1, 2, 3, 0]; + input.enemy.mon.pp = [30, 0xc0, 0xc1, 0]; + input.enemy.disabledMove = 1; + expect(chooseTrainerMove(input)).toEqual({ + move: 3, + index: 2, + scores: [80, 80, 20, 80], + }); + }); + it("still scores exhausted moves and consumes randomness before Struggle", () => { + const input = fixture( + [move(1, C.EFFECT_ATTACK_UP, 0), move(2)], + [128, 3, 0], + ); + input.weights = 2; + input.enemy.mon.pp.fill(0); + expect(chooseTrainerMove(input)).toEqual({ + move: 1, + index: 0, + scores: [78, 80, 80, 80], + }); + }); + it("still selects when the only move is disabled", () => { + const input = fixture([move(1)], [3, 0]); + input.enemy.mon.moves = [1, 0, 0, 0]; + input.enemy.disabledMove = 1; + expect(chooseTrainerMove(input)).toEqual({ + move: 1, + index: 0, + scores: [80, 20, 80, 80], + }); + }); + it("refuses empty move lists instead of the cartridge's infinite loop", () => { + const input = fixture(); + input.enemy.mon.moves.fill(0); + expect(() => chooseTrainerMove(input)).toThrow(/nonzero move/); + }); + it("Basic discourages status on a statused or safeguarded target exactly once", () => { + const input = fixture([move(1, C.EFFECT_SLEEP, 0), move(2)], [1]); + input.weights = 1; + input.player.mon.status = 8; + input.player.screens = 1 << C.SCREENS_SAFEGUARD; + expect(chooseTrainerMove(input).scores).toEqual([30, 20, 80, 80]); + }); + it("Basic preserves Nightmare and Future Sight's cartridge redundancy bugs", () => { + const input = fixture( + [move(1, C.EFFECT_NIGHTMARE, 0), move(2, C.EFFECT_FUTURE_SIGHT)], + [0], + ); + input.weights = 1; + input.player.mon.status = 8; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); + input.random = () => 1; + input.enemy.screens = 1 << C.SCREENS_UNUSED; + input.player.mon.status = 0; + input.random = () => 0; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 30]); + }); + it("Setup uses each side's first turn and exact 128/30 byte thresholds", () => { + const input = fixture( + [move(1, C.EFFECT_ATTACK_UP, 0), move(2, C.EFFECT_ATTACK_DOWN, 0)], + [128, 30, 0], + ); + input.weights = 2; + input.player.turnsTaken = 1; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([18, 22]); + input.random = (() => { + const bytes = [127, 29, 1]; + return () => bytes.shift()!; + })(); + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); + }); + it("Types discourages resistance only when another damaging type exists", () => { + const input = fixture([move(1, 0, 40, 20), move(2, 0, 0, 21)], [0]); + input.weights = 4; + input.battleData.typeMatchups = () => [ + { attacker: 20, defender: 0, multiplier: 5 }, + ]; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); + input.data.move = (id) => + id === 1 ? move(1, 0, 40, 20) : move(2, 0, 40, 21); + input.random = () => 1; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 20]); + }); + it("Types encourages damaging super-effective moves and respects Foresight", () => { + const input = fixture([move(1, 0, 40, 0), move(2, 0, 40, 20)], [1]); + input.weights = 4; + input.player.mon.types = [8, 8]; + input.battleData.typeMatchups = () => [ + { attacker: 20, defender: 8, multiplier: 20 }, + { attacker: 0xfe, defender: 0, multiplier: 0 }, + { attacker: 0, defender: 8, multiplier: 0 }, + ]; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 19]); + input.player.sub = [0, 1 << C.SUBSTATUS_IDENTIFIED, 0, 0, 0, 0]; + input.random = () => 1; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 19]); + }); + it("Status discourages poison against Poison and damaging immunities", () => { + const input = fixture([move(1, C.EFFECT_POISON, 0), move(2)], [0]); + input.weights = 256; + input.player.mon.types = [3, 3]; + input.battleData.typeMatchups = () => [ + { attacker: 0, defender: 3, multiplier: 0 }, + ]; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 30]); + }); + it("Cautious stops the entire layer on its first failed random check", () => { + const input = fixture(undefined, [230, 1]); + input.weights = 128; + input.enemy.turnsTaken = 1; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); + input.random = (() => { + const bytes = [229, 230, 1]; + return () => bytes.shift()!; + })(); + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 20]); + }); + it("Offensive penalizes non-damaging moves by two", () => { + const input = fixture([move(1, C.EFFECT_ATTACK_DOWN, 0), move(2)], [1]); + input.weights = 8; + expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([22, 20]); + }); +}); diff --git a/tests/trainer-battle.test.ts b/tests/trainer-battle.test.ts new file mode 100644 index 0000000..984ac7b --- /dev/null +++ b/tests/trainer-battle.test.ts @@ -0,0 +1,151 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { CrystalAssets } from "../src/game/rom/assets"; +import { TrainerData } from "../src/game/rom/trainers"; +import { + Battle, + UnsupportedBattle, + type BattleInput, + type BattleOptions, +} from "../src/game/battle/battle"; +import { generatePartyMon } from "../src/game/party/pokemon"; +import { defaultTextContext } from "../src/game/scripts/text"; + +const romPath = + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; +describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => { + let assets: CrystalAssets, trainers: TrainerData; + beforeAll(() => { + assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); + trainers = new TrainerData(assets.reader); + }); + const setup = (level = 15) => { + const trainer = trainers.trainer(22, 1); // Youngster Joey, level 4 Rattata. + const mon = generatePartyMon(assets.pokemon, { + species: 155, + level, + item: 0, + otId: 1234, + ot: "CHRIS", + dvs: 0x823b, + caughtData: 0, + }); + let seed = 1; + const options: BattleOptions = { + data: assets.pokemon, + party: [mon], + playerId: 1234, + playerName: "CHRIS", + timeOfDay: 1, + pokedex: { seen: new Set(), caught: new Set() }, + random: () => { + seed = (seed * 1103515245 + 12345) >>> 0; + return (seed >>> 16) & 255; + }, + text: () => defaultTextContext("CHRIS"), + trainer: { + name: trainer.name, + className: trainer.className, + party: trainers.createParty(trainer), + aiFlags: trainer.attributes.aiMoveFlags, + reward: trainer.reward, + }, + }; + return { mon, options }; + }; + const finish = (battle: Battle, runFirst = false) => { + const gen = battle.run(); + const texts: string[] = []; + let input: BattleInput, + triedRun = false; + for (let guard = 0; guard < 2000; guard++) { + const next = gen.next(input); + if (next.done) + return { ...next.value, texts: texts.join(" ").replace(/\n/g, " ") }; + const effect = next.value; + input = undefined; + if (effect.kind === "text") texts.push(effect.text); + if (effect.kind === "action") { + input = + runFirst && !triedRun + ? { action: "run" } + : { action: "fight", move: 0 }; + triedRun = true; + } + if (effect.kind === "yesno") input = 0; + if (effect.kind === "forget") input = 4; + } + throw new Error("Battle did not finish."); + }; + it("announces the trainer, refuses escape without spending a turn, and awards trainer experience and money", () => { + const direct = setup(), + escape = setup(); + const before = direct.mon.exp; + const won = finish(new Battle(direct.options)); + const escaped = finish(new Battle(escape.options), true); + expect(won.result).toBe(0); + expect(won.prizeMoney).toBe(64); + expect(won.texts).toContain("YOUNGSTER JOEY wants to battle!"); + expect(won.texts).toContain("sent out RATTATA!"); + expect(won.texts).toContain("was defeated!"); + expect(won.texts).toContain("CHRIS got ¥64"); + expect(escaped.texts).toContain("trainer battle"); + expect(escape.mon).toEqual(direct.mon); + expect(direct.mon.exp - before).toBe(48); // floor(57 * 4 / 7), then 1.5x. + expect(direct.options.pokedex.seen.has(19)).toBe(true); + }); + it("doubles prize money when the active Pokémon holds an Amulet Coin", () => { + const { mon, options } = setup(); + mon.item = 0x5b; + const result = finish(new Battle(options)); + expect(result.prizeMoney).toBe(128); + expect(result.texts).toContain("CHRIS got ¥128"); + }); + it("scores exhausted trainer moves before the menu, then uses Struggle", () => { + const { options } = setup(); + options.trainer!.party[0].pp.fill(0); + let draws = 0; + options.random = () => { draws++; return 0; }; + const battle = new Battle(options), run = battle.run(); + for (let guard = 0; guard < 20; guard++) { + const next = run.next(); + if (!next.done && next.value.kind === "action") break; + } + expect(draws).toBeGreaterThan(0); + // The AI selected a normal move despite exhausted PP; ParseEnemyAction + // replaces it before the first attack, as the cartridge does. + expect(battle.enemy.curMove).toBe(33); + run.next({ action: "fight", move: 0 }); + expect(battle.enemy.curMove).toBe(165); + }); + it("loses without receiving prize money", () => { + const { mon, options } = setup(2); + mon.hp = 1; + const lost = finish(new Battle(options)); + expect(lost.result).toBe(1); + expect(lost.prizeMoney).toBeUndefined(); + expect(mon.hp).toBe(0); + }); + it("heals the party after a CANLOSE victory", () => { + const { mon, options } = setup(); + options.canLose = true; + mon.hp--; + finish(new Battle(options)); + expect(mon.hp).toBe(mon.stats[0]); + expect(mon.status).toBe(0); + expect(mon.pp[0]).toBe(assets.pokemon.move(mon.moves[0]).pp); + }); + it("refuses unported party switching and AI before mutating the party or Pokédex", () => { + for (const kind of ["party", "ai"] as const) { + const { options } = setup(); + if (kind === "party") + options.trainer!.party.push(structuredClone(options.trainer!.party[0])); + else options.trainer!.aiFlags |= 1 << 4; + const before = structuredClone(options.party); + expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle); + expect(options.party).toEqual(before); + expect(options.pokedex.seen.size).toBe(0); + } + }); +}); diff --git a/tests/trainer-browser.spec.ts b/tests/trainer-browser.spec.ts new file mode 100644 index 0000000..efb4795 --- /dev/null +++ b/tests/trainer-browser.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "@playwright/test"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const romPath = resolve(process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"); +test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM; trainer script is a test fixture.")); + +test("trainer UI refuses RUN, wins against Joey and saves the reward", async ({ page }) => { + // Route substitution stays in the test harness; production imports no fixture. + await page.route("**/src/game/runtime.ts", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const needle = "const world = new GameWorld(assets);"; + expect(source).toContain(needle); + await route.fulfill({ response, body: source.replace(needle, + `const { trainerFixtureWorld } = await import('/tests/trainer-fixture.ts'); + const world = trainerFixtureWorld(assets, undefined, false, true);`) }); + }); + await page.clock.install({ time: new Date("2026-09-22T12:00:00") }); + await page.goto("/"); + await page.locator("#rom-file").setInputFiles(romPath); + await expect(page.locator("#status")).toContainText("verified"); + await page.clock.pauseAt(new Date("2026-09-22T12:05:00")); + await page.locator("#play").click(); + await page.keyboard.press("KeyX"); + await expect(page.locator("#battle")).toBeVisible(); + await expect(page.locator("#battle-foe-name")).toHaveText("RATTATA"); + let triedRun = false; + const messages = new Set(); + for (let guard = 0; guard < 60; guard++) { + if (!(await page.locator("#battle").isVisible())) break; + messages.add(await page.locator("#battle-text").innerText()); + const menu = page.locator("#battle-menu"); + const fight = menu.getByRole("button", { name: "FIGHT", exact: true }); + if (await fight.count()) { + if (!triedRun) { + triedRun = true; + await menu.getByRole("button", { name: "RUN", exact: true }).click(); + } else { + await fight.click(); + await menu.getByRole("button", { name: /TACKLE/ }).click(); + } + } else if (await menu.getByRole("button", { name: /Continue/ }).count()) { + await menu.getByRole("button", { name: /Continue/ }).click(); + } else await page.clock.runFor(1200); + } + await expect(page.locator("#battle")).toBeHidden(); + await expect(page.locator("#game-notice")).toHaveText(""); + const shown = [...messages].join("\n"); + expect(shown).toContain("JOEY"); + expect(shown).toMatch(/No!.*running/s); + expect(shown).toContain("Ack! I lost again!"); + expect(shown).toContain("64"); + await page.locator("#tab-saves").click(); + const download = page.waitForEvent("download"); + await page.locator("#backup").click(); + const path = (await (await download).path())!; + const record = JSON.parse(readFileSync(path, "utf8")); + const state = JSON.parse(Buffer.from(record.state, "base64").toString("utf8")); + expect(state.money).toBe(3064); + expect(state.pokedex.seen).toContain(19); + expect(state.events).toContain(256); +}); diff --git a/tests/trainer-fixture.ts b/tests/trainer-fixture.ts new file mode 100644 index 0000000..b2b6542 --- /dev/null +++ b/tests/trainer-fixture.ts @@ -0,0 +1,30 @@ +import { CrystalAssets } from "../src/game/rom/assets"; +import { generatePartyMon } from "../src/game/party/pokemon"; +import { martFixtureWorld } from "./mart-fixture"; + +// Test-only script on an already accessible clerk. The actual trainer party, +// class attributes, moves and battle text are read from the local ROM. +export function trainerFixtureWorld(assets: CrystalAssets, tail = [0x60, 0x33, 0x00, 0x01, 0x91], lose = false, winText = false) { + const clerk = assets.map("26:4").objects[0]; + // Place the synthetic script in Joey's bank to test winlosstext's near pointer. + if (winText) clerk.script = 0x68 * 0x4000 + 0x3f00; + assets.reader.bytes.set([ + 0x64, ...(winText ? [0xa2, 0x58] : [0, 0]), 0, 0, // Joey beaten text or default + 0x5e, 22, 1, // loadtrainer YOUNGSTER, JOEY1 + ...(lose ? [0x1e, 3, 1] : []), // loadvar VAR_BATTLETYPE, BATTLETYPE_CANLOSE + 0x5f, ...tail, + ], clerk.script); + const world = martFixtureWorld(assets); + const mon = generatePartyMon(assets.pokemon, { + species: 155, level: lose ? 2 : 50, item: 0, otId: world.playerId, + ot: "CHRIS", dvs: 0x823b, caughtData: 0, + }); + mon.moves = [33, 0, 0, 0]; + mon.pp = [35, 0, 0, 0]; + if (lose) mon.hp = 1; + world.state.party = [mon]; + world.state.pokedex.seen.add(155); + world.state.pokedex.caught.add(155); + return world; +} + diff --git a/tests/trainer-world.test.ts b/tests/trainer-world.test.ts new file mode 100644 index 0000000..b9f9997 --- /dev/null +++ b/tests/trainer-world.test.ts @@ -0,0 +1,107 @@ +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 { trainerFixtureWorld } from "./trainer-fixture"; +import type { GameWorld } from "../src/game/world/world"; + +const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; + +const fixture = (tail?: number[], lose = false, winText = false) => + trainerFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), tail, lose, winText); + +function finish(world: GameWorld) { + const messages: string[] = []; + for (let tick = 0; tick < 10000; tick++) { + const view = world.battle; + if (view) { + if (view.message) messages.push(view.message.text); + if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 }); + else if (view.prompt?.kind === "yesno") world.battleChoose(1); + else if (view.prompt?.kind === "forget") world.battleChoose(3); + else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact(); + else world.tick(); + } else if (world.dialogue) world.advanceDialogue(); + else if (world.busy) world.tick(); + else return messages.join("\n"); + } + throw new Error(`Trainer battle did not settle: ${world.diagnostic}`); +} + +describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", () => { + it("commits Joey's ROM reward, party experience, sighting and postbattle event together", () => { + const world = fixture(); + const before = world.save(); + world.interact(); + expect(world.battle).toBeDefined(); + expect(world.save()).toEqual(before); + const messages = finish(world); + expect(world.notice, world.diagnostic).toBe(""); + expect(messages).toContain("JOEY"); + expect(world.state.money).toBe(before.money + 64); + expect(world.state.party[0].exp).toBeGreaterThan(before.party[0].exp); + expect(world.state.pokedex.seen.has(19)).toBe(true); + expect(world.state.events.has(256)).toBe(true); + const saved = world.save(); + world.restore(saved); + expect(world.save()).toEqual(saved); + }); + + it("reads scripted win text from the script bank's ROM pointer", () => { + const world = fixture(undefined, false, true); + world.interact(); + const messages = finish(world); + expect(world.notice, world.diagnostic).toBe(""); + expect(messages).toContain("Ack! I lost again!"); + expect(messages.indexOf("Ack! I lost again!")).toBeLessThan(messages.lastIndexOf("64")); + }); + + it("caps trainer rewards at the money limit", () => { + const world = fixture(); + world.state.money = C.MAX_MONEY - 1; + world.interact(); + finish(world); + expect(world.notice, world.diagnostic).toBe(""); + expect(world.state.money).toBe(C.MAX_MONEY); + }); + + it("rolls back reward, experience and sightings if the following script is unsupported", () => { + const world = fixture([0xff]); + const before = world.save(); + world.interact(); + finish(world); + expect(world.notice).toContain("not been ported"); + expect(world.save()).toEqual(before); + }); + + it("restores a midbattle save without partially paying the reward", () => { + const world = fixture(); + const before = world.save(); + world.interact(); + for (let tick = 0; tick < 1000 && !world.battle?.prompt; tick++) { + if (world.battle?.message?.prompt) world.interact(); + else world.tick(); + } + expect(world.save()).toEqual(before); + world.restore(before); + expect(world.battle).toBeUndefined(); + expect(world.save()).toEqual(before); + }); + + it("CANLOSE scripts receive LOSE in wScriptVar without forcing a whiteout", () => { + // ifequal LOSE, .lost; invalid; .lost: setevent 256; end + const world = fixture([0x91], true); + const clerk = world.assets.map("26:4").objects[0]; + const start = clerk.script + 12; // after winlosstext/loadtrainer/loadvar/startbattle + const target = ((start + 5) % 0x4000) + 0x4000; + world.assets.reader.bytes.set([0x06, 1, target & 255, target >> 8, 0xff, 0x33, 0, 1, 0x91], start); + const before = world.save(); + world.interact(); + const messages = finish(world); + expect(world.notice, world.diagnostic).toBe(""); + expect(world.state.events.has(256)).toBe(true); + expect(world.state.money).toBe(before.money); + expect(world.save().map).toBe(before.map); + expect(messages).not.toMatch(/whited\s+out/i); + }); +}); diff --git a/tests/trainers.test.ts b/tests/trainers.test.ts new file mode 100644 index 0000000..e201727 --- /dev/null +++ b/tests/trainers.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { TrainerData } from "../src/game/rom/trainers"; +import { RomReader } from "../src/game/rom/reader"; +import { CrystalAssets } from "../src/game/rom/assets"; +import { NUM_TRAINER_CLASSES } from "../src/game/rom/profile"; + +const romPath = + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; +describe.skipIf(!existsSync(romPath))("ROM trainer decoder", () => { + const bytes = existsSync(romPath) + ? new Uint8Array(readFileSync(romPath)) + : new Uint8Array(); + const data = new TrainerData(new RomReader(bytes)); + it("reads Joey and Mikey, including class DVs and actual payout", () => { + const joey = data.trainer(22, 1), + mikey = data.trainer(22, 2); + expect(joey).toMatchObject({ + name: "JOEY", + className: "YOUNGSTER", + dvs: 0x9888, + reward: 64, + partyType: 0, + }); + expect(joey.party).toEqual([{ species: 19, level: 4, item: 0 }]); + expect(mikey.party.map((mon) => [mon.species, mon.level])).toEqual([ + [16, 2], + [19, 4], + ]); + expect(data.createParty(joey)[0]).toMatchObject({ + moves: [33, 39, 0, 0], + dvs: 0x9888, + statExp: [0, 0, 0, 0, 0], + }); + }); + it("reads Falkner's explicit moves and the rival starter variants", () => { + const falkner = data.trainer(1, 1); + expect(falkner).toMatchObject({ + name: "FALKNER", + partyType: 1, + dvs: 0x9a77, + reward: 900, + }); + const party = data.createParty(falkner, { ot: "CHRIS", otId: 123 }); + expect(party.map((mon) => mon.moves)).toEqual([ + [33, 189, 0, 0], + [33, 189, 16, 0], + ]); + expect(party[0].pp.slice(0, 2)).toEqual([35, 10]); + expect( + party.every((mon) => mon.hp === mon.stats[0] && mon.otId === 123), + ).toBe(true); + expect([1, 2, 3].map((id) => data.trainer(9, id).party[0].species)).toEqual( + [152, 155, 158], + ); + expect(data.trainer(9, 1).dvs).toBe(0xdddd); + }); + it("reads held items with and without explicit moves", () => { + expect(data.trainer(59, 1).partyType).toBe(2); + const gaven = data.trainer(27, 7); + expect(gaven.partyType).toBe(3); + expect(gaven.party[1].item).toBe(82); + expect(data.createParty(gaven)[1].moves).toEqual(gaven.party[1].moves); + }); + it.skipIf(!existsSync(".reference/pokecrystal/data/trainers/parties.asm"))( + "sweeps every party listed in the pinned source", + () => { + const source = readFileSync( + ".reference/pokecrystal/data/trainers/parties.asm", + "utf8", + ); + const groups = source.split(/^\w+Group:\s*$/m).slice(1); + expect(groups).toHaveLength(NUM_TRAINER_CLASSES); + const formats = new Set(); + let total = 0; + groups.forEach((group, index) => { + const count = [...group.matchAll(/db -1 ; end/g)].length; + for (let id = 1; id <= count; id++) { + const trainer = data.trainer(index + 1, id); + formats.add(trainer.partyType); + total++; + if (index + 1 === 12 && id === 2) { + expect(() => data.createParty(trainer)).toThrow(/Mystery Gift/); + continue; + } + const party = data.createParty(trainer); + expect(party.length).toBeGreaterThan(0); + expect( + party.every((mon) => mon.hp > 0 && mon.hp === mon.stats[0]), + ).toBe(true); + } + expect(() => data.trainer(index + 1, count + 1)).toThrow(); + }); + expect(formats).toEqual(new Set([0, 1, 2, 3])); + expect(total).toBeGreaterThan(500); + }, + ); + it("reads map trainer headers and vision range", () => { + const assets = new CrystalAssets(bytes); + const route = assets.map("26:1"); + const trainers = route.objects.filter((object) => object.kind === 2); + expect(trainers.length).toBeGreaterThan(0); + for (const object of trainers) { + expect(object.sightRange).toBeGreaterThan(0); + const header = data.header(object.script); + expect(header.seenText).toBeGreaterThan(0); + expect(header.beatenText).toBeGreaterThan(0); + expect( + data.trainer(header.classId, header.id).party.length, + ).toBeGreaterThan(0); + } + }); + it("rejects invalid class and trainer IDs", () => { + for (const classId of [0, -1, 68, 1.5]) + expect(() => data.trainer(classId, 1)).toThrow(); + for (const id of [0, -1, 256, 1.5, 2]) + expect(() => data.trainer(1, id)).toThrow(); + }); +});