- Day-Care and breeding: deposits, withdrawal pricing and growth, compatibility, egg species, DVs and moves, DayCareStep, egg collection and the Odd Egg, compared with the cartridge's routines. - CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep in the original's order; HatchEggs with the ROM's hatch animation, compared frame by frame with the original. - MAIL: all ten stationery types and the Portrait MAIL picture, compared with the cartridge's tilemaps, graphics and palettes. - Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio take the original's absent-printer path. - Hall of Fame records (saved apart from the League flag, ready for the League to call) and the Hall of Fame PC, compared with the cartridge's _HallOfFamePC on 255 pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1461 lines
55 KiB
TypeScript
1461 lines
55 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets, MON_SPRITE_ID } from "../src/game/rom/assets";
|
|
import { GameWorld } from "../src/game/world/world";
|
|
import {
|
|
calcStats,
|
|
generatePartyMon,
|
|
type PartyMon,
|
|
} from "../src/game/party/pokemon";
|
|
import { toBoxMon, type BoxMon } from "../src/game/party/storage";
|
|
import {
|
|
breedingCompatibility,
|
|
dayCareStep,
|
|
emptyDayCare,
|
|
initBreeding,
|
|
oddEgg,
|
|
} from "../src/game/party/day-care";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { settle } from "./world-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
|
|
describe.skipIf(!existsSync(romPath))(
|
|
"The Day-Care, breeding and Eggs with the local ROM",
|
|
() => {
|
|
const assets = existsSync(romPath)
|
|
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
|
|
: undefined;
|
|
const data = () => assets!.pokemon;
|
|
|
|
const DAY_CARE = "11:24",
|
|
ROUTE_34 = "11:1";
|
|
const PICHU = 172,
|
|
PIKACHU = 25,
|
|
CYNDAQUIL = 155,
|
|
QUILAVA = 156,
|
|
TYPHLOSION = 157,
|
|
TOTODILE = 158,
|
|
NIDORAN_F = 29,
|
|
NIDORAN_M = 32,
|
|
NIDORINO = 33,
|
|
DITTO = 132,
|
|
MAGNEMITE = 81,
|
|
MAGIKARP = 129,
|
|
TYROGUE = 236,
|
|
EGG_SPECIES = 0xfd;
|
|
// Engine flags (constants/engine_flags.asm) and events (event_flags.asm).
|
|
const HAS_EGG = 5,
|
|
MAN_HAS_MON = 6,
|
|
LADY_HAS_MON = 7;
|
|
const EVENT_GOT_ODD_EGG = 0x33e,
|
|
EVENT_DAY_CARE_MAN_IN_DAY_CARE = 0x6e5,
|
|
EVENT_DAY_CARE_MAN_ON_ROUTE_34 = 0x6e6,
|
|
EVENT_DAY_CARE_MON_1 = 0x6e7,
|
|
EVENT_DAY_CARE_MON_2 = 0x6e8;
|
|
const EGG_TICKET = 0x81;
|
|
|
|
/** attack, defense, speed, special DVs as a 16-bit value. */
|
|
const dvs = (atk: number, def: number, spd: number, spc: number) =>
|
|
(atk << 12) | (def << 8) | (spd << 4) | spc;
|
|
/** Attack and Speed DVs of 15 make a male of any species with a ratio
|
|
* below 255; zeros make a female (one that is not male-only). */
|
|
const MALE = (def = 0, spc = 0) => dvs(15, def, 15, spc);
|
|
const FEMALE = (def = 0, spc = 0) => dvs(0, def, 0, spc);
|
|
|
|
const moveId = (name: string) => {
|
|
for (let move = 1; move <= 251; move++)
|
|
if (data().moveName(move) === name) return move;
|
|
throw new Error(`No move ${name}`);
|
|
};
|
|
|
|
/** A world whose clock `now` moves, and whose random bytes come from a
|
|
* fixed generator. */
|
|
const world = (date = "2026-09-22T12:00:00") => {
|
|
let seed = 13;
|
|
const clock = { now: new Date(date) };
|
|
const game = new GameWorld(
|
|
assets!,
|
|
() => clock.now,
|
|
() => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 16) & 255;
|
|
},
|
|
);
|
|
return { game, clock };
|
|
};
|
|
const mon = (
|
|
game: GameWorld,
|
|
species: number,
|
|
level: number,
|
|
extra: Partial<PartyMon> = {},
|
|
): PartyMon =>
|
|
Object.assign(
|
|
generatePartyMon(data(), {
|
|
species,
|
|
level,
|
|
item: 0,
|
|
otId: game.playerId,
|
|
ot: "KAWA",
|
|
dvs: extra.dvs ?? 0x8888,
|
|
caughtData: 0,
|
|
}),
|
|
extra,
|
|
);
|
|
/** Enter `map`, stand at (x, y) facing `face`, press A and answer. */
|
|
const talk = (
|
|
game: GameWorld,
|
|
map: string,
|
|
x: number,
|
|
y: number,
|
|
face: "up" | "down" | "left" | "right",
|
|
answers: number[] = [],
|
|
) => {
|
|
if (game.mapId !== map) {
|
|
game.warpTo(map, x, y);
|
|
settle(game);
|
|
}
|
|
game.restore({ ...game.save(), x, y, direction: face });
|
|
game.interact();
|
|
const shown = settle(game, answers).join(" ");
|
|
expect(game.notice, game.diagnostic).toBe("");
|
|
return shown;
|
|
};
|
|
/** The Day-Care man stands at (2, 3) and his wife at (5, 3). */
|
|
const man = (game: GameWorld, answers: number[] = []) =>
|
|
talk(game, DAY_CARE, 2, 4, "up", answers);
|
|
const lady = (game: GameWorld, answers: number[] = []) =>
|
|
talk(game, DAY_CARE, 5, 4, "up", answers);
|
|
/** A fresh world whose Odd Egg has been given, with `party` (by default a
|
|
* Cyndaquil and a Totodile). */
|
|
const started = (party?: (game: GameWorld) => PartyMon[]) => {
|
|
const created = world();
|
|
created.game.state.events.add(EVENT_GOT_ODD_EGG);
|
|
created.game.state.party = party
|
|
? party(created.game)
|
|
: [mon(created.game, CYNDAQUIL, 20), mon(created.game, TOTODILE, 15)];
|
|
return created;
|
|
};
|
|
/** A pair that breeds, and a third Pokémon to keep in the party. */
|
|
const breedingParty = (game: GameWorld) => [
|
|
mon(game, CYNDAQUIL, 20, { dvs: FEMALE(2, 1), otId: 11 }),
|
|
mon(game, TYPHLOSION, 40, { dvs: MALE(7, 2), otId: 22 }),
|
|
mon(game, TOTODILE, 15),
|
|
];
|
|
|
|
/** A byte stream for the Random routine: an error when it runs dry. */
|
|
const bytes = (...values: number[]) => {
|
|
let index = 0;
|
|
const next = () => {
|
|
if (index >= values.length) throw new Error("Random ran out of bytes.");
|
|
return values[index++];
|
|
};
|
|
return Object.assign(next, { used: () => index });
|
|
};
|
|
const boxed = (
|
|
game: GameWorld,
|
|
species: number,
|
|
extra: Partial<PartyMon> = {},
|
|
): BoxMon => toBoxMon(mon(game, species, 20, extra));
|
|
|
|
describe("breeding compatibility (CheckBreedmonCompatibility)", () => {
|
|
const { game } = world();
|
|
/** Two stored Pokémon of different trainers unless said otherwise. */
|
|
const pair = (
|
|
a: Partial<PartyMon> & { species: number },
|
|
b: Partial<PartyMon> & { species: number },
|
|
) =>
|
|
breedingCompatibility(
|
|
data(),
|
|
boxed(game, a.species, { otId: 1, ...a }),
|
|
boxed(game, b.species, { otId: 2, ...b }),
|
|
);
|
|
|
|
it("rates the same species by whether the OT IDs match", () => {
|
|
const male = { species: CYNDAQUIL, dvs: MALE(3, 1) },
|
|
female = { species: CYNDAQUIL, dvs: FEMALE(8, 2) };
|
|
expect(pair({ ...male, otId: 1 }, { ...female, otId: 2 })).toBe(254);
|
|
expect(pair({ ...male, otId: 7 }, { ...female, otId: 7 })).toBe(
|
|
254 - 77,
|
|
);
|
|
});
|
|
|
|
it("rates different species of a shared egg group lower", () => {
|
|
// Pikachu and Cyndaquil share the Field group.
|
|
const a = { species: PIKACHU, dvs: MALE(3, 1) },
|
|
b = { species: CYNDAQUIL, dvs: FEMALE(8, 2) };
|
|
expect(pair({ ...a, otId: 1 }, { ...b, otId: 2 })).toBe(128);
|
|
expect(pair({ ...a, otId: 5 }, { ...b, otId: 5 })).toBe(128 - 77);
|
|
});
|
|
|
|
it("does not breed Pokémon of different egg groups, or in the No Eggs group", () => {
|
|
expect(
|
|
pair(
|
|
{ species: PIKACHU, dvs: MALE() },
|
|
{ species: MAGIKARP, dvs: FEMALE(4) },
|
|
),
|
|
).toBe(0);
|
|
// Pichu is a baby, and Nidorina is in Undiscovered too.
|
|
expect(
|
|
pair(
|
|
{ species: PICHU, dvs: MALE() },
|
|
{ species: PIKACHU, dvs: FEMALE(4) },
|
|
),
|
|
).toBe(0);
|
|
expect(
|
|
pair(
|
|
{ species: PIKACHU, dvs: FEMALE(4) },
|
|
{ species: TYROGUE, dvs: MALE() },
|
|
),
|
|
).toBe(0);
|
|
// A Ditto suits every group but that one.
|
|
expect(
|
|
pair(
|
|
{ species: DITTO, dvs: 0x1234 },
|
|
{ species: PICHU, dvs: MALE() },
|
|
),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("needs two genders, or a Ditto", () => {
|
|
expect(
|
|
pair(
|
|
{ species: CYNDAQUIL, dvs: MALE(3) },
|
|
{ species: CYNDAQUIL, dvs: MALE(9) },
|
|
),
|
|
).toBe(0);
|
|
expect(
|
|
pair(
|
|
{ species: CYNDAQUIL, dvs: FEMALE(3) },
|
|
{ species: CYNDAQUIL, dvs: FEMALE(9) },
|
|
),
|
|
).toBe(0);
|
|
// Gender-unknown Pokémon only breed with Ditto.
|
|
expect(
|
|
pair(
|
|
{ species: MAGNEMITE, dvs: 0x1111 },
|
|
{ species: MAGNEMITE, dvs: 0x2222 },
|
|
),
|
|
).toBe(0);
|
|
expect(
|
|
pair(
|
|
{ species: MAGNEMITE, dvs: 0x1111 },
|
|
{ species: DITTO, dvs: 0x2222, otId: 99 },
|
|
),
|
|
).toBe(128);
|
|
expect(
|
|
pair(
|
|
{ species: DITTO, dvs: 0x2222 },
|
|
{ species: MAGNEMITE, dvs: 0x1111, otId: 99 },
|
|
),
|
|
).toBe(128);
|
|
// A Ditto pair never breeds.
|
|
expect(
|
|
pair(
|
|
{ species: DITTO, dvs: 0x1111 },
|
|
{ species: DITTO, dvs: 0x2222 },
|
|
),
|
|
).toBe(0);
|
|
// A Ditto with a gendered Pokémon is rated like any pair.
|
|
expect(
|
|
pair(
|
|
{ species: DITTO, dvs: 0x1111, otId: 3 },
|
|
{ species: PIKACHU, dvs: MALE(2), otId: 3 },
|
|
),
|
|
).toBe(128 - 77);
|
|
expect(
|
|
pair(
|
|
{ species: DITTO, dvs: 0x1111 },
|
|
{ species: PIKACHU, dvs: FEMALE(2) },
|
|
),
|
|
).toBe(128);
|
|
});
|
|
|
|
it("avoids breeding Pokémon with equal Defense DVs and low Special DV bits (255)", () => {
|
|
const a = { species: CYNDAQUIL, dvs: MALE(5, 3) };
|
|
// Special DVs 3 and 11 agree in their low 3 bits: the pair is avoided.
|
|
expect(pair(a, { species: CYNDAQUIL, dvs: FEMALE(5, 11) })).toBe(255);
|
|
// A different Defense DV, or a different low bit, is not.
|
|
expect(pair(a, { species: CYNDAQUIL, dvs: FEMALE(6, 11) })).toBe(254);
|
|
expect(pair(a, { species: CYNDAQUIL, dvs: FEMALE(5, 4) })).toBe(254);
|
|
});
|
|
});
|
|
|
|
describe("making the Egg (DayCare_InitBreeding)", () => {
|
|
const { game } = world();
|
|
const player = { id: 0x1234, name: "CHRIS" };
|
|
/** A Day-Care holding two Pokémon, ready for InitBreeding. */
|
|
const holding = (mon1: BoxMon, mon2: BoxMon) => {
|
|
const host = {
|
|
flags: new Set([MAN_HAS_MON, LADY_HAS_MON]),
|
|
dayCare: emptyDayCare(),
|
|
};
|
|
host.dayCare.mon1 = mon1;
|
|
host.dayCare.mon2 = mon2;
|
|
return host;
|
|
};
|
|
|
|
it("rolls the steps to the egg check from 150 up, and hatches the mother's baby form", () => {
|
|
// Pikachu (the mother) and a male Cyndaquil: Pichu cannot breed, but
|
|
// its egg is Pichu, the pre-evolution of the mother.
|
|
const host = holding(
|
|
boxed(game, PIKACHU, { dvs: FEMALE(2, 1), otId: 1 }),
|
|
boxed(game, CYNDAQUIL, { dvs: MALE(6, 4), otId: 2 }),
|
|
);
|
|
const random = bytes(10, 149, 150 + 7, 0x12, 0x34);
|
|
initBreeding(data(), host, player, random);
|
|
const { dayCare } = host;
|
|
// 10 and 149 are rejected: the byte must be at least 150.
|
|
expect(dayCare.stepsToEgg).toBe(157);
|
|
expect(dayCare.compatible).toBe(true);
|
|
expect(random.used()).toBe(5);
|
|
expect(dayCare.motherOrNonDitto).toBe(0);
|
|
const egg = dayCare.egg!;
|
|
expect(egg).toMatchObject({
|
|
species: PICHU,
|
|
item: 0,
|
|
otId: player.id,
|
|
ot: "CHRIS",
|
|
nickname: "EGG",
|
|
level: 5,
|
|
happiness: 10,
|
|
pokerus: 0,
|
|
caughtData: 0,
|
|
statExp: [0, 0, 0, 0, 0],
|
|
exp: data().expAtLevel(data().baseData(PICHU).growthRate, 5),
|
|
egg: true,
|
|
});
|
|
});
|
|
|
|
it("takes the mother from the second slot when the first is male", () => {
|
|
const host = holding(
|
|
boxed(game, CYNDAQUIL, { dvs: MALE(6, 4), otId: 2 }),
|
|
boxed(game, PIKACHU, { dvs: FEMALE(2, 1), otId: 1 }),
|
|
);
|
|
initBreeding(data(), host, player, bytes(200, 0, 0));
|
|
expect(host.dayCare.motherOrNonDitto).toBe(1);
|
|
expect(host.dayCare.egg!.species).toBe(PICHU);
|
|
});
|
|
|
|
it("lets a Ditto's partner be the mother whichever slot it is in", () => {
|
|
for (const dittoFirst of [true, false]) {
|
|
const ditto = boxed(game, DITTO, { dvs: 0x4321, otId: 9 }),
|
|
pikachu = boxed(game, PIKACHU, { dvs: MALE(2, 1), otId: 1 });
|
|
const host = holding(
|
|
...((dittoFirst ? [ditto, pikachu] : [pikachu, ditto]) as [
|
|
BoxMon,
|
|
BoxMon,
|
|
]),
|
|
);
|
|
initBreeding(data(), host, player, bytes(150, 0xff, 0xff));
|
|
expect(host.dayCare.motherOrNonDitto).toBe(dittoFirst ? 1 : 0);
|
|
expect(host.dayCare.egg!.species).toBe(PICHU);
|
|
// The Egg's DVs come from the Ditto: its Defense DV (3) and the low
|
|
// bits of its Special DV (1), with the random high bits.
|
|
expect(host.dayCare.egg!.dvs).toBe(0xf3f9);
|
|
}
|
|
});
|
|
|
|
it("gives Nidoran♀ either gender of Nidoran", () => {
|
|
const female = boxed(game, NIDORAN_F, { dvs: FEMALE(1, 1), otId: 1 }),
|
|
male = boxed(game, NIDORINO, { dvs: MALE(8, 4), otId: 2 });
|
|
for (const [draw, species] of [
|
|
[0, NIDORAN_F],
|
|
[127, NIDORAN_F],
|
|
[128, NIDORAN_M],
|
|
[255, NIDORAN_M],
|
|
] as const) {
|
|
const host = holding(female, male);
|
|
const random = bytes(160, draw, 0x40, 0x40);
|
|
initBreeding(data(), host, player, random);
|
|
expect(host.dayCare.egg!.species, `draw ${draw}`).toBe(species);
|
|
expect(random.used()).toBe(4);
|
|
}
|
|
// Nidoran♂ is not given the draw: its Egg is always a Nidoran♂.
|
|
const host = holding(
|
|
boxed(game, DITTO, { dvs: 0x1111 }),
|
|
boxed(game, NIDORAN_M, { dvs: MALE(2, 2) }),
|
|
);
|
|
const random = bytes(170, 0x40, 0x40);
|
|
initBreeding(data(), host, player, random);
|
|
expect(host.dayCare.egg!.species).toBe(NIDORAN_M);
|
|
expect(random.used()).toBe(3);
|
|
});
|
|
|
|
it("gives a female Egg the father's Defense DV and a male Egg the mother's", () => {
|
|
// Cyndaquil is 12.5% female: the Egg is male when the Attack and Speed
|
|
// DV bytes make a value above 31.
|
|
const mother = boxed(game, CYNDAQUIL, { dvs: FEMALE(0xa, 5), otId: 1 }),
|
|
father = boxed(game, CYNDAQUIL, { dvs: MALE(3, 0xb), otId: 2 });
|
|
// A male Egg (0xa5, 0xc3: value 0xac) takes the mother's Defense DV
|
|
// and low Special bits; a female (0x05, 0x13: value 1) the father's.
|
|
for (const motherFirst of [true, false]) {
|
|
const [first, second] = motherFirst
|
|
? [mother, father]
|
|
: [father, mother];
|
|
const maleHost = holding(first, second);
|
|
initBreeding(data(), maleHost, player, bytes(150, 0xa5, 0xc3));
|
|
expect(
|
|
maleHost.dayCare.egg!.dvs,
|
|
`male, mother first ${motherFirst}`,
|
|
).toBe((((0xa5 & 0xf0) | 0xa) << 8) | ((0xc3 & 0xf8) | (5 & 7)));
|
|
const femaleHost = holding(first, second);
|
|
initBreeding(data(), femaleHost, player, bytes(150, 0x05, 0x13));
|
|
expect(
|
|
femaleHost.dayCare.egg!.dvs,
|
|
`female, mother first ${motherFirst}`,
|
|
).toBe((((0x05 & 0xf0) | 3) << 8) | ((0x13 & 0xf8) | (0xb & 7)));
|
|
}
|
|
});
|
|
|
|
it("keeps the random DVs of a gender-unknown Egg", () => {
|
|
const host = holding(
|
|
boxed(game, MAGNEMITE, { dvs: 0x1111 }),
|
|
boxed(game, DITTO, { dvs: 0x2222 }),
|
|
);
|
|
// Ditto is the second Pokémon, so it is the DV source for every
|
|
// gendered egg; a Magnemite egg has no gender and skips inheritance
|
|
// only when neither parent is a Ditto: here Ditto still applies.
|
|
initBreeding(data(), host, player, bytes(150, 0xab, 0xcd));
|
|
expect(host.dayCare.egg!.species).toBe(MAGNEMITE);
|
|
expect(host.dayCare.egg!.dvs).toBe(
|
|
(((0xab & 0xf0) | 0x2) << 8) | ((0xcd & 0xf8) | 2),
|
|
);
|
|
});
|
|
|
|
it("does not breed incompatible or avoided pairs, or when one is missing", () => {
|
|
const avoided = holding(
|
|
boxed(game, CYNDAQUIL, { dvs: MALE(5, 3) }),
|
|
boxed(game, CYNDAQUIL, { dvs: FEMALE(5, 11) }),
|
|
);
|
|
initBreeding(data(), avoided, player, bytes());
|
|
expect(avoided.dayCare.compatible).toBe(false);
|
|
expect(avoided.dayCare.egg).toBeNull();
|
|
const alone = holding(
|
|
boxed(game, CYNDAQUIL, { dvs: MALE(5, 3) }),
|
|
boxed(game, CYNDAQUIL, { dvs: FEMALE(2, 1) }),
|
|
);
|
|
alone.flags.delete(LADY_HAS_MON);
|
|
initBreeding(data(), alone, player, bytes());
|
|
expect(alone.dayCare.compatible).toBe(false);
|
|
});
|
|
|
|
describe("moves", () => {
|
|
/** Cyndaquil's egg moves include Quick Attack and Reversal. */
|
|
const QUICK_ATTACK = moveId("QUICK ATTACK"),
|
|
REVERSAL = moveId("REVERSAL"),
|
|
SMOKESCREEN = moveId("SMOKESCREEN"),
|
|
SUNNY_DAY = moveId("SUNNY DAY"),
|
|
THUNDERBOLT = moveId("THUNDERBOLT"),
|
|
SCRATCH = moveId("SCRATCH"),
|
|
TACKLE = moveId("TACKLE"),
|
|
LEER = moveId("LEER");
|
|
const eggFor = (
|
|
mother: Partial<PartyMon> & { species: number },
|
|
father: Partial<PartyMon> & { species: number },
|
|
order: "motherFirst" | "fatherFirst" = "motherFirst",
|
|
) => {
|
|
const m = boxed(game, mother.species, {
|
|
dvs: FEMALE(1, 1),
|
|
otId: 1,
|
|
...mother,
|
|
});
|
|
const f = boxed(game, father.species, {
|
|
dvs: MALE(8, 4),
|
|
otId: 2,
|
|
...father,
|
|
});
|
|
const host = holding(
|
|
...((order === "motherFirst" ? [m, f] : [f, m]) as [
|
|
BoxMon,
|
|
BoxMon,
|
|
]),
|
|
);
|
|
initBreeding(data(), host, player, bytes(150, 0x40, 0x40));
|
|
return host.dayCare.egg!;
|
|
};
|
|
|
|
it("starts with the baby's level-up moves at level 5", () => {
|
|
const egg = eggFor(
|
|
{ species: CYNDAQUIL },
|
|
{ species: TYPHLOSION, moves: [0, 0, 0, 0] },
|
|
);
|
|
expect(egg.moves).toEqual([33, 43, 0, 0]);
|
|
expect(egg.pp).toEqual([35, 30, 0, 0]);
|
|
});
|
|
|
|
it("inherits the father's egg moves", () => {
|
|
const egg = eggFor(
|
|
{ species: CYNDAQUIL },
|
|
{
|
|
species: PIKACHU,
|
|
moves: [QUICK_ATTACK, REVERSAL, THUNDERBOLT, 0],
|
|
},
|
|
);
|
|
expect(egg.moves).toEqual([TACKLE, LEER, QUICK_ATTACK, REVERSAL]);
|
|
expect(egg.pp).toEqual([
|
|
35,
|
|
30,
|
|
data().move(QUICK_ATTACK).pp,
|
|
data().move(REVERSAL).pp,
|
|
]);
|
|
// Thunderbolt is neither an egg move nor a TM Cyndaquil learns.
|
|
expect(data().canLearnTMHM(CYNDAQUIL, THUNDERBOLT)).toBe(false);
|
|
expect(egg.moves).not.toContain(THUNDERBOLT);
|
|
});
|
|
|
|
it("uses the mother's moves only for the level-up moves both know", () => {
|
|
// Smokescreen is a level-up move of Cyndaquil (at 6, after the Egg's
|
|
// moves) and no TM, so the egg gets it only when the mother knows
|
|
// it too.
|
|
expect(data().canLearnTMHM(CYNDAQUIL, SMOKESCREEN)).toBe(false);
|
|
const father = {
|
|
species: TYPHLOSION,
|
|
moves: [SMOKESCREEN, 0, 0, 0] as PartyMon["moves"],
|
|
};
|
|
expect(
|
|
eggFor(
|
|
{ species: CYNDAQUIL, moves: [SCRATCH, SMOKESCREEN, 0, 0] },
|
|
father,
|
|
).moves,
|
|
).toContain(SMOKESCREEN);
|
|
expect(
|
|
eggFor({ species: CYNDAQUIL, moves: [SCRATCH, 0, 0, 0] }, father)
|
|
.moves,
|
|
).not.toContain(SMOKESCREEN);
|
|
// The same holds with the Pokémon in either slot.
|
|
expect(
|
|
eggFor(
|
|
{ species: CYNDAQUIL, moves: [SCRATCH, SMOKESCREEN, 0, 0] },
|
|
father,
|
|
"fatherFirst",
|
|
).moves,
|
|
).toContain(SMOKESCREEN);
|
|
});
|
|
|
|
it("inherits the father's TM and HM moves the baby can learn", () => {
|
|
expect(data().canLearnTMHM(CYNDAQUIL, SUNNY_DAY)).toBe(true);
|
|
const egg = eggFor(
|
|
{ species: CYNDAQUIL },
|
|
{ species: TYPHLOSION, moves: [SUNNY_DAY, THUNDERBOLT, 0, 0] },
|
|
);
|
|
expect(egg.moves).toContain(SUNNY_DAY);
|
|
expect(egg.moves).not.toContain(THUNDERBOLT);
|
|
});
|
|
|
|
it("shifts the baby's first move out when all four slots are full", () => {
|
|
// Cyndaquil starts with two moves; a father with four egg moves
|
|
// fills the other two, then pushes out the oldest of them.
|
|
const egg = eggFor(
|
|
{ species: CYNDAQUIL },
|
|
{
|
|
species: PIKACHU,
|
|
moves: [
|
|
QUICK_ATTACK,
|
|
REVERSAL,
|
|
moveId("THRASH"),
|
|
moveId("FORESIGHT"),
|
|
],
|
|
},
|
|
);
|
|
// Tackle and Leer were shifted out for the two newest moves.
|
|
expect(egg.moves).toEqual([
|
|
QUICK_ATTACK,
|
|
REVERSAL,
|
|
moveId("THRASH"),
|
|
moveId("FORESIGHT"),
|
|
]);
|
|
});
|
|
|
|
it("takes a Ditto's moves only when the Ditto is the father", () => {
|
|
// Ditto knows Transform: it is no egg move, level-up move or TM.
|
|
const TRANSFORM = moveId("TRANSFORM");
|
|
const ditto = (moves: PartyMon["moves"]) =>
|
|
boxed(game, DITTO, { dvs: 0x4321, moves });
|
|
const female = boxed(game, CYNDAQUIL, {
|
|
dvs: FEMALE(2, 1),
|
|
moves: [SCRATCH, QUICK_ATTACK, 0, 0],
|
|
});
|
|
const male = boxed(game, CYNDAQUIL, {
|
|
dvs: MALE(2, 1),
|
|
moves: [QUICK_ATTACK, REVERSAL, 0, 0],
|
|
});
|
|
// Female partner: Ditto is the father, so its moves are offered.
|
|
const withFemale = holding(
|
|
ditto([TRANSFORM, QUICK_ATTACK, 0, 0]),
|
|
female,
|
|
);
|
|
initBreeding(data(), withFemale, player, bytes(150, 0x40, 0x40));
|
|
expect(withFemale.dayCare.egg!.moves).toContain(QUICK_ATTACK);
|
|
expect(withFemale.dayCare.egg!.moves).not.toContain(TRANSFORM);
|
|
// Male partner: the male is the father; the Ditto's moves are not.
|
|
const withMale = holding(
|
|
male,
|
|
ditto([TRANSFORM, moveId("THRASH"), 0, 0]),
|
|
);
|
|
initBreeding(data(), withMale, player, bytes(150, 0x40, 0x40));
|
|
expect(withMale.dayCare.egg!.moves).toContain(REVERSAL);
|
|
expect(withMale.dayCare.egg!.moves).not.toContain(moveId("THRASH"));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("the step counter and the egg chance (DayCareStep)", () => {
|
|
const { game } = world();
|
|
const holding = (compatibleMons = true) => {
|
|
const host = {
|
|
flags: new Set([MAN_HAS_MON, LADY_HAS_MON]),
|
|
dayCare: emptyDayCare(),
|
|
};
|
|
host.dayCare.mon1 = boxed(game, CYNDAQUIL, {
|
|
dvs: MALE(2, 1),
|
|
otId: 1,
|
|
});
|
|
host.dayCare.mon2 = boxed(game, CYNDAQUIL, {
|
|
dvs: FEMALE(7, 2),
|
|
otId: 2,
|
|
});
|
|
host.dayCare.compatible = compatibleMons;
|
|
host.dayCare.stepsToEgg = 200;
|
|
return host;
|
|
};
|
|
|
|
it("adds an experience point per step to each Pokémon in", () => {
|
|
const host = holding(false);
|
|
const exp1 = host.dayCare.mon1!.exp;
|
|
for (let step = 0; step < 300; step++)
|
|
dayCareStep(data(), host, bytes());
|
|
expect(host.dayCare.mon1!.exp).toBe(exp1 + 300);
|
|
expect(host.dayCare.mon2!.exp).toBe(exp1 + 300);
|
|
// A Pokémon withdrawn (flag clear) or stored at level 100 gains none.
|
|
host.flags.delete(LADY_HAS_MON);
|
|
host.dayCare.mon2!.level = 100;
|
|
const before = host.dayCare.mon2!.exp;
|
|
dayCareStep(data(), host, bytes());
|
|
expect(host.dayCare.mon2!.exp).toBe(before);
|
|
expect(host.dayCare.mon1!.exp).toBe(exp1 + 301);
|
|
});
|
|
|
|
it("carries the experience between its bytes", () => {
|
|
const host = holding(false);
|
|
host.dayCare.mon1!.exp = 0x0100ff;
|
|
dayCareStep(data(), host, bytes());
|
|
expect(host.dayCare.mon1!.exp).toBe(0x010100);
|
|
host.dayCare.mon1!.exp = 0x00ffff;
|
|
dayCareStep(data(), host, bytes());
|
|
expect(host.dayCare.mon1!.exp).toBe(0x010000);
|
|
});
|
|
|
|
it("counts wStepsToEgg down only while compatible and checks the egg at zero", () => {
|
|
const idle = holding(false);
|
|
dayCareStep(data(), idle, bytes());
|
|
expect(idle.dayCare.stepsToEgg).toBe(200);
|
|
const host = holding();
|
|
host.dayCare.stepsToEgg = 3;
|
|
dayCareStep(data(), host, bytes());
|
|
dayCareStep(data(), host, bytes());
|
|
expect(host.dayCare.stepsToEgg).toBe(1);
|
|
// At zero: a new step count, then the chance (compatibility 128 + ...
|
|
// here two Cyndaquil with different OTs rate 254: below 31 percent + 1
|
|
// = 80 gives an egg, else nothing).
|
|
const draws = bytes(33, 79);
|
|
dayCareStep(data(), host, draws);
|
|
expect(draws.used()).toBe(2);
|
|
expect(host.dayCare.stepsToEgg).toBe(33);
|
|
expect(host.dayCare.compatible).toBe(false);
|
|
expect(host.flags.has(HAS_EGG)).toBe(true);
|
|
});
|
|
|
|
it("uses 31, 16, 12 and 4 percent for the four bands of compatibility", () => {
|
|
// Chance bytes: 31 percent + 1 = 80, 16 percent = 40, 12 percent = 30,
|
|
// 4 percent = 10; the egg comes when the draw is below it.
|
|
const rate = (
|
|
a: Partial<PartyMon>,
|
|
b: Partial<PartyMon>,
|
|
draw: number,
|
|
) => {
|
|
const host = holding();
|
|
host.dayCare.mon1 = boxed(game, CYNDAQUIL, a);
|
|
host.dayCare.mon2 = boxed(
|
|
game,
|
|
a.species === TOTODILE ? TOTODILE : CYNDAQUIL,
|
|
b,
|
|
);
|
|
host.dayCare.stepsToEgg = 1;
|
|
dayCareStep(data(), host, bytes(50, draw));
|
|
return host.flags.has(HAS_EGG);
|
|
};
|
|
const m = (extra: Partial<PartyMon>) => ({ dvs: MALE(2, 1), ...extra });
|
|
const f = (extra: Partial<PartyMon>) => ({
|
|
dvs: FEMALE(7, 2),
|
|
...extra,
|
|
});
|
|
// Same species, different OTs: 254.
|
|
expect([
|
|
rate(m({ otId: 1 }), f({ otId: 2 }), 79),
|
|
rate(m({ otId: 1 }), f({ otId: 2 }), 80),
|
|
]).toEqual([true, false]);
|
|
// Same species, same OT: 177 (the 16 percent band).
|
|
expect([
|
|
rate(m({ otId: 1 }), f({ otId: 1 }), 39),
|
|
rate(m({ otId: 1 }), f({ otId: 1 }), 40),
|
|
]).toEqual([true, false]);
|
|
// Different species, different OTs: 128 (the 12 percent band).
|
|
const different = (draw: number) => {
|
|
const host = holding();
|
|
host.dayCare.mon1 = boxed(game, PIKACHU, {
|
|
dvs: MALE(2, 1),
|
|
otId: 1,
|
|
});
|
|
host.dayCare.mon2 = boxed(game, CYNDAQUIL, {
|
|
dvs: FEMALE(7, 2),
|
|
otId: 2,
|
|
});
|
|
host.dayCare.stepsToEgg = 1;
|
|
dayCareStep(data(), host, bytes(50, draw));
|
|
return host.flags.has(HAS_EGG);
|
|
};
|
|
expect([different(29), different(30)]).toEqual([true, false]);
|
|
// Different species, same OT: 51 (the 4 percent band).
|
|
const sameOwner = (draw: number) => {
|
|
const host = holding();
|
|
host.dayCare.mon1 = boxed(game, PIKACHU, {
|
|
dvs: MALE(2, 1),
|
|
otId: 3,
|
|
});
|
|
host.dayCare.mon2 = boxed(game, CYNDAQUIL, {
|
|
dvs: FEMALE(7, 2),
|
|
otId: 3,
|
|
});
|
|
host.dayCare.stepsToEgg = 1;
|
|
dayCareStep(data(), host, bytes(50, draw));
|
|
return host.flags.has(HAS_EGG);
|
|
};
|
|
expect([sameOwner(9), sameOwner(10)]).toEqual([true, false]);
|
|
});
|
|
});
|
|
|
|
describe("depositing a Pokémon", () => {
|
|
it("asks, takes the chosen Pokémon and prepares nothing with one alone", () => {
|
|
const { game } = started();
|
|
const shown = man(game, [1, 1]);
|
|
expect(shown).toContain(
|
|
"I'm the DAY-CARE\nMAN. Want me to\nraise a POKéMON?",
|
|
);
|
|
expect(shown).toContain("What should I\nraise for you?");
|
|
expect(shown).toContain("OK. I'll raise\nyour TOTODILE.");
|
|
expect(shown).toContain("Come back for it\nlater.");
|
|
expect(game.state.party.map((m) => m.species)).toEqual([CYNDAQUIL]);
|
|
const { dayCare } = game.state;
|
|
expect(dayCare.mon1).toMatchObject({
|
|
species: TOTODILE,
|
|
nickname: "TOTODILE",
|
|
ot: "KAWA",
|
|
level: 15,
|
|
});
|
|
expect(dayCare.mon1).not.toHaveProperty("hp");
|
|
expect(dayCare.manActive).toBe(true);
|
|
expect(game.state.flags.has(MAN_HAS_MON)).toBe(true);
|
|
expect(dayCare.compatible).toBe(false);
|
|
expect(dayCare.egg).toBeNull();
|
|
});
|
|
|
|
it("uses the longer introduction for the lady once, and keeps a deposited Pokémon's nickname", () => {
|
|
const { game } = started((g) => [
|
|
mon(g, CYNDAQUIL, 20, { nickname: "FLAME" }),
|
|
mon(g, TOTODILE, 15, { nickname: "SPLASH" }),
|
|
]);
|
|
const first = lady(game, [0]);
|
|
expect(first).toContain("about EGGS?");
|
|
expect(game.state.dayCare.ladyActive).toBe(true);
|
|
expect(first).toContain("Come again.");
|
|
expect(game.state.party).toHaveLength(2);
|
|
const second = lady(game, [1, 0]);
|
|
expect(second).not.toContain("EGGS");
|
|
expect(second).toContain("Should I raise a\nPOKéMON for you?");
|
|
expect(game.state.dayCare.mon2).toMatchObject({
|
|
nickname: "FLAME",
|
|
species: CYNDAQUIL,
|
|
});
|
|
expect(game.state.flags.has(LADY_HAS_MON)).toBe(true);
|
|
});
|
|
|
|
it("says no to a lone Pokémon, a cancelled menu, an Egg, the last healthy one and MAIL", () => {
|
|
const { game } = started((g) => [mon(g, CYNDAQUIL, 20)]);
|
|
expect(man(game, [1])).toContain("Oh? But you have\njust one POKéMON.");
|
|
expect(game.state.dayCare.mon1).toBeNull();
|
|
|
|
const party = (...mons: PartyMon[]) => (game.state.party = mons);
|
|
party(mon(game, CYNDAQUIL, 20), mon(game, TOTODILE, 15));
|
|
// B in the party menu.
|
|
expect(man(game, [1, -1])).toContain("Oh, fine then.");
|
|
expect(man(game, [1, -1])).toContain("Come again.");
|
|
// An Egg.
|
|
party(
|
|
mon(game, CYNDAQUIL, 20),
|
|
mon(game, PICHU, 5, { egg: true, hp: 0, nickname: "EGG" }),
|
|
);
|
|
expect(man(game, [1, 1])).toContain(
|
|
"Sorry, but I can't\naccept an EGG.",
|
|
);
|
|
// Only the chosen Pokémon can fight.
|
|
party(mon(game, CYNDAQUIL, 20, { hp: 0 }), mon(game, TOTODILE, 15));
|
|
expect(man(game, [1, 1])).toContain(
|
|
"If you give me\nthat, what will\nyou battle with?",
|
|
);
|
|
// Fainted others do not count, nor do Eggs.
|
|
party(
|
|
mon(game, CYNDAQUIL, 20, { hp: 0 }),
|
|
mon(game, TOTODILE, 15),
|
|
mon(game, PICHU, 5, { egg: true, hp: 0 }),
|
|
);
|
|
expect(man(game, [1, 1])).toContain("what will\nyou battle with?");
|
|
// MAIL.
|
|
const mail = Array.from({ length: 190 }, (_, i) => i + 1).find((item) =>
|
|
data().itemIsMail(item),
|
|
)!;
|
|
const letter = {
|
|
message: "HELLO",
|
|
author: "KAWA",
|
|
authorId: game.playerId,
|
|
species: TOTODILE,
|
|
type: mail,
|
|
};
|
|
party(
|
|
mon(game, CYNDAQUIL, 20),
|
|
mon(game, TOTODILE, 15, { item: mail, mail: letter }),
|
|
);
|
|
expect(man(game, [1, 1])).toContain(
|
|
"Remove MAIL before\nyou come see me.",
|
|
);
|
|
expect(game.state.dayCare.mon1).toBeNull();
|
|
expect(game.state.party).toHaveLength(2);
|
|
// And a plain no to the introduction's question.
|
|
expect(man(game, [0])).toContain("Come again.");
|
|
expect(game.state.party).toHaveLength(2);
|
|
});
|
|
|
|
it("prepares an Egg when the pair breeds and keeps it hidden until it is found", () => {
|
|
const { game } = started(breedingParty);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
const { dayCare } = game.state;
|
|
expect(game.state.party.map((m) => m.species)).toEqual([TOTODILE]);
|
|
expect(dayCare.mon1!.species).toBe(CYNDAQUIL);
|
|
expect(dayCare.mon2!.species).toBe(TYPHLOSION);
|
|
expect(dayCare.compatible).toBe(true);
|
|
expect(dayCare.stepsToEgg).toBeGreaterThanOrEqual(150);
|
|
expect(dayCare.egg!.species).toBe(CYNDAQUIL);
|
|
expect(game.state.flags.has(HAS_EGG)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the walking Day-Care (DayCareStep from the overworld)", () => {
|
|
it("gives both Pokémon experience for each step and counts down the egg", () => {
|
|
const { game } = started(breedingParty);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
const before = structuredClone(game.state.dayCare);
|
|
game.warpTo(DAY_CARE, 3, 5);
|
|
settle(game);
|
|
let walked = 0;
|
|
for (const direction of [
|
|
"right",
|
|
"right",
|
|
"left",
|
|
"left",
|
|
"right",
|
|
"left",
|
|
] as const) {
|
|
expect(game.move(direction)).toBe(true);
|
|
settle(game);
|
|
walked++;
|
|
}
|
|
const after = game.state.dayCare;
|
|
expect(after.mon1!.exp).toBe(before.mon1!.exp + walked);
|
|
expect(after.mon2!.exp).toBe(before.mon2!.exp + walked);
|
|
expect(after.stepsToEgg).toBe(before.stepsToEgg - walked);
|
|
});
|
|
|
|
it("skips the Day-Care on the step that starts a hatch, as CountStep does", () => {
|
|
const { game } = started(breedingParty);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
game.warpTo(DAY_CARE, 3, 5);
|
|
settle(game);
|
|
game.state.party.push({
|
|
...structuredClone(game.state.party[0]),
|
|
egg: true,
|
|
nickname: "EGG",
|
|
happiness: 1,
|
|
hp: 0,
|
|
});
|
|
// Step 127 counts for the Day-Care; step 128 runs DoEggStep, whose
|
|
// ready Egg jumps to the hatch before DayCareStep.
|
|
game.state.stepCount = 0x7e;
|
|
const before = structuredClone(game.state.dayCare);
|
|
expect(game.move("right")).toBe(true);
|
|
settle(game);
|
|
expect(game.state.dayCare.mon1!.exp).toBe(before.mon1!.exp + 1);
|
|
expect(game.state.dayCare.stepsToEgg).toBe(before.stepsToEgg - 1);
|
|
const counted = structuredClone(game.state.dayCare);
|
|
expect(game.move("left")).toBe(true);
|
|
for (let i = 0; i < 100 && !game.dialogue && game.busy; i++)
|
|
game.tick();
|
|
expect(game.dialogue).toBeDefined();
|
|
expect(game.state.stepCount).toBe(0x80);
|
|
expect(game.state.dayCare).toEqual(counted);
|
|
});
|
|
});
|
|
|
|
describe("taking a Pokémon back", () => {
|
|
/** A Totodile (level 15) left with the man, then given the experience
|
|
* of a level 21 Pokémon and `extra`. */
|
|
const left = (extra = 0, party?: (game: GameWorld) => PartyMon[]) => {
|
|
const { game, clock } = started(party);
|
|
man(game, [1, 1]);
|
|
const growth = data().baseData(TOTODILE).growthRate;
|
|
game.state.dayCare.mon1!.exp = data().expAtLevel(growth, 21) + extra;
|
|
return { game, clock };
|
|
};
|
|
|
|
it("charges ¥100 and ¥100 a level, grows it and teaches the moves it learned", () => {
|
|
const { game } = left(37);
|
|
const money = game.state.money;
|
|
const shown = man(game, [1, 1]);
|
|
expect(shown).toContain(
|
|
"Are we geniuses or\nwhat? Want to see\nyour TOTODILE?",
|
|
);
|
|
expect(shown).toContain("has grown a lot.");
|
|
expect(shown).toContain("it's\ngrown by 6.");
|
|
expect(shown).toContain("cost ¥700.");
|
|
expect(shown).toContain("Perfect! Here's\nyour POKéMON.");
|
|
expect(shown).toContain("CHRIS got back\nTOTODILE.");
|
|
expect(shown).toContain("Come again.");
|
|
expect(game.state.money).toBe(money - 700);
|
|
const back = game.state.party[1];
|
|
expect(back).toMatchObject({
|
|
species: TOTODILE,
|
|
level: 21,
|
|
status: 0,
|
|
nickname: "TOTODILE",
|
|
ot: "KAWA",
|
|
});
|
|
// Bite at level 20 pushes Scratch out; the stored experience beyond
|
|
// level 21 is lost ("Pokémon deposited in the Day-Care might lose
|
|
// experience").
|
|
expect(back.moves).toEqual([
|
|
moveId("LEER"),
|
|
moveId("RAGE"),
|
|
moveId("WATER GUN"),
|
|
moveId("BITE"),
|
|
]);
|
|
expect(back.exp).toBe(
|
|
data().expAtLevel(data().baseData(TOTODILE).growthRate, 21),
|
|
);
|
|
expect(back.hp).toBe(back.stats[0]);
|
|
expect(back.stats).toEqual(
|
|
calcStats(data(), TOTODILE, 0x8888, 21, [0, 0, 0, 0, 0]),
|
|
);
|
|
expect(back.pp).toEqual(back.moves.map((move) => data().move(move).pp));
|
|
expect(game.state.dayCare.mon1).toBeNull();
|
|
expect(game.state.flags.has(MAN_HAS_MON)).toBe(false);
|
|
expect(game.state.dayCare.compatible).toBe(false);
|
|
});
|
|
|
|
it("shifts the PP Ups with the moves, and gives a learned move none", () => {
|
|
const { game } = left();
|
|
// Scratch, Leer (1 PP Up), Rage and Water Gun (2 PP Ups).
|
|
game.state.dayCare.mon1!.pp = [35, (1 << 6) | 30, 20, (2 << 6) | 25];
|
|
man(game, [1, 1]);
|
|
const back = game.state.party[1];
|
|
// Bite pushed Scratch out: FillMoves moves the PP bytes along (the
|
|
// byte it shares with wEvolutionOldSpecies is TRUE), then RestoreAllPP
|
|
// refills each from its move and its own PP Ups.
|
|
expect(back.moves).toEqual([
|
|
moveId("LEER"),
|
|
moveId("RAGE"),
|
|
moveId("WATER GUN"),
|
|
moveId("BITE"),
|
|
]);
|
|
expect(back.pp).toEqual([
|
|
(1 << 6) | (30 + 6),
|
|
20,
|
|
(2 << 6) | (25 + 2 * 5),
|
|
data().move(moveId("BITE")).pp,
|
|
]);
|
|
});
|
|
|
|
it("says it is too soon for ¥100 when no level was gained", () => {
|
|
const { game } = started();
|
|
man(game, [1, 1]);
|
|
const money = game.state.money;
|
|
const shown = man(game, [1]);
|
|
expect(shown).toContain("Huh? Back already?");
|
|
expect(shown).toContain("cost ¥100.");
|
|
expect(shown).not.toContain("geniuses");
|
|
expect(game.state.money).toBe(money - 100);
|
|
expect(game.state.party.map((m) => m.species)).toEqual([
|
|
CYNDAQUIL,
|
|
TOTODILE,
|
|
]);
|
|
// The experience was not reset to the level's minimum by an exact
|
|
// level-up of zero levels? It is: the original sets it either way.
|
|
expect(game.state.party[1].level).toBe(15);
|
|
});
|
|
|
|
it("refuses politely, for want of money, or for want of room", () => {
|
|
const { game } = left();
|
|
const money = game.state.money;
|
|
expect(man(game, [0])).toContain("Oh, fine then.");
|
|
expect(man(game, [1, 0])).toContain("Oh, fine then.");
|
|
expect(game.state.dayCare.mon1).not.toBeNull();
|
|
game.state.money = 699;
|
|
expect(man(game, [1, 1])).toContain("You don't have\nenough money.");
|
|
expect(game.state.dayCare.mon1).not.toBeNull();
|
|
game.state.money = money;
|
|
game.state.party = Array.from({ length: 6 }, () =>
|
|
mon(game, CYNDAQUIL, 20),
|
|
);
|
|
expect(man(game, [1, 1])).toContain("You have no room\nfor it.");
|
|
expect(game.state.dayCare.mon1).not.toBeNull();
|
|
expect(game.state.money).toBe(money);
|
|
});
|
|
|
|
it("returns the lady's Pokémon and clears the man's Egg check", () => {
|
|
const { game } = started(breedingParty);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
expect(game.state.dayCare.compatible).toBe(true);
|
|
const shown = lady(game, [1]);
|
|
expect(shown).toContain("Huh? Back already?");
|
|
expect(game.state.dayCare.compatible).toBe(false);
|
|
expect(game.state.dayCare.mon2).toBeNull();
|
|
expect(game.state.flags.has(LADY_HAS_MON)).toBe(false);
|
|
expect(game.state.party.map((m) => m.species)).toContain(TYPHLOSION);
|
|
// The man's Pokémon stays.
|
|
expect(game.state.dayCare.mon1!.species).toBe(CYNDAQUIL);
|
|
});
|
|
|
|
it("grows a Pokémon left to a level cap at 100 and bills for the levels", () => {
|
|
const { game } = started();
|
|
man(game, [1, 1]);
|
|
game.state.dayCare.mon1!.exp = data().expAtLevel(
|
|
data().baseData(TOTODILE).growthRate,
|
|
100,
|
|
);
|
|
game.state.money = C.MAX_MONEY;
|
|
const shown = man(game, [1, 1]);
|
|
expect(shown).toContain("it's\ngrown by 85.");
|
|
expect(shown).toContain("cost ¥8600.");
|
|
expect(game.state.party[1].level).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe("Eggs: the man outside on Route 34", () => {
|
|
/** A Day-Care with a pair and an Egg waiting; the world is on Route 34. */
|
|
const waiting = (party?: (game: GameWorld) => PartyMon[]) => {
|
|
const { game, clock } = started(party ?? breedingParty);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
game.state.flags.add(HAS_EGG);
|
|
game.state.dayCare.compatible = false;
|
|
return { game, clock };
|
|
};
|
|
|
|
it("puts the man outside, and the lady refers to him", () => {
|
|
const { game } = waiting();
|
|
expect(lady(game)).toContain("Gramps was looking\nfor you.");
|
|
game.warpTo(ROUTE_34, 15, 18);
|
|
settle(game);
|
|
expect(game.state.events.has(EVENT_DAY_CARE_MAN_IN_DAY_CARE)).toBe(
|
|
true,
|
|
);
|
|
expect(game.state.events.has(EVENT_DAY_CARE_MAN_ON_ROUTE_34)).toBe(
|
|
false,
|
|
);
|
|
game.warpTo(DAY_CARE, 3, 5);
|
|
settle(game);
|
|
expect(game.objects().map((o) => o.id)).toEqual([3]);
|
|
});
|
|
|
|
it("hands over the Egg, clears the flag, and prepares the next", () => {
|
|
const { game } = waiting();
|
|
const egg = game.state.dayCare.egg!;
|
|
game.state.party = [mon(game, TOTODILE, 15)];
|
|
const shown = talk(game, ROUTE_34, 15, 17, "up");
|
|
expect(shown).toContain("Your POKéMON had\nan EGG!");
|
|
expect(shown).toContain("CHRIS received\nthe EGG!");
|
|
expect(shown).toContain("Take good care of\nit.");
|
|
const given = game.state.party[1];
|
|
expect(given).toMatchObject({
|
|
species: egg.species,
|
|
egg: true,
|
|
nickname: "EGG",
|
|
ot: "CHRIS",
|
|
otId: game.playerId,
|
|
level: 5,
|
|
hp: 0,
|
|
status: 0,
|
|
happiness: data().baseData(egg.species).eggCycles,
|
|
dvs: egg.dvs,
|
|
moves: egg.moves,
|
|
});
|
|
expect(given.stats).toEqual(
|
|
data()
|
|
.baseData(egg.species)
|
|
.stats.map((_, i) => given.stats[i]),
|
|
);
|
|
expect(game.state.flags.has(HAS_EGG)).toBe(false);
|
|
// DayCare_InitBreeding runs again for the pair still there.
|
|
expect(game.state.dayCare.compatible).toBe(true);
|
|
expect(game.state.dayCare.stepsToEgg).toBeGreaterThanOrEqual(150);
|
|
// The man walked in; the Pokédex is not touched.
|
|
expect(game.state.pokedex.seen.has(egg.species)).toBe(false);
|
|
});
|
|
|
|
it("keeps the Egg waiting when the party is full", () => {
|
|
const { game } = waiting();
|
|
game.state.party = Array.from({ length: 6 }, () =>
|
|
mon(game, TOTODILE, 15),
|
|
);
|
|
const shown = talk(game, ROUTE_34, 15, 17, "up");
|
|
expect(shown).toContain(
|
|
"You have no room\nin your party.\nCome back later.",
|
|
);
|
|
expect(game.state.party).toHaveLength(6);
|
|
expect(game.state.flags.has(HAS_EGG)).toBe(true);
|
|
expect(game.state.dayCare.egg).not.toBeNull();
|
|
});
|
|
|
|
it("loses the Egg when the player says no (the script clears the flag)", () => {
|
|
const { game } = waiting();
|
|
game.state.party = [mon(game, TOTODILE, 15)];
|
|
const shown = talk(game, ROUTE_34, 15, 17, "up", [0]);
|
|
expect(shown).toContain("Well then, I'll\nkeep it. Thanks!");
|
|
expect(game.state.party).toHaveLength(1);
|
|
expect(game.state.flags.has(HAS_EGG)).toBe(false);
|
|
// InitBreeding did not run again: the pair stays unprepared.
|
|
expect(game.state.dayCare.compatible).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the Pokémon in Route 34's fence (DayCareMon1, DayCareMon2)", () => {
|
|
const fence = (game: GameWorld) =>
|
|
game.map.objects.filter(
|
|
(object) => object.sprite === 0xe0 || object.sprite === 0xe1,
|
|
);
|
|
|
|
it("shows only the Pokémon that are in the Day-Care, with their own sprites", () => {
|
|
const { game } = started(breedingParty);
|
|
game.warpTo(ROUTE_34, 10, 14);
|
|
settle(game);
|
|
expect(game.state.events.has(EVENT_DAY_CARE_MON_1)).toBe(true);
|
|
expect(game.state.events.has(EVENT_DAY_CARE_MON_2)).toBe(true);
|
|
expect(fence(game)).toHaveLength(2);
|
|
expect(game.poses().some((pose) => pose.sprite >= MON_SPRITE_ID)).toBe(
|
|
false,
|
|
);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
game.warpTo(ROUTE_34, 10, 14);
|
|
settle(game);
|
|
expect(game.state.events.has(EVENT_DAY_CARE_MON_1)).toBe(false);
|
|
expect(game.state.events.has(EVENT_DAY_CARE_MON_2)).toBe(false);
|
|
expect(
|
|
game
|
|
.poses()
|
|
.map((pose) => pose.sprite)
|
|
.filter((sprite) => sprite >= MON_SPRITE_ID)
|
|
.sort(),
|
|
).toEqual(
|
|
[MON_SPRITE_ID + CYNDAQUIL, MON_SPRITE_ID + TYPHLOSION].sort(),
|
|
);
|
|
});
|
|
|
|
/** Talk to a Pokémon in the fence from whichever side is open. */
|
|
const fenceTalk = (game: GameWorld, which: 1 | 2) => {
|
|
game.warpTo(ROUTE_34, 10, 14);
|
|
settle(game);
|
|
const object = fence(game)[which - 1];
|
|
const at = game
|
|
.objects()
|
|
.find((candidate) => candidate.id === object.id)!;
|
|
for (const [dx, dy, face] of [
|
|
[0, 1, "up"],
|
|
[0, -1, "down"],
|
|
[-1, 0, "right"],
|
|
[1, 0, "left"],
|
|
] as const) {
|
|
try {
|
|
game.restore({
|
|
...game.save(),
|
|
x: at.x + dx,
|
|
y: at.y + dy,
|
|
direction: face,
|
|
});
|
|
} catch {
|
|
continue;
|
|
}
|
|
game.interact();
|
|
return settle(game).join(" ");
|
|
}
|
|
throw new Error("The fence Pokémon cannot be reached.");
|
|
};
|
|
/** Both Pokémon in through the scripts, then replaced by `mon1` and
|
|
* `mon2` (named FLARE and EMBER) to rate. */
|
|
const pairOf = (
|
|
mon1: Partial<PartyMon> & { species: number },
|
|
mon2: Partial<PartyMon> & { species: number },
|
|
) => {
|
|
const { game } = started(breedingParty);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
const { dayCare } = game.state;
|
|
dayCare.mon1 = boxed(game, mon1.species, {
|
|
nickname: "FLARE",
|
|
...mon1,
|
|
});
|
|
dayCare.mon2 = boxed(game, mon2.species, {
|
|
nickname: "EMBER",
|
|
...mon2,
|
|
});
|
|
return game;
|
|
};
|
|
|
|
it("names the Pokémon and, alone, only that", () => {
|
|
const { game } = started(breedingParty);
|
|
man(game, [1, 0]);
|
|
const shown = fenceTalk(game, 1);
|
|
expect(shown).toContain(
|
|
"It's CYNDAQUIL\nthat was left with\nthe DAY-CARE MAN.",
|
|
);
|
|
expect(shown).not.toContain("interest");
|
|
});
|
|
|
|
it("says how the two get on, by compatibility", () => {
|
|
const male = { species: CYNDAQUIL, dvs: MALE(7, 2) },
|
|
female = { species: CYNDAQUIL, dvs: FEMALE(2, 1) };
|
|
// Same species, different OTs: 254, "appears to care".
|
|
const game = pairOf({ ...female, otId: 1 }, { ...male, otId: 2 });
|
|
const shown = fenceTalk(game, 1);
|
|
expect(shown).toContain(
|
|
"It's FLARE\nthat was left with\nthe DAY-CARE MAN.",
|
|
);
|
|
expect(shown).toContain("It appears to care\nfor EMBER.");
|
|
const other = fenceTalk(game, 2);
|
|
expect(other).toContain(
|
|
"It's EMBER\nthat was left with\nthe DAY-CARE LADY.",
|
|
);
|
|
expect(other).toContain("It appears to care\nfor FLARE.");
|
|
const set = (
|
|
a: Partial<PartyMon> & { species: number },
|
|
b: Partial<PartyMon> & { species: number },
|
|
) => {
|
|
game.state.dayCare.mon1 = boxed(game, a.species, {
|
|
nickname: "FLARE",
|
|
...a,
|
|
});
|
|
game.state.dayCare.mon2 = boxed(game, b.species, {
|
|
nickname: "EMBER",
|
|
...b,
|
|
});
|
|
};
|
|
// Same species and OT: 177, "friendly".
|
|
set({ ...female, otId: 1 }, { ...male, otId: 1 });
|
|
expect(fenceTalk(game, 1)).toContain("It's friendly with\nEMBER.");
|
|
// Equal Defense DVs: 255, "brimming with energy".
|
|
set(
|
|
{ ...female, otId: 1 },
|
|
{ species: CYNDAQUIL, dvs: MALE(2, 1), otId: 2 },
|
|
);
|
|
expect(fenceTalk(game, 1)).toContain("It's brimming with\nenergy.");
|
|
// Two males: 0, "no interest".
|
|
set(
|
|
{ ...male, otId: 1 },
|
|
{ species: CYNDAQUIL, dvs: MALE(9, 4), otId: 2 },
|
|
);
|
|
expect(fenceTalk(game, 1)).toContain("It has no interest\nin EMBER.");
|
|
// Different species and OTs: 128, "friendly"; the same OT: 51, "shows
|
|
// interest".
|
|
set(
|
|
{ species: PIKACHU, dvs: FEMALE(2, 1), otId: 1 },
|
|
{ ...male, otId: 2 },
|
|
);
|
|
expect(fenceTalk(game, 1)).toContain("It's friendly with\nEMBER.");
|
|
set(
|
|
{ species: PIKACHU, dvs: FEMALE(2, 1), otId: 1 },
|
|
{ ...male, otId: 1 },
|
|
);
|
|
expect(fenceTalk(game, 1)).toContain("It shows interest\nin EMBER.");
|
|
});
|
|
});
|
|
|
|
describe("the Odd Egg (GiveOddEgg)", () => {
|
|
it("is given once, with an Egg Ticket used up if there is one", () => {
|
|
const { game } = world();
|
|
game.state.party = [mon(game, CYNDAQUIL, 20)];
|
|
game.state.bag.keyItems.push(EGG_TICKET);
|
|
const shown = man(game);
|
|
expect(shown).toContain("Well, wouldn't you\nlike this EGG?");
|
|
expect(shown).toContain("CHRIS received\nODD EGG!");
|
|
expect(shown).toContain("I found that when");
|
|
const egg = game.state.party[1];
|
|
expect(egg).toMatchObject({
|
|
egg: true,
|
|
nickname: "EGG",
|
|
ot: "ODD",
|
|
level: 5,
|
|
hp: 0,
|
|
happiness: 20,
|
|
});
|
|
expect([172, 173, 174, 238, 240, 239, 236]).toContain(egg.species);
|
|
expect(game.state.bag.keyItems).not.toContain(EGG_TICKET);
|
|
expect(game.state.events.has(EVENT_GOT_ODD_EGG)).toBe(true);
|
|
// Afterwards the man is the keeper.
|
|
expect(man(game, [0])).toContain("Want me to\nraise a POKéMON?");
|
|
expect(game.state.party).toHaveLength(2);
|
|
});
|
|
|
|
it("is refused with a full party and tried again", () => {
|
|
const { game } = world();
|
|
game.state.party = Array.from({ length: 6 }, () =>
|
|
mon(game, CYNDAQUIL, 20),
|
|
);
|
|
expect(man(game)).toContain("You've no room for\nthis.");
|
|
expect(game.state.events.has(EVENT_GOT_ODD_EGG)).toBe(false);
|
|
expect(game.state.party).toHaveLength(6);
|
|
});
|
|
|
|
it("picks from the ROM's table by two random bytes", () => {
|
|
const rows = (sub: number, add: number) =>
|
|
oddEgg(data(), bytes(sub, add));
|
|
// Probabilities are percentages of $ffff: Pichu 8 + 1, then Cleffa
|
|
// 16 + 3 ... in the table's order, normal and shiny variants.
|
|
const species = (sub: number, add: number) => rows(sub, add).species;
|
|
expect(species(0x00, 0x00)).toBe(PICHU);
|
|
expect(species(0x14, 0x7a)).toBe(PICHU); // 8 percent of $ffff = $147a
|
|
expect(species(0x14, 0x7b)).toBe(PICHU); // the 1 percent shiny Pichu follows
|
|
const first = rows(0x00, 0x00),
|
|
second = rows(0x15, 0x00);
|
|
expect(first.dvs).toBe(0);
|
|
expect(second.dvs).toBe(0x2aaa);
|
|
expect(first.otId).toBe(8);
|
|
expect(species(0xff, 0xfe)).toBe(TYROGUE);
|
|
expect(rows(0xff, 0xff).species).toBe(TYROGUE);
|
|
// The full table: every row is a valid party Pokémon of its own.
|
|
for (let sub = 0; sub < 256; sub += 5)
|
|
for (const add of [0, 0x80, 0xff]) {
|
|
const egg = oddEgg(data(), bytes(sub, add));
|
|
expect(egg).toMatchObject({
|
|
egg: true,
|
|
level: 5,
|
|
hp: 0,
|
|
nickname: "EGG",
|
|
ot: "ODD",
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("saving", () => {
|
|
it("keeps the Pokémon, the Egg and the counters through a reload", () => {
|
|
const { game } = started(breedingParty);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
game.state.flags.add(HAS_EGG);
|
|
game.state.dayCare.compatible = false;
|
|
game.state.dayCare.stepsToEgg = 77;
|
|
const saved = JSON.parse(JSON.stringify(game.save()));
|
|
const other = world().game;
|
|
other.restore(saved);
|
|
expect(other.state.dayCare).toEqual(game.state.dayCare);
|
|
expect(other.state.flags.has(HAS_EGG)).toBe(true);
|
|
expect(JSON.parse(JSON.stringify(other.save())).dayCare).toEqual(
|
|
saved.dayCare,
|
|
);
|
|
// The Egg can still be collected after the reload.
|
|
other.state.party = [mon(other, TOTODILE, 15)];
|
|
expect(talk(other, ROUTE_34, 15, 17, "up")).toContain(
|
|
"CHRIS received\nthe EGG!",
|
|
);
|
|
});
|
|
|
|
it("rejects breeding without a prepared Egg and simultaneous ready/breeding flags", () => {
|
|
const { game } = started(breedingParty);
|
|
man(game, [1, 0]);
|
|
lady(game, [1, 0]);
|
|
const saved = JSON.parse(JSON.stringify(game.save()));
|
|
const eggless = structuredClone(saved);
|
|
eggless.dayCare.egg = null;
|
|
expect(() => world().game.restore(eggless)).toThrow(
|
|
/Invalid native save/,
|
|
);
|
|
const alreadyReady = structuredClone(saved);
|
|
alreadyReady.flags.push(HAS_EGG);
|
|
expect(() => world().game.restore(alreadyReady)).toThrow(
|
|
/Invalid native save/,
|
|
);
|
|
const explicitNull = structuredClone(saved);
|
|
explicitNull.dayCare = null;
|
|
expect(() => world().game.restore(explicitNull)).toThrow(
|
|
/Invalid native save/,
|
|
);
|
|
});
|
|
|
|
it("migrates a save without the Day-Care to an empty one, and rejects inconsistent ones", () => {
|
|
const { game } = started();
|
|
const saved = JSON.parse(JSON.stringify(game.save()));
|
|
delete saved.dayCare;
|
|
const loaded = world().game;
|
|
loaded.restore(saved);
|
|
expect(loaded.state.dayCare).toEqual(emptyDayCare());
|
|
|
|
const bad = (change: (save: any) => void) => {
|
|
const copy = JSON.parse(JSON.stringify(game.save()));
|
|
change(copy);
|
|
expect(() => world().game.restore(copy)).toThrow(
|
|
/Invalid native save/,
|
|
);
|
|
};
|
|
// A flag with no Pokémon, and a Pokémon with no flag.
|
|
bad((save) => save.flags.push(MAN_HAS_MON));
|
|
man(game, [1, 1]);
|
|
const withMon = JSON.parse(JSON.stringify(game.save()));
|
|
const noFlag = structuredClone(withMon);
|
|
noFlag.flags = noFlag.flags.filter(
|
|
(flag: number) => flag !== MAN_HAS_MON,
|
|
);
|
|
expect(() => world().game.restore(noFlag)).toThrow(
|
|
/Invalid native save/,
|
|
);
|
|
// An Egg waiting without an Egg, and a damaged step count.
|
|
const eggless = structuredClone(withMon);
|
|
eggless.flags.push(HAS_EGG);
|
|
expect(() => world().game.restore(eggless)).toThrow(
|
|
/Invalid native save/,
|
|
);
|
|
const steps = structuredClone(withMon);
|
|
steps.dayCare.stepsToEgg = 300;
|
|
expect(() => world().game.restore(steps)).toThrow(
|
|
/Invalid native save/,
|
|
);
|
|
const species = structuredClone(withMon);
|
|
species.dayCare.mon1.species = 252;
|
|
expect(() => world().game.restore(species)).toThrow(
|
|
/Invalid native save/,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("matches the species constants it uses", () => {
|
|
expect(data().speciesName(NIDORAN_F)).toBe("NIDORAN♀");
|
|
expect(data().speciesName(NIDORAN_M)).toBe("NIDORAN♂");
|
|
expect(data().speciesName(DITTO)).toBe("DITTO");
|
|
expect(data().speciesName(EGG_SPECIES)).toBe("EGG");
|
|
});
|
|
},
|
|
);
|