Savegames now lists independent save slots made with the loaded ROM: new game, continue, rename, duplicate, export and delete. The native database moves to IndexedDB version 2; the singleton save becomes "Save 1" inside the upgrade transaction, so it migrates exactly once and the old store is left untouched. Each write adds a generation and drops older ones in one transaction, keeping two records with CRC-32 checksums: a damaged newest record falls back to the previous one. Slots and records from a newer player are kept and never written. Imports are checked in a scratch world before anything changes and previewed with the trainer, progress, saved time, game data version and the slots of the same trainer; they go into a new save or replace one, whose previous record stays recoverable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
3.8 KiB
TypeScript
102 lines
3.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { GameWorld } from "../src/game/world/world";
|
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
|
import { CRYSTAL_ROM } from "../src/rom/validate";
|
|
import { encodeSave } from "../src/storage/saves";
|
|
import { foundationFixture } from "./foundation-fixture";
|
|
|
|
// Backups for the manual checks on the project board's "A tester" column,
|
|
// built from the local ROM and imported through Savegames → Import backup
|
|
// (then "Add as a new save"). They hold names read from the ROM, so they
|
|
// stay out of the repository (*.crystal-save.json is ignored).
|
|
//
|
|
// MAKE_TEST_SAVES=1 npx vitest run tests/human-test-saves.test.ts
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
const OUT = "test-saves";
|
|
/** NativeRuntime.id, which an imported backup must name. */
|
|
const CORE_ID = "crystal-native-world-v1";
|
|
|
|
describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
|
|
"saves for manual testing",
|
|
() => {
|
|
const assets = () =>
|
|
new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
function write(name: string, world: GameWorld) {
|
|
const state = new TextEncoder().encode(JSON.stringify(world.save()));
|
|
// The backup must load as the app would load it.
|
|
new GameWorld(assets()).restore(JSON.parse(new TextDecoder().decode(state)));
|
|
mkdirSync(OUT, { recursive: true });
|
|
writeFileSync(
|
|
`${OUT}/${name}.crystal-save.json`,
|
|
encodeSave({
|
|
version: 1,
|
|
romHash: CRYSTAL_ROM.sha1,
|
|
coreId: CORE_ID,
|
|
savedAt: new Date().toISOString(),
|
|
state,
|
|
}),
|
|
);
|
|
}
|
|
|
|
it("writes fly-teleport: New Bark Town with Fly, Teleport and Escape Ropes", () => {
|
|
const a = assets();
|
|
// Pidgeot with Fly, the Storm Badge, New Bark and Cherrygrove visited.
|
|
const world = foundationFixture(a, "fly");
|
|
const abra = generatePartyMon(a.pokemon, {
|
|
species: 63, // ABRA
|
|
level: 20,
|
|
item: 0,
|
|
otId: world.playerId,
|
|
ot: "CHRIS",
|
|
dvs: 0x9abc,
|
|
caughtData: 0,
|
|
});
|
|
abra.moves = [0x64, 0, 0, 0]; // TELEPORT
|
|
abra.pp = [20, 0, 0, 0];
|
|
world.state.party.push(abra);
|
|
// Violet City too, whose map is not ported yet.
|
|
world.state.flags.add(a.spawnEngineFlag(16)!);
|
|
world.state.lastSpawnMap = "26:3"; // Cherrygrove City
|
|
world.state.bag.items = [{ item: 0x13, quantity: 3 }]; // ESCAPE ROPE
|
|
expect(world.mapId).toBe("24:4");
|
|
write("fly-teleport", world);
|
|
});
|
|
|
|
it("writes field-items: New Bark Town's shore with rods, MAIL and a Repel", () => {
|
|
const a = assets();
|
|
// Cyndaquil facing the sea with the Old Rod.
|
|
const world = foundationFixture(a, "fishing");
|
|
world.state.party.push(
|
|
generatePartyMon(a.pokemon, {
|
|
species: 16, // PIDGEY
|
|
level: 4,
|
|
item: 0,
|
|
otId: world.playerId,
|
|
ot: "CHRIS",
|
|
dvs: 0x9abc,
|
|
caughtData: 0,
|
|
}),
|
|
);
|
|
world.state.bag.items = [
|
|
{ item: 0x12, quantity: 5 }, // POTION
|
|
{ item: 0x9e, quantity: 2 }, // FLOWER_MAIL
|
|
{ item: 0x14, quantity: 1 }, // REPEL
|
|
];
|
|
world.state.bag.keyItems.push(0x3b, 0x3d); // GOOD_ROD, SUPER_ROD
|
|
expect(world.mapId).toBe("24:4");
|
|
write("field-items", world);
|
|
});
|
|
|
|
it("writes headbutt: Route 29 facing a Headbutt tree", () => {
|
|
// Totodile with Headbutt and Tackle, below the first reachable tree.
|
|
const world = foundationFixture(assets(), "headbutt");
|
|
expect(world.mapId).toBe("24:3");
|
|
write("headbutt", world);
|
|
});
|
|
},
|
|
);
|