This commit is contained in:
1 parent
6a74f59e46
commit
3e92936cb9
20 files changed
+1417
-32
No files matched your search
@@ -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<PokemonData, "move" | "baseData">;
|
||||
battleData: Pick<BattleData, "byteList" | "typeMatchups">;
|
||||
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.",
|
||||
);
|
||||
}
|
||||
+151
-24
@@ -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<T = void> = Generator<BattleEffect, T, BattleInput>;
|
||||
|
||||
@@ -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<BattleOutcome> {
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -71,6 +71,7 @@ const CHARACTERS: Record<number, string> = {
|
||||
export interface TextContext {
|
||||
player: string;
|
||||
rival: string;
|
||||
trainer?: string;
|
||||
mom: string;
|
||||
/** Native strings stored at the RAM addresses named by text_ram. */
|
||||
strings: Readonly<Record<number, string>>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user