Run the forced-shiny, trap, forced-item, Celebi, Suicune, roaming and Bug-Catching Contest battle types. Roaming battles keep their struct's DVs and HP byte, and every other battle makes CleanUpBattleRAM's roaming roll. The contest shows PARKBALL×, counts down Park Balls, ends when they run out, keeps its catch outside the party and asks before switching. Scripts load the special types with loadvar, and while a contest runs, encounters use BugCatchingContestBattleScript and ContestMons. Validated with 596 unit tests, 155 browser tests (including the contest menu and switch screen), native/reference builds and reference/vendor/release checks. National Park's contest event, the roaming release and the Ilex Forest Celebi event come with their maps. Closes #28 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b547ca29ff
commit
8fae1f0c0f
15 files changed
+787
-42
No files matched your search
+182
-19
@@ -111,8 +111,12 @@ export type BattleEffect =
|
||||
| { kind: "text"; text: string; prompt: boolean }
|
||||
/** HP, status or level changed; renderers re-read the battle state. */
|
||||
| { kind: "hud" }
|
||||
/** The battle menu: answered with an action. */
|
||||
| { kind: "action" }
|
||||
/** The battle menu: answered with an action. In the Bug-Catching
|
||||
* Contest, PACK is PARKBALL× with this wParkBallsRemaining. */
|
||||
| { kind: "action"; parkBalls?: number }
|
||||
/** DisplayCaughtContestMonStats: the stocked contest Pokémon beside the
|
||||
* one just caught, with ContestAskSwitchText; answered Yes/No (1/0). */
|
||||
| { kind: "contestswitch"; text: string; stock: ContestMonSummary; caught: ContestMonSummary }
|
||||
| { kind: "yesno"; text: string }
|
||||
/** Choose a move to forget (0-3); 4 cancels, like B on the list. */
|
||||
| { kind: "forget"; moves: number[]; learning: number }
|
||||
@@ -127,6 +131,36 @@ export type BattleEffect =
|
||||
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
|
||||
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
|
||||
| { kind: "move"; text: string; moves: number[]; pp: number[] };
|
||||
/** A name, level and max HP, as DisplayCaughtContestMonStats prints them. */
|
||||
export type ContestMonSummary = { name: string; level: number; maxHp: number };
|
||||
/** The Bug-Catching Contest's wParkBallsRemaining and wContestMon. */
|
||||
export interface BugContest {
|
||||
parkBalls: number;
|
||||
mon: PartyMon | null;
|
||||
}
|
||||
/** A wRoamMon struct: Raikou's, Entei's or Suicune's. An HP of 0 means
|
||||
* the DVs and HP are drawn at the next encounter. */
|
||||
export interface RoamMon {
|
||||
species: number;
|
||||
level: number;
|
||||
/** The map it roams, or null (GROUP_N_A/MAP_N_A) once defeated or caught. */
|
||||
mapId: string | null;
|
||||
/** The low byte of its HP. */
|
||||
hp: number;
|
||||
dvs: number;
|
||||
}
|
||||
/** BATTLERESULT_CAUGHT_CELEBI, which CheckCaughtCelebi reads. */
|
||||
export const BATTLERESULT_CAUGHT_CELEBI = 0x40;
|
||||
/** wBattleType values that BattleOptions.battleType accepts. */
|
||||
const SPECIAL_BATTLE_TYPES: readonly number[] = [
|
||||
C.BATTLETYPE_ROAMING,
|
||||
C.BATTLETYPE_CONTEST,
|
||||
C.BATTLETYPE_FORCESHINY,
|
||||
C.BATTLETYPE_TRAP,
|
||||
C.BATTLETYPE_FORCEITEM,
|
||||
C.BATTLETYPE_CELEBI,
|
||||
C.BATTLETYPE_SUICUNE,
|
||||
];
|
||||
/** An item from a pack slot (wCurItem and wCurItemQuantity). */
|
||||
type ItemUse = { item: number; slot: number };
|
||||
export type BattleAction =
|
||||
@@ -134,7 +168,8 @@ export type BattleAction =
|
||||
| { action: "run" }
|
||||
/** PKMN: open the party menu to switch. */
|
||||
| { action: "pkmn" }
|
||||
/** PACK: use the item in this slot of its pocket (only Balls so far). */
|
||||
/** PACK: use the item in this slot of its pocket. In the Bug-Catching
|
||||
* Contest it throws a PARK_BALL (any slot). */
|
||||
| { action: "item"; item: number; slot: number };
|
||||
export type BattleInput = number | string | BattleAction | undefined;
|
||||
/** wBattleResult, the prize money and wEvolvableFlags (a bit per party
|
||||
@@ -147,6 +182,9 @@ export type BattleOutcome = {
|
||||
/** wPayDayMoney as CheckPayDay adds it to the wallet after a win. */
|
||||
payDayMoney?: number;
|
||||
evolvable?: number;
|
||||
/** BattleEnd_HandleRoamMons called UpdateRoamMons: after a roaming
|
||||
* battle that was not won, and after one battle in sixteen otherwise. */
|
||||
updateRoamMons?: true;
|
||||
};
|
||||
|
||||
export interface BattleTrainer {
|
||||
@@ -195,6 +233,14 @@ export interface BattleOptions {
|
||||
tree?: boolean;
|
||||
/** BATTLETYPE_FISH: a wild Pokémon hooked on a fishing rod. */
|
||||
fish?: boolean;
|
||||
/** wBattleType for the other wild battles: BATTLETYPE_FORCESHINY, TRAP,
|
||||
* FORCEITEM, CELEBI, SUICUNE, CONTEST (with `contest`) or ROAMING (with
|
||||
* `roamer`). */
|
||||
battleType?: number;
|
||||
/** The contest's Park Balls and caught Pokémon, which the battle updates. */
|
||||
contest?: BugContest;
|
||||
/** The roaming Pokémon's struct, which the battle updates. */
|
||||
roamer?: RoamMon;
|
||||
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
|
||||
landmark?: number;
|
||||
/** wMapGroup:wMapNumber, which LoadEnemyMon compares with the Lake of
|
||||
@@ -561,6 +607,7 @@ export class Battle {
|
||||
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
|
||||
else if (options.tree) this.battleType = C.BATTLETYPE_TREE;
|
||||
else if (options.fish) this.battleType = C.BATTLETYPE_FISH;
|
||||
else if (options.battleType !== undefined) this.battleType = options.battleType;
|
||||
}
|
||||
private get reader(): RomReader {
|
||||
return this.options.data.reader;
|
||||
@@ -741,12 +788,24 @@ export class Battle {
|
||||
throw new UnsupportedBattle("Unown encounters are not ported yet.");
|
||||
const data = this.options.data,
|
||||
base = data.baseData(species);
|
||||
// 25% chance of an item: 23% the first, 2% the second.
|
||||
// 25% chance of an item: 23% the first, 2% the second. Ho-Oh, Lugia
|
||||
// and Snorlax (BATTLETYPE_FORCEITEM) always hold the first.
|
||||
let item = 0;
|
||||
if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
|
||||
if (this.battleType === C.BATTLETYPE_FORCEITEM) item = base.items[0];
|
||||
else if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
|
||||
// Roaming Pokémon keep their DVs, drawn (Speed/Special first) at the
|
||||
// first encounter; the Red Gyarados is shiny.
|
||||
const roamer = this.battleType === C.BATTLETYPE_ROAMING ? this.options.roamer! : undefined;
|
||||
let dvs: number;
|
||||
do dvs = (this.random() << 8) | this.random();
|
||||
while (species === C.MAGIKARP && this.rerollMagikarp(dvs));
|
||||
if (roamer) {
|
||||
if (!roamer.hp) {
|
||||
const low = this.random();
|
||||
roamer.dvs = (this.random() << 8) | low;
|
||||
}
|
||||
dvs = roamer.dvs;
|
||||
} else if (this.battleType === C.BATTLETYPE_FORCESHINY) dvs = 0xeaaa;
|
||||
else dvs = (this.random() << 8) | this.random();
|
||||
while (species === C.MAGIKARP && this.rerollMagikarp(dvs)) dvs = (this.random() << 8) | this.random();
|
||||
const stats = calcStats(data, species, dvs, level);
|
||||
const moves = this.levelMoves(species, level);
|
||||
this.enemySpecies = species;
|
||||
@@ -760,7 +819,8 @@ export class Battle {
|
||||
level,
|
||||
// .TreeMon: some Pokémon fall from trees asleep.
|
||||
status: this.sleepingTreeMon(species) ? C.TREEMON_SLEEP_TURNS : 0,
|
||||
hp: stats[0],
|
||||
// A roaming Pokémon's struct keeps only its HP's low byte.
|
||||
hp: roamer?.hp ? (stats[0] & 0xff00) | roamer.hp : stats[0],
|
||||
maxHp: stats[0],
|
||||
stats: stats.slice(1),
|
||||
types: [base.types[0], base.types[1]],
|
||||
@@ -771,6 +831,7 @@ export class Battle {
|
||||
catchRate: base.catchRate,
|
||||
exp: base.baseExp,
|
||||
};
|
||||
if (roamer && !roamer.hp) roamer.hp = stats[0] & 0xff;
|
||||
this.options.pokedex.seen.add(species);
|
||||
this.enemy.stats = [...this.enemy.mon.stats];
|
||||
this.wildMoves = [...moves];
|
||||
@@ -889,11 +950,27 @@ export class Battle {
|
||||
|
||||
/** StartBattle through ExitBattle. */
|
||||
*run(): Gen<BattleOutcome> {
|
||||
const outcome = yield* this.doBattle();
|
||||
// ExitBattle: CheckPayDay after a win.
|
||||
if (outcome.result & 0x0f) return outcome;
|
||||
const payDayMoney = yield* this.checkPayDay();
|
||||
return payDayMoney ? { ...outcome, payDayMoney } : outcome;
|
||||
let outcome = yield* this.doBattle();
|
||||
// ExitBattle: CheckPayDay after a win. EvolveAfterBattle follows in
|
||||
// the world, before CleanUpBattleRAM's roaming update below.
|
||||
if (!(outcome.result & 0x0f)) {
|
||||
const payDayMoney = yield* this.checkPayDay();
|
||||
if (payDayMoney) outcome = { ...outcome, payDayMoney };
|
||||
}
|
||||
return this.handleRoamMons() ? { ...outcome, updateRoamMons: true } : outcome;
|
||||
}
|
||||
/** BattleEnd_HandleRoamMons: a roaming Pokémon keeps its HP, or leaves
|
||||
* the map for good when defeated or caught. Other battles move the
|
||||
* roaming Pokémon one time in sixteen. True when UpdateRoamMons runs. */
|
||||
private handleRoamMons(): boolean {
|
||||
if (this.battleType !== C.BATTLETYPE_ROAMING) return !(this.random() & 0x0f);
|
||||
const roamer = this.options.roamer!;
|
||||
if (!(this.battleResult & 0x0f)) {
|
||||
Object.assign(roamer, { hp: 0, mapId: null, species: 0 });
|
||||
return false;
|
||||
}
|
||||
roamer.hp = this.enemy.mon.hp & 0xff;
|
||||
return true;
|
||||
}
|
||||
/** CheckPayDay: the coins, doubled by an Amulet Coin, for the wallet. */
|
||||
private *checkPayDay(): Gen<number> {
|
||||
@@ -927,6 +1004,15 @@ export class Battle {
|
||||
}
|
||||
if (this.options.tutorial && (trainer || !this.options.wild))
|
||||
throw new UnsupportedBattle("The catching tutorial needs a wild Pokémon.");
|
||||
if (this.options.battleType !== undefined) {
|
||||
const type = this.options.battleType;
|
||||
if (!SPECIAL_BATTLE_TYPES.includes(type) || trainer || !this.options.wild)
|
||||
throw new UnsupportedBattle(`Battle type ${type} is not supported here.`);
|
||||
if (type === C.BATTLETYPE_CONTEST && !this.options.contest)
|
||||
throw new UnsupportedBattle("A contest battle needs the contest's state.");
|
||||
if (type === C.BATTLETYPE_ROAMING && this.options.roamer?.species !== this.options.wild.species)
|
||||
throw new UnsupportedBattle("A roaming battle needs its roaming Pokémon.");
|
||||
}
|
||||
this.loadEnemyMon();
|
||||
this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0);
|
||||
if (this.curBattleMon < 0)
|
||||
@@ -951,7 +1037,9 @@ export class Battle {
|
||||
? BATTLE.HookedPokemonAttackedText
|
||||
: this.battleType === C.BATTLETYPE_TREE
|
||||
? BATTLE.PokemonFellFromTreeText
|
||||
: BATTLE.WildPokemonAppearedText,
|
||||
: this.battleType === C.BATTLETYPE_CELEBI
|
||||
? BATTLE.WildCelebiAppearedText
|
||||
: BATTLE.WildPokemonAppearedText,
|
||||
);
|
||||
this.resetBattleParticipants();
|
||||
this.initBattleMon();
|
||||
@@ -1026,6 +1114,11 @@ export class Battle {
|
||||
|
||||
private *battleTurn(): Gen {
|
||||
for (let turns = 0; turns < 1000; turns++) {
|
||||
// CheckContestBattleOver: out of Park Balls, the battle ends a draw.
|
||||
if (this.battleType === C.BATTLETYPE_CONTEST && !this.options.contest!.parkBalls) {
|
||||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||||
return;
|
||||
}
|
||||
this.curDamage = 0;
|
||||
this.enemyIsSwitching = 0;
|
||||
this.playerIsSwitching = 0;
|
||||
@@ -1146,10 +1239,14 @@ export class Battle {
|
||||
* the battle ended from the menu. */
|
||||
private *battleMenu(): Gen<boolean> {
|
||||
for (;;) {
|
||||
const input = yield { kind: "action" };
|
||||
const contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
|
||||
const input = yield contest ? { kind: "action", parkBalls: contest.parkBalls } : { kind: "action" };
|
||||
if (!input || typeof input !== "object") continue;
|
||||
if (input.action === "item") {
|
||||
const used = yield* this.battleMenuPack(input.item, input.slot);
|
||||
// BattleMenu_Pack's .contest: PARKBALL× throws a Park Ball.
|
||||
const used = contest
|
||||
? yield* this.pokeBallEffect(C.PARK_BALL, -1)
|
||||
: yield* this.battleMenuPack(input.item, input.slot);
|
||||
if (this.wildMon) {
|
||||
// BattleMenu_Pack.UseItem's .run: a caught Pokémon ends the
|
||||
// battle as a WIN, keeping the result's flag bits.
|
||||
@@ -1524,7 +1621,14 @@ export class Battle {
|
||||
* Dude's own ball and passes -1. Returns false if no ball was used. */
|
||||
private *pokeBallEffect(item: number, slot: number): Gen<boolean> {
|
||||
const data = this.options.data,
|
||||
tutorial = this.battleType === C.BATTLETYPE_TUTORIAL;
|
||||
tutorial = this.battleType === C.BATTLETYPE_TUTORIAL,
|
||||
contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
|
||||
// .return_from_capture: the tutorial keeps the Dude's ball, the contest
|
||||
// counts down its Park Balls, and other balls leave the pack.
|
||||
const useBall = () => {
|
||||
if (contest) contest.parkBalls = (contest.parkBalls - 1) & 0xff;
|
||||
else if (!tutorial) this.tossItem("balls", item, slot);
|
||||
};
|
||||
// _DoItemEffect: GetItemName, then CopyName1 into wStringBuffer2.
|
||||
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item);
|
||||
if (this.battleMode !== C.WILD_BATTLE) {
|
||||
@@ -1545,6 +1649,8 @@ export class Battle {
|
||||
// The Dude's tutorial reaches this check too, and the original then
|
||||
// repeats the unusable ball forever (docs/bugs_and_glitches.md).
|
||||
if (tutorial) throw new UnsupportedBattle("The Dude's Poké Ball cannot be used with a full party and box.");
|
||||
// The contest's menu would go on to the player's move.
|
||||
if (contest) throw new UnsupportedBattle("A Park Ball cannot be used with a full party and box.");
|
||||
if (!storage) throw new UnsupportedBattle("Missing PC boxes.");
|
||||
yield* this.say(BATTLE.BallBoxFullText);
|
||||
return false;
|
||||
@@ -1568,7 +1674,7 @@ export class Battle {
|
||||
BATTLE.BallSoCloseText,
|
||||
][wobbles - 1],
|
||||
);
|
||||
if (!tutorial) this.tossItem("balls", item, slot);
|
||||
useBall();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1578,7 +1684,7 @@ export class Battle {
|
||||
// to wWildMonMoves and wWildMonPP. A transformed Pokémon is reloaded as
|
||||
// a Ditto with its backup DVs and keeps that Ditto's moves (a kept bug
|
||||
// for anything else).
|
||||
if (this.random() >= percent(75) + 1) this.random();
|
||||
if (this.battleType !== C.BATTLETYPE_FORCEITEM && this.random() >= percent(75) + 1) this.random();
|
||||
if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) {
|
||||
const moves = this.levelMoves(C.DITTO, this.enemy.mon.level);
|
||||
Object.assign(this.enemy.mon, {
|
||||
@@ -1610,6 +1716,12 @@ export class Battle {
|
||||
// NewPokedexEntry's Pokédex page is not presented yet.
|
||||
yield* this.say(BATTLE.NewDexDataText);
|
||||
}
|
||||
if (contest) {
|
||||
yield* this.setCaughtContestMon(contest, species, level);
|
||||
useBall();
|
||||
return true;
|
||||
}
|
||||
if (this.battleType === C.BATTLETYPE_CELEBI) this.battleResult |= BATTLERESULT_CAUGHT_CELEBI;
|
||||
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
|
||||
const name = data.speciesName(species);
|
||||
if (this.options.party.length >= PARTY_LENGTH) {
|
||||
@@ -1643,6 +1755,47 @@ export class Battle {
|
||||
this.tossItem("balls", item, slot);
|
||||
return true;
|
||||
}
|
||||
/** BugContest_SetCaughtContestMon: a second catch shows both Pokémon
|
||||
* (DisplayCaughtContestMonStats) and asks to switch. The kept one is
|
||||
* GeneratePartyMonStats' copy of the wild Pokémon, which has no caught
|
||||
* data; the contest names it later. */
|
||||
private *setCaughtContestMon(contest: BugContest, species: number, level: number): Gen {
|
||||
const data = this.options.data,
|
||||
enemy = this.enemy.mon;
|
||||
if (contest.mon) {
|
||||
const stock = contest.mon;
|
||||
this.strings[RAM.stringBuffer1] = data.speciesName(stock.species);
|
||||
yield* this.say(BATTLE["DisplayAlreadyCaughtText.ContestAlreadyCaughtText"]);
|
||||
const answer = yield {
|
||||
kind: "contestswitch",
|
||||
text: readTextEnd(this.reader, BATTLE.ContestAskSwitchText, this.context()).text,
|
||||
stock: { name: data.speciesName(stock.species), level: stock.level, maxHp: stock.stats[0] },
|
||||
caught: { name: enemy.nickname, level: enemy.level, maxHp: enemy.maxHp },
|
||||
};
|
||||
if (!answer) return;
|
||||
}
|
||||
contest.mon = {
|
||||
species,
|
||||
item: enemy.item,
|
||||
moves: [...this.wildMoves] as PartyMon["moves"],
|
||||
otId: this.options.playerId,
|
||||
ot: this.options.playerName,
|
||||
nickname: data.speciesName(species),
|
||||
exp: data.expAtLevel(data.baseData(species).growthRate, level),
|
||||
statExp: [0, 0, 0, 0, 0],
|
||||
dvs: enemy.dvs,
|
||||
pp: [...this.wildPP] as PartyMon["pp"],
|
||||
happiness: C.BASE_HAPPINESS,
|
||||
pokerus: 0,
|
||||
caughtData: 0,
|
||||
level,
|
||||
status: enemy.status,
|
||||
hp: enemy.hp,
|
||||
stats: calcStats(data, species, enemy.dvs, level),
|
||||
};
|
||||
this.strings[RAM.stringBuffer1] = data.speciesName(species);
|
||||
yield* this.say(BATTLE["BugContest_SetCaughtContestMon.ContestCaughtMonText"]);
|
||||
}
|
||||
/** PokeBallEffect's decision, before animation and caught-mon loading. */
|
||||
private rollCapture(item: number, tutorial: boolean) {
|
||||
let caught = tutorial || item === C.MASTER_BALL;
|
||||
@@ -2086,6 +2239,16 @@ export class Battle {
|
||||
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape.
|
||||
* The Speed compared is the battle Pokémon's, except after a faint. */
|
||||
private *tryToRunAway(playerSpeed = this.player.mon.stats[C.SPEED]): Gen<boolean> {
|
||||
// The contest always lets the player go; the event battles never do.
|
||||
if (this.battleType === C.BATTLETYPE_CONTEST) {
|
||||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||||
return true;
|
||||
}
|
||||
if (this.cannotFlee()) {
|
||||
yield* this.say(BATTLE.BattleText_CantEscape);
|
||||
return false;
|
||||
}
|
||||
if (this.options.trainer) {
|
||||
yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle);
|
||||
return false;
|
||||
|
||||
Reference in new issue
Block a user