Add scripted trainer battle foundation and native move AI
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-24 09:33:33 +02:00
1 parent 6a74f59e46
commit 3e92936cb9
20 files changed
+1417 -32

No files matched your search

+1
View File
@@ -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,
+13
View File
@@ -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;
+197
View File
@@ -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),
};
}
}