Two Stuck rollbacks fired on rung 10 inside half an hour, both on a fly that was walking, and both were the ratchet working to contract: the stall window is reset by exploration -- one tile the run has never stood on -- and a fly crossing a town it has already covered to reach the rung's own door earns none of it. Entering a map for the first time already counts, because a new map is ground nobody has stood on; re-entering one does not, which is what the museum was. So the window gains a second signal, passed in by the caller and meaningless to the ratchet itself, exactly as coverage is: the Pokemon loop answers with "nearer the objective, in map hops, than this run has ever been", over the same map graph GO OBJECTIVE walks. It can fire at most once per step of the road, it spends no budget, it captures nothing and it skips no trigger. Nothing in the macro layer reads it back and no button is bound on it. The checkpointed ratchet state is untouched: the signal is a level on one sample, not a counter.
693 lines
32 KiB
Rust
693 lines
32 KiB
Rust
//! Pokémon Red behind the sim loop's macro seam (`crate::macros`).
|
|
//!
|
|
//! Agent B's [`MacroMachine`] reads the game through [`MacroState`] and agent A's [`PokeState`]
|
|
//! implements it over live WRAM; this file is the twenty lines that put the two together and hand
|
|
//! the result to the loop as a [`MacroPalette`]. It is the only place where the executor, the
|
|
//! scene detector and a `MemoryReader` meet.
|
|
//!
|
|
//! What it adds beyond the wiring: the palette the last `observe` bound is *remembered*, because
|
|
//! `MacroMachine::start` takes the palette it is starting a slot from and the loop should not have
|
|
//! to carry one. The remembered palette is one frame old at most — `observe` runs after every
|
|
//! frame and `start` before the next one, which read the same WRAM — and `start` checks the scene
|
|
//! against it anyway (`Refusal::WrongScene`), so a stale palette refuses rather than acting.
|
|
|
|
use crate::adapter::MemoryReader;
|
|
use crate::macros::{
|
|
MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started,
|
|
};
|
|
|
|
use super::super::mapgrid::MapGrids;
|
|
use super::super::state::PokeState;
|
|
use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile};
|
|
use super::geography;
|
|
use super::executor::{MacroAbort, MacroMachine, Refusal};
|
|
use super::palette::{self, MacroId, Palette};
|
|
use super::plan;
|
|
use super::state::{GameState, Scene};
|
|
|
|
/// The macro palette over Pokémon Red.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PokemonPalette {
|
|
machine: MacroMachine,
|
|
/// Whether a slot is a channel or a rank in the scene's plan
|
|
/// ([`PaletteMode`], `docs/design/macros.md` sections 3 and 9).
|
|
///
|
|
/// The only thing this changes is which of two functions deals the slots. Everything after
|
|
/// that — the executor, the refusals, the outcomes, the feed — cannot tell the two modes
|
|
/// apart, which is the point: a plan *is* a palette whose slots are ranks.
|
|
mode: PaletteMode,
|
|
/// The palette the last [`MacroPalette::observe`] bound, and the scene it was bound for.
|
|
palette: Option<Palette>,
|
|
/// What this session has talked to (`docs/design/macros.md` section 12).
|
|
///
|
|
/// Owned here because this is the one type that both builds the state the macros read and
|
|
/// steps the machine that writes to it: [`MacroMachine::take_talked`] hands over a finished
|
|
/// `TALK`'s target and this records it, one frame later than the press and before the next
|
|
/// `observe` asks about it. Session state, not run state -- it does not reach the checkpoint
|
|
/// and a restored run offers every person once more.
|
|
talked: Talked,
|
|
/// Which targets are excluded and which have been reached
|
|
/// (`docs/design/macros.md` section 12, the Viridian stall). Owned here for the same reason
|
|
/// the talked ledger is: this is the one type that both builds the state the macros read and
|
|
/// steps the machine that writes to it. Session state -- not checkpointed.
|
|
targets: Targets,
|
|
/// The ground this session has watched the fly stand on (`docs/design/macros.md` section
|
|
/// 12.7). Owned here for the same reason the two ledgers above are: this is the one type that
|
|
/// both builds the state the macros read and sees every frame the fly stands on. Session
|
|
/// state -- not checkpointed.
|
|
stood: Stood,
|
|
/// Which of each area's errands this run has discharged (`docs/design/macros.md` section 13).
|
|
///
|
|
/// Owned here for the same reason the three ledgers above are: this is the one type that both
|
|
/// builds the state the macros read and sees every frame the fly is standing somewhere. The
|
|
/// entry is written on *arrival* -- the frame the fly is observed standing on a mart's or a
|
|
/// centre's own map -- which is the moment the errand is discharged. A purchase or a heal marks
|
|
/// nothing: what the errand asked for was the visit. Session state, not checkpointed.
|
|
areas: Areas,
|
|
/// Maps whose frontier a `GO FRONTIER` has proved unreachable (`docs/design/macros.md`
|
|
/// section 12.14).
|
|
///
|
|
/// Owned here beside the other session ledgers and written from the same two places they are:
|
|
/// the machine's refusal on one side and the frame the fly is standing on new ground on the
|
|
/// other. Like [`Pushed`] it has no window -- ground the map has fenced off is still fenced
|
|
/// off ten brain minutes later -- and unlike it, it is *cleared*, by the only event that can
|
|
/// change the answer.
|
|
frontiers: Frontiers,
|
|
/// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37).
|
|
///
|
|
/// Owned here beside the other session ledgers and for the same reason. Unlike the target
|
|
/// ledgers this one has no window: Viridian City's (19, 9) prints "This is private property!"
|
|
/// and walks the fly down on every frame it stands there until the Pokédex exists, and a map
|
|
/// does not stop being like that ten brain minutes later.
|
|
pushed: Pushed,
|
|
/// The decoded walkability of the map the fly is on (`docs/design/macros.md` section 15).
|
|
///
|
|
/// Owned here beside the session ledgers because it is the same shape of thing: built from
|
|
/// the cartridge, valid for as long as the map is loaded, dropped on arrival somewhere else,
|
|
/// and never checkpointed -- a restored run decodes the map again on its first overworld
|
|
/// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the
|
|
/// cartridge's own tables say about the ground.
|
|
grids: MapGrids,
|
|
/// The fewest map hops between the fly and its objective this run has managed, and which
|
|
/// objective that was (`docs/design/macros.md` section 12.15).
|
|
///
|
|
/// Session state beside the ledgers and never checkpointed, and unlike them it is not a fact
|
|
/// about the map at all: it is the one reading the *sim loop* takes from the macro layer, for
|
|
/// the ratchet's stall window. The objective is carried with the number because the ladder's
|
|
/// next rung changes as the run climbs, and "nearer" means nothing across two different
|
|
/// places.
|
|
nearest: Option<(u8, u32)>,
|
|
/// Whether the last `observe` was the frame that number fell on.
|
|
nearer: bool,
|
|
/// The brain clock of the frame being decided, from [`MacroPalette::clock`].
|
|
///
|
|
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
|
|
/// than a frame count of its own: a macro's frames and the brain's milliseconds are not the
|
|
/// same unit and the exclusion is quoted in brain minutes.
|
|
now_ms: f64,
|
|
}
|
|
|
|
impl PokemonPalette {
|
|
pub fn new(seed: u32) -> Self {
|
|
Self::with_mode(seed, PaletteMode::Palette)
|
|
}
|
|
|
|
pub fn with_mode(seed: u32, mode: PaletteMode) -> Self {
|
|
Self {
|
|
machine: MacroMachine::new(seed),
|
|
mode,
|
|
palette: None,
|
|
talked: Talked::default(),
|
|
targets: Targets::new(),
|
|
stood: Stood::default(),
|
|
areas: Areas::default(),
|
|
frontiers: Frontiers::default(),
|
|
pushed: Pushed::default(),
|
|
grids: MapGrids::default(),
|
|
nearest: None,
|
|
nearer: false,
|
|
now_ms: 0.0,
|
|
}
|
|
}
|
|
|
|
/// Frames the running macro has spent, for a log line.
|
|
pub fn frames(&self) -> u32 {
|
|
self.machine.frames()
|
|
}
|
|
|
|
/// How many things this session has talked to, for a log line and the tests.
|
|
pub fn talked(&self) -> usize {
|
|
self.talked.len()
|
|
}
|
|
|
|
/// How many targets are excluded and how many reached, for a log line and the tests.
|
|
pub fn targets(&self) -> (usize, usize) {
|
|
self.targets.len()
|
|
}
|
|
|
|
/// How many tiles this session has watched the fly stand on, for a log line and the tests.
|
|
pub fn stood(&self) -> usize {
|
|
self.stood.len()
|
|
}
|
|
|
|
/// How many of the run's errands have been discharged, for a log line and the tests.
|
|
pub fn errands(&self) -> usize {
|
|
self.areas.len()
|
|
}
|
|
|
|
/// How many tiles the cartridge has pushed the fly off, for a log line and the tests.
|
|
pub fn pushed(&self) -> usize {
|
|
self.pushed.len()
|
|
}
|
|
|
|
/// How many maps have proved their frontier unreachable, for a log line and the tests.
|
|
pub fn exhausted(&self) -> usize {
|
|
self.frontiers.len()
|
|
}
|
|
|
|
/// Take whatever the machine's last finished macro earned into the session's ledgers.
|
|
fn record_talk(&mut self) {
|
|
if let Some((map, target)) = self.machine.take_talked() {
|
|
self.talked.record(map, target);
|
|
}
|
|
// Several at once, for a refusal that found no route to any of its goals.
|
|
while let Some((map, target)) = self.machine.take_blocked() {
|
|
self.targets.record_blocked(map, target);
|
|
}
|
|
if let Some((map, target)) = self.machine.take_reached() {
|
|
self.targets.record_reached(map, target);
|
|
}
|
|
// A tile the cartridge drove the fly off: no window, because the map is like that until
|
|
// the event that unlocks it, and nothing here knows which event that is (row 37).
|
|
if let Some((map, tile)) = self.machine.take_pushed() {
|
|
self.pushed.record(map, tile);
|
|
}
|
|
// A frontier the walk could not reach any of: a fact about this map's ground, with no
|
|
// window on it (section 12.14).
|
|
if let Some(map) = self.machine.take_exhausted() {
|
|
self.frontiers.record(map);
|
|
}
|
|
if let Some((map, target, closer)) = self.machine.take_timeout() {
|
|
self.targets.record_timeout(map, target, closer);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MacroPalette for PokemonPalette {
|
|
fn clock(&mut self, ms: f64) {
|
|
self.now_ms = ms;
|
|
self.targets.clock(ms);
|
|
}
|
|
|
|
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
|
let (scene, bindings, standing, approach) = {
|
|
let Self {
|
|
machine,
|
|
mode,
|
|
palette: cached,
|
|
talked,
|
|
targets,
|
|
stood,
|
|
areas,
|
|
frontiers,
|
|
pushed,
|
|
grids,
|
|
..
|
|
} = self;
|
|
let mut state = PokeState::with_ledgers(
|
|
memory, ledger, talked, targets, &*stood, &*areas, &*pushed,
|
|
)
|
|
.caching_grid(grids)
|
|
.with_frontiers(&*frontiers);
|
|
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
|
|
// palette and the scene the feed reports cannot disagree about which frame they are
|
|
// for.
|
|
let scene = state.scene();
|
|
// Whether a conversation has ended, and how, is a question about the frames *after*
|
|
// the `TALK` gave the buttons back, so the machine is given every frame rather than
|
|
// only the ones it owns (`docs/design/macros.md` section 12.4).
|
|
machine.observe_frame(&mut state);
|
|
let palette = match mode {
|
|
PaletteMode::Palette => Palette::for_scene(scene, &mut state),
|
|
PaletteMode::Plan => plan::plan_for(scene, &mut state),
|
|
};
|
|
let bindings = bindings(&palette);
|
|
// Where the fly is standing, for the ledger below. Only on a frame the fly is its own
|
|
// master: while the cartridge is walking it -- a warp in flight, a ledge hop, a script
|
|
// -- the coordinates and the loaded map header are from different frames, and a tile
|
|
// recorded from that pair is a tile of nowhere.
|
|
let standing = (!state.scripted()).then(|| state.player()).flatten();
|
|
// How far the objective is, over the same map graph `GO OBJECTIVE` walks (section
|
|
// 12.15). Read from the same frame and the same state everything else is, and only
|
|
// where the fly is its own master, for the same reason the ground is.
|
|
let approach = standing.and_then(|player| {
|
|
let objective = palette::objective_place(&mut state)?;
|
|
let hops =
|
|
geography::hops(geography::region_at(player.map, player.y), objective.map)?;
|
|
Some((objective.map, hops))
|
|
});
|
|
*cached = Some(palette);
|
|
(scene, bindings, standing, approach)
|
|
};
|
|
// Section 12.7: the macro layer's own answer to "has the run stood here", because the
|
|
// adapter's reward ledger cannot record a doormat.
|
|
if let Some(player) = standing {
|
|
// New ground under the fly is the one thing that can change which tiles of this map
|
|
// it can reach, so it is what clears the map's frontier mark (section 12.14). A tile
|
|
// the ledger already had changes nothing and clears nothing.
|
|
if self.stood.record(player.map, Tile::new(player.x, player.y)) {
|
|
self.frontiers.clear(player.map);
|
|
}
|
|
// Section 13's `areaVisited(kind, area)`: the errand is paid on *entering*, so the
|
|
// ledger is written from the same frame that records the ground. Standing on the
|
|
// building's own map is the whole test -- the fly is inside it -- and it is written
|
|
// only on a frame the fly is its own master, for the same reason the ground is: while
|
|
// the cartridge is walking it through a warp the coordinates and the loaded map header
|
|
// are from different frames.
|
|
if let Some(kind) = geography::amenity_at(player.map)
|
|
&& let Some(area) = geography::area_of(player.map)
|
|
{
|
|
self.areas.record(kind, area);
|
|
}
|
|
}
|
|
// Section 12.15: nearer the objective than this run has ever been, which is the other
|
|
// thing that is plainly progress and which the ratchet's stall window cannot see in the
|
|
// exploration ledger. A level, true on the frame the number falls and false after, so
|
|
// there is nothing to checkpoint and nothing to drift. A different objective starts the
|
|
// measurement again: the ladder's next rung moves as the run climbs and "nearer" means
|
|
// nothing across two different places.
|
|
self.nearer = match (self.nearest, approach) {
|
|
(_, None) => false,
|
|
(None, Some(_)) => false,
|
|
(Some((was, best)), Some((map, hops))) => map == was && hops < best,
|
|
};
|
|
self.nearest = match (self.nearest, approach) {
|
|
(_, None) => self.nearest,
|
|
(Some((was, best)), Some((map, hops))) if map == was => Some((map, best.min(hops))),
|
|
(_, Some(now)) => Some(now),
|
|
};
|
|
// The talked entry `observe_frame` may just have earned, into the ledger the next frame
|
|
// reads.
|
|
self.record_talk();
|
|
Observed { scene: scene_id(scene), bindings }
|
|
}
|
|
|
|
fn start(
|
|
&mut self,
|
|
slot: u8,
|
|
memory: &mut dyn MemoryReader,
|
|
ledger: &dyn RunLedger,
|
|
) -> Started {
|
|
let Some(palette) = self.palette else {
|
|
// Nothing has been observed yet, so there is no palette to start a slot from.
|
|
return Started::Refused { name: None, reason: "unobserved" };
|
|
};
|
|
let slot = MacroId(slot);
|
|
let name = palette.slot(slot).map(|spec| spec.name);
|
|
let begun = {
|
|
let mut state = PokeState::with_ledgers(
|
|
memory,
|
|
ledger,
|
|
&self.talked,
|
|
&self.targets,
|
|
&self.stood,
|
|
&self.areas,
|
|
&self.pushed,
|
|
)
|
|
.caching_grid(&mut self.grids)
|
|
.with_frontiers(&self.frontiers);
|
|
self.machine.start(&palette, slot, &mut state)
|
|
};
|
|
let started = match begun {
|
|
// A started macro always has a name: the machine refuses an unbound slot before it
|
|
// can start one. If that ever stopped being true, abandoning the macro is better
|
|
// than publishing a nameless one and inventing its outcome later.
|
|
Ok(()) => match name {
|
|
Some(name) => Started::Running(name),
|
|
None => {
|
|
self.machine.cancel();
|
|
let _ = self.machine.take_outcome();
|
|
Started::Refused { name: None, reason: "unbound" }
|
|
}
|
|
},
|
|
Err(refused) => Started::Refused {
|
|
// An unbound slot is "no action", not a failed macro, so it carries no name and
|
|
// the loop reports no outcome for it.
|
|
name: if refused.reason == Refusal::Unbound { None } else { name },
|
|
reason: refused.reason.label(),
|
|
},
|
|
};
|
|
// A `no route` refusal earns blocked-ledger entries and presses nothing, so nothing else
|
|
// would ever collect them: the sim loop calls `step` only for a macro that started. Taken
|
|
// here, before the next decision, so the exclusion is in the ledger the next `observe`
|
|
// reads rather than one hold late.
|
|
self.record_talk();
|
|
started
|
|
}
|
|
|
|
fn step(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Option<u8> {
|
|
let mask = {
|
|
let mut state = PokeState::with_ledgers(
|
|
memory,
|
|
ledger,
|
|
&self.talked,
|
|
&self.targets,
|
|
&self.stood,
|
|
&self.areas,
|
|
&self.pushed,
|
|
)
|
|
.caching_grid(&mut self.grids)
|
|
.with_frontiers(&self.frontiers);
|
|
self.machine.step(&mut state)
|
|
};
|
|
// A macro that just finished may have been the press that talked to something; the ledger
|
|
// is written after the step so the state the step read is the one the fly chose in.
|
|
self.record_talk();
|
|
mask
|
|
}
|
|
|
|
fn running(&self) -> Option<&'static str> {
|
|
self.machine.running()
|
|
}
|
|
|
|
fn take_finished(&mut self) -> Option<(&'static str, Outcome)> {
|
|
let (name, abort) = self.machine.take_outcome()?;
|
|
// An unbound slot records a nameless refusal, because the machine cannot name a macro
|
|
// that does not exist in this scene. That is "no action" rather than an outcome: it is
|
|
// taken (so it cannot linger into the next frame) and reported as nothing.
|
|
if name.is_empty() {
|
|
return None;
|
|
}
|
|
Some((name, outcome(abort)))
|
|
}
|
|
|
|
fn nearer_the_objective(&self) -> bool {
|
|
self.nearer
|
|
}
|
|
|
|
fn cancel(&mut self) {
|
|
self.machine.cancel();
|
|
// A cancelled macro talked to nothing, and a stale entry would silence a person for the
|
|
// rest of the session.
|
|
let _ = self.machine.take_talked();
|
|
// Nor did it fail at its target: a rollback is the loop's doing and not the map's, so
|
|
// neither ledger is written. `cancel` reports `Blocked` to the feed, which is the honest
|
|
// outcome for the macro, and excluding a target for it would punish the fly for a
|
|
// rollback it did not cause.
|
|
while self.machine.take_blocked().is_some() {}
|
|
let _ = self.machine.take_timeout();
|
|
let _ = self.machine.take_reached();
|
|
// A rollback is not the map pushing the fly anywhere, nor its frontier going out of
|
|
// reach: the fly is about to be standing somewhere else.
|
|
let _ = self.machine.take_pushed();
|
|
let _ = self.machine.take_exhausted();
|
|
// The cached palette was dealt for a frame that is being thrown away. Dropping it makes
|
|
// the next `start` before the next `observe` a nameless refusal, which presses nothing
|
|
// and reports nothing, rather than a named refusal against a scene that no longer exists.
|
|
self.palette = None;
|
|
}
|
|
}
|
|
|
|
/// Every bound slot of `palette`, in slot order, with its gloss.
|
|
fn bindings(palette: &Palette) -> Vec<SlotBinding> {
|
|
(0..SLOTS)
|
|
.filter_map(|slot| {
|
|
let spec = palette.slot(MacroId(slot))?;
|
|
Some(SlotBinding {
|
|
slot,
|
|
name: spec.name,
|
|
gloss: spec.kind.gloss(),
|
|
channel: spec.kind.channel(),
|
|
tag: spec.kind.channel_tag(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// `Scene` onto the feed's closed set. A forced switch is its own name because it is the one
|
|
/// battle state with a different palette; every other battle frame is `battle`.
|
|
fn scene_id(scene: Scene) -> SceneId {
|
|
match scene {
|
|
Scene::Title => SceneId::Title,
|
|
Scene::Overworld => SceneId::Overworld,
|
|
Scene::Dialog => SceneId::Dialog,
|
|
Scene::Menu => SceneId::Menu,
|
|
Scene::Battle { forced_switch: true, .. } => SceneId::BattleSwitch,
|
|
Scene::Battle { .. } => SceneId::Battle,
|
|
Scene::Shop => SceneId::Shop,
|
|
Scene::Pc => SceneId::Pc,
|
|
Scene::Unknown => SceneId::Unknown,
|
|
}
|
|
}
|
|
|
|
const fn outcome(abort: MacroAbort) -> Outcome {
|
|
match abort {
|
|
MacroAbort::Done => Outcome::Done,
|
|
MacroAbort::Blocked => Outcome::Blocked,
|
|
MacroAbort::Timeout => Outcome::Timeout,
|
|
MacroAbort::Refused => Outcome::Refused,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::adapter::{MapEdge, MapExit};
|
|
use crate::macros::NoLedger;
|
|
use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, Wram};
|
|
use crate::pokemon_red::macros::geography::Amenity;
|
|
use crate::pokemon_red::maps;
|
|
use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState};
|
|
|
|
/// A ledger with one exit in it, for the wiring test below.
|
|
struct OneVisited(MapExit);
|
|
|
|
impl RunLedger for OneVisited {
|
|
fn exit_visited(&self, exit: MapExit) -> bool {
|
|
exit == self.0
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_fresh_cartridge_reads_as_the_title_and_binds_nothing() {
|
|
// All-zero WRAM: the game-timer bit is clear, which is the title screen.
|
|
let mut wram = Wram::new();
|
|
let mut palette = PokemonPalette::new(7);
|
|
let observed = palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(observed.scene, SceneId::Title);
|
|
assert!(observed.bindings.is_empty(), "{:?}", observed.bindings);
|
|
assert_eq!(palette.running(), None);
|
|
// A slot of an empty palette presses nothing and reports no outcome.
|
|
assert_eq!(
|
|
palette.start(0, &mut wram, &NoLedger),
|
|
Started::Refused { name: None, reason: "unbound" }
|
|
);
|
|
assert_eq!(palette.take_finished(), None);
|
|
}
|
|
|
|
/// A ledger whose objective is one map, for the approach reading below.
|
|
struct Bound(u8);
|
|
|
|
impl RunLedger for Bound {
|
|
fn exit_visited(&self, _exit: MapExit) -> bool {
|
|
false
|
|
}
|
|
|
|
fn objective(&self) -> Option<crate::adapter::MapPlace> {
|
|
Some(crate::adapter::MapPlace {
|
|
map: self.0,
|
|
tile: None,
|
|
warp: None,
|
|
edge: None,
|
|
target: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_objective_getting_nearer_is_read_once_per_step_of_the_road() {
|
|
// Section 12.15, the rung-10 stall. The ratchet's stall window is reset by ground never
|
|
// stood on, and a fly walking a road it has already covered earns none -- so this is the
|
|
// other reading, and what it has to be is a *level* that is true on the frame the hop
|
|
// count falls and false on every frame after it. Pewter's own museum is the road: the
|
|
// upper floor is three hops from the gym, the ground floor two, the town one.
|
|
let mut wram = Wram::new();
|
|
wram.started().map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6).facing(0).house_collision();
|
|
let mut palette = PokemonPalette::new(7);
|
|
// Both of Pewter's errands discharged, so the objective is the rung's own place for the
|
|
// whole test: section 13 puts an unvisited mart or centre *ahead* of it, and that is a
|
|
// different place to be near.
|
|
palette.areas.record(Amenity::Mart, maps::PEWTER_CITY);
|
|
palette.areas.record(Amenity::Center, maps::PEWTER_CITY);
|
|
let ledger = Bound(maps::PEWTER_GYM);
|
|
|
|
palette.observe(&mut wram, &ledger);
|
|
assert!(!palette.nearer_the_objective(), "the first reading is a measurement, not a step");
|
|
palette.observe(&mut wram, &ledger);
|
|
assert!(!palette.nearer_the_objective(), "standing still is not nearer");
|
|
|
|
wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6);
|
|
palette.observe(&mut wram, &ledger);
|
|
assert!(palette.nearer_the_objective(), "down the stairs is one hop nearer");
|
|
palette.observe(&mut wram, &ledger);
|
|
assert!(!palette.nearer_the_objective(), "and it is read once, not held");
|
|
|
|
// Back upstairs is not progress, and it does not undo the number either: the measurement
|
|
// is the best this run has managed, so walking the road twice pays once.
|
|
wram.map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6);
|
|
palette.observe(&mut wram, &ledger);
|
|
assert!(!palette.nearer_the_objective());
|
|
wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6);
|
|
palette.observe(&mut wram, &ledger);
|
|
assert!(!palette.nearer_the_objective(), "ground already gained is not gained again");
|
|
|
|
wram.map(maps::PEWTER_CITY, 4, 4, 3, 6);
|
|
palette.observe(&mut wram, &ledger);
|
|
assert!(palette.nearer_the_objective(), "out of the front door is nearer still");
|
|
}
|
|
|
|
#[test]
|
|
fn standing_on_new_ground_is_what_clears_a_maps_frontier_mark() {
|
|
// Section 12.14's other half, wired: the mark is written by a `GO FRONTIER` that could
|
|
// reach none of its goals and cleared by the fly standing somewhere on that map it had
|
|
// not stood on before -- the only event that can change which tiles it can reach. A tile
|
|
// the stood ledger already has changes nothing, which is what keeps the mark from being
|
|
// cleared by the fly pacing the ground it has covered.
|
|
let mut wram = Wram::overworld();
|
|
let mut palette = PokemonPalette::new(7);
|
|
palette.frontiers.record(REDS_HOUSE_1F);
|
|
assert_eq!(palette.exhausted(), 1);
|
|
|
|
palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(palette.exhausted(), 0, "the first frame is new ground, so it clears");
|
|
|
|
palette.frontiers.record(REDS_HOUSE_1F);
|
|
palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(
|
|
palette.exhausted(),
|
|
1,
|
|
"standing on the same tile again is not new ground and clears nothing"
|
|
);
|
|
|
|
wram.map(REDS_HOUSE_1F, 4, 4, 3, 5);
|
|
palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(palette.exhausted(), 0, "a tile the run had not stood on clears it");
|
|
}
|
|
|
|
#[test]
|
|
fn every_bound_slot_carries_a_name_and_a_gloss() {
|
|
let mut wram = Wram::overworld();
|
|
let mut palette = PokemonPalette::new(7);
|
|
let observed = palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(observed.scene, SceneId::Overworld);
|
|
assert!(!observed.bindings.is_empty());
|
|
for binding in &observed.bindings {
|
|
assert!(binding.slot < SLOTS);
|
|
assert!(!binding.name.is_empty() && binding.name.len() <= 14, "{binding:?}");
|
|
assert!(!binding.gloss.is_empty(), "{binding:?}");
|
|
assert!(
|
|
binding.gloss.split_whitespace().count() <= 3,
|
|
"a gloss is two or three words: {binding:?}"
|
|
);
|
|
}
|
|
let slots: Vec<u8> = observed.bindings.iter().map(|binding| binding.slot).collect();
|
|
let mut sorted = slots.clone();
|
|
sorted.sort_unstable();
|
|
sorted.dedup();
|
|
assert_eq!(slots, sorted, "slots are reported once each, in order");
|
|
}
|
|
|
|
/// The ledger reaches the palette: a map whose only warp is already in it has no second exit
|
|
/// for `GO OUT` to prefer, and one that is not has one.
|
|
///
|
|
/// This is the whole of the wiring under test — `AdapterLedger` -> `PokeState::with_ledger` ->
|
|
/// `MacroState::exit_visited` -> `palette::ways` — and the fallback is what makes it
|
|
/// observable: with every exit visited the slot is still bound (a room whose doors are all in
|
|
/// the ledger still has to be left), so what changes is *which* exit the walk aims at, which
|
|
/// is why this test asks the ledger directly as well.
|
|
#[test]
|
|
fn the_exploration_ledger_reaches_the_palette() {
|
|
// Red's ground floor: the staircase at (7, 1) and a doormat at (2, 3) on the bottom row.
|
|
let mut wram = Wram::overworld();
|
|
wram.warps(&[(7, 1, 2, 0x26), (2, 3, 1, 0xff)]);
|
|
let mut palette = PokemonPalette::new(7);
|
|
|
|
let fresh = palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(fresh.scene, SceneId::Overworld);
|
|
assert!(
|
|
fresh.bindings.iter().any(|binding| binding.name == "GO OUT"),
|
|
"a map with two warps binds a way out: {:?}",
|
|
fresh.bindings
|
|
);
|
|
|
|
// The staircase, by its tile on this map, which is how the adapter's ledger names it.
|
|
let stairs = OneVisited(MapExit::Warp { map: REDS_HOUSE_1F, x: 7, y: 1 });
|
|
let mut state = PokeState::with_ledger(&mut wram, &stairs);
|
|
assert!(
|
|
state.exit_visited(ExitId::Warp(0)),
|
|
"the ledger's answer crosses the seam for the warp the test put in it"
|
|
);
|
|
assert!(!state.exit_visited(ExitId::Warp(1)), "and only for that one");
|
|
assert!(!state.exit_visited(ExitId::Warp(9)), "a warp index past the table is not an exit");
|
|
assert!(
|
|
!state.exit_visited(ExitId::Edge(Edge::North)),
|
|
"an edge of this map is a different key"
|
|
);
|
|
|
|
// And an edge asked for as an edge.
|
|
let north = OneVisited(MapExit::Edge { map: REDS_HOUSE_1F, edge: MapEdge::North });
|
|
let mut state = PokeState::with_ledger(&mut wram, &north);
|
|
assert!(state.exit_visited(ExitId::Edge(Edge::North)));
|
|
assert!(!state.exit_visited(ExitId::Edge(Edge::South)));
|
|
}
|
|
|
|
/// The tile the fly is standing on is visited, whatever the run ledger can record.
|
|
///
|
|
/// `infra/docs/macros-traps.md` row 32: the adapter's `exploration` ledger is a *reward*
|
|
/// ledger and its payout gate rejects every frame with `wMovementFlags`' door and warp bits
|
|
/// set, so a doormat — walkable, standable ground — can never be recorded in it. The macro
|
|
/// layer keeps its own answer, written once per frame by `observe`, and `NoLedger` here is
|
|
/// exactly what the adapter looks like for such a tile: it records nothing at all.
|
|
#[test]
|
|
fn the_tile_the_fly_stands_on_is_visited_although_the_run_ledger_records_nothing() {
|
|
// Red's ground floor, the fly at (3, 6).
|
|
let mut wram = Wram::overworld();
|
|
let mut palette = PokemonPalette::new(7);
|
|
{
|
|
let mut state = PokeState::with_ledger(&mut wram, &NoLedger);
|
|
assert!(!state.tile_visited(3, 6), "an empty run ledger holds no ground");
|
|
}
|
|
assert_eq!(palette.stood(), 0, "nothing observed yet");
|
|
|
|
palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(palette.stood(), 1, "one frame on one tile is one tile of ground");
|
|
{
|
|
// The same seam every macro reads the ledgers through.
|
|
let mut state = PokeState::with_ledgers(
|
|
&mut wram,
|
|
&NoLedger,
|
|
&palette.talked,
|
|
&palette.targets,
|
|
&palette.stood,
|
|
&palette.areas,
|
|
&palette.pushed,
|
|
);
|
|
assert!(state.tile_visited(3, 6), "and the answer crosses the seam");
|
|
assert!(!state.tile_visited(3, 5), "only the tile it stood on");
|
|
}
|
|
|
|
// A second frame on the same tile adds nothing; a step adds one.
|
|
palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(palette.stood(), 1);
|
|
wram.set(crate::pokemon_red::symbols::ram::wYCoord, 5);
|
|
palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(palette.stood(), 2);
|
|
|
|
// A frame the cartridge is driving records nothing: the coordinates and the loaded map
|
|
// header are from different frames then, so the pair is a tile of nowhere.
|
|
wram.set(crate::pokemon_red::symbols::ram::wJoyIgnore, 1);
|
|
wram.set(crate::pokemon_red::symbols::ram::wYCoord, 4);
|
|
palette.observe(&mut wram, &NoLedger);
|
|
assert_eq!(palette.stood(), 2, "nothing is recorded while the fly is not its own master");
|
|
}
|
|
}
|