Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
@@ -0,0 +1,186 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import {
|
||||
Battle,
|
||||
UnsupportedBattle,
|
||||
type BattleEffect,
|
||||
type BattleInput,
|
||||
} from "../src/game/battle/battle";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import {
|
||||
calcStats,
|
||||
generatePartyMon,
|
||||
type PartyMon,
|
||||
} 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))(
|
||||
"native battle engine with the local ROM",
|
||||
() => {
|
||||
let data: CrystalAssets["pokemon"];
|
||||
beforeAll(() => {
|
||||
data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
|
||||
});
|
||||
const PIDGEY = 16,
|
||||
CYNDAQUIL = 155,
|
||||
TACKLE = 33;
|
||||
const starter = (level = 5, exp?: number): PartyMon => {
|
||||
const mon = generatePartyMon(data, {
|
||||
species: CYNDAQUIL,
|
||||
level,
|
||||
item: 0,
|
||||
otId: 1234,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x823b,
|
||||
caughtData: 0,
|
||||
});
|
||||
if (exp !== undefined) mon.exp = exp;
|
||||
return mon;
|
||||
};
|
||||
/** A reproducible stand-in for the hardware Random routine. */
|
||||
const seeded = (seed: number) => () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
};
|
||||
const battle = (
|
||||
party: PartyMon[],
|
||||
wild: { species: number; level: number },
|
||||
seed = 1,
|
||||
) => {
|
||||
const pokedex = { seen: new Set<number>(), caught: new Set<number>() };
|
||||
return {
|
||||
pokedex,
|
||||
battle: new Battle({
|
||||
data,
|
||||
party,
|
||||
pokedex,
|
||||
playerId: 1234,
|
||||
playerName: "CHRIS",
|
||||
timeOfDay: 1,
|
||||
random: seeded(seed),
|
||||
wild,
|
||||
text: () => defaultTextContext("CHRIS"),
|
||||
}),
|
||||
};
|
||||
};
|
||||
/** Play a battle, answering the menu and prompts; returns texts and result. */
|
||||
const play = (
|
||||
battle: Battle,
|
||||
answer: (effect: BattleEffect) => BattleInput = (effect) =>
|
||||
effect.kind === "action"
|
||||
? { action: "fight", move: 0 }
|
||||
: effect.kind === "yesno"
|
||||
? 1
|
||||
: undefined,
|
||||
) => {
|
||||
const texts: string[] = [];
|
||||
const effects: BattleEffect[] = [];
|
||||
const run = battle.run();
|
||||
let input: BattleInput;
|
||||
for (let step = 0; step < 2000; step++) {
|
||||
const next = run.next(input);
|
||||
if (next.done) return { texts, effects, result: next.value.result };
|
||||
effects.push(next.value);
|
||||
if (next.value.kind === "text")
|
||||
texts.push(next.value.text.replace(/\n/g, " "));
|
||||
input = answer(next.value);
|
||||
}
|
||||
throw new Error("Battle did not end.");
|
||||
};
|
||||
|
||||
it("wins a wild battle with the original messages, experience and stat experience", () => {
|
||||
const mon = starter();
|
||||
const { battle: game, pokedex } = battle([mon], {
|
||||
species: PIDGEY,
|
||||
level: 3,
|
||||
});
|
||||
const { texts, result } = play(game);
|
||||
expect(texts.slice(0, 3)).toEqual([
|
||||
"Wild PIDGEY appeared!",
|
||||
"Go! CYNDAQUIL!",
|
||||
"CYNDAQUIL used TACKLE!",
|
||||
]);
|
||||
expect(texts).toContain("Enemy PIDGEY fainted!");
|
||||
// Base experience 55 × level 3 ÷ 7.
|
||||
expect(texts.at(-1)).toBe("CYNDAQUIL gained 23 EXP. Points!");
|
||||
expect(result).toBe(C.WIN);
|
||||
expect(mon.exp).toBe(135 + 23);
|
||||
expect(mon.statExp).toEqual([40, 45, 40, 56, 35]);
|
||||
expect(mon.pp[0]).toBeLessThan(35);
|
||||
expect(pokedex.seen.has(PIDGEY)).toBe(true);
|
||||
expect(mon.hp).toBeLessThan(mon.stats[0]);
|
||||
});
|
||||
|
||||
it("escapes when the player's Pokémon is faster", () => {
|
||||
const mon = starter(20);
|
||||
const { battle: game } = battle([mon], { species: PIDGEY, level: 2 });
|
||||
const { texts, result } = play(game, (effect) =>
|
||||
effect.kind === "action" ? { action: "run" } : undefined,
|
||||
);
|
||||
expect(texts.slice(-1)).toEqual(["Got away safely!"]);
|
||||
expect(result).toBe(C.DRAW);
|
||||
expect(mon.exp).toBe(data.expAtLevel(3, 20));
|
||||
});
|
||||
|
||||
it("loses when the only Pokémon faints", () => {
|
||||
const mon = starter();
|
||||
mon.hp = 1;
|
||||
const { battle: game } = battle([mon], { species: 19, level: 10 }, 5);
|
||||
const { texts, result } = play(game, (effect) =>
|
||||
effect.kind === "action" ? { action: "fight", move: 1 } : undefined,
|
||||
);
|
||||
expect(texts).toContain("CYNDAQUIL fainted!");
|
||||
expect(result).toBe(C.LOSE);
|
||||
expect(mon.hp).toBe(0);
|
||||
expect(mon.status).toBe(0);
|
||||
});
|
||||
|
||||
it("levels up, shows the stat box and learns the level's move", () => {
|
||||
const mon = starter(5, 178);
|
||||
const { battle: game } = battle([mon], { species: PIDGEY, level: 3 });
|
||||
const { texts, effects } = play(game);
|
||||
expect(texts).toContain("CYNDAQUIL grew to level 6!");
|
||||
const box = effects.find((effect) => effect.kind === "stats");
|
||||
expect(box).toMatchObject({
|
||||
labels: ["ATTACK", "DEFENSE", "SPCL.ATK", "SPCL.DEF", "SPEED"],
|
||||
});
|
||||
expect(texts).toContain("CYNDAQUIL learned SMOKESCREEN!");
|
||||
expect(mon.level).toBe(6);
|
||||
expect(mon.moves).toEqual([TACKLE, 43, 108, 0]);
|
||||
// Level-up recalculates stats with the new stat experience.
|
||||
expect(mon.stats).toEqual(
|
||||
calcStats(data, CYNDAQUIL, mon.dvs, 6, mon.statExp),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks which move to forget when four moves are known", () => {
|
||||
const mon = starter(5, 178);
|
||||
mon.moves = [TACKLE, 43, 52, 98];
|
||||
mon.pp = [35, 30, 25, 30];
|
||||
const { battle: game } = battle([mon], { species: PIDGEY, level: 3 });
|
||||
const { texts } = play(game, (effect) =>
|
||||
effect.kind === "action"
|
||||
? { action: "fight", move: 0 }
|
||||
: effect.kind === "yesno"
|
||||
? 1
|
||||
: effect.kind === "forget"
|
||||
? 1
|
||||
: undefined,
|
||||
);
|
||||
expect(texts).toContain("Which move should be forgotten?");
|
||||
expect(texts.some((text) => text.startsWith("1, 2 and…"))).toBe(true);
|
||||
expect(mon.moves).toEqual([TACKLE, 108, 52, 98]);
|
||||
expect(mon.pp[1]).toBe(data.move(108).pp);
|
||||
});
|
||||
|
||||
it("refuses battles that need untranslated rules", () => {
|
||||
const { battle: game } = battle([starter()], { species: 129, level: 5 });
|
||||
expect(() => play(game)).toThrow(UnsupportedBattle);
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user