Files
Crystal/tests/day-care.test.ts
T
kawaandClaude Opus 5.5 e49bc6de9a Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)
- 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>
2026-10-17 08:52:30 +02:00

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");
});
},
);