flybrain/services/flysim/crates/flybrain-gb/src/adapter.rs
acamilo 2e7eed0a78 rewards: talk and item, boundary indoors pays nothing, adapter v7 with a v6 migration
The operator's decision of 2026-09-23: pay the fly for engaging with what is
inside a building, and stop paying it for walking back out of one. Chosen over
a pad rule and over weighting the choice, and like v0.5.0's catch reward it is
a catalog change, not a loop-review fix.

Indoors. engage::indoor is two of the cartridge's own tables: not outside by
CheckIfInOutsideMap (tileset OVERWORLD or PLATEAU; WarpFound2 labels the other
branch .indoorMaps) and not a place BikeRidingTilesets lets the bike be ridden
(OVERWORLD, FOREST, UNDERGROUND, SHIP_PORT, CAVERN). That is every house, mart,
center, gym, gate, lab, museum, ship, tower, mansion and hideout, and not
Viridian Forest, a cave, the Underground Path or the dock.

`talk`, +0.10, 100 ms, the catalog's tenth kind. Paid when the text box closes
on a conversation that (1) opened on the sample after one where the fly had the
joypad (state::controllable) and was standing still (wWalkCounter zero, the only
state the overworld reads A in) on the same tile; (2) is with the thing in
front: DisplayTextID copies its argument into wSpriteIndex, a sprite slot up to
wNumSprites whose sprite stands on the tile the player faces (or one further
across a counter, on a tileset with counter tiles), or a text id matching the
sign on that tile; an item ball is not a person; (3) opened indoors; (4) closed
on the same map. Keyed talk:<map>:sprite:<slot> / talk:<map>:sign:<id> in the
lifetime `seen` ledger, which is checkpointed and survives a rollback -- not the
macros' session `talked` ledger.

`item`, +0.15, 120 ms, the eleventh. An item ball is a toggleable sprite of this
map (wToggleableObjectList) whose wMapSpriteExtraData is (item id, 0), the shape
LoadMapHeader writes for an ITEM object_event and nothing else; PickUpItem sets
its global bit in wToggleableObjectFlags after GiveItem succeeds. A hidden item
is a bit of wObtainedHiddenItemsFlags, set by FoundHiddenItemText after GiveItem
and by nothing else. Either pays when its bit rises between two playable
samples, once per item (item:<global> / hidden:<index>) for the ledger's life.

`boundary` still writes every key indoors, so exit_visited answers what it did
and the macros see no change, but emits nothing on an indoor map. Outdoor,
forest and cave exits pay as before.

v6 -> v7. STATE_VERSION stays 4 and no field is added: the new ledgers are keys
in `seen`. A v6 state restores with no talk: keys, and the first playable sample
that finds `items:seeded` absent writes one key per item the game already shows
as taken, pays for none, and marks the seed -- so a rollback that un-takes a v6
pickup cannot pay for it. The two item balls a script reveals (the Rocket
Hideout's Silph Scope and Lift Key, toggles $87 and $88, the only ITEM entries
toggleable_objects.asm starts OFF) are left out of the seed. MIGRATES_FROM is
["pokered-unique8-v6"]; v5 is no longer migrated.

Feed kinds: both publish on `explore`, the family of new ground and a door found,
at the same quiet scale; not `area` (maps, notable), `story` or `wildwin`. No
feed-protocol change.

The compatibility string differs from main's in exactly one segment:
pokered-unique8-v6 -> pokered-unique8-v7.

Tests: catalog values and order; indoor over all 24 tilesets; a talk pays once
per (map, object), not while the box is open, not re-talked, not outdoors/in the
forest/in a cave, not for text opened with the joypad taken, simulated, scripted,
mid-step, about someone not in front, or the start menu; not across a warp or a
rollback; counter reach only with counter tiles; item balls, people, trainers,
hidden items, the seed and the script-shown balls; boundary indoors records and
pays nothing; a v6 state and a v6 FLYSIM01 envelope migrate with the new ledgers
empty and the items seeded. rom.rs's bedroom walk now proves the stairs are
recorded and unpaid on the cartridge.
2026-09-23 14:34:10 +00:00

508 lines
23 KiB
Rust

//! What the sim loop needs from a game, independent of which game it is.
//!
//! One `flysim` binary serves both planned demos (Pokémon Red and a Game Boy
//! platformer), selecting the adapter at startup. Everything Pokémon-specific
//! lives in [`crate::pokemon_red`]; [`crate::ratchet`] is generic over
//! [`ProgressSnapshot::rank`] and imports no game symbols.
use std::collections::BTreeMap;
use serde::Serialize;
use serde_json::Value;
use crate::ratchet::RecoveryPolicy;
/// Anything the reward adapter can sample bytes from.
///
/// `&mut self` because implementations cache: [`crate::Emulator`] memoizes each
/// address for the current frame, and each adapter memoizes again per sample, so
/// a sample crosses the FFI boundary at most once per address. This is the Rust
/// equivalent of the prototype's `MemoryReader` interface.
pub trait MemoryReader {
fn read8(&mut self, address: u16) -> u8;
/// One byte of a ROM bank, by bank number rather than off the CPU bus.
///
/// [`MemoryReader::read8`] reads the bus, where banks 1 and up are whichever
/// bank the cartridge's last switch left mapped -- so a table in bank 3 is
/// unreadable through it, and the only way to make it readable would be to
/// *write* the mapper's bank register. The joypad is the only write this
/// workspace makes into a running game (`docs/design/macros.md` section 12),
/// so this reads the cartridge image the process already holds instead: the
/// same bytes, addressed the way the disassembly addresses them.
///
/// `address` is a CPU address: below `$4000` it is bank 0 whatever `bank`
/// says, and `$4000..$8000` is the banked window. Anything else, and any
/// offset past the end of the image, is `None`.
///
/// The default is `None`: a reader with no cartridge behind it cannot answer,
/// and every caller of this is written to narrow rather than guess when it
/// does not (`docs/design/macros-wram.md`, the whole-map grid).
fn read_rom(&mut self, _bank: u8, _address: u16) -> Option<u8> {
None
}
}
impl MemoryReader for &mut dyn MemoryReader {
fn read8(&mut self, address: u16) -> u8 {
(**self).read8(address)
}
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
(**self).read_rom(bank, address)
}
}
/// One reward payout in one frame.
///
/// `kind` is an adapter-owned interned name (Pokémon: `milestone`,
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`,
/// `talk`, `item`); it is the key the statistics counters and the on-screen ticker group
/// by. Field names serialize exactly as the prototype's `RewardEvent` did, so a
/// checkpoint written by either implementation reads in the other.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RewardEvent {
pub kind: &'static str,
pub label: String,
#[serde(rename = "brainMs")]
pub brain_ms: f64,
pub value: f64,
/// PAM stimulation this payout drives, from the adapter's catalog. Not part
/// of the prototype's serialized event; skipped so checkpoints match.
#[serde(skip)]
pub stimulation_ms: u32,
}
/// Everything the stream page shows about "how far along is it", with no game
/// knowledge on the page side.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ProgressSnapshot {
/// Position on the adapter's milestone ladder, `0..=rank_max`.
pub rank: u32,
/// Highest rank the ladder defines.
pub rank_max: u32,
/// Label for the current rank, taken from [`GameAdapter::rank_ladder`].
pub rank_label: &'static str,
/// The game's headline countable: badges for Pokémon, lives for a
/// platformer.
pub counter: u32,
/// What `counter` counts, for the page's label.
pub counter_label: &'static str,
/// Distinct player positions the adapter has observed (the prototype's
/// `uniqueTiles`).
pub unique_locations: usize,
/// Lifetime sum of every payout.
pub reward_total: f64,
/// Lifetime payout count per `kind`.
pub counts: BTreeMap<&'static str, u64>,
}
/// Which decoder configuration the game wants. Both demos are Game Boy titles,
/// but they need different timings: `docs/design/platformer.md` §4 explains why a
/// side-scroller needs gapless direction holds and a 300 ms jump, and the sim
/// loop selects the configuration from this id without learning about games.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DecoderPresetId {
GameBoy,
Platformer,
}
/// One way out of a map, in the terms an exploration ledger records.
///
/// The macro palette's `GO EXIT` needs to know which of the current map's exits this run has
/// already been through, and the answer is lifetime state an adapter already keeps: Pokémon Red's
/// `boundary` rule (`docs/design/room-escape.md` section 2) pays the first step next to and the
/// first step onto each exit, so its ledger holds a key per exit per map. This is the shape of the
/// question, with no game in it — a warp is a tile and an edge is a compass direction — and
/// [`GameAdapter::exit_visited`] is the answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MapExit {
/// A warp — a door, a staircase, a cave mouth — on this tile of this map.
Warp { map: u8, x: u8, y: u8 },
/// A step off this edge of this map.
Edge { map: u8, edge: MapEdge },
}
/// Which edge of a map a [`MapExit::Edge`] crosses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MapEdge {
North,
South,
East,
West,
}
/// One tile of one map, in the terms an exploration ledger records.
///
/// [`MapExit`]'s companion, and the same kind of question: `GO FRONTIER` walks to "the nearest
/// tile bordering ground this run has never stood on" (`docs/design/macros.md` section 9), and
/// which tiles this run has stood on is lifetime state an adapter already keeps -- Pokemon Red's
/// `exploration` rule counts distinct player coordinates per map. [`GameAdapter::tile_visited`]
/// is the answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MapTile {
pub map: u8,
pub x: u8,
pub y: u8,
}
/// Where the ladder's next unreached rung is, as far as the adapter's rung catalog knows.
///
/// `GO OBJECTIVE` "paths toward the next unreached ladder rung's place where the catalog knows
/// one (map id and, when known, a tile or warp)" (`docs/design/macros.md` section 9). The map id
/// is the only part the Pokemon catalog can derive for every rung it knows at all, so `tile` and
/// `warp` are both optional and both unset rather than guessed; a rung with no place at all is
/// `None` from [`GameAdapter::objective`] and the plan falls through to its next entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MapPlace {
/// The map the rung is earned on.
pub map: u8,
/// A tile on that map to stand on, when the catalog knows one.
pub tile: Option<(u8, u8)>,
/// An index into that map's warp table, when the catalog knows one.
pub warp: Option<u8>,
/// A map edge to step off, when the place is a connection rather than a tile.
///
/// The third way a rung's place can be finer than "this map", and the one the cartridge makes
/// cheapest: Oak stops the fly at Pallet Town's north exit, and "the north exit" is a
/// connection rather than a warp or a coordinate. A tile would have to be invented from
/// memory, which `docs/design/ladder.md`'s own "verified against the decomp" rule forbids;
/// an edge is read straight off the map's connection bits.
pub edge: Option<MapEdge>,
/// What on that map earns the rung, when the rung is earned by talking to something rather
/// than by standing somewhere.
///
/// The fourth way a place can be finer than "this map", and the one the ladder needs most:
/// most rungs of the early game are *conversations*. Oak's parcel is delivered by pressing A
/// at Oak; the Pokédex comes out of the same script; the starter is a Pokéball on a table; a
/// badge is the gym leader. A place that is only a map sends `GO OBJECTIVE` to the map's own
/// door and calls that arriving — which is the Pallet Town loop of 2026-09-17
/// (`infra/docs/macros-traps.md` row 29): the doormat, then `GO OUT` straight back out, every
/// three brain seconds at Oak's lab door.
///
/// Deliberately **not a sprite slot**. A slot is the index of the map's `object_event` list,
/// which lives in a ROM bank this crate cannot reach, and `docs/design/ladder.md`'s rule is
/// that a number nobody verified against the decomp does not go in. A *kind* needs no number:
/// the macro layer already reads the loaded map's sprite list and already splits it into people
/// and objects ([`crate::pokemon_red::macros::path`]), so "a person on this map" and "an object
/// on this map" are both answerable from WRAM.
pub target: Option<PlaceKind>,
}
/// What kind of thing on a map earns a rung ([`MapPlace::target`]).
///
/// The same split the macro palette already makes, and for the same reason: pokered's
/// `FIRST_STILL_SPRITE` divides the sprite list into people and things, `GO NPC` takes the first
/// half and `GO ITEM` the second, and the talked ledger keys both by the identity the cartridge
/// gives them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PlaceKind {
/// Someone to talk to: Oak with the parcel, a gym leader with a badge.
Person,
/// Something to press A at: the starter's Pokéball, an item ball, a sign.
Object,
}
impl MapPlace {
/// A place that is a whole map and nothing finer.
pub const fn at(map: u8) -> Self {
Self { map, tile: None, warp: None, edge: None, target: None }
}
/// A place that is one of a map's edges: the connection out of it.
pub const fn edge(map: u8, edge: MapEdge) -> Self {
Self { map, tile: None, warp: None, edge: Some(edge), target: None }
}
/// A place that is a person somewhere on this map.
pub const fn person(map: u8) -> Self {
Self { map, tile: None, warp: None, edge: None, target: Some(PlaceKind::Person) }
}
/// A place that is an object somewhere on this map.
pub const fn object(map: u8) -> Self {
Self { map, tile: None, warp: None, edge: None, target: Some(PlaceKind::Object) }
}
}
/// Reasons an adapter refuses a checkpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterError(pub &'static str);
impl std::fmt::Display for AdapterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for AdapterError {}
/// A game, as the sim loop sees it.
pub trait GameAdapter: Send {
/// Adapter version string, pinned into the checkpoint compatibility string.
/// Pokémon: `pokered-unique8-v7`.
fn id(&self) -> &'static str;
/// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration
/// this adapter has written down and tested.
///
/// The default is empty: an adapter migrates from nothing unless it says otherwise, which
/// is the behaviour every adapter had before this existed. It is only half of the gate --
/// [`crate::compatibility::decide`] also requires the operator to have named the same id in
/// `FLY_ACCEPT_ADAPTERS` for that deploy -- so listing an id here never migrates a live run
/// on its own.
fn migrates_from(&self) -> &'static [&'static str] {
&[]
}
/// Whether semantic rewards are enabled for this cartridge. An adapter that
/// says no must still sample without paying anything, so the stream keeps
/// running with a visible "rewards off" mode.
fn rom_allowed(&self, sha256: &str) -> bool;
/// Sample game state after one completed frame and return this frame's
/// payouts. `ms` is the brain clock, recorded on each event.
fn sample(&mut self, memory: &mut dyn MemoryReader, ms: f64) -> Vec<RewardEvent>;
/// Short human-readable state for the page: `BOOT`, `OVERWORLD`, `BATTLE`…
fn mode(&self) -> &str;
/// Whether the readout should use its permissive boot variant, which is what
/// lets Start and Select fire on a title screen.
///
/// The default is the Pokémon rule the sim loop used before this existed:
/// exactly the `BOOT` mode string. An adapter with several non-playable
/// states overrides it — the platformer's `DEMO`, `GAME OVER` and
/// `TRANSITION` all need Start available (`docs/design/platformer.md` §4).
fn boot(&self) -> bool {
self.mode() == "BOOT"
}
/// Id of the area the player is in, when the adapter knows one.
///
/// `docs/feed-protocol.md` carries this as `game.map` (`number | null`). It is an accessor
/// over state the adapter already tracks, not a new observation: the default returns `None`
/// for an adapter with no notion of an area.
fn map_id(&self) -> Option<u32> {
None
}
/// Where the player is: the area id and the tile coordinates within it.
///
/// An accessor over state the adapter already samples, like [`GameAdapter::map_id`]: it is not
/// a new observation and it reaches nothing but the sim loop, which compares it with the
/// previous frame's to decide whether the button it is holding moved the player at all (the
/// readout's blocked-direction cooldown, `docs/readout.md`). The default returns `None` for
/// an adapter with no notion of a location, which switches the rule off.
///
/// The area is part of it because the coordinates alone are not enough: a warp very often
/// lands on the coordinates it left -- Red's staircase is (7, 1) on both floors -- so a
/// caller watching only `(x, y)` would read a change of room as standing still.
fn location(&self) -> Option<(u32, u32, u32)> {
None
}
fn progress(&self) -> ProgressSnapshot;
/// The ratchet's "safe observation" gate: true only when the game is in a
/// state a snapshot can be restored into without landing mid-script.
fn safe_for_snapshot(&self) -> bool;
/// Whether the run has just ended in a way that discards it: a platformer
/// game over. The ratchet restores immediately when this is true and the
/// game's [`RecoveryPolicy`] enables the trigger.
///
/// The default is false, which is Pokémon's behaviour: a blackout there costs
/// money and teleports the player, it does not end the run.
fn game_over(&self) -> bool {
false
}
/// Recovery limits and triggers for this game. The default is the Pokémon
/// policy (120 s stall, 180 s cooldown, 3 attempts per rank, 36 lifetime for
/// the 38-rung ladder, no game-over trigger), so an adapter that ignores this
/// is unaffected.
fn recovery_policy(&self) -> RecoveryPolicy {
RecoveryPolicy::default()
}
/// Provenance of the semantics a checkpoint was earned under, for the last
/// segment of the compatibility string ([`crate::Compatibility`]).
///
/// The default is the pokered commit, which is what the sim loop passed for
/// every game before this existed, so Pokémon Red's compatibility string is
/// unchanged to the byte and its checkpoints keep loading. An adapter whose
/// symbols come from somewhere else returns its own, which makes a
/// checkpoint from one game structurally unloadable under the other —
/// `docs/design/platformer.md` §8.5 asks for exactly that, so that a ROM
/// revision or an adapter version cannot be swapped underneath a run.
fn symbol_provenance(&self) -> String {
crate::pokemon_red::symbols::POKERED_COMMIT.to_string()
}
/// Whether this run has already been through `exit`, from the adapter's own exploration
/// ledger.
///
/// A **read** accessor: it takes `&self`, records nothing and pays nothing, so adding it
/// leaves [`GameAdapter::id`] and the checkpoint compatibility string untouched. The macro
/// palette's `GO EXIT` is its only caller (`docs/design/macros.md` section 3: "visit sets for
/// `GO EXIT` are the adapter's existing per-map exploration ledger").
///
/// The default is `false` — every exit unvisited — for an adapter that keeps no such ledger.
/// That is the honest default rather than a convenient one: it makes `GO EXIT` prefer nothing,
/// which is what it did before this existed.
fn exit_visited(&self, exit: MapExit) -> bool {
let _ = exit;
false
}
/// Whether this run has already stood on `tile`, from the adapter's own exploration ledger.
///
/// A **read** accessor, exactly as [`GameAdapter::exit_visited`] is: `&self`, records nothing,
/// pays nothing, and so leaves [`GameAdapter::id`] and the checkpoint compatibility string
/// untouched. `GO FRONTIER` is its only caller (`docs/design/macros.md` section 9).
///
/// The default is `false` -- no ground covered -- for an adapter with no such ledger, which
/// makes every tile frontier and `GO FRONTIER` a walk to the nearest reachable tile.
fn tile_visited(&self, tile: MapTile) -> bool {
let _ = tile;
false
}
/// Whether this run has ever been on `map`, from the same ledger.
///
/// `GO ROUTE` prefers "a door into a building whose interior is unvisited"
/// (`docs/design/macros.md` section 9.1), and that is this question. Read-only like the other
/// two; the default is `false`, i.e. every interior unvisited.
fn map_visited(&self, map: u8) -> bool {
let _ = map;
false
}
/// Where the ladder's next unreached rung is, when the adapter's rung catalog knows.
///
/// The one place map knowledge crosses from the adapter to the macro layer, and it crosses as
/// a *place* rather than as a route: `GO OBJECTIVE` still has to find its own way there over
/// the warp and collision data it can see, and gives the buttons back when it cannot
/// (`docs/design/macros.md` section 9). Read-only, and `None` both for an adapter with no
/// ladder places and for a rung whose place is not derivable -- in which case the plan falls
/// through to its next entry rather than inventing one.
fn objective(&self) -> Option<MapPlace> {
None
}
/// Which rung of [`GameAdapter::rank_ladder`] the fly is trying for, when the adapter knows.
///
/// The lowest rung this run has *not* earned, which is not `rank + 1`: the rank is the maximum
/// over satisfied rungs, so a rung earned out of order carries it past every rung skipped on
/// the way (`docs/design/macros.md` section 12.4). The header's `milestone.next` is this label,
/// because a fly walking two maps south to deliver a parcel while the screen says "→ VIRIDIAN
/// FOREST" is a screen that is lying about what the fly is doing.
///
/// `None` for an adapter with no ladder, and at the top of one.
fn next_rung(&self) -> Option<u32> {
None
}
/// Labels for ranks `0..=rank_max`, so the page never hardcodes a ladder.
fn rank_ladder(&self) -> &'static [&'static str];
fn decoder_preset(&self) -> DecoderPresetId;
/// Drop observations that a rollback invalidates, keeping lifetime history.
fn clear_transient(&mut self);
fn export_state(&self) -> Value;
/// Restore lifetime history. Incompatible schema versions rebaseline
/// silently (returning `Ok`); malformed state is an error.
fn import_state(&mut self, state: &Value) -> Result<(), AdapterError>;
}
/// Build the adapter for a game id: `pokemon-red` or `platformer`.
///
/// The platformer adapter comes back with no ROM pin, so it reports
/// `SEMANTIC REWARDS OFF` for every cartridge. Use [`adapter_for_with_rom_pin`]
/// to supply the hash from `[game.platformer] rom_sha256`.
pub fn adapter_for(game: &str) -> Option<Box<dyn GameAdapter>> {
adapter_for_with_rom_pin(game, None)
}
/// [`adapter_for`], with the configured cartridge hash for games whose ROM pin
/// is configuration rather than a build constant.
///
/// Only the platformer has one: the design pins Super Mario Land (World) (Rev A)
/// but records only its SHA-1, so the SHA-256 arrives from the config file
/// (`docs/design/platformer.md` §8.5). Pokémon Red's pin stays a constant and
/// `rom_pin` is ignored for it.
pub fn adapter_for_with_rom_pin(game: &str, rom_pin: Option<&str>) -> Option<Box<dyn GameAdapter>> {
match game {
"pokemon-red" => Some(Box::new(crate::pokemon_red::PokemonRedReward::new())),
"platformer" => Some(Box::new(crate::platformer::PlatformerAdapter::with_rom_pin(
rom_pin,
))),
_ => None,
}
}
/// Every game id [`adapter_for`] accepts.
pub const GAME_IDS: &[&str] = &["pokemon-red", "platformer"];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_advertised_game_id_builds() {
for id in GAME_IDS {
assert!(adapter_for(id).is_some(), "{id}");
}
assert!(adapter_for("sonic").is_none());
}
#[test]
fn the_default_trait_methods_preserve_the_pokemon_behaviour() {
let adapter = adapter_for("pokemon-red").unwrap();
assert_eq!(adapter.mode(), "BOOT");
assert!(adapter.boot(), "the default boot rule is the BOOT mode string");
assert!(!adapter.game_over(), "Pokémon has no game-over trigger");
assert_eq!(adapter.recovery_policy(), RecoveryPolicy::default());
assert_eq!(adapter.recovery_policy().game_over_cooldown_ms, None);
assert_eq!(
adapter.symbol_provenance(),
crate::pokemon_red::symbols::POKERED_COMMIT,
"the compatibility string must not move under existing checkpoints"
);
}
#[test]
fn the_two_games_cannot_share_a_checkpoint() {
let pokemon = adapter_for("pokemon-red").unwrap();
let platformer =
adapter_for_with_rom_pin("platformer", Some(&"a".repeat(64))).unwrap();
assert_ne!(pokemon.id(), platformer.id());
assert!(
!platformer.migrates_from().contains(&pokemon.id()),
"a migration never crosses games"
);
assert_ne!(pokemon.symbol_provenance(), platformer.symbol_provenance());
// And two ROM revisions of the same game cannot either.
let other = adapter_for_with_rom_pin("platformer", Some(&"b".repeat(64))).unwrap();
assert_ne!(platformer.symbol_provenance(), other.symbol_provenance());
}
#[test]
fn a_rank_ladder_covers_every_rank_the_adapter_can_report() {
for id in GAME_IDS {
let adapter = adapter_for(id).unwrap();
let snapshot = adapter.progress();
assert_eq!(
adapter.rank_ladder().len(),
snapshot.rank_max as usize + 1,
"{id}"
);
assert!(snapshot.rank <= snapshot.rank_max, "{id}");
}
}
}