Merge fix/loop-20260922T1217: readable YES/NO prompts deal their answers, the nurse enters the talked ledger, a prompt that reopens unchanged is excluded
This commit is contained in:
commit
d2e2d2e51d
17 changed files with 1307 additions and 36 deletions
|
|
@ -125,7 +125,18 @@ condition the plan wanted, at the cost of no new state.
|
||||||
## Recovery budgets
|
## Recovery budgets
|
||||||
|
|
||||||
Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window
|
Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window
|
||||||
unchanged (120 s without new exploration, 180 s since last recovery). The ratchet's rank bound is
|
unchanged (120 s without new exploration, 180 s since last recovery).
|
||||||
|
|
||||||
|
**Amended 2026-09-22 (rung 10).** "New exploration" is the adapter's lifetime tile ledger, so a map
|
||||||
|
entered for the first time resets the window -- a new map is a map's worth of tiles nobody has stood
|
||||||
|
on -- and a map *re-entered* does not. Two "Stuck" rollbacks fired inside half an hour on a fly that
|
||||||
|
was walking a town it had already covered toward the rung's own door, and both were this rule
|
||||||
|
working. The window now takes a second signal beside the tile count: **the fly being nearer its
|
||||||
|
objective, in map hops, than this run has ever been** (`docs/design/macros.md` section 12.15). The
|
||||||
|
ratchet treats it exactly as it treats new exploration -- it restarts the window and does nothing
|
||||||
|
else -- it can fire at most once per step of the road, and the ratchet itself knows no more about
|
||||||
|
what it means than it knows what a tile is. Nothing else about the budgets, the triggers or the
|
||||||
|
checkpointed state changes. The ratchet's rank bound is
|
||||||
now the running adapter's ladder length, passed in rather than a constant, because the bound belongs
|
now the running adapter's ladder length, passed in rather than a constant, because the bound belongs
|
||||||
to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16
|
to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16
|
||||||
(`docs/design/platformer.md` §3). Only the *budgets* are per-game, from the adapter's
|
(`docs/design/platformer.md` §3). Only the *budgets* are per-game, from the adapter's
|
||||||
|
|
|
||||||
|
|
@ -1080,6 +1080,98 @@ while a prompt is readable, and `TALK` is on no pad at a rested nurse.
|
||||||
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
|
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
|
||||||
untouched.
|
untouched.
|
||||||
|
|
||||||
|
### 12.13 `UNKNOWN` is two states, and one of them has nothing to press (2026-09-22, rung 10, five and a half hours)
|
||||||
|
|
||||||
|
Live on v0.4.5, rank 10, the fly inside the Pewter museum with rung 11's BOULDER BADGE two doors
|
||||||
|
away: since the 11:30 restart the macro starts were `GO FRONTIER` **1,235**, `BACK` **678**,
|
||||||
|
`GO OBJECTIVE` 267, `GO OUT` 242, `YES` 169, and the previous review had already named the shape --
|
||||||
|
`BACK` pressed 189 times "in a text box" on map `0x02`, surrounded by `GO OBJECTIVE` and
|
||||||
|
`GO FRONTIER`. `infra/docs/macros-traps.md` has the reproduction; three things were wrong and all
|
||||||
|
three are here, and the first of them is not a text box at all.
|
||||||
|
|
||||||
|
- **`BACK` was dealt by `Scene::Unknown`, on frames with nothing drawn.** `BACK` is on no overworld
|
||||||
|
pad and on no dialog pad, so every one of those presses came from `Unknown` — and `Unknown` holds
|
||||||
|
two states under one name. One is a screen this crate cannot name: the Pokédex, the trainer card,
|
||||||
|
OPTION, where A and B are what leave it and 13.1 put them there on purpose. The other is a frame
|
||||||
|
of the **overworld** where the buttons are not reaching the player — a warp in flight, a scripted
|
||||||
|
push-back, the museum guide walking the fly through the door — which `scene::detect` calls
|
||||||
|
`Unknown` because its overworld branch needs `controllable`. On the second, `NEXT` and `BACK` are
|
||||||
|
an A and a B pressed into somebody else's script: they change nothing, they complete where the fly
|
||||||
|
stands, and that is 12.2's trap with no box to advance. So the pad is dealt on **whether a box is
|
||||||
|
drawn** (`wFontLoaded`, the reading `text_box` already makes), and a scripted overworld frame is an
|
||||||
|
empty pad the fly waits out. Nothing else can wait it out: the cartridge gives the buttons back by
|
||||||
|
itself, which is the difference between this and every other empty pad 13.1 enumerates.
|
||||||
|
|
||||||
|
### 12.14 A frontier no walk can reach is a fact about the map (2026-09-22, the same run)
|
||||||
|
|
||||||
|
The museum's ground floor is 98 walkable tiles, **62** of them reachable from the door and **39**
|
||||||
|
never stood on — and almost all of those 39 are behind the admission desk. `GO FRONTIER` aims at
|
||||||
|
ground the run has not stood on, the route search cannot reach any of it, so the macro refuses
|
||||||
|
`no route`, presses nothing and writes every goal to the blocked ledger (12.1). That ledger is a
|
||||||
|
**ten brain minute window**: it lapsed, all of it was a candidate again, and the refusal happened
|
||||||
|
again, once per hold, for hours.
|
||||||
|
|
||||||
|
A window is the right shape for a target somebody is standing in front of and the wrong shape for
|
||||||
|
ground the map has fenced off. So the refusal is remembered **per map** instead, with no window,
|
||||||
|
beside the pushed-tile ledger of row 37 — and it is *cleared* by the one event that can change the
|
||||||
|
answer: the fly standing somewhere on that map it has not stood on before, because a door opened, a
|
||||||
|
script carried it through, or somebody moved out of a doorway. Re-entering the map clears nothing;
|
||||||
|
that was the loop the window made. Session state like every other ledger, never checkpointed.
|
||||||
|
|
||||||
|
### 12.15 The ratchet's stall window cannot see a fly walking a road it has already covered
|
||||||
|
|
||||||
|
Two "Stuck" rollbacks fired on this rung inside half an hour, attempts 0 → 2, each one putting the
|
||||||
|
fly back where it had started. Both were the ratchet working exactly to contract: its stall window
|
||||||
|
is restarted by *exploration* — `progress.unique_locations`, ground the run has never stood on
|
||||||
|
(`docs/design/ladder.md`, and row 22) — and 120 brain seconds of safe overworld samples without one
|
||||||
|
new tile is a stall by definition. Entering a map for the first time already counts, because a new
|
||||||
|
map is a map's worth of tiles nobody has stood on; **re-entering** one does not, and two museum
|
||||||
|
floors and a covered town are exactly that.
|
||||||
|
|
||||||
|
What is plainly progress and is not ground: **being nearer the objective than this run has ever
|
||||||
|
been**, counted in hops over the same map graph `GO OBJECTIVE` walks (`geography::hops`). The macro
|
||||||
|
layer answers it, the sim loop passes it to the ratchet beside the coverage figure, and the ratchet
|
||||||
|
treats it exactly as it treats a rise in coverage — it restarts the window and does nothing else: no
|
||||||
|
budget spent, no snapshot taken, no trigger skipped. It can fire at most once per step of the road,
|
||||||
|
and nothing in the macro layer reads it back: no macro is ranked by it and no button is bound on it.
|
||||||
|
|
||||||
|
The checkpointed ratchet state does not move. The signal is a level on one sample, not a counter, so
|
||||||
|
there is nothing to serialise and nothing to drift across a restore.
|
||||||
|
|
||||||
|
### 12.16 What is still in the way of the badge, measured rather than fixed
|
||||||
|
|
||||||
|
With 12.13, 12.14 and the museum's two rows on the map graph, the ROM-gated run from the live
|
||||||
|
checkpoint reaches the gym's own interior on **15 macros** — against never, in five and a half live
|
||||||
|
hours. Two things it then does are worth naming, because neither is a bug and both cost the rung:
|
||||||
|
|
||||||
|
- **the town's errands come first, and they are session state.** Section 13 puts an unvisited mart
|
||||||
|
or Pokémon Center ahead of the rung's place for every map in that area, and the gym is in Pewter's
|
||||||
|
area like everything else. The ledger does not survive a restart, so the 11:30 restart re-armed
|
||||||
|
both errands and `GO OBJECTIVE` aimed at them before the leader. They are paid once and the run
|
||||||
|
goes on; the cost is minutes, not hours.
|
||||||
|
- **`GO FRONTIER` is still most of the run** — 1,247 starts in 55 brain minutes, 649 of them in
|
||||||
|
Pewter City itself. There the frontier is genuinely reachable, one tile at a time, because the
|
||||||
|
whole-map grid is refused on a walking fly (below) and the windowed frontier is the nearest
|
||||||
|
unstood tile on screen. It is covering ground rather than standing still, which is why it is a
|
||||||
|
residual and not a trap.
|
||||||
|
|
||||||
|
**The trap hunt does not improve.** Distinct tiles 193 -> 175 and flagged windows 59 -> 69, with
|
||||||
|
`BACK` in a text box 295 -> 0 and battle frames 6,948 -> 20,894. What the old cycle is replaced by
|
||||||
|
is a new one on the same five tiles -- `GO FRONTIER`, `GO HEAL`, `GO ROUTE`, x42, for seven and a
|
||||||
|
half brain minutes -- and then four brain minutes inside one battle, which the hunt's tile rule
|
||||||
|
flags as hard as it flags a stall. `infra/docs/macros-traps.md` has both arms whole and row 54 is
|
||||||
|
the next brief. The ethos check's "the trap hunt improves" does not hold for this branch; the
|
||||||
|
ROM-gated run does, and both are reported rather than one of them.
|
||||||
|
|
||||||
|
**The whole-map grid is refused while the fly is moving.** `pokemon_red::state::map_grid` checks its
|
||||||
|
decode against the screen buffer over the fly's own tile and its four neighbours, and on a frame
|
||||||
|
mid-step the two are a tile apart: `wYCoord` is the tile being walked *to* while the background is
|
||||||
|
still scrolling. Measured on Pewter City from the rung-10 checkpoint: standing still it decodes on
|
||||||
|
**118 of 120** frames, and the frame the survey caught disagreed on three tiles by exactly one row
|
||||||
|
in the direction of travel. A walk planned on such a frame is planned over the ten-by-nine window of
|
||||||
|
section 15's "before". Naming it needs a WRAM reading of "a step is in progress" that this crate's
|
||||||
|
reviewed symbol list does not carry, so it is reported here and by the probes rather than guessed at.
|
||||||
|
|
||||||
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
||||||
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
||||||
## for the Pokécenter. heal should be a macro.")
|
## for the Pokécenter. heal should be a macro.")
|
||||||
|
|
@ -1210,6 +1302,7 @@ measured where it cannot.
|
||||||
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
|
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
|
||||||
| the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt |
|
| the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt |
|
||||||
| a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change |
|
| a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change |
|
||||||
|
| an `Unknown` frame that is the **overworld with the cartridge driving** -- a warp in flight, a scripted push-back, a guide walking the fly through a door | **empty on purpose** (12.13). There is no box to advance and no screen to leave, so an A or a B press is a press into somebody else's script: it changes nothing and completes where the fly stands. This is the one empty pad that ends itself -- the cartridge gives the buttons back within a few frames -- and `game.padEmptyMs` reports it like any other |
|
||||||
| the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens |
|
| the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens |
|
||||||
|
|
||||||
And because "closed where a macro can close it" is not "closed":
|
And because "closed where a macro can close it" is not "closed":
|
||||||
|
|
|
||||||
|
|
@ -1941,3 +1941,148 @@ checkpoint's own half-finished conversation plus the one `NO` that answered the
|
||||||
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
||||||
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1
|
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1
|
||||||
through v0.4.4. Decoder, reward catalog, adapter version and roles untouched.
|
through v0.4.4. Decoder, reward catalog, adapter version and roles untouched.
|
||||||
|
|
||||||
|
## 2026-09-22, rows 51 to 53: the road the museum had no row for, and a `BACK` with no box under it
|
||||||
|
|
||||||
|
Flagged live by the watchdog within the hour of v0.4.5 going out: rank 10 (PEWTER CITY) for five
|
||||||
|
and a half hours, the objective rung 11's BOULDER BADGE whose place is the gym leader in map
|
||||||
|
`0x36`, and since the 11:30 restart the macro starts were `GO FRONTIER` **1,235**, `BACK` **678**,
|
||||||
|
`GO OBJECTIVE` 267, `GO OUT` 242, `YES` 169. Between 11:46 and 12:17 the ratchet fired **two**
|
||||||
|
"Stuck: rolled back to PEWTER CITY" recoveries, attempts 0 -> 2. The fly was on map 52, the Pewter
|
||||||
|
museum's ground floor. This is the residual the row-41 review named: `BACK` 189 times "in a text
|
||||||
|
box" on map `0x02`, surrounded by `GO OBJECTIVE` and `GO FRONTIER`, with `GO FRONTIER` runs of 112.
|
||||||
|
|
||||||
|
`docs/design/macros.md` sections 12.13 to 12.16 are the design; this is the reproduction, the
|
||||||
|
surveys and the before/after.
|
||||||
|
|
||||||
|
### Where the fly was standing
|
||||||
|
|
||||||
|
`examples/scene_probe.rs` from the live checkpoint:
|
||||||
|
|
||||||
|
- map `0x34` (52), **20x8**, the player at **(10, 7)** facing up -- standing on the museum's own
|
||||||
|
front doormat, one of four `LAST_MAP` warps on the south wall;
|
||||||
|
- the scene reads **`Unknown`**, not `Dialog`: `font=0x00` and **no text box at all**
|
||||||
|
(`corners=(0x10,0x10,0x10,0x10)`), with `flags5=0x24` -- the cartridge holding the joypad. The
|
||||||
|
pad is **`NEXT`, `BACK`**;
|
||||||
|
- the objective is map `0x36` and `next_hop(Region { map: 52 }, 0x36)` answers **`None`**, because
|
||||||
|
`neighbours(52)` is **empty**: the museum has no row on the map graph. So
|
||||||
|
`objective_goals` is `[]`, `GO OBJECTIVE` is off the pad, and so are `GO SHOP` and `GO HEAL`;
|
||||||
|
- the map grid decodes: **98 walkable tiles, 62 reachable from the door, 39 never stood on** --
|
||||||
|
and `frontier_aims` offers **40** of them, almost all behind the admission desk on the east side.
|
||||||
|
|
||||||
|
### The three mechanisms, and none of them is the text box
|
||||||
|
|
||||||
|
1. **`BACK` was never in a text box.** It is on no overworld pad and on no dialog pad, so every one
|
||||||
|
of the 678 was dealt by `Scene::Unknown` -- which is two states under one name. A screen this
|
||||||
|
crate cannot name (the Pokedex, the trainer card, OPTION) is one, and the hunt's own attribution
|
||||||
|
counts `unknown` frames as "a text box" (`dialog_map`), which is what made the residual read the
|
||||||
|
way it did. The other is a frame of the **overworld** with the cartridge driving: `detect`'s
|
||||||
|
overworld branch needs `controllable`, and a warp in flight, a scripted push-back or a guide
|
||||||
|
walking the fly through a door all fail it. `NEXT` and `BACK` there are an A and a B pressed into
|
||||||
|
somebody else's script.
|
||||||
|
2. **The museum is not on the map graph**, which the row-41 review named as a residual and left:
|
||||||
|
"adding the row would give the fly the road to the gym from indoors". Both rows come from the
|
||||||
|
cartridge's own warp table rather than from counting: Pewter City names `0x34` at (14, 7) and at
|
||||||
|
(19, 5) -- the museum's two doors -- and `0x34` names `0x35` at (7, 7), the staircase.
|
||||||
|
3. **The museum's frontier is unreachable and the blocked ledger is a window.** A `GO FRONTIER`
|
||||||
|
that can reach none of its goals refuses `no route` and writes all forty tiles to the ledger;
|
||||||
|
ten brain minutes later they are candidates again and it refuses again, once per hold.
|
||||||
|
|
||||||
|
And the ratchet's two rollbacks were the ratchet working to contract: its stall window is restarted
|
||||||
|
by *exploration*, and two museum floors and a town the run had already covered earn none. Entering
|
||||||
|
a map for the first time does count -- a new map is a map's worth of tiles nobody has stood on --
|
||||||
|
which is the half of the 2026-09-17 rule that already held and is now pinned by a test of its own.
|
||||||
|
|
||||||
|
| # | trap | trigger | test | fix, or why it is left |
|
||||||
|
| ---: | --- | --- | --- | --- |
|
||||||
|
| 51 | `Scene::Unknown` deals `NEXT` and `BACK` on a frame of the overworld the cartridge is driving, where an A and a B press are presses into a script | every warp, every scripted push-back, every guide that walks the fly somewhere -- `BACK` 678 starts in 47 live minutes, 189 of them on map `0x02` | `an_unknown_frame_with_no_box_on_it_deals_nothing`, `an_unknown_scene_is_dialog_with_advance_only`, `the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint` (ROM-gated: `BACK` in a box 0, unknown pads with no box 0) | **fixed**: the pad is dealt on whether a box is drawn. A scripted overworld frame is an empty pad the fly waits out, and it is the one empty pad that ends itself -- the cartridge gives the buttons back within a few frames |
|
||||||
|
| 52 | a building with no row on the map graph has no neighbours, so from inside it there is no road to the objective and no errand either | the whole rung-10 stall: `GO OBJECTIVE`, `GO SHOP` and `GO HEAL` all off the pad on maps `0x34` and `0x35` | `the_museums_two_floors_know_the_road_to_the_gym`, `the_hop_count_is_the_road_measured_rather_than_named`, the ROM run below | **fixed**: two rows, both surveyed from the cartridge's warp table. The upper floor has no *area* -- no front door of its own, like a bedroom -- so it offers no errand, and its road out is the staircase |
|
||||||
|
| 53 | a frontier the route search cannot reach is excluded by a ten brain minute window, so it comes back every ten minutes for ever | `GO FRONTIER` 1,235 starts in 47 minutes over two museum floors and a covered town; 39 unstood tiles on map `0x34`, almost all behind the admission desk | `a_frontier_no_walk_can_reach_takes_go_frontier_off_the_pad_and_keeps_it_off`, `a_frontier_mark_is_the_stood_ledgers_to_clear`, `standing_on_new_ground_is_what_clears_a_maps_frontier_mark` | **fixed**: the refusal is remembered per map with no window, beside the pushed-tile ledger of row 37, and cleared by the fly standing on ground of that map it had not stood on before -- the only event that can change which tiles it can reach |
|
||||||
|
|
||||||
|
### The ROM-gated run, from the live checkpoint
|
||||||
|
|
||||||
|
`FLY_PEWTER_CHECKPOINT`, macros mode, the stub rotation, 200,000 frames (55.8 brain minutes):
|
||||||
|
|
||||||
|
| measure | value |
|
||||||
|
| --- | --- |
|
||||||
|
| the checkpoint's map | `0x34`, the museum's ground floor, on its doormat |
|
||||||
|
| reached **map `0x36`**, the gym's interior | **frame 2,423, on 15 macros** |
|
||||||
|
| `TALK` on the pad inside the gym | yes -- and 15 `TALK` starts on map `0x36` |
|
||||||
|
| `GO OBJECTIVE` bound on the gym's own pad | yes |
|
||||||
|
| `BACK` on a `dialog` or `unknown` frame | **0** (189 in the before hunt, on map `0x02` alone) |
|
||||||
|
| pads dealt on an `unknown` frame with nothing drawn | **0** |
|
||||||
|
| `GO FRONTIER` starts on the museum's two floors | **0** (1,235 live over those floors and the town) |
|
||||||
|
| `GO FRONTIER` starts overall | 1,247 -- 649 of them in Pewter City itself |
|
||||||
|
| route | `0x34` -> Pewter City -> the gym, then the town's shops and houses |
|
||||||
|
| the rank at the end | 10 (PEWTER CITY), **0 badges**: the road is open, the badge is not won |
|
||||||
|
|
||||||
|
The fly is out of the museum and into the gym in the first minute, and then spends the run in
|
||||||
|
Pewter City's buildings: the town's errands are session state and the 11:30 restart re-armed both,
|
||||||
|
so `GO OBJECTIVE` aims at the mart and the centre before the leader (section 13, and `BUY
|
||||||
|
ANTIDOTE` 3, `BUY BALL` 2, `HEAL` 1 in the same run say it was paid).
|
||||||
|
|
||||||
|
### The trap hunt, before and after — and it does not improve
|
||||||
|
|
||||||
|
Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge,
|
||||||
|
from the release container's own rung-10 Pewter checkpoint, **driven by the brain**.
|
||||||
|
|
||||||
|
| measure | before (v0.4.5) | after |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| distinct (map, tile) | **193** | 175 |
|
||||||
|
| windows flagged | **59/73** | 69/73 |
|
||||||
|
| macros started | 1283 | 1056 |
|
||||||
|
| `BACK` starts | **334** | 133 |
|
||||||
|
| `BACK` in a text box on map `0x02` | **291** | **0** |
|
||||||
|
| `BACK` on any text box | 295 | **0** |
|
||||||
|
| `GO FRONTIER` starts | 531 | 242 |
|
||||||
|
| `GO HEAL` starts | 0 | **204** |
|
||||||
|
| frames in `overworld` | 43,797 | 30,861 |
|
||||||
|
| frames in `unknown` | 16,595 | 13,990 |
|
||||||
|
| frames in `battle` | 6,948 | **20,894** |
|
||||||
|
| recoveries | 0 | 0 |
|
||||||
|
| the repeated sequence | `BACK, GO FRONTIER, GO FRONTIER, GO OBJECTIVE` x16 and `GO OUT, BACK, GO FRONTIER, GO FRONTIER` x37 | `GO FRONTIER, GO HEAL, GO ROUTE` x42 |
|
||||||
|
|
||||||
|
**The before arm is the live loop whole**: the two four-macro cycles it flags are the two the
|
||||||
|
watchdog's own log named, `BACK` is 291 of them in a text box on map `0x02`, and the fly ends the
|
||||||
|
run having covered 193 tiles of a 879-tile town.
|
||||||
|
|
||||||
|
**The after arm does not improve on either of the hunt's two measures, and this says so.** `BACK`
|
||||||
|
in a box goes 295 -> 0, which is row 51 closed; the museum is left in the first window and never
|
||||||
|
returned to, which is rows 52 and 53. What replaces the old cycle is a *new* one on the same five
|
||||||
|
tiles -- `GO FRONTIER, GO HEAL, GO ROUTE` x42 from brain minute 1.0 to 8.5 -- and after that the
|
||||||
|
fly covers ground (up to 103 tiles in a window) and spends the last four brain minutes inside one
|
||||||
|
battle, which the hunt's tile rule flags as hard as it flags a loop. Battle frames go **6,948 ->
|
||||||
|
20,894**, and section 15 of `docs/design/macros.md` measured the same trade the same way: a fly
|
||||||
|
that covers more ground walks into more grass, and the hunt cannot tell a long fight from a stall.
|
||||||
|
|
||||||
|
| # | trap | trigger | test | fix, or why it is left |
|
||||||
|
| ---: | --- | --- | --- | --- |
|
||||||
|
| 54 | `GO FRONTIER`, `GO HEAL` and `GO ROUTE` cycle on five tiles: three walks that each end where they began | measured in the **after** arm only, brain minutes 1.0 to 8.5, `GO HEAL` **204** starts at a mean net of 0.0 tiles and a mean reach of 0.0, `GO ROUTE` 211 at a net of 0.2 | -- | **named, not worked, and it is the next brief.** Two readings fit and the hunt cannot separate them: the errand walking the fly in and out of a building whose door is underfoot (row 2's shape, with `GO HEAL` in `GO ROUTE`'s place), and the windowed walk oscillation of 12.3's row 23 -- which the whole-map grid was built to end and which is back **because the grid is refused on every frame the fly is mid-step** (below). Rows 1, 2b, 23, 24 and 41 were each found this way |
|
||||||
|
|
||||||
|
### Residuals, named rather than worked around
|
||||||
|
|
||||||
|
- **The whole-map grid is refused while the fly is moving.** `map_grid` checks its decode against
|
||||||
|
the screen buffer over the fly's tile and its four neighbours, and mid-step the two are one tile
|
||||||
|
apart: `wYCoord` is the tile being walked *to* while the background is still scrolling. Measured
|
||||||
|
on Pewter City from this checkpoint: standing still it decodes on **118 of 120** frames, and the
|
||||||
|
frame the survey caught disagreed on three tiles by exactly one row in the direction of travel.
|
||||||
|
A walk planned on such a frame is planned over the ten-by-nine window of section 15's "before",
|
||||||
|
which is what row 23's oscillation is made of. The honest fix is a WRAM reading of "a step is in
|
||||||
|
progress", which this crate's reviewed symbol list does not carry, so it is reported by the
|
||||||
|
probes and left.
|
||||||
|
- **The town's errands are session state**, so every restart re-arms them and `GO OBJECTIVE` aims
|
||||||
|
at the mart and the centre before the rung's place, the gym included. Section 13's own design.
|
||||||
|
- **`MOVE n` still reports `blocked`** with the move list drawn and its cursor placeable but not
|
||||||
|
accepting input (row 50): 42 of 65 in the after arm. Unchanged since v0.4.3.
|
||||||
|
|
||||||
|
### Gates
|
||||||
|
|
||||||
|
- `cargo test --workspace` with `FLY_ROM` set: green except
|
||||||
|
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
|
||||||
|
which is the known debug-build boot failure on this box -- the service took 49.2 s to its first
|
||||||
|
healthy `/healthz` and `/status` still read the booting header (`milestone.total` 1, the
|
||||||
|
placeholder `simloop.rs` uses before the adapter exists). Pre-existing and unrelated.
|
||||||
|
- `cargo clippy --all-targets`: clean.
|
||||||
|
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
||||||
|
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1
|
||||||
|
through v0.4.5. Decoder, reward catalog, adapter version and roles untouched.
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,27 @@ pub trait MacroPalette: Send {
|
||||||
/// Give up on whatever is running, because the sim loop is rolling the game back. The
|
/// Give up on whatever is running, because the sim loop is rolling the game back. The
|
||||||
/// abandoned macro is reported by the next [`MacroPalette::take_finished`].
|
/// abandoned macro is reported by the next [`MacroPalette::take_finished`].
|
||||||
fn cancel(&mut self);
|
fn cancel(&mut self);
|
||||||
|
|
||||||
|
/// Whether the last [`MacroPalette::observe`] saw the fly **nearer its objective than it has
|
||||||
|
/// been** since that objective was set, measured in map hops.
|
||||||
|
///
|
||||||
|
/// The ratchet's stall window is reset by exploration -- one new tile
|
||||||
|
/// (`docs/design/ladder.md`, the 2026-09-17 progress rule) -- and a fly crossing a town it
|
||||||
|
/// has already covered to reach the rung's own door earns no new ground while it does it.
|
||||||
|
/// That is the rung-10 stall of 2026-09-22 in one line: two "Stuck" rollbacks inside half an
|
||||||
|
/// hour, both of them on a fly that was walking, both of them landing it back where it had
|
||||||
|
/// started. Getting nearer the objective than this run has ever been is the other thing that
|
||||||
|
/// is plainly progress, and it is a *level* rather than a counter so nothing is checkpointed
|
||||||
|
/// and nothing can drift: it is true on the frame the distance falls and false after.
|
||||||
|
///
|
||||||
|
/// Read by the sim loop and by nothing else. No macro is ranked by it, no button is bound on
|
||||||
|
/// it and it presses nothing (`docs/design/macros.md` section 12): it is the loop's own
|
||||||
|
/// answer to "is this run getting somewhere".
|
||||||
|
///
|
||||||
|
/// The default is `false`, which is a palette with no objective to be nearer to.
|
||||||
|
fn nearer_the_objective(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every macro channel a game's palette can ever bind, in the contract's own order, or empty for
|
/// Every macro channel a game's palette can ever bind, in the contract's own order, or empty for
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,14 @@ pub trait MacroState: GameState {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable.
|
||||||
|
///
|
||||||
|
/// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state
|
||||||
|
/// that cannot answer has proved nothing, which is a fresh session.
|
||||||
|
fn frontier_exhausted(&mut self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the cartridge has pushed the fly off this tile of the loaded map.
|
/// Whether the cartridge has pushed the fly off this tile of the loaded map.
|
||||||
///
|
///
|
||||||
/// [`PushedLedger`] is the evidence and the measurement. The default is `false`: a state that
|
/// [`PushedLedger`] is the evidence and the measurement. The default is `false`: a state that
|
||||||
|
|
@ -465,6 +473,22 @@ pub trait MacroState: GameState {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a text box is open at all: `wFontLoaded`'s bit, and nothing drawn.
|
||||||
|
///
|
||||||
|
/// The one thing that tells a screen with words on it from a frame of the overworld the
|
||||||
|
/// cartridge happens to be driving, and [`super::palette::scene_set`] deals
|
||||||
|
/// [`Scene::Unknown`]'s pad on it (**section 12.13**). `Unknown` is two different states
|
||||||
|
/// wearing one name: a screen this crate cannot name -- the Pokedex, the trainer card,
|
||||||
|
/// OPTION -- where `NEXT` and `BACK` are the A and B that leave it; and a *scripted* overworld
|
||||||
|
/// frame, where `scene::detect` falls through to `Unknown` because the buttons are not
|
||||||
|
/// reaching the player, and where an A or a B press is a press into somebody else's script.
|
||||||
|
///
|
||||||
|
/// The default is `false`, which narrows: with no reading, `Unknown` deals nothing and the
|
||||||
|
/// fly waits, which is what the doctrine says a scene with nothing to press does.
|
||||||
|
fn text_open(&mut self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the box on screen is the two-option YES/NO prompt rather than a plain text box.
|
/// Whether the box on screen is the two-option YES/NO prompt rather than a plain text box.
|
||||||
///
|
///
|
||||||
/// `pokemon_red::state::yes_no_prompt`: the border `DisplayTwoOptionMenu` draws plus the
|
/// `pokemon_red::state::yes_no_prompt`: the border `DisplayTwoOptionMenu` draws plus the
|
||||||
|
|
@ -612,9 +636,13 @@ impl StoodLedger for NoStood {
|
||||||
pub struct Stood(std::collections::BTreeSet<(u8, Tile)>);
|
pub struct Stood(std::collections::BTreeSet<(u8, Tile)>);
|
||||||
|
|
||||||
impl Stood {
|
impl Stood {
|
||||||
/// Record the tile the fly is standing on. Idempotent.
|
/// Record the tile the fly is standing on, and say whether it is ground this run had not
|
||||||
pub fn record(&mut self, map: u8, tile: Tile) {
|
/// stood on before. Idempotent.
|
||||||
self.0.insert((map, tile));
|
///
|
||||||
|
/// The answer is what clears a map's frontier mark ([`Frontiers`], section 12.14): new
|
||||||
|
/// ground under the fly is the one thing that can have changed which tiles it can reach.
|
||||||
|
pub fn record(&mut self, map: u8, tile: Tile) -> bool {
|
||||||
|
self.0.insert((map, tile))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much ground this session has watched, for a log line and the tests.
|
/// How much ground this session has watched, for a log line and the tests.
|
||||||
|
|
@ -633,6 +661,75 @@ impl StoodLedger for Stood {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps whose frontier the run has proved it cannot reach any more.
|
||||||
|
///
|
||||||
|
/// **The rung-10 museum and Pewter City, 2026-09-22.** `GO FRONTIER` ran 1,235 times in 47
|
||||||
|
/// minutes over two museum floors and a town whose walkable ground the fly had already covered.
|
||||||
|
/// The ground it was aiming at was real -- the museum's exhibit hall behind the admission desk,
|
||||||
|
/// the far side of a fence -- and unreachable: 98 walkable tiles on map `0x34`, 62 of them
|
||||||
|
/// reachable from the door, 39 never stood on and all but a handful of those behind the desk.
|
||||||
|
/// A walk that can reach none of its goals refuses `no route` and writes every one of them to
|
||||||
|
/// the blocked ledger ([`Targets`]), which is a **window**: ten brain minutes later all forty
|
||||||
|
/// tiles were candidates again, the button was back on the pad, and the refusal happened again.
|
||||||
|
/// A window is right for a target a person is standing in front of and wrong for ground the map
|
||||||
|
/// has fenced off.
|
||||||
|
///
|
||||||
|
/// So the refusal is remembered per map instead. It is written when a `GO FRONTIER` refuses for
|
||||||
|
/// want of a route -- the measured fact "from here, no unstood tile of this map can be walked
|
||||||
|
/// to" -- and it is cleared the moment the fly **stands somewhere on that map it has not stood
|
||||||
|
/// before**, because that is the only thing that can have changed the answer: a door opened, a
|
||||||
|
/// script carried the fly through, somebody moved out of a doorway. Not on re-entering the map,
|
||||||
|
/// which is the loop the window made.
|
||||||
|
///
|
||||||
|
/// Session state beside [`Talked`], [`Stood`], [`Areas`] and [`Pushed`], never checkpointed: a
|
||||||
|
/// restored run tries the frontier once more, which is the honest answer for a ledger that did
|
||||||
|
/// not survive.
|
||||||
|
pub trait FrontierLedger {
|
||||||
|
/// Whether a `GO FRONTIER` has proved this map's remaining frontier unreachable.
|
||||||
|
fn frontier_exhausted(&self, map: u8) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ledger that has proved nothing: every map's frontier is still worth a walk.
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct NoFrontiers;
|
||||||
|
|
||||||
|
impl FrontierLedger for NoFrontiers {
|
||||||
|
fn frontier_exhausted(&self, _map: u8) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session's own record of which maps have nothing left to walk to.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Frontiers(std::collections::BTreeSet<u8>);
|
||||||
|
|
||||||
|
impl Frontiers {
|
||||||
|
/// Record that `map`'s frontier could not be reached. Idempotent.
|
||||||
|
pub fn record(&mut self, map: u8) {
|
||||||
|
self.0.insert(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget `map`'s mark, because the run has just stood somewhere on it that it had not.
|
||||||
|
pub fn clear(&mut self, map: u8) {
|
||||||
|
self.0.remove(&map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many maps are marked, for a log line and the tests.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrontierLedger for Frontiers {
|
||||||
|
fn frontier_exhausted(&self, map: u8) -> bool {
|
||||||
|
self.0.contains(&map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tiles the cartridge has pushed the fly off, as the macros ask it.
|
/// Tiles the cartridge has pushed the fly off, as the macros ask it.
|
||||||
///
|
///
|
||||||
/// **The Viridian private-property tile** (2026-09-17, `infra/docs/macros-traps.md` row 37).
|
/// **The Viridian private-property tile** (2026-09-17, `infra/docs/macros-traps.md` row 37).
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@ use crate::macros::{
|
||||||
|
|
||||||
use super::super::mapgrid::MapGrids;
|
use super::super::mapgrid::MapGrids;
|
||||||
use super::super::state::PokeState;
|
use super::super::state::PokeState;
|
||||||
use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile};
|
use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile};
|
||||||
use super::geography;
|
use super::geography;
|
||||||
use super::executor::{MacroAbort, MacroMachine, Refusal};
|
use super::executor::{MacroAbort, MacroMachine, Refusal};
|
||||||
use super::palette::{MacroId, Palette};
|
use super::palette::{self, MacroId, Palette};
|
||||||
use super::plan;
|
use super::plan;
|
||||||
use super::state::{GameState, Scene};
|
use super::state::{GameState, Scene};
|
||||||
|
|
||||||
|
|
@ -64,6 +64,15 @@ pub struct PokemonPalette {
|
||||||
/// centre's own map -- which is the moment the errand is discharged. A purchase or a heal marks
|
/// 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.
|
/// nothing: what the errand asked for was the visit. Session state, not checkpointed.
|
||||||
areas: Areas,
|
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).
|
/// 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
|
/// Owned here beside the other session ledgers and for the same reason. Unlike the target
|
||||||
|
|
@ -79,6 +88,17 @@ pub struct PokemonPalette {
|
||||||
/// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the
|
/// 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.
|
/// cartridge's own tables say about the ground.
|
||||||
grids: MapGrids,
|
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 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
|
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
|
||||||
|
|
@ -101,8 +121,11 @@ impl PokemonPalette {
|
||||||
targets: Targets::new(),
|
targets: Targets::new(),
|
||||||
stood: Stood::default(),
|
stood: Stood::default(),
|
||||||
areas: Areas::default(),
|
areas: Areas::default(),
|
||||||
|
frontiers: Frontiers::default(),
|
||||||
pushed: Pushed::default(),
|
pushed: Pushed::default(),
|
||||||
grids: MapGrids::default(),
|
grids: MapGrids::default(),
|
||||||
|
nearest: None,
|
||||||
|
nearer: false,
|
||||||
now_ms: 0.0,
|
now_ms: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +160,11 @@ impl PokemonPalette {
|
||||||
self.pushed.len()
|
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.
|
/// Take whatever the machine's last finished macro earned into the session's ledgers.
|
||||||
fn record_talk(&mut self) {
|
fn record_talk(&mut self) {
|
||||||
if let Some((map, target)) = self.machine.take_talked() {
|
if let Some((map, target)) = self.machine.take_talked() {
|
||||||
|
|
@ -154,6 +182,11 @@ impl PokemonPalette {
|
||||||
if let Some((map, tile)) = self.machine.take_pushed() {
|
if let Some((map, tile)) = self.machine.take_pushed() {
|
||||||
self.pushed.record(map, tile);
|
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() {
|
if let Some((map, target, closer)) = self.machine.take_timeout() {
|
||||||
self.targets.record_timeout(map, target, closer);
|
self.targets.record_timeout(map, target, closer);
|
||||||
}
|
}
|
||||||
|
|
@ -167,14 +200,25 @@ impl MacroPalette for PokemonPalette {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
||||||
let (scene, bindings, standing) = {
|
let (scene, bindings, standing, approach) = {
|
||||||
let Self {
|
let Self {
|
||||||
machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, ..
|
machine,
|
||||||
|
mode,
|
||||||
|
palette: cached,
|
||||||
|
talked,
|
||||||
|
targets,
|
||||||
|
stood,
|
||||||
|
areas,
|
||||||
|
frontiers,
|
||||||
|
pushed,
|
||||||
|
grids,
|
||||||
|
..
|
||||||
} = self;
|
} = self;
|
||||||
let mut state = PokeState::with_ledgers(
|
let mut state = PokeState::with_ledgers(
|
||||||
memory, ledger, talked, targets, &*stood, &*areas, &*pushed,
|
memory, ledger, talked, targets, &*stood, &*areas, &*pushed,
|
||||||
)
|
)
|
||||||
.caching_grid(grids);
|
.caching_grid(grids)
|
||||||
|
.with_frontiers(&*frontiers);
|
||||||
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
|
// `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
|
// palette and the scene the feed reports cannot disagree about which frame they are
|
||||||
// for.
|
// for.
|
||||||
|
|
@ -193,13 +237,27 @@ impl MacroPalette for PokemonPalette {
|
||||||
// -- the coordinates and the loaded map header are from different frames, and a tile
|
// -- the coordinates and the loaded map header are from different frames, and a tile
|
||||||
// recorded from that pair is a tile of nowhere.
|
// recorded from that pair is a tile of nowhere.
|
||||||
let standing = (!state.scripted()).then(|| state.player()).flatten();
|
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);
|
*cached = Some(palette);
|
||||||
(scene, bindings, standing)
|
(scene, bindings, standing, approach)
|
||||||
};
|
};
|
||||||
// Section 12.7: the macro layer's own answer to "has the run stood here", because the
|
// 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.
|
// adapter's reward ledger cannot record a doormat.
|
||||||
if let Some(player) = standing {
|
if let Some(player) = standing {
|
||||||
self.stood.record(player.map, Tile::new(player.x, player.y));
|
// 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
|
// 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
|
// 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
|
// building's own map is the whole test -- the fly is inside it -- and it is written
|
||||||
|
|
@ -212,6 +270,22 @@ impl MacroPalette for PokemonPalette {
|
||||||
self.areas.record(kind, area);
|
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
|
// The talked entry `observe_frame` may just have earned, into the ledger the next frame
|
||||||
// reads.
|
// reads.
|
||||||
self.record_talk();
|
self.record_talk();
|
||||||
|
|
@ -240,7 +314,8 @@ impl MacroPalette for PokemonPalette {
|
||||||
&self.areas,
|
&self.areas,
|
||||||
&self.pushed,
|
&self.pushed,
|
||||||
)
|
)
|
||||||
.caching_grid(&mut self.grids);
|
.caching_grid(&mut self.grids)
|
||||||
|
.with_frontiers(&self.frontiers);
|
||||||
self.machine.start(&palette, slot, &mut state)
|
self.machine.start(&palette, slot, &mut state)
|
||||||
};
|
};
|
||||||
let started = match begun {
|
let started = match begun {
|
||||||
|
|
@ -281,7 +356,8 @@ impl MacroPalette for PokemonPalette {
|
||||||
&self.areas,
|
&self.areas,
|
||||||
&self.pushed,
|
&self.pushed,
|
||||||
)
|
)
|
||||||
.caching_grid(&mut self.grids);
|
.caching_grid(&mut self.grids)
|
||||||
|
.with_frontiers(&self.frontiers);
|
||||||
self.machine.step(&mut state)
|
self.machine.step(&mut state)
|
||||||
};
|
};
|
||||||
// A macro that just finished may have been the press that talked to something; the ledger
|
// A macro that just finished may have been the press that talked to something; the ledger
|
||||||
|
|
@ -305,6 +381,10 @@ impl MacroPalette for PokemonPalette {
|
||||||
Some((name, outcome(abort)))
|
Some((name, outcome(abort)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn nearer_the_objective(&self) -> bool {
|
||||||
|
self.nearer
|
||||||
|
}
|
||||||
|
|
||||||
fn cancel(&mut self) {
|
fn cancel(&mut self) {
|
||||||
self.machine.cancel();
|
self.machine.cancel();
|
||||||
// A cancelled macro talked to nothing, and a stale entry would silence a person for the
|
// A cancelled macro talked to nothing, and a stale entry would silence a person for the
|
||||||
|
|
@ -317,8 +397,10 @@ impl MacroPalette for PokemonPalette {
|
||||||
while self.machine.take_blocked().is_some() {}
|
while self.machine.take_blocked().is_some() {}
|
||||||
let _ = self.machine.take_timeout();
|
let _ = self.machine.take_timeout();
|
||||||
let _ = self.machine.take_reached();
|
let _ = self.machine.take_reached();
|
||||||
// A rollback is not the map pushing the fly anywhere.
|
// 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_pushed();
|
||||||
|
let _ = self.machine.take_exhausted();
|
||||||
// The cached palette was dealt for a frame that is being thrown away. Dropping it makes
|
// 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
|
// 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.
|
// and reports nothing, rather than a named refusal against a scene that no longer exists.
|
||||||
|
|
@ -373,6 +455,8 @@ mod tests {
|
||||||
use crate::adapter::{MapEdge, MapExit};
|
use crate::adapter::{MapEdge, MapExit};
|
||||||
use crate::macros::NoLedger;
|
use crate::macros::NoLedger;
|
||||||
use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, Wram};
|
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};
|
use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState};
|
||||||
|
|
||||||
/// A ledger with one exit in it, for the wiring test below.
|
/// A ledger with one exit in it, for the wiring test below.
|
||||||
|
|
@ -401,6 +485,95 @@ mod tests {
|
||||||
assert_eq!(palette.take_finished(), None);
|
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]
|
#[test]
|
||||||
fn every_bound_slot_carries_a_name_and_a_gloss() {
|
fn every_bound_slot_carries_a_name_and_a_gloss() {
|
||||||
let mut wram = Wram::overworld();
|
let mut wram = Wram::overworld();
|
||||||
|
|
|
||||||
|
|
@ -575,6 +575,14 @@ pub struct MacroMachine {
|
||||||
/// that finds no route to *any* of its goals has failed at all of them, and each is a key.
|
/// that finds no route to *any* of its goals has failed at all of them, and each is a key.
|
||||||
blocked: Vec<(u8, TargetKey)>,
|
blocked: Vec<(u8, TargetKey)>,
|
||||||
reached: Option<(u8, TargetKey)>,
|
reached: Option<(u8, TargetKey)>,
|
||||||
|
/// A map whose frontier a `GO FRONTIER` has just proved unreachable, waiting to be taken
|
||||||
|
/// into the session's ledger ([`super::cartridge::Frontiers`], section 12.14).
|
||||||
|
///
|
||||||
|
/// The same shape as `pushed_tile` and for the same reason: it is a fact about the *ground*
|
||||||
|
/// rather than about a target, so the blocked ledger's ten-minute window is the wrong home
|
||||||
|
/// for it -- the window is what brought forty unreachable museum tiles back every ten
|
||||||
|
/// minutes, once per hold, for hours.
|
||||||
|
exhausted: Option<u8>,
|
||||||
/// A tile the cartridge pushed the fly off, waiting to be taken into the session's ledger.
|
/// A tile the cartridge pushed the fly off, waiting to be taken into the session's ledger.
|
||||||
///
|
///
|
||||||
/// Row 37 of `infra/docs/macros-traps.md`: a scripted push-back is a fact about the *ground*,
|
/// Row 37 of `infra/docs/macros-traps.md`: a scripted push-back is a fact about the *ground*,
|
||||||
|
|
@ -651,6 +659,7 @@ impl MacroMachine {
|
||||||
outcome: None,
|
outcome: None,
|
||||||
blocked: Vec::new(),
|
blocked: Vec::new(),
|
||||||
reached: None,
|
reached: None,
|
||||||
|
exhausted: None,
|
||||||
pushed_tile: None,
|
pushed_tile: None,
|
||||||
timed_out: None,
|
timed_out: None,
|
||||||
resume: VecDeque::new(),
|
resume: VecDeque::new(),
|
||||||
|
|
@ -695,6 +704,13 @@ impl MacroMachine {
|
||||||
// could not reach is what empties the list and takes the button off the pad.
|
// could not reach is what empties the list and takes the button off the pad.
|
||||||
if let Some(map) = state.player().map(|player| player.map) {
|
if let Some(map) = state.player().map(|player| player.map) {
|
||||||
self.blocked.extend(unreachable.into_iter().map(|key| (map, key)));
|
self.blocked.extend(unreachable.into_iter().map(|key| (map, key)));
|
||||||
|
// And for the frontier, the same fact one level up: every tile of this map the
|
||||||
|
// run has not stood on is unreachable from where the fly is standing. The
|
||||||
|
// blocked ledger is a window and this is not -- ground the map has fenced off is
|
||||||
|
// still fenced off ten brain minutes later (section 12.14).
|
||||||
|
if spec.kind == MacroKind::GoFrontier {
|
||||||
|
self.exhausted = Some(map);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return refuse(self, Refusal::NoRoute);
|
return refuse(self, Refusal::NoRoute);
|
||||||
};
|
};
|
||||||
|
|
@ -857,6 +873,11 @@ impl MacroMachine {
|
||||||
self.reached.take()
|
self.reached.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The map a `no route` from `GO FRONTIER` earned, taken rather than read (section 12.14).
|
||||||
|
pub fn take_exhausted(&mut self) -> Option<u8> {
|
||||||
|
self.exhausted.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// The tile a scripted push-back earned, taken rather than read (row 37).
|
/// The tile a scripted push-back earned, taken rather than read (row 37).
|
||||||
pub fn take_pushed(&mut self) -> Option<(u8, Tile)> {
|
pub fn take_pushed(&mut self) -> Option<(u8, Tile)> {
|
||||||
self.pushed_tile.take()
|
self.pushed_tile.take()
|
||||||
|
|
@ -883,8 +904,10 @@ impl MacroMachine {
|
||||||
// that so a cancelled queue cannot leak into the next macro's finish.
|
// that so a cancelled queue cannot leak into the next macro's finish.
|
||||||
self.blocked.clear();
|
self.blocked.clear();
|
||||||
self.reached = None;
|
self.reached = None;
|
||||||
// A rollback is not the map pushing the fly anywhere.
|
// A rollback is not the map pushing the fly anywhere, and it is not the frontier being
|
||||||
|
// out of reach either: the fly is about to be somewhere else entirely.
|
||||||
self.pushed_tile = None;
|
self.pushed_tile = None;
|
||||||
|
self.exhausted = None;
|
||||||
self.timed_out = None;
|
self.timed_out = None;
|
||||||
// A rollback puts the fly somewhere else on the map, so every suspended route is a route
|
// A rollback puts the fly somewhere else on the map, so every suspended route is a route
|
||||||
// from a tile it is no longer standing on. `take_resume` would refuse them one at a time;
|
// from a tile it is no longer standing on. `take_resume` would refuse them one at a time;
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,14 @@ const LINKS: &[(u8, u8)] = &[
|
||||||
(maps::VIRIDIAN_FOREST_NORTH_GATE, maps::ROUTE_2),
|
(maps::VIRIDIAN_FOREST_NORTH_GATE, maps::ROUTE_2),
|
||||||
(maps::VIRIDIAN_FOREST_NORTH_GATE, maps::VIRIDIAN_FOREST),
|
(maps::VIRIDIAN_FOREST_NORTH_GATE, maps::VIRIDIAN_FOREST),
|
||||||
(maps::PEWTER_GYM, maps::PEWTER_CITY),
|
(maps::PEWTER_GYM, maps::PEWTER_CITY),
|
||||||
|
// The museum, both floors. It is on the graph for the same reason the gym is: the rung-10
|
||||||
|
// loop of 2026-09-22 spent its hours on these two maps with `GO OBJECTIVE` off the pad
|
||||||
|
// entirely, because a map with no row here has no neighbours, so `next_hop` answers nothing
|
||||||
|
// and `area_of` answers nothing -- no road to the gym from indoors, and no errand either.
|
||||||
|
// Both rows are the cartridge's own warp table, surveyed from the rung-10 checkpoint: Pewter
|
||||||
|
// City names `$34` at (14, 7) and (19, 5), and `$34` names `$35` at (7, 7).
|
||||||
|
(maps::PEWTER_MUSEUM_1F, maps::PEWTER_CITY),
|
||||||
|
(maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F),
|
||||||
(maps::PEWTER_MART, maps::PEWTER_CITY),
|
(maps::PEWTER_MART, maps::PEWTER_CITY),
|
||||||
(maps::PEWTER_POKECENTER, maps::PEWTER_CITY),
|
(maps::PEWTER_POKECENTER, maps::PEWTER_CITY),
|
||||||
(maps::MT_MOON_1F, maps::ROUTE_3),
|
(maps::MT_MOON_1F, maps::ROUTE_3),
|
||||||
|
|
@ -333,6 +341,39 @@ pub fn next_hop(from: Region, to: u8) -> Option<u8> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many hops the shortest known route from `from` to the map `to` takes, or `None` when none
|
||||||
|
/// is known.
|
||||||
|
///
|
||||||
|
/// [`next_hop`]'s own breadth-first walk, counting instead of naming: `Some(0)` when the fly is
|
||||||
|
/// already on `to`, `Some(1)` for a door out of this map into it, and `None` for a map the table
|
||||||
|
/// cannot route to -- which is the same "nothing is guessed" [`next_hop`] answers with.
|
||||||
|
///
|
||||||
|
/// What it is *for* is the ratchet (`docs/design/ladder.md`, the 2026-09-17 progress rule): the
|
||||||
|
/// stall window is reset by exploration, and a fly crossing a town it has already covered to
|
||||||
|
/// reach the rung's own door earns no new ground while it does it. "Nearer the objective than
|
||||||
|
/// this run has ever been" is the other thing that is plainly progress, and it is this number
|
||||||
|
/// falling. Nothing about the *choice* reads it: no macro is ranked by it and no button is bound
|
||||||
|
/// on it.
|
||||||
|
pub fn hops(from: Region, to: u8) -> Option<u32> {
|
||||||
|
if from.map == to {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
let mut seen: HashSet<Region> = HashSet::from([from]);
|
||||||
|
let mut queue: VecDeque<(Region, u32)> = VecDeque::new();
|
||||||
|
queue.push_back((from, 0));
|
||||||
|
while let Some((region, depth)) = queue.pop_front() {
|
||||||
|
if region.map == to {
|
||||||
|
return Some(depth);
|
||||||
|
}
|
||||||
|
for next in region_neighbours(region) {
|
||||||
|
if seen.insert(next) {
|
||||||
|
queue.push_back((next, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// A building an area has at most one of, and which this run may not have been into yet.
|
/// A building an area has at most one of, and which this run may not have been into yet.
|
||||||
///
|
///
|
||||||
/// `docs/design/macros.md` section 13: the two errands. A kind rather than two parallel tables
|
/// `docs/design/macros.md` section 13: the two errands. A kind rather than two parallel tables
|
||||||
|
|
@ -551,6 +592,68 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_museums_two_floors_know_the_road_to_the_gym() {
|
||||||
|
// The rung-10 loop of 2026-09-22: five and a half hours on `PEWTER_CITY` with the
|
||||||
|
// objective two doors away, and inside the museum `GO OBJECTIVE` was off the pad because
|
||||||
|
// the map had no row here at all. Both floors, because the fly spent the run on both.
|
||||||
|
let at = |map: u8| Region::whole(map);
|
||||||
|
assert_eq!(
|
||||||
|
next_hop(at(maps::PEWTER_MUSEUM_1F), maps::PEWTER_GYM),
|
||||||
|
Some(maps::PEWTER_CITY),
|
||||||
|
"the way to the gym from the museum's ground floor is out of its front door"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
next_hop(at(maps::PEWTER_MUSEUM_2F), maps::PEWTER_GYM),
|
||||||
|
Some(maps::PEWTER_MUSEUM_1F),
|
||||||
|
"and from the upper floor it is the staircase"
|
||||||
|
);
|
||||||
|
assert_eq!(next_hop(at(maps::PEWTER_CITY), maps::PEWTER_GYM), Some(maps::PEWTER_GYM));
|
||||||
|
// The other direction, which is what `GO OBJECTIVE` asks when the errand is the museum's
|
||||||
|
// own town: the museum is one hop from Pewter City and two from its upper floor.
|
||||||
|
assert_eq!(
|
||||||
|
next_hop(at(maps::PEWTER_CITY), maps::PEWTER_MUSEUM_2F),
|
||||||
|
Some(maps::PEWTER_MUSEUM_1F)
|
||||||
|
);
|
||||||
|
// And the area, which is what puts the town's errands on the pad indoors. The upper
|
||||||
|
// floor has no area, exactly as `REDS_HOUSE_2F` has none: a floor with no front door of
|
||||||
|
// its own is not "in" anywhere, so it offers no errand -- and the road out is still the
|
||||||
|
// staircase above, which is what the fly needs there.
|
||||||
|
assert_eq!(area_of(maps::PEWTER_MUSEUM_1F), Some(maps::PEWTER_CITY));
|
||||||
|
assert_eq!(area_of(maps::PEWTER_MUSEUM_2F), None);
|
||||||
|
// The museum is neither a mart nor a centre, so it is nobody's errand.
|
||||||
|
assert_eq!(amenity_at(maps::PEWTER_MUSEUM_1F), None);
|
||||||
|
assert_eq!(amenity_at(maps::PEWTER_MUSEUM_2F), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hop_count_is_the_road_measured_rather_than_named() {
|
||||||
|
let at = |map: u8| Region::whole(map);
|
||||||
|
assert_eq!(hops(at(maps::PEWTER_CITY), maps::PEWTER_CITY), Some(0), "already there");
|
||||||
|
assert_eq!(hops(at(maps::PEWTER_CITY), maps::PEWTER_GYM), Some(1), "one door");
|
||||||
|
assert_eq!(hops(at(maps::PEWTER_MUSEUM_1F), maps::PEWTER_GYM), Some(2));
|
||||||
|
assert_eq!(hops(at(maps::PEWTER_MUSEUM_2F), maps::PEWTER_GYM), Some(3));
|
||||||
|
// The count agrees with the hop by hop answer, which is the thing it has to: walking the
|
||||||
|
// road one `next_hop` at a time takes exactly this many steps.
|
||||||
|
let mut here = at(maps::PEWTER_MUSEUM_2F);
|
||||||
|
let mut steps = 0;
|
||||||
|
while let Some(hop) = next_hop(here, maps::PEWTER_GYM) {
|
||||||
|
here = Region::whole(hop);
|
||||||
|
steps += 1;
|
||||||
|
assert!(steps < 10, "the road to the gym does not wander");
|
||||||
|
}
|
||||||
|
assert_eq!(here.map, maps::PEWTER_GYM);
|
||||||
|
assert_eq!(steps, 3);
|
||||||
|
// A split map is measured from the piece the fly is standing in, exactly as `next_hop` is.
|
||||||
|
assert_eq!(hops(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(1));
|
||||||
|
// Five from the south half, because the belt of trees between the halves needs CUT and
|
||||||
|
// the road is the forest: the south gate, the forest, the north gate, Route 2's north
|
||||||
|
// half, Pewter.
|
||||||
|
assert_eq!(hops(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), Some(5));
|
||||||
|
// And nothing is guessed.
|
||||||
|
assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_maps_area_is_its_town_indoors_and_out() {
|
fn a_maps_area_is_its_town_indoors_and_out() {
|
||||||
// Outdoors a map is its own area, including a route -- which has no amenity row, so it
|
// Outdoors a map is its own area, including a route -- which has no amenity row, so it
|
||||||
|
|
|
||||||
|
|
@ -476,7 +476,26 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
|
||||||
// Section 2 treats `Unknown` like a dialog -- advance only -- and `BACK` is the other half
|
// Section 2 treats `Unknown` like a dialog -- advance only -- and `BACK` is the other half
|
||||||
// of advancing, because `Unknown` is also where the Pokédex, the trainer card and OPTION
|
// of advancing, because `Unknown` is also where the Pokédex, the trainer card and OPTION
|
||||||
// land (`docs/design/macros-wram.md`) and B is what leaves all three.
|
// land (`docs/design/macros-wram.md`) and B is what leaves all three.
|
||||||
Scene::Unknown => vec![Next, Back],
|
//
|
||||||
|
// **Only while there is something on screen with words in it** (section 12.13, the
|
||||||
|
// rung-10 Pewter loop). `Unknown` is the detector's residue and it holds two states, not
|
||||||
|
// one: a screen this crate cannot name, where A and B are what leave it, and a frame of
|
||||||
|
// the *overworld* where the cartridge is driving -- a warp in flight, a scripted
|
||||||
|
// push-back, the museum guide walking the fly through the door -- which
|
||||||
|
// `scene::detect` calls `Unknown` because the buttons are not reaching the player. On the
|
||||||
|
// second, `NEXT` and `BACK` are an A and a B pressed into somebody else's script: they
|
||||||
|
// change nothing, they complete where the fly stands, and they are section 12.2's trap
|
||||||
|
// with no text box to advance. Measured live on rung 10: `BACK` **678** macro starts in
|
||||||
|
// 47 minutes, 189 of them on map `0x02` with no box on screen at all. So the pad is
|
||||||
|
// empty there and the fly waits, which is the doctrine's own answer for a scene with
|
||||||
|
// nothing sensible to press -- and the cartridge gives the buttons back by itself.
|
||||||
|
Scene::Unknown => {
|
||||||
|
if state.text_open() {
|
||||||
|
vec![Next, Back]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
// `NEXT`, `YES`, `NO` for a plain box -- A and B both advance one, and what the three
|
// `NEXT`, `YES`, `NO` for a plain box -- A and B both advance one, and what the three
|
||||||
// buy is the fly being *able* to answer no. On the one box that **is** a choice, the pad
|
// buy is the fly being *able* to answer no. On the one box that **is** a choice, the pad
|
||||||
// is the choice's own answers: section 12.12.
|
// is the choice's own answers: section 12.12.
|
||||||
|
|
@ -1369,6 +1388,16 @@ pub fn frontier_aims(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
|
||||||
if counter_pending(state) {
|
if counter_pending(state) {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
// **A map whose frontier this run has already proved it cannot reach** (section 12.14, the
|
||||||
|
// rung-10 museum). The unstood tiles are still there and still unstood -- the exhibit hall
|
||||||
|
// behind the admission desk, the far side of a fence -- and a walk that could not reach any
|
||||||
|
// of them refuses `no route`, writes them all to the blocked ledger and comes back ten brain
|
||||||
|
// minutes later when the window lapses, for ever. The mark has no window; it is cleared by
|
||||||
|
// the fly standing somewhere on this map it had not stood before, which is the only thing
|
||||||
|
// that can have changed the answer.
|
||||||
|
if state.frontier_exhausted() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
let mut local: Vec<(Tile, Facing)> = Vec::new();
|
let mut local: Vec<(Tile, Facing)> = Vec::new();
|
||||||
for (tile, facing) in path::frontier(state) {
|
for (tile, facing) in path::frontier(state) {
|
||||||
// A tile the blocked ledger is resting, or one a script pushes the fly off (row 37).
|
// A tile the blocked ledger is resting, or one a script pushes the fly off (row 37).
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,11 @@ struct World {
|
||||||
areas: BTreeSet<(Amenity, u8)>,
|
areas: BTreeSet<(Amenity, u8)>,
|
||||||
/// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37).
|
/// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37).
|
||||||
pushes: BTreeSet<Tile>,
|
pushes: BTreeSet<Tile>,
|
||||||
|
/// Maps a `GO FRONTIER` has proved it cannot reach the frontier of (section 12.14).
|
||||||
|
///
|
||||||
|
/// Written by [`drive`] from the machine, exactly as `PokemonPalette` writes it in the sim
|
||||||
|
/// loop, so a test sees the ledger the next decision would see.
|
||||||
|
exhausted: BTreeSet<u8>,
|
||||||
visited: BTreeSet<ExitId>,
|
visited: BTreeSet<ExitId>,
|
||||||
/// Tiles of this map the run has stood on, for `GO FRONTIER` and the plan's "untalked" test.
|
/// Tiles of this map the run has stood on, for `GO FRONTIER` and the plan's "untalked" test.
|
||||||
stood: BTreeSet<Tile>,
|
stood: BTreeSet<Tile>,
|
||||||
|
|
@ -164,6 +169,13 @@ struct World {
|
||||||
/// A frame at which the cartridge heals the party, which is what a Pokémon Center does while
|
/// A frame at which the cartridge heals the party, which is what a Pokémon Center does while
|
||||||
/// its text box is open (`docs/design/macros.md` section 13).
|
/// its text box is open (`docs/design/macros.md` section 13).
|
||||||
heal_at: Option<u32>,
|
heal_at: Option<u32>,
|
||||||
|
/// Whether a text box is drawn on a scene that is not [`Scene::Dialog`]
|
||||||
|
/// ([`MacroState::text_open`]).
|
||||||
|
///
|
||||||
|
/// `Dialog` *is* an open box, so the reading is true there by construction; the field is for
|
||||||
|
/// `Unknown`, which holds both a screen with words on it -- the Pokedex, the trainer card,
|
||||||
|
/// OPTION -- and a frame of the overworld the cartridge is driving (section 12.13).
|
||||||
|
box_open: bool,
|
||||||
/// Whether the two-option YES/NO box is the thing on screen ([`MacroState::yes_no_prompt`]).
|
/// Whether the two-option YES/NO box is the thing on screen ([`MacroState::yes_no_prompt`]).
|
||||||
///
|
///
|
||||||
/// A field rather than a shape of the `list`, because on the cartridge it is a *drawn box*
|
/// A field rather than a shape of the `list`, because on the cartridge it is a *drawn box*
|
||||||
|
|
@ -225,6 +237,7 @@ impl World {
|
||||||
counters: BTreeSet::new(),
|
counters: BTreeSet::new(),
|
||||||
areas: BTreeSet::new(),
|
areas: BTreeSet::new(),
|
||||||
pushes: BTreeSet::new(),
|
pushes: BTreeSet::new(),
|
||||||
|
exhausted: BTreeSet::new(),
|
||||||
stock: Vec::new(),
|
stock: Vec::new(),
|
||||||
visited: BTreeSet::new(),
|
visited: BTreeSet::new(),
|
||||||
stood: BTreeSet::new(),
|
stood: BTreeSet::new(),
|
||||||
|
|
@ -243,6 +256,7 @@ impl World {
|
||||||
pending: None,
|
pending: None,
|
||||||
switch: None,
|
switch: None,
|
||||||
heal_at: None,
|
heal_at: None,
|
||||||
|
box_open: false,
|
||||||
prompt: false,
|
prompt: false,
|
||||||
scripted: false,
|
scripted: false,
|
||||||
scripted_at: None,
|
scripted_at: None,
|
||||||
|
|
@ -658,6 +672,17 @@ impl MacroState for World {
|
||||||
self.scripted
|
self.scripted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `wFontLoaded` is set for every dialogue box, which is what `Dialog` is; on `Unknown` the
|
||||||
|
/// fixture has to say, because that is the reading that tells a screen from a scripted
|
||||||
|
/// overworld frame (section 12.13).
|
||||||
|
fn text_open(&mut self) -> bool {
|
||||||
|
self.scene == Scene::Dialog || self.box_open
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frontier_exhausted(&mut self) -> bool {
|
||||||
|
self.exhausted.contains(&self.map)
|
||||||
|
}
|
||||||
|
|
||||||
/// A drawn box is what the reading rests on, so a prompt cannot be open with no box open:
|
/// A drawn box is what the reading rests on, so a prompt cannot be open with no box open:
|
||||||
/// `pokemon_red::state::yes_no_prompt` gates on `wFontLoaded` before it looks at the tiles.
|
/// `pokemon_red::state::yes_no_prompt` gates on `wFontLoaded` before it looks at the tiles.
|
||||||
fn yes_no_prompt(&mut self) -> bool {
|
fn yes_no_prompt(&mut self) -> bool {
|
||||||
|
|
@ -758,6 +783,9 @@ fn drive(
|
||||||
while let Some((map, target)) = machine.take_blocked() {
|
while let Some((map, target)) = machine.take_blocked() {
|
||||||
world.targets.record_blocked(map, target);
|
world.targets.record_blocked(map, target);
|
||||||
}
|
}
|
||||||
|
if let Some(map) = machine.take_exhausted() {
|
||||||
|
world.exhausted.insert(map);
|
||||||
|
}
|
||||||
return Err(refused);
|
return Err(refused);
|
||||||
}
|
}
|
||||||
// A walk's cap is its plan's, so the bound here is the ceiling on any macro plus slack.
|
// A walk's cap is its plan's, so the bound here is the ceiling on any macro plus slack.
|
||||||
|
|
@ -779,6 +807,9 @@ fn drive(
|
||||||
while let Some((map, target)) = machine.take_blocked() {
|
while let Some((map, target)) = machine.take_blocked() {
|
||||||
world.targets.record_blocked(map, target);
|
world.targets.record_blocked(map, target);
|
||||||
}
|
}
|
||||||
|
if let Some(map) = machine.take_exhausted() {
|
||||||
|
world.exhausted.insert(map);
|
||||||
|
}
|
||||||
if let Some((map, target, closer)) = machine.take_timeout() {
|
if let Some((map, target, closer)) = machine.take_timeout() {
|
||||||
world.targets.record_timeout(map, target, closer);
|
world.targets.record_timeout(map, target, closer);
|
||||||
}
|
}
|
||||||
|
|
@ -990,12 +1021,31 @@ fn the_dialog_row_is_next_yes_and_no() {
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_scene_is_dialog_with_advance_only() {
|
fn an_unknown_scene_is_dialog_with_advance_only() {
|
||||||
let mut world = World::room();
|
let mut world = World::room();
|
||||||
|
world.box_open = true;
|
||||||
let palette = Palette::for_scene(Scene::Unknown, &mut world);
|
let palette = Palette::for_scene(Scene::Unknown, &mut world);
|
||||||
// Row 9 of `infra/docs/macros-traps.md`, closed by section 13.1: B is what leaves the Pokédex,
|
// Row 9 of `infra/docs/macros-traps.md`, closed by section 13.1: B is what leaves the Pokédex,
|
||||||
// the trainer card and OPTION, and all three read `Unknown`.
|
// the trainer card and OPTION, and all three read `Unknown` with a box drawn.
|
||||||
assert_eq!(names(&palette), ["NEXT", "BACK"]);
|
assert_eq!(names(&palette), ["NEXT", "BACK"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_frame_with_no_box_on_it_deals_nothing() {
|
||||||
|
// Section 12.13, the rung-10 Pewter loop. The other half of `Unknown` is the overworld with
|
||||||
|
// the cartridge driving -- a warp in flight, a push-back, the museum guide walking the fly in
|
||||||
|
// -- where `scene::detect` falls through because the buttons are not reaching the player.
|
||||||
|
// There is no box to advance and no screen to leave, so `NEXT` and `BACK` are an A and a B
|
||||||
|
// pressed into somebody else's script: they change nothing and they complete where the fly
|
||||||
|
// stands, which is section 12.2's trap. The pad is empty and the fly waits.
|
||||||
|
let mut world = World::room();
|
||||||
|
world.scripted = true;
|
||||||
|
assert!(!world.text_open());
|
||||||
|
let palette = Palette::for_scene(Scene::Unknown, &mut world);
|
||||||
|
assert_eq!(palette.bound(), 0, "an A and a B into a script are not buttons");
|
||||||
|
// And the moment the cartridge draws something, both are back.
|
||||||
|
world.box_open = true;
|
||||||
|
assert_eq!(names(&Palette::for_scene(Scene::Unknown, &mut world)), ["NEXT", "BACK"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_menu_row_is_close_confirm_back() {
|
fn the_menu_row_is_close_confirm_back() {
|
||||||
let mut world = World::room();
|
let mut world = World::room();
|
||||||
|
|
@ -2525,6 +2575,7 @@ fn a_forced_switch_plans_one_entry_and_the_other_scenes_plan_their_one_move() {
|
||||||
] {
|
] {
|
||||||
let mut world = World::room();
|
let mut world = World::room();
|
||||||
world.scene = scene;
|
world.scene = scene;
|
||||||
|
world.box_open = scene == Scene::Unknown;
|
||||||
assert_eq!(plan(&mut world), entries, "{}", scene.label());
|
assert_eq!(plan(&mut world), entries, "{}", scene.label());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3139,6 +3190,10 @@ fn no_playable_scene_deals_an_empty_pad() {
|
||||||
] {
|
] {
|
||||||
let mut world = World::room();
|
let mut world = World::room();
|
||||||
world.scene = scene;
|
world.scene = scene;
|
||||||
|
// `Unknown` is dealt on what is drawn (section 12.13): a screen with words on it has
|
||||||
|
// `NEXT` and `BACK`, and a scripted overworld frame is the one deliberate empty pad,
|
||||||
|
// which `an_unknown_frame_with_no_box_on_it_deals_nothing` is about.
|
||||||
|
world.box_open = scene == Scene::Unknown;
|
||||||
world.battle = matches!(scene, Scene::Battle { .. })
|
world.battle = matches!(scene, Scene::Battle { .. })
|
||||||
.then_some((BattleKind::Wild, false, false));
|
.then_some((BattleKind::Wild, false, false));
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -3154,6 +3209,109 @@ fn no_playable_scene_deals_an_empty_pad() {
|
||||||
assert_eq!(plan::plan_for(Scene::Title, &mut title).bound(), 0);
|
assert_eq!(plan::plan_for(Scene::Title, &mut title).bound(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_badges_rung_resolves_to_the_person_standing_in_the_gym() {
|
||||||
|
// Rung 11 is BOULDER BADGE and `docs/design/ladder.md` gives its place as a *person* in the
|
||||||
|
// Pewter gym (12.5's `PlaceKind`). What the rung-10 loop needed was the two halves of that
|
||||||
|
// working together: the road into map `0x36` (`geography`, the museum rows and the gym's
|
||||||
|
// own), and, once inside, the objective naming somebody to walk to rather than a map to be
|
||||||
|
// on. This is the second half, on the map the rung is earned on.
|
||||||
|
let mut world = World::room();
|
||||||
|
world.map = maps::PEWTER_GYM;
|
||||||
|
// Both of Pewter's errands discharged. Section 13 puts an unvisited mart or centre *ahead*
|
||||||
|
// of the rung's place, and the gym is inside Pewter's area like everything else in the town,
|
||||||
|
// so until they are paid the objective is a building and not the leader. That is the errand
|
||||||
|
// working, and it is why the ROM run below spends its first minutes in the town's shops.
|
||||||
|
world.areas.insert((Amenity::Mart, maps::PEWTER_CITY));
|
||||||
|
world.areas.insert((Amenity::Center, maps::PEWTER_CITY));
|
||||||
|
world.npcs = vec![Npc { slot: 1, picture: 0x05, x: 4, y: 2, facing: Facing::Down }];
|
||||||
|
world.objective = Some(Objective {
|
||||||
|
map: maps::PEWTER_GYM,
|
||||||
|
tile: None,
|
||||||
|
warp: None,
|
||||||
|
edge: None,
|
||||||
|
target: Some(PlaceKind::Person),
|
||||||
|
});
|
||||||
|
let targets = super::palette::objective_targets(&mut world);
|
||||||
|
assert_eq!(targets.len(), 1, "the person the rung names: {targets:?}");
|
||||||
|
assert_eq!(targets[0].0, Tile::new(4, 2));
|
||||||
|
// And the walk aims at standing beside them and turning to face them, which is `GO NPC`'s own
|
||||||
|
// arrival and all `GO OBJECTIVE` ever promises: the press is `TALK`'s and the fly's.
|
||||||
|
let goals: Vec<Tile> = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect();
|
||||||
|
assert!(goals.contains(&Tile::new(4, 3)), "a tile beside them: {goals:?}");
|
||||||
|
assert!(goals.iter().all(|tile| tile.distance(Tile::new(4, 2)) == 1), "{goals:?}");
|
||||||
|
// The room the rung is in is not left while the thing that earns it is standing in it (12.5).
|
||||||
|
assert!(ways(&mut world, Way::Exit).is_empty(), "the gym's door is not a candidate yet");
|
||||||
|
// Talked to, and the objective has nothing left here: the button leaves the pad and the ways
|
||||||
|
// out come back, which is what carries the run on to the next rung.
|
||||||
|
world.talked.insert(targets[0].1);
|
||||||
|
assert!(super::palette::objective_targets(&mut world).is_empty());
|
||||||
|
assert!(objective_goals(&mut world).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frontier_no_walk_can_reach_takes_go_frontier_off_the_pad_and_keeps_it_off() {
|
||||||
|
// Section 12.14, the rung-10 museum. The sealed pocket below is map `0x34` in miniature: the
|
||||||
|
// unstood ground is real and it is fenced off, so `GO FRONTIER` refuses `no route` and writes
|
||||||
|
// every tile it could not reach to the blocked ledger -- which is a *window*. Before this the
|
||||||
|
// window lapsed after ten brain minutes and all of it was a candidate again: 1,235 starts in
|
||||||
|
// 47 minutes over two museum floors and a town. The mark has no window.
|
||||||
|
let mut world = World::room();
|
||||||
|
for y in 0..8 {
|
||||||
|
for x in 0..8 {
|
||||||
|
world.stood.insert(Tile::new(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
world.stood.remove(&Tile::new(7, 7));
|
||||||
|
world.walls.insert(Tile::new(6, 7));
|
||||||
|
world.walls.insert(Tile::new(6, 6));
|
||||||
|
world.walls.insert(Tile::new(7, 5));
|
||||||
|
world.player = Tile::new(0, 0);
|
||||||
|
assert!(!super::palette::frontier_aims(&mut world).is_empty(), "the pocket is a frontier");
|
||||||
|
assert!(precondition(MacroKind::GoFrontier, &mut world), "so the button is on the pad");
|
||||||
|
|
||||||
|
let refused = run(&mut world, MacroKind::GoFrontier).expect_err("the pocket is sealed");
|
||||||
|
assert_eq!(refused.reason, Refusal::NoRoute);
|
||||||
|
assert!(world.exhausted.contains(&world.map), "the map is marked: {:?}", world.exhausted);
|
||||||
|
assert!(super::palette::frontier_aims(&mut world).is_empty(), "nothing left to aim at");
|
||||||
|
assert!(!precondition(MacroKind::GoFrontier, &mut world), "and the button is off the pad");
|
||||||
|
|
||||||
|
// Ten brain minutes later the blocked window has lapsed and every tile of the pocket is a
|
||||||
|
// candidate again -- and the button is still off the pad, because the ground has not moved.
|
||||||
|
world.targets.clock(11.0 * 60_000.0);
|
||||||
|
assert!(!world.targets.blocked(world.map, TargetKey::Tile(Tile::new(7, 6))));
|
||||||
|
assert!(
|
||||||
|
super::palette::frontier_aims(&mut world).is_empty(),
|
||||||
|
"the mark is not a window"
|
||||||
|
);
|
||||||
|
// A map the fly walks to instead is untouched: the mark is one map's.
|
||||||
|
world.map = 0x35;
|
||||||
|
assert!(!super::palette::frontier_aims(&mut world).is_empty(), "another map is its own");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frontier_mark_is_the_stood_ledgers_to_clear() {
|
||||||
|
// The two halves of the rule as the types have them: the stood ledger answers "this is
|
||||||
|
// ground the run had not stood on", which is the only event that can change which tiles the
|
||||||
|
// fly can reach, and that answer is what clears the map's mark.
|
||||||
|
let mut stood = super::cartridge::Stood::default();
|
||||||
|
assert!(stood.record(2, Tile::new(4, 4)), "the first time is new ground");
|
||||||
|
assert!(!stood.record(2, Tile::new(4, 4)), "the second time is not");
|
||||||
|
assert!(stood.record(0x34, Tile::new(4, 4)), "and a tile is a tile of one map");
|
||||||
|
|
||||||
|
use super::cartridge::FrontierLedger;
|
||||||
|
let mut frontiers = super::cartridge::Frontiers::default();
|
||||||
|
assert!(!frontiers.frontier_exhausted(0x34));
|
||||||
|
frontiers.record(0x34);
|
||||||
|
frontiers.record(0x34);
|
||||||
|
assert!(frontiers.frontier_exhausted(0x34), "idempotent");
|
||||||
|
assert!(!frontiers.frontier_exhausted(0x35), "and one map's");
|
||||||
|
assert_eq!(frontiers.len(), 1);
|
||||||
|
frontiers.clear(0x34);
|
||||||
|
assert!(!frontiers.frontier_exhausted(0x34));
|
||||||
|
assert!(frontiers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_walk_that_could_only_get_closer_now_excludes_what_it_cannot_reach() {
|
fn a_walk_that_could_only_get_closer_now_excludes_what_it_cannot_reach() {
|
||||||
// Two readings of the same pocket, in the order they were measured
|
// Two readings of the same pocket, in the order they were measured
|
||||||
|
|
@ -4087,6 +4245,9 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
|
||||||
] {
|
] {
|
||||||
let mut world = World::room();
|
let mut world = World::room();
|
||||||
world.scene = scene;
|
world.scene = scene;
|
||||||
|
// See the sweep in `no_playable_scene_deals_an_empty_pad`: `Unknown` with nothing drawn
|
||||||
|
// on it is the overworld being driven by the cartridge, and its pad is empty by design.
|
||||||
|
world.box_open = scene == Scene::Unknown;
|
||||||
world.mons.clear();
|
world.mons.clear();
|
||||||
worst(&mut world);
|
worst(&mut world);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@
|
||||||
//! `PEWTER_MART` `$38` and `PEWTER_POKECENTER` `$3a` sit in the five ids between `PEWTER_GYM`
|
//! `PEWTER_MART` `$38` and `PEWTER_POKECENTER` `$3a` sit in the five ids between `PEWTER_GYM`
|
||||||
//! `$36` and `MT_MOON_1F` `$3b`; `CERULEAN_POKECENTER` `$40` and `CERULEAN_MART` `$43` sit around
|
//! `$36` and `MT_MOON_1F` `$3b`; `CERULEAN_POKECENTER` `$40` and `CERULEAN_MART` `$43` sit around
|
||||||
//! `CERULEAN_GYM` `$41`. [`super::tests`] pins each against its neighbour rather than on its own.
|
//! `CERULEAN_GYM` `$41`. [`super::tests`] pins each against its neighbour rather than on its own.
|
||||||
|
//!
|
||||||
|
//! **2026-09-22, the rung-10 museum.** `PEWTER_MUSEUM_1F` `$34` and `PEWTER_MUSEUM_2F` `$35` are
|
||||||
|
//! the two ids between `VIRIDIAN_FOREST` `$33` and `PEWTER_GYM` `$36`, and both are confirmed by
|
||||||
|
//! the cartridge rather than by counting: Pewter City's warp table names `$34` at (14, 7) and at
|
||||||
|
//! (19, 5) -- the museum's two doors -- and `$34`'s own warp at (7, 7) names `$35`, which is the
|
||||||
|
//! staircase. Surveyed from the release container's rung-10 checkpoint
|
||||||
|
//! (`infra/docs/macros-traps.md`, 2026-09-22).
|
||||||
|
|
||||||
// Outdoors: `$00`..`$24`.
|
// Outdoors: `$00`..`$24`.
|
||||||
pub const PALLET_TOWN: u8 = 0x00;
|
pub const PALLET_TOWN: u8 = 0x00;
|
||||||
|
|
@ -68,6 +75,8 @@ pub const VIRIDIAN_GYM: u8 = 0x2d;
|
||||||
pub const VIRIDIAN_FOREST_NORTH_GATE: u8 = 0x2f;
|
pub const VIRIDIAN_FOREST_NORTH_GATE: u8 = 0x2f;
|
||||||
pub const VIRIDIAN_FOREST_SOUTH_GATE: u8 = 0x32;
|
pub const VIRIDIAN_FOREST_SOUTH_GATE: u8 = 0x32;
|
||||||
pub const VIRIDIAN_FOREST: u8 = 0x33;
|
pub const VIRIDIAN_FOREST: u8 = 0x33;
|
||||||
|
pub const PEWTER_MUSEUM_1F: u8 = 0x34;
|
||||||
|
pub const PEWTER_MUSEUM_2F: u8 = 0x35;
|
||||||
pub const PEWTER_GYM: u8 = 0x36;
|
pub const PEWTER_GYM: u8 = 0x36;
|
||||||
pub const PEWTER_MART: u8 = 0x38;
|
pub const PEWTER_MART: u8 = 0x38;
|
||||||
pub const PEWTER_POKECENTER: u8 = 0x3a;
|
pub const PEWTER_POKECENTER: u8 = 0x3a;
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,9 @@ use crate::adapter::{MapEdge, MapExit, MapTile, MemoryReader};
|
||||||
use crate::macros::{RunLedger, NoLedger};
|
use crate::macros::{RunLedger, NoLedger};
|
||||||
|
|
||||||
use super::macros::cartridge::{
|
use super::macros::cartridge::{
|
||||||
AreaLedger, Edge, ExitId, MacroState, NoAreas, NoPushed, NoStood, NoTalk, NoTargets,
|
AreaLedger, Edge, ExitId, FrontierLedger, MacroState, NoAreas, NoFrontiers, NoPushed, NoStood,
|
||||||
Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile,
|
NoTalk, NoTargets, Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey,
|
||||||
|
TargetLedger, Tile,
|
||||||
};
|
};
|
||||||
use super::macros::geography::Amenity;
|
use super::macros::geography::Amenity;
|
||||||
use super::mapgrid::{self, MapGrids};
|
use super::mapgrid::{self, MapGrids};
|
||||||
|
|
@ -937,6 +938,33 @@ impl GridRefusal {
|
||||||
/// ([`GridRefusal::NoScreen`]) rather than trusted, because `wOverworldMap` shares its bytes with
|
/// ([`GridRefusal::NoScreen`]) rather than trusted, because `wOverworldMap` shares its bytes with
|
||||||
/// the picture buffer and a battle is exactly when the blocks under it are somebody else's.
|
/// the picture buffer and a battle is exactly when the blocks under it are somebody else's.
|
||||||
pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
|
pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
|
||||||
|
// The decode first, so a frame with no header answers `NoHeader` rather than whatever the
|
||||||
|
// player's coordinates happen to read as: the refusals are in the order they are checked.
|
||||||
|
let grid = map_grid_decode(memory)?;
|
||||||
|
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
|
||||||
|
// The cross-check. `map_tile_id` reads the screen buffer at the offset
|
||||||
|
// `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for
|
||||||
|
// is agreeing with the cartridge's own reading of the same ground.
|
||||||
|
let mut checked = 0;
|
||||||
|
for (x, y) in neighbourhood(player.x, player.y) {
|
||||||
|
let Some(screen) = map_tile_id(memory, x, y) else { continue };
|
||||||
|
if grid.tile_id(x, y) != Some(screen) {
|
||||||
|
return Err(GridRefusal::ScreenDisagrees);
|
||||||
|
}
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
if checked == 0 {
|
||||||
|
return Err(GridRefusal::NoScreen);
|
||||||
|
}
|
||||||
|
Ok(grid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`map_grid`] without the cross-check: the blocks, the blockset and the collision list, decoded.
|
||||||
|
///
|
||||||
|
/// Split out so [`grid_disagreement`] can say what the decode answered on a frame the check
|
||||||
|
/// refused. Nothing outside this module and the probes may use it: a grid that has not been
|
||||||
|
/// checked against the screen is exactly the reading section 15 refuses to trust.
|
||||||
|
fn map_grid_decode(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
|
||||||
let size = map_size(memory).ok_or(GridRefusal::NoHeader)?;
|
let size = map_size(memory).ok_or(GridRefusal::NoHeader)?;
|
||||||
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
|
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
|
||||||
let passable = collision_list(memory).ok_or(GridRefusal::NoCollisionList)?;
|
let passable = collision_list(memory).ok_or(GridRefusal::NoCollisionList)?;
|
||||||
|
|
@ -979,23 +1007,28 @@ pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
|
||||||
if grid.width() != size.width || grid.height() != size.height {
|
if grid.width() != size.width || grid.height() != size.height {
|
||||||
return Err(GridRefusal::NoHeader);
|
return Err(GridRefusal::NoHeader);
|
||||||
}
|
}
|
||||||
// The cross-check. `map_tile_id` reads the screen buffer at the offset
|
|
||||||
// `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for
|
|
||||||
// is agreeing with the cartridge's own reading of the same ground.
|
|
||||||
let mut checked = 0;
|
|
||||||
for (x, y) in neighbourhood(player.x, player.y) {
|
|
||||||
let Some(screen) = map_tile_id(memory, x, y) else { continue };
|
|
||||||
if grid.tile_id(x, y) != Some(screen) {
|
|
||||||
return Err(GridRefusal::ScreenDisagrees);
|
|
||||||
}
|
|
||||||
checked += 1;
|
|
||||||
}
|
|
||||||
if checked == 0 {
|
|
||||||
return Err(GridRefusal::NoScreen);
|
|
||||||
}
|
|
||||||
Ok(grid)
|
Ok(grid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The decode and the screen, tile by tile, for the five tiles [`map_grid`] cross-checks.
|
||||||
|
///
|
||||||
|
/// The diagnostic half of [`GridRefusal::ScreenDisagrees`]: the refusal says the two readings
|
||||||
|
/// disagree and this says *where* and *by how much*, which is the difference between "the grid is
|
||||||
|
/// off on this map" and "this frame was mid-warp". `(x, y, decoded, screen)`, with `None` for a
|
||||||
|
/// tile either reading cannot answer for. It decodes the map a second time rather than being
|
||||||
|
/// folded into [`map_grid`], because the check's job on the hot path is to refuse and this is only
|
||||||
|
/// ever asked by a probe.
|
||||||
|
pub fn grid_disagreement(memory: &mut dyn MemoryReader) -> Vec<(u8, u8, Option<u8>, Option<u8>)> {
|
||||||
|
let Some(player) = player(memory) else { return Vec::new() };
|
||||||
|
let grid = map_grid_decode(memory).ok();
|
||||||
|
neighbourhood(player.x, player.y)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(x, y)| {
|
||||||
|
(x, y, grid.as_ref().and_then(|grid| grid.tile_id(x, y)), map_tile_id(memory, x, y))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a cached grid is still the map that is loaded, checked from the tile the fly is on.
|
/// Whether a cached grid is still the map that is loaded, checked from the tile the fly is on.
|
||||||
///
|
///
|
||||||
/// The map id, the map header and the block data are written by different parts of a warp, so
|
/// The map id, the map header and the block data are written by different parts of a warp, so
|
||||||
|
|
@ -1164,6 +1197,10 @@ pub struct PokeState<'a> {
|
||||||
/// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a
|
/// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a
|
||||||
/// refcount instead of a map.
|
/// refcount instead of a map.
|
||||||
grids: Option<&'a mut MapGrids>,
|
grids: Option<&'a mut MapGrids>,
|
||||||
|
/// Which maps have proved their frontier unreachable (`docs/design/macros.md` section
|
||||||
|
/// 12.14). A builder rather than a constructor parameter, exactly as the grid cache is: the
|
||||||
|
/// sim loop passes one and everything else narrows to "nothing proved".
|
||||||
|
frontiers: &'a dyn FrontierLedger,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PokeState<'a> {
|
impl<'a> PokeState<'a> {
|
||||||
|
|
@ -1182,6 +1219,7 @@ impl<'a> PokeState<'a> {
|
||||||
areas: &NoAreas,
|
areas: &NoAreas,
|
||||||
pushed: &NoPushed,
|
pushed: &NoPushed,
|
||||||
grids: None,
|
grids: None,
|
||||||
|
frontiers: &NoFrontiers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1196,6 +1234,7 @@ impl<'a> PokeState<'a> {
|
||||||
areas: &NoAreas,
|
areas: &NoAreas,
|
||||||
pushed: &NoPushed,
|
pushed: &NoPushed,
|
||||||
grids: None,
|
grids: None,
|
||||||
|
frontiers: &NoFrontiers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1213,7 +1252,7 @@ impl<'a> PokeState<'a> {
|
||||||
areas: &'a dyn AreaLedger,
|
areas: &'a dyn AreaLedger,
|
||||||
pushed: &'a dyn PushedLedger,
|
pushed: &'a dyn PushedLedger,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None }
|
Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None, frontiers: &NoFrontiers }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keep the decoded map grid in `grids` instead of decoding it per question.
|
/// Keep the decoded map grid in `grids` instead of decoding it per question.
|
||||||
|
|
@ -1226,6 +1265,16 @@ impl<'a> PokeState<'a> {
|
||||||
self.grids = Some(grids);
|
self.grids = Some(grids);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Answer [`MacroState::frontier_exhausted`] from `frontiers` instead of "nothing proved".
|
||||||
|
///
|
||||||
|
/// A builder for the same reason the grid cache is one: it is the sim loop's own session
|
||||||
|
/// state ([`super::macros::driver::PokemonPalette`]) and every other caller -- the tests, the
|
||||||
|
/// probes, the ROM harnesses -- wants the narrowing.
|
||||||
|
pub fn with_frontiers(mut self, frontiers: &'a dyn FrontierLedger) -> Self {
|
||||||
|
self.frontiers = frontiers;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameState for PokeState<'_> {
|
impl GameState for PokeState<'_> {
|
||||||
|
|
@ -1310,6 +1359,14 @@ impl MacroState for PokeState<'_> {
|
||||||
!controllable(self.memory)
|
!controllable(self.memory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn text_open(&mut self) -> bool {
|
||||||
|
text_box(self.memory).open
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frontier_exhausted(&mut self) -> bool {
|
||||||
|
player(self.memory).is_some_and(|player| self.frontiers.frontier_exhausted(player.map))
|
||||||
|
}
|
||||||
|
|
||||||
fn yes_no_prompt(&mut self) -> bool {
|
fn yes_no_prompt(&mut self) -> bool {
|
||||||
yes_no_prompt(self.memory)
|
yes_no_prompt(self.memory)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,44 @@ impl Ratchet {
|
||||||
game_over: bool,
|
game_over: bool,
|
||||||
capture: F,
|
capture: F,
|
||||||
) -> bool
|
) -> bool
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Snapshot,
|
||||||
|
{
|
||||||
|
self.observe_with_progress(safe, rank, coverage, now, game_over, false, capture)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Ratchet::observe_with_game_over`] plus a progress signal the coverage figure cannot
|
||||||
|
/// carry.
|
||||||
|
///
|
||||||
|
/// `progressed` is the caller saying "something happened on this sample that is plainly
|
||||||
|
/// progress, and it is not a number that only grows". The stall window is restarted by it
|
||||||
|
/// exactly as a rise in `coverage` restarts it, and nothing else changes: no budget is
|
||||||
|
/// spent, no snapshot is taken and no trigger is skipped.
|
||||||
|
///
|
||||||
|
/// It exists because coverage is the *only* progress the window could see, and coverage is
|
||||||
|
/// ground never stood on. A fly crossing a town it has already covered to reach the rung's
|
||||||
|
/// own door is getting somewhere and earns none. Measured on rung 10, 2026-09-22: two
|
||||||
|
/// "Stuck" rollbacks inside half an hour, both on a fly that was walking, each one landing
|
||||||
|
/// it back where it had started. What the Pokemon loop passes here is "nearer the objective,
|
||||||
|
/// in map hops, than this run has ever been" (`docs/design/macros.md` section 12.15), which
|
||||||
|
/// can only fire as many times as the road is long.
|
||||||
|
///
|
||||||
|
/// Nothing in this module knows what the signal means, which is the same bargain `coverage`
|
||||||
|
/// is: the ladder, the map graph and the objective all belong to the adapter.
|
||||||
|
// One argument past clippy's seven, and the argument is the point: this is the whole of one
|
||||||
|
// sample. A struct for it would be a type that exists only to be destructured at the one
|
||||||
|
// call site the sim loop has, which is the same idiom `decoder.rs` and `lif.rs` take here.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn observe_with_progress<F>(
|
||||||
|
&mut self,
|
||||||
|
safe: bool,
|
||||||
|
rank: u64,
|
||||||
|
coverage: u64,
|
||||||
|
now: u64,
|
||||||
|
game_over: bool,
|
||||||
|
progressed: bool,
|
||||||
|
capture: F,
|
||||||
|
) -> bool
|
||||||
where
|
where
|
||||||
F: FnOnce() -> Snapshot,
|
F: FnOnce() -> Snapshot,
|
||||||
{
|
{
|
||||||
|
|
@ -200,7 +238,7 @@ impl Ratchet {
|
||||||
} else {
|
} else {
|
||||||
self.unsafe_since = None;
|
self.unsafe_since = None;
|
||||||
}
|
}
|
||||||
if coverage > self.state.coverage {
|
if coverage > self.state.coverage || progressed {
|
||||||
self.state.last_progress = now;
|
self.state.last_progress = now;
|
||||||
}
|
}
|
||||||
self.state.coverage = self.state.coverage.max(coverage);
|
self.state.coverage = self.state.coverage.max(coverage);
|
||||||
|
|
@ -458,4 +496,51 @@ mod tests {
|
||||||
assert!(!ratchet.observe(true, 1, 2, 230_000, || snapshot(1)));
|
assert!(!ratchet.observe(true, 1, 2, 230_000, || snapshot(1)));
|
||||||
assert!(ratchet.observe(true, 1, 2, 239_001, || snapshot(1)));
|
assert!(ratchet.observe(true, 1, 2, 239_001, || snapshot(1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_map_the_run_has_never_stood_on_is_coverage_and_resets_the_window() {
|
||||||
|
// The half of the 2026-09-17 rule that already held, pinned because the other half is
|
||||||
|
// new: entering a map for the first time is ground never stood on, so it arrives here as
|
||||||
|
// a rise in `coverage` and needs nothing of its own. Re-entering a map the run has
|
||||||
|
// covered is not, which is exactly the rung-10 museum and why the signal below exists.
|
||||||
|
let mut ratchet = Ratchet::new();
|
||||||
|
ratchet.observe(true, 1, 100, 0, || snapshot(1));
|
||||||
|
// A new map: eighty tiles nobody had stood on.
|
||||||
|
assert!(!ratchet.observe(true, 1, 180, 100_000, || snapshot(1)));
|
||||||
|
// Walking it again for two minutes adds none, and the window ages from the arrival.
|
||||||
|
assert!(!ratchet.observe(true, 1, 180, 219_000, || snapshot(1)));
|
||||||
|
assert!(ratchet.observe(true, 1, 180, 220_001, || snapshot(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn getting_nearer_the_objective_resets_the_stall_window_without_spending_anything() {
|
||||||
|
// The rung-10 rollbacks of 2026-09-22: a fly walking a town it has already covered
|
||||||
|
// toward the rung's own door earns no coverage while it does it, and the window aged out
|
||||||
|
// twice in half an hour. A caller's progress signal restarts the window exactly as
|
||||||
|
// coverage does -- and does nothing else.
|
||||||
|
let mut ratchet = Ratchet::new();
|
||||||
|
ratchet.observe(true, 1, 100, 0, || snapshot(1));
|
||||||
|
// 119 s of walking ground the run has covered, and then one step nearer the gym.
|
||||||
|
assert!(!ratchet.observe_with_progress(true, 1, 100, 119_000, false, true, || snapshot(1)));
|
||||||
|
let state = ratchet.state;
|
||||||
|
assert_eq!(state.last_progress, 119_000);
|
||||||
|
assert_eq!((state.recoveries, state.attempts), (0, 0), "progress spends no budget");
|
||||||
|
// The window now runs from there rather than from the start.
|
||||||
|
assert!(!ratchet.observe(true, 1, 100, 238_000, || snapshot(1)));
|
||||||
|
assert!(ratchet.observe(true, 1, 100, 239_001, || snapshot(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_progress_signal_cannot_conjure_a_recovery_or_skip_a_trigger() {
|
||||||
|
// It restarts the window; it is not a trigger and it is not a snapshot. With nothing
|
||||||
|
// archived there is nothing to recover to, and an unsafe sample still never recovers.
|
||||||
|
let mut ratchet = Ratchet::new();
|
||||||
|
assert!(!ratchet.observe_with_progress(true, 0, 0, 0, false, true, || snapshot(1)));
|
||||||
|
assert!(ratchet.snapshot.is_none(), "progress captures nothing");
|
||||||
|
let mut armed = Ratchet::new();
|
||||||
|
armed.observe(true, 1, 1, 0, || snapshot(1));
|
||||||
|
// Unsafe, stalled, and told that something progressed: still no recovery, because an
|
||||||
|
// unsafe sample would restore on top of whatever made it unsafe.
|
||||||
|
assert!(!armed.observe_with_progress(false, 1, 1, 400_000, false, true, || snapshot(1)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@
|
||||||
//! | `FLY_PROBE_FRAMES` | 200000 | frames to drive before giving up |
|
//! | `FLY_PROBE_FRAMES` | 200000 | frames to drive before giving up |
|
||||||
//! | `FLY_PROBE_STUCK` | 600 | consecutive frames in one non-overworld scene that count as stuck |
|
//! | `FLY_PROBE_STUCK` | 600 | consecutive frames in one non-overworld scene that count as stuck |
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use flybrain_core::decoder::PopulationDecoder;
|
use flybrain_core::decoder::PopulationDecoder;
|
||||||
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
|
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
|
||||||
use flybrain_core::ordered::NumberMap;
|
use flybrain_core::ordered::NumberMap;
|
||||||
|
|
@ -161,6 +163,16 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
||||||
// same call `MacroState::map_grid` makes, and the only reading that can say *which* of section
|
// same call `MacroState::map_grid` makes, and the only reading that can say *which* of section
|
||||||
// 15's refusals a frame is.
|
// 15's refusals a frame is.
|
||||||
let refusal = flybrain_gb::pokemon_red::state::map_grid(gb).err();
|
let refusal = flybrain_gb::pokemon_red::state::map_grid(gb).err();
|
||||||
|
// Which tile the decode and the screen disagree about, when that is the refusal. The label
|
||||||
|
// alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the
|
||||||
|
// two want opposite fixes (`docs/design/macros.md` section 15). Read here, beside the refusal
|
||||||
|
// itself, because everything below holds a borrow of the emulator.
|
||||||
|
let disagreement = match refusal {
|
||||||
|
Some(flybrain_gb::pokemon_red::state::GridRefusal::ScreenDisagrees) => {
|
||||||
|
flybrain_gb::pokemon_red::state::grid_disagreement(gb)
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
let ledger = AdapterLedger(adapter);
|
let ledger = AdapterLedger(adapter);
|
||||||
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
||||||
let state: &mut dyn MacroState = &mut poke;
|
let state: &mut dyn MacroState = &mut poke;
|
||||||
|
|
@ -212,6 +224,22 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Which tile the decode and the screen disagree about, when that is the refusal. The label
|
||||||
|
// alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the
|
||||||
|
// two want opposite fixes (`docs/design/macros.md` section 15).
|
||||||
|
if !disagreement.is_empty() {
|
||||||
|
println!("- the decode against the screen, tile by tile:");
|
||||||
|
for (x, y, decoded, screen) in disagreement {
|
||||||
|
println!(
|
||||||
|
" - ({x:2}, {y:2}) decoded {decoded:?} screen {screen:?}{}",
|
||||||
|
if decoded.is_some() && screen.is_some() && decoded != screen {
|
||||||
|
" <- the disagreement"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The
|
// The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The
|
||||||
// session's own stood ledger (`docs/design/macros.md` section 12.7) is owned by the driver's
|
// session's own stood ledger (`docs/design/macros.md` section 12.7) is owned by the driver's
|
||||||
// palette, which this probe does not reach into, so a doormat the running fly has already
|
// palette, which this probe does not reach into, so a doormat the running fly has already
|
||||||
|
|
@ -710,6 +738,25 @@ fn main() {
|
||||||
&& surveyed >= 60
|
&& surveyed >= 60
|
||||||
{
|
{
|
||||||
println!("\nThe fly reached map {map:#04x} at frame {frame}.");
|
println!("\nThe fly reached map {map:#04x} at frame {frame}.");
|
||||||
|
// Stand still for a while and count how many of those frames the whole-map grid can be
|
||||||
|
// decoded on. A walking fly is mid-step on most frames -- `wYCoord` is the tile it is
|
||||||
|
// walking *to* while the background is still scrolling -- and the screen buffer the
|
||||||
|
// cross-check reads is the one that is a tile behind, so "is the grid refused on this
|
||||||
|
// map" and "is the grid refused while the fly is moving" are different questions with
|
||||||
|
// different fixes (`docs/design/macros.md` section 15).
|
||||||
|
let mut refusals: BTreeMap<&'static str, usize> = BTreeMap::new();
|
||||||
|
for _ in 0..env_usize("FLY_PROBE_SETTLE", 120) {
|
||||||
|
gb.set_buttons(flybrain_gb::buttons::NONE);
|
||||||
|
gb.run_frame().expect("a frame should complete");
|
||||||
|
ms += MS_PER_FRAME;
|
||||||
|
adapter.sample(&mut gb, ms);
|
||||||
|
let label = match flybrain_gb::pokemon_red::state::map_grid(&mut gb) {
|
||||||
|
Ok(_) => "decoded",
|
||||||
|
Err(refusal) => refusal.label(),
|
||||||
|
};
|
||||||
|
*refusals.entry(label).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
println!("\nStanding still on it, frame by frame: {refusals:?}");
|
||||||
cartridge(&mut gb, &adapter, "The save on the surveyed map");
|
cartridge(&mut gb, &adapter, "The save on the surveyed map");
|
||||||
pad(&mut gb, &adapter, "The pad on the surveyed map");
|
pad(&mut gb, &adapter, "The pad on the surveyed map");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,15 @@ impl MacroLayer {
|
||||||
/// The sim loop passes this to [`flybrain_core::decoder::PopulationDecoder::decode_bound`] on
|
/// The sim loop passes this to [`flybrain_core::decoder::PopulationDecoder::decode_bound`] on
|
||||||
/// the same frame, which is why it is the layer's to answer: the bindings are what the last
|
/// the same frame, which is why it is the layer's to answer: the bindings are what the last
|
||||||
/// `observe` dealt, and nothing else in the loop knows them.
|
/// `observe` dealt, and nothing else in the loop knows them.
|
||||||
|
/// Whether this frame saw the fly nearer its objective than the run has managed before.
|
||||||
|
///
|
||||||
|
/// Straight through from the palette ([`MacroPalette::nearer_the_objective`]), for the
|
||||||
|
/// ratchet's stall window and for nothing else: the macro layer neither reads it back nor
|
||||||
|
/// presses anything because of it.
|
||||||
|
pub fn nearer_the_objective(&self) -> bool {
|
||||||
|
self.palette.nearer_the_objective()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn bound_channels(&self) -> Vec<String> {
|
pub fn bound_channels(&self) -> Vec<String> {
|
||||||
self.bindings
|
self.bindings
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -1054,12 +1054,20 @@ impl Sim {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let recover = self.ratchet.observe_with_game_over(
|
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17
|
||||||
|
// rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a
|
||||||
|
// town it has already covered to reach the rung's own door earns none of it while it is
|
||||||
|
// plainly getting somewhere. The macro layer answers with the map graph it already walks
|
||||||
|
// (`docs/design/macros.md` section 12.15); in raw mode there is no layer and no
|
||||||
|
// objective, and the answer is false.
|
||||||
|
let nearer = self.macros.as_ref().is_some_and(MacroLayer::nearer_the_objective);
|
||||||
|
let recover = self.ratchet.observe_with_progress(
|
||||||
safe,
|
safe,
|
||||||
u64::from(progress.rank),
|
u64::from(progress.rank),
|
||||||
progress.unique_locations as u64,
|
progress.unique_locations as u64,
|
||||||
ms as u64,
|
ms as u64,
|
||||||
self.adapter.game_over(),
|
self.adapter.game_over(),
|
||||||
|
nearer,
|
||||||
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
|
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
|
||||||
);
|
);
|
||||||
if recover {
|
if recover {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,10 @@ const VIRIDIAN_FOREST: u32 = 0x33;
|
||||||
const PEWTER_POKECENTER: u32 = 0x3a;
|
const PEWTER_POKECENTER: u32 = 0x3a;
|
||||||
/// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside.
|
/// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside.
|
||||||
const MUSEUM_2F: u32 = 0x35;
|
const MUSEUM_2F: u32 = 0x35;
|
||||||
|
/// Pewter City and its two museum floors and gym: the rung-10 loop's whole world.
|
||||||
|
const PEWTER_CITY: u32 = 0x02;
|
||||||
|
const MUSEUM_1F: u32 = 0x34;
|
||||||
|
const PEWTER_GYM: u32 = 0x36;
|
||||||
/// The forest's *northern* gate, which is the first hop from the forest toward Pewter
|
/// The forest's *northern* gate, which is the first hop from the forest toward Pewter
|
||||||
/// (`macros::geography`, and rung 10's own road).
|
/// (`macros::geography`, and rung 10's own road).
|
||||||
const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f;
|
const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f;
|
||||||
|
|
@ -266,6 +270,31 @@ struct Run {
|
||||||
///
|
///
|
||||||
/// The door into the ring (section 12.12). `false` is the claim.
|
/// The door into the ring (section 12.12). `false` is the claim.
|
||||||
talk_at_a_rested_nurse: bool,
|
talk_at_a_rested_nurse: bool,
|
||||||
|
/// `BACK` starts on a frame the scene detector called `dialog` or `unknown` (section 12.13).
|
||||||
|
///
|
||||||
|
/// The same attribution `examples/trap_hunt.rs` makes, so the number is comparable with the
|
||||||
|
/// residual it comes from: **189** `BACK` in a text box on map `0x02` in twenty brain
|
||||||
|
/// minutes, and 678 starts in the 47 live minutes. `BACK` is on no overworld and no dialog
|
||||||
|
/// pad, so every one of them was dealt by `Unknown`.
|
||||||
|
back_in_a_box: u32,
|
||||||
|
/// Frames where the scene was `unknown`, nothing was drawn, and the pad was **not** empty.
|
||||||
|
///
|
||||||
|
/// Section 12.13's claim, and it is zero: a frame of the overworld the cartridge is driving
|
||||||
|
/// has nothing an A or a B press can do, so it deals nothing and the fly waits.
|
||||||
|
unknown_pads_with_no_box: u32,
|
||||||
|
/// Maps on whose overworld pad `TALK` was ever bound: where the fly stood facing somebody
|
||||||
|
/// this run has not talked to.
|
||||||
|
///
|
||||||
|
/// Rung 11 is BOULDER BADGE and its place is a *person* in map `0x36`, so "the fly reaches
|
||||||
|
/// the leader" is this map being in the set: the fly inside the gym, in front of one of its
|
||||||
|
/// people, with the press that talks to them on the pad. Whether it presses is the fly's
|
||||||
|
/// (section 12). Read off the shipping pad rather than recomputed, so it cannot disagree
|
||||||
|
/// with what the fly was actually offered.
|
||||||
|
talk_on_pad_by_map: std::collections::BTreeSet<u32>,
|
||||||
|
/// `TALK` starts by map, for the record beside it.
|
||||||
|
talk_starts_by_map: std::collections::BTreeMap<u32, u32>,
|
||||||
|
/// `GO FRONTIER` starts by map, for the museum (section 12.14).
|
||||||
|
frontier_by_map: std::collections::BTreeMap<u32, u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Run {
|
impl Run {
|
||||||
|
|
@ -362,6 +391,11 @@ impl Run {
|
||||||
prompt_frames: 0,
|
prompt_frames: 0,
|
||||||
next_on_a_prompt: false,
|
next_on_a_prompt: false,
|
||||||
talk_at_a_rested_nurse: false,
|
talk_at_a_rested_nurse: false,
|
||||||
|
back_in_a_box: 0,
|
||||||
|
unknown_pads_with_no_box: 0,
|
||||||
|
talk_on_pad_by_map: std::collections::BTreeSet::new(),
|
||||||
|
talk_starts_by_map: std::collections::BTreeMap::new(),
|
||||||
|
frontier_by_map: std::collections::BTreeMap::new(),
|
||||||
menu_alternation: 0,
|
menu_alternation: 0,
|
||||||
last_start: None,
|
last_start: None,
|
||||||
}
|
}
|
||||||
|
|
@ -463,6 +497,11 @@ impl Run {
|
||||||
prompt_frames: 0,
|
prompt_frames: 0,
|
||||||
next_on_a_prompt: false,
|
next_on_a_prompt: false,
|
||||||
talk_at_a_rested_nurse: false,
|
talk_at_a_rested_nurse: false,
|
||||||
|
back_in_a_box: 0,
|
||||||
|
unknown_pads_with_no_box: 0,
|
||||||
|
talk_on_pad_by_map: std::collections::BTreeSet::new(),
|
||||||
|
talk_starts_by_map: std::collections::BTreeMap::new(),
|
||||||
|
frontier_by_map: std::collections::BTreeMap::new(),
|
||||||
menu_alternation: 0,
|
menu_alternation: 0,
|
||||||
last_start: None,
|
last_start: None,
|
||||||
}
|
}
|
||||||
|
|
@ -766,6 +805,19 @@ impl Run {
|
||||||
*self.started_on_the_first_map.entry(name).or_insert(0) += 1;
|
*self.started_on_the_first_map.entry(name).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
*self.started.entry(name).or_insert(0) += 1;
|
*self.started.entry(name).or_insert(0) += 1;
|
||||||
|
// Section 12.13: which of the two `Unknown`s a `BACK` was dealt on, attributed
|
||||||
|
// exactly as the trap hunt attributes it so the numbers can be compared.
|
||||||
|
if name == "BACK" && matches!(self.layer.scene_name(), "dialog" | "unknown") {
|
||||||
|
self.back_in_a_box += 1;
|
||||||
|
}
|
||||||
|
if name == "GO FRONTIER" {
|
||||||
|
let map = self.map();
|
||||||
|
*self.frontier_by_map.entry(map).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
if name == "TALK" {
|
||||||
|
let map = self.map();
|
||||||
|
*self.talk_starts_by_map.entry(map).or_insert(0) += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.gb.set_buttons(mask as u8);
|
self.gb.set_buttons(mask as u8);
|
||||||
self.gb.run_frame().expect("a frame should complete");
|
self.gb.run_frame().expect("a frame should complete");
|
||||||
|
|
@ -810,6 +862,13 @@ impl Run {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Section 12.13's claim, per frame: an `Unknown` with nothing drawn deals nothing.
|
||||||
|
if self.layer.scene_name() == "unknown"
|
||||||
|
&& !flybrain_gb::pokemon_red::state::text_box(&mut self.gb).open
|
||||||
|
&& !self.layer.bound_channels().is_empty()
|
||||||
|
{
|
||||||
|
self.unknown_pads_with_no_box += 1;
|
||||||
|
}
|
||||||
if self.layer.scene_name() == "overworld"
|
if self.layer.scene_name() == "overworld"
|
||||||
&& self.rested_nurse()
|
&& self.rested_nurse()
|
||||||
&& self.layer.bound_channels().iter().any(|channel| channel.as_str() == "macro_talk")
|
&& self.layer.bound_channels().iter().any(|channel| channel.as_str() == "macro_talk")
|
||||||
|
|
@ -827,6 +886,11 @@ impl Run {
|
||||||
if dealt.iter().any(|channel| channel.as_str() == "macro_menu") {
|
if dealt.iter().any(|channel| channel.as_str() == "macro_menu") {
|
||||||
self.menu_on_pad.insert(map);
|
self.menu_on_pad.insert(map);
|
||||||
}
|
}
|
||||||
|
// Standing in front of somebody this run has not talked to, which on the gym's own
|
||||||
|
// map is standing in front of one of the people rung 11's place names.
|
||||||
|
if dealt.iter().any(|channel| channel.as_str() == "macro_talk") {
|
||||||
|
self.talk_on_pad_by_map.insert(map);
|
||||||
|
}
|
||||||
if dealt.is_empty() {
|
if dealt.is_empty() {
|
||||||
self.empty_overworld_pads.insert(map);
|
self.empty_overworld_pads.insert(map);
|
||||||
}
|
}
|
||||||
|
|
@ -2181,3 +2245,139 @@ fn the_fly_leaves_the_pokemon_center_from_the_rung_ten_checkpoint() {
|
||||||
run.macros_on_the_first_map
|
run.macros_on_the_first_map
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rung-10 Pewter checkpoint, or `None` to skip.
|
||||||
|
fn pewter_checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||||
|
std::env::var_os("FLY_PEWTER_CHECKPOINT").map(|path| {
|
||||||
|
flysim::store::load(std::path::Path::new(&path))
|
||||||
|
.expect("the checkpoint should be a FLYSIM01 envelope")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From the rung-10 Pewter checkpoint: the fly gets out of the museum and into the gym.
|
||||||
|
///
|
||||||
|
/// **What was live** (2026-09-22, v0.4.5, rank 10 PEWTER CITY, five and a half hours on the
|
||||||
|
/// rung): the fly on **map 0x34**, the museum's ground floor, and since the 11:30 restart the
|
||||||
|
/// macro starts were `GO FRONTIER` **1,235**, `BACK` **678**, `GO OBJECTIVE` 267, `GO OUT` 242,
|
||||||
|
/// `YES` 169 -- with the objective, rung 11's BOULDER BADGE, two doors away in map `0x36`. Two
|
||||||
|
/// "Stuck" rollbacks fired inside the last half hour and put the fly back where it started.
|
||||||
|
///
|
||||||
|
/// **What the survey found** (`infra/docs/macros-traps.md`, 2026-09-22, and
|
||||||
|
/// `examples/scene_probe.rs`): three things, none of them the text box the brief expected.
|
||||||
|
///
|
||||||
|
/// - `BACK` was not in a text box at all. It is on no overworld and no dialog pad, so every one
|
||||||
|
/// of those 678 was `Scene::Unknown` -- which is *also* what a frame of the overworld reads as
|
||||||
|
/// while the cartridge drives the fly through a door. An A and a B press into somebody else's
|
||||||
|
/// script (section 12.13).
|
||||||
|
/// - the museum had **no row on the map graph**, so from inside it `next_hop` answered nothing
|
||||||
|
/// and `GO OBJECTIVE` had no goal: the road to the gym did not exist indoors (12.7's rule, and
|
||||||
|
/// the residual the previous review named).
|
||||||
|
/// - the museum's frontier is 39 tiles the run has never stood on, of which almost all are
|
||||||
|
/// behind the admission desk: unreachable, refused `no route`, excluded for ten brain minutes
|
||||||
|
/// by the blocked ledger and then offered again, once per hold (section 12.14).
|
||||||
|
///
|
||||||
|
/// The claims, none of them about which button the fly presses:
|
||||||
|
///
|
||||||
|
/// - the fly reaches **map 0x36**, the gym's own interior, on a bounded number of macros;
|
||||||
|
/// - it stands **facing the thing the rung names** while it is there, which is all `GO OBJECTIVE`
|
||||||
|
/// ever promises (12.5);
|
||||||
|
/// - `BACK` on a `dialog` or `unknown` frame stays **under five**, against 189 in the hunt;
|
||||||
|
/// - no frame deals a pad on an `Unknown` with nothing drawn on it.
|
||||||
|
///
|
||||||
|
/// ```sh
|
||||||
|
/// FLY_ROM=/path/to/pokemon-red.gb \
|
||||||
|
/// FLY_PEWTER_CHECKPOINT=.local/checkpoints/release-rank10-pewter.checkpoint \
|
||||||
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
||||||
|
/// ```
|
||||||
|
#[test]
|
||||||
|
fn the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint() {
|
||||||
|
let rom = skip_without_rom!();
|
||||||
|
let Some(checkpoint) = pewter_checkpoint() else {
|
||||||
|
eprintln!("skipped: no FLY_PEWTER_CHECKPOINT");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
||||||
|
let from = run.map();
|
||||||
|
assert_eq!(from, MUSEUM_1F, "the checkpoint is the room the stream stalled in");
|
||||||
|
|
||||||
|
let mut reached = None;
|
||||||
|
let mut macros_to_the_gym = 0;
|
||||||
|
for frame in 0..200_000u32 {
|
||||||
|
run.frame();
|
||||||
|
if reached.is_none() {
|
||||||
|
macros_to_the_gym = run.started.values().sum::<u32>();
|
||||||
|
if run.map() == PEWTER_GYM {
|
||||||
|
reached = Some(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, GO FRONTIER by \
|
||||||
|
map {:?}, TALK on the pad on {:?}, TALK starts {:?}, BACK in a box {}, unknown pads \
|
||||||
|
with no box {}",
|
||||||
|
run.ms / 60_000.0,
|
||||||
|
run.route,
|
||||||
|
run.started,
|
||||||
|
run.frontier_by_map,
|
||||||
|
run.talk_on_pad_by_map,
|
||||||
|
run.talk_starts_by_map,
|
||||||
|
run.back_in_a_box,
|
||||||
|
run.unknown_pads_with_no_box
|
||||||
|
);
|
||||||
|
|
||||||
|
let Some(frame) = reached else {
|
||||||
|
panic!(
|
||||||
|
"the fly never reached the gym (map {:#04x}, route {:?}, macros {:?})",
|
||||||
|
run.map(),
|
||||||
|
run.route,
|
||||||
|
run.started
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let progress = run.adapter.progress();
|
||||||
|
eprintln!(
|
||||||
|
"it reached map {PEWTER_GYM:#04x} on frame {frame}, on {macros_to_the_gym} macros; \
|
||||||
|
the run ends on rank {} ({}) with {} badges",
|
||||||
|
progress.rank,
|
||||||
|
progress.rank_label,
|
||||||
|
run.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wObtainedBadges).count_ones()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
macros_to_the_gym < 600,
|
||||||
|
"reaching the gym cost {macros_to_the_gym} macros: {:?}",
|
||||||
|
run.started
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
run.talk_on_pad_by_map.contains(&PEWTER_GYM),
|
||||||
|
"the fly never stood in front of one of the gym's people: {:?}",
|
||||||
|
run.talk_on_pad_by_map
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
run.objective_on_pad.contains(&PEWTER_GYM),
|
||||||
|
"`GO OBJECTIVE` had nothing to aim at inside the gym: {:?}",
|
||||||
|
run.objective_on_pad
|
||||||
|
);
|
||||||
|
// Section 12.13, against the 189 the hunt measured on map 0x02 alone.
|
||||||
|
assert!(
|
||||||
|
run.back_in_a_box < 5,
|
||||||
|
"`BACK` was dealt {} times on a dialog or unknown frame",
|
||||||
|
run.back_in_a_box
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
run.unknown_pads_with_no_box, 0,
|
||||||
|
"a pad was dealt on an unknown frame with nothing drawn on it"
|
||||||
|
);
|
||||||
|
// Section 12.14: the museum's frontier is proved unreachable once and then left alone. The
|
||||||
|
// live run started GO FRONTIER 1,235 times over these two floors and a town.
|
||||||
|
let museum: u32 = run.frontier_by_map.get(&MUSEUM_1F).copied().unwrap_or(0)
|
||||||
|
+ run.frontier_by_map.get(&MUSEUM_2F).copied().unwrap_or(0);
|
||||||
|
assert!(
|
||||||
|
museum < 40,
|
||||||
|
"`GO FRONTIER` started {museum} times on the museum's floors: {:?}",
|
||||||
|
run.frontier_by_map
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
run.route.contains(&PEWTER_CITY),
|
||||||
|
"the road to the gym is out of the museum's front door: {:?}",
|
||||||
|
run.route
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue