macros: a purchase the counter's cursor cannot reach is not on the pad
A mart's buy list scrolls. The cursor walks rows 0, 1, 2 and then the window moves under it, so an item's position in the counter's stock is its cursor index only for the first three entries; the offset that would name the rest is not a pinned address. Pewter's counter carries seven items and the Antidote is its fourth, so `BUY ANTIDOTE` there aimed a cursor step above the list's own max and reported `blocked` on its first frame, having pressed nothing -- and a purchase has no walk target, so nothing was recorded and the button came back on the next hold. One accessor answers "which index, if any" for both the pad and the plan, and the clerk talking is refused through the seam rather than here.
This commit is contained in:
parent
4a1df7c3be
commit
8523daf745
5 changed files with 168 additions and 20 deletions
|
|
@ -103,6 +103,19 @@ pub const CHEAPEST_PURCHASE: u32 = {
|
||||||
least
|
least
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Rows of a mart's priced buy list the shared cursor can sit on.
|
||||||
|
///
|
||||||
|
/// Three, measured rather than derived (`infra/docs/macros-traps.md` row 55): on the live list in
|
||||||
|
/// the Pewter mart the cursor walked `0, 1, 2` under DOWN and then stopped moving while the
|
||||||
|
/// *window scrolled* under it, so the fourth drawn row is a look-ahead the cursor never occupies.
|
||||||
|
/// The absolute position of the item the cursor is on is that index plus `wListScrollOffset`,
|
||||||
|
/// which is not in the reviewed address list and cannot be pinned without the disassembly
|
||||||
|
/// `gen_symbols.py` reads -- so an item past the third of a counter's stock has no index this seam
|
||||||
|
/// can name, and a purchase aimed at one is a button whose script gives up before it presses
|
||||||
|
/// anything. [`super::palette::stock_index`] is where the rule is applied, once, for both the pad
|
||||||
|
/// and the plan.
|
||||||
|
pub const MART_CURSOR_ROWS: usize = 3;
|
||||||
|
|
||||||
/// Cursor indices of the battle menu, as `state::BattleMenu::Main` documents them: "0 FIGHT,
|
/// Cursor indices of the battle menu, as `state::BattleMenu::Main` documents them: "0 FIGHT,
|
||||||
/// 1 PKMN, 2 ITEM, 3 RUN".
|
/// 1 PKMN, 2 ITEM, 3 RUN".
|
||||||
pub mod battle_entry {
|
pub mod battle_entry {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ use super::palette::{
|
||||||
facing_target, listing, move_index, move_list, nurse_prompt, objective_goals, party_rested,
|
facing_target, listing, move_index, move_list, nurse_prompt, objective_goals, party_rested,
|
||||||
potion_slot,
|
potion_slot,
|
||||||
precondition,
|
precondition,
|
||||||
shop_screen, throw_slot, untalked_objects, untalked_people, ways,
|
shop_screen, stock_index, throw_slot, untalked_objects, untalked_people, ways,
|
||||||
};
|
};
|
||||||
use super::path::{self, Route, Way};
|
use super::path::{self, Route, Way};
|
||||||
use super::state::{Facing, Scene, ShopScreen};
|
use super::state::{Facing, Scene, ShopScreen};
|
||||||
|
|
@ -2098,10 +2098,11 @@ fn approach(state: &mut dyn MacroState, targets: &[(Tile, TalkTarget)]) -> Vec<G
|
||||||
///
|
///
|
||||||
/// One script over all four of section 13's purchases, which differ only in which item id they are
|
/// One script over all four of section 13's purchases, which differ only in which item id they are
|
||||||
/// pointed at -- exactly as `GO NPC` and `GO ITEM` differ only in which half of the object data
|
/// pointed at -- exactly as `GO NPC` and `GO ITEM` differ only in which half of the object data
|
||||||
/// they read. The item's **position in the stock list is its cursor index** in the buy list
|
/// they read. The item's position in the stock list is its cursor index in the buy list *while the
|
||||||
/// (`docs/design/macros-wram.md`, `wItemList`), so the navigation is a cursor read and never a
|
/// list is not scrolled* (`docs/design/macros-wram.md`, `wItemList`), so the navigation is a
|
||||||
/// count of presses; an item the counter does not stock has no index and the script refuses before
|
/// cursor read and never a count of presses; an item the counter does not stock, or one past the
|
||||||
/// anything is pressed.
|
/// rows the cursor can reach, has no index and the script refuses before anything is pressed
|
||||||
|
/// ([`super::palette::stock_index`], row 55).
|
||||||
///
|
///
|
||||||
/// Quantity one, always: the prompt opens on one and this macro presses A at it. "Buy ONE unit"
|
/// Quantity one, always: the prompt opens on one and this macro presses A at it. "Buy ONE unit"
|
||||||
/// is section 13's own word, and a quantity the fly did not choose is not one this crate types.
|
/// is section 13's own word, and a quantity the fly did not choose is not one this crate types.
|
||||||
|
|
@ -2109,8 +2110,7 @@ fn approach(state: &mut dyn MacroState, targets: &[(Tile, TalkTarget)]) -> Vec<G
|
||||||
/// Which screen the mart is on is read from agent A's `ShopScreen` rather than assumed, so the
|
/// Which screen the mart is on is read from agent A's `ShopScreen` rather than assumed, so the
|
||||||
/// script works both from a freshly opened counter and from the buy list.
|
/// script works both from a freshly opened counter and from the buy list.
|
||||||
fn shop_plan(state: &mut dyn MacroState, want: u8) -> Option<Vec<Step>> {
|
fn shop_plan(state: &mut dyn MacroState, want: u8) -> Option<Vec<Step>> {
|
||||||
let index = state.shop_stock().iter().position(|stocked| *stocked == want)?;
|
let index = stock_index(state, want)?;
|
||||||
let index = u8::try_from(index).ok()?;
|
|
||||||
let mut steps = Vec::new();
|
let mut steps = Vec::new();
|
||||||
if shop_screen(state)? == ShopScreen::BuySellQuit {
|
if shop_screen(state)? == ShopScreen::BuySellQuit {
|
||||||
// BUY is the counter menu's first entry.
|
// BUY is the counter menu's first entry.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//! B 5.
|
//! B 5.
|
||||||
|
|
||||||
use super::cartridge::{
|
use super::cartridge::{
|
||||||
CHEAPEST_PURCHASE, FACINGS, opposite,
|
CHEAPEST_PURCHASE, FACINGS, MART_CURSOR_ROWS, opposite,
|
||||||
ExitId, ListKind, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget,
|
ExitId, ListKind, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget,
|
||||||
TargetKey, Tile, item, outdoors,
|
TargetKey, Tile, item, outdoors,
|
||||||
};
|
};
|
||||||
|
|
@ -908,19 +908,52 @@ pub fn objective_place(state: &mut dyn MacroState) -> Option<Objective> {
|
||||||
errand_place(state).or_else(|| state.objective())
|
errand_place(state).or_else(|| state.objective())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the open mart stocks `item` and the money on hand covers it.
|
/// Whether the open mart stocks `item` within reach of its cursor, and the money on hand covers
|
||||||
|
/// it.
|
||||||
///
|
///
|
||||||
/// Both halves, and the stock half is the one that surprises: Viridian's counter sells POKE BALL,
|
/// Three halves now, and each one has been the answer at a different counter.
|
||||||
/// ANTIDOTE, PARLYZ HEAL and BURN HEAL and **no Potion at all** (`data/items/marts.asm` at the
|
///
|
||||||
/// pinned commit), so `BUY POTION` is correctly off the pad in the first mart the fly ever walks
|
/// - **Stock.** Viridian's counter sells POKE BALL, ANTIDOTE, PARLYZ HEAL and BURN HEAL and **no
|
||||||
/// into. That is the precondition working, not a gap.
|
/// Potion at all** (`data/items/marts.asm` at the pinned commit), so `BUY POTION` is correctly
|
||||||
|
/// off the pad in the first mart the fly ever walks into. That is the precondition working, not
|
||||||
|
/// a gap.
|
||||||
|
/// - **Money**, which is section 13's "money allows at least one".
|
||||||
|
/// - **Reach**, which is row 55. A purchase is navigated by *reading the cursor*, and a mart's
|
||||||
|
/// buy list scrolls: the cursor sits on rows `0, 1, 2` and the window moves under it, so the
|
||||||
|
/// absolute position of the item the cursor is on is the index plus a scroll offset this seam
|
||||||
|
/// cannot read ([`super::cartridge::MART_CURSOR_ROWS`]). Pewter's counter carries seven items and
|
||||||
|
/// ANTIDOTE is its fourth, so `BUY ANTIDOTE` there is a button whose script gives up before it
|
||||||
|
/// presses anything -- 747 starts and 747 `blocked` in ten brain minutes, none of them pressing
|
||||||
|
/// a button and none of them changing a byte. A macro that cannot run is not on the pad, so the
|
||||||
|
/// first three of a counter's stock are what the four purchases are bound on, and the rest wait
|
||||||
|
/// for `wListScrollOffset` to be a pinned address.
|
||||||
|
///
|
||||||
|
/// The clerk's own text box is the fourth thing this refuses, and it refuses it through the seam
|
||||||
|
/// rather than here: [`ShopScreen::Talking`] is not one of the two screens a purchase can start
|
||||||
|
/// from (`docs/design/macros-wram.md` section 7, row 55).
|
||||||
fn affordable(state: &mut dyn MacroState, item: u8, cost: u32) -> bool {
|
fn affordable(state: &mut dyn MacroState, item: u8, cost: u32) -> bool {
|
||||||
// Sell cursors index the bag, not the counter's stock
|
// Sell cursors index the bag, not the counter's stock; and while the clerk is talking there is
|
||||||
state
|
// no list up at all.
|
||||||
|
if !state
|
||||||
.shop()
|
.shop()
|
||||||
.is_some_and(|shop| matches!(shop.screen, ShopScreen::BuySellQuit | ShopScreen::Buying))
|
.is_some_and(|shop| matches!(shop.screen, ShopScreen::BuySellQuit | ShopScreen::Buying))
|
||||||
&& state.money() >= cost
|
{
|
||||||
&& state.shop_stock().contains(&item)
|
return false;
|
||||||
|
}
|
||||||
|
state.money() >= cost && stock_index(state, item).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cursor index of `item` in the open counter's stock, when the cursor can reach it.
|
||||||
|
///
|
||||||
|
/// One accessor rather than the same `position` in the precondition and in the script, so the
|
||||||
|
/// button and the plan cannot disagree about which items a mart can sell the fly (row 6's rule:
|
||||||
|
/// the cheap question and the real one have to be the same question when they are the same fact).
|
||||||
|
pub fn stock_index(state: &mut dyn MacroState, item: u8) -> Option<u8> {
|
||||||
|
let index = state.shop_stock().iter().position(|stocked| *stocked == item)?;
|
||||||
|
if index >= MART_CURSOR_ROWS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
u8::try_from(index).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `GO SHOP` or `GO HEAL` walks to, or empty when there is nothing to walk to.
|
/// What `GO SHOP` or `GO HEAL` walks to, or empty when there is nothing to walk to.
|
||||||
|
|
@ -1932,6 +1965,15 @@ pub fn listing(state: &mut dyn MacroState) -> Option<Listing> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(shop) = state.shop() {
|
if let Some(shop) = state.shop() {
|
||||||
|
// **The clerk talking is not a list.** Row 55: `wListMenuID` keeps `PRICEDITEMLISTMENU`
|
||||||
|
// across the mart's own text, and the cursor bytes left behind belong to a two-option box
|
||||||
|
// -- so a cursor step that read this would take its length and its direction from a menu
|
||||||
|
// that is not on screen, which is section 12.11's rule in the one scene it had not
|
||||||
|
// reached. Reporting nothing makes the step *wait*, pressing nothing, exactly as it waits
|
||||||
|
// for a list that has not drawn yet.
|
||||||
|
if shop.screen == ShopScreen::Talking {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
return Some(Listing {
|
return Some(Listing {
|
||||||
kind: ListKind::Shop,
|
kind: ListKind::Shop,
|
||||||
current: shop.cursor.current,
|
current: shop.cursor.current,
|
||||||
|
|
|
||||||
|
|
@ -1308,14 +1308,18 @@ fn the_four_purchases_are_bound_by_the_counters_own_stock() {
|
||||||
"Viridian stocks no Potion and no Repel"
|
"Viridian stocks no Potion and no Repel"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cerulean's, which stocks both of the other two.
|
// Cerulean's, which stocks both of the other two -- and whose fourth entry is out of the
|
||||||
|
// cursor's reach, exactly as Pewter's Antidote is (row 55): the list scrolls, so only the
|
||||||
|
// first three of a counter's stock have an index this seam can aim at.
|
||||||
world.stock = vec![item::POKE_BALL, item::POTION, item::REPEL, item::ANTIDOTE];
|
world.stock = vec![item::POKE_BALL, item::POTION, item::REPEL, item::ANTIDOTE];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names(&Palette::for_scene(Scene::Shop, &mut world)),
|
names(&Palette::for_scene(Scene::Shop, &mut world)),
|
||||||
["CONFIRM", "BUY POTION", "BUY BALL", "BUY ANTIDOTE", "BUY REPEL", "LEAVE"]
|
["CONFIRM", "BUY POTION", "BUY BALL", "BUY REPEL", "LEAVE"]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Money is the other half, per item: 150 buys an Antidote and nothing else.
|
// Money is the other half, per item: 150 buys an Antidote and nothing else, at a counter
|
||||||
|
// whose Antidote the cursor can reach.
|
||||||
|
world.stock = vec![item::POKE_BALL, item::ANTIDOTE, 15, 12];
|
||||||
world.money = 150;
|
world.money = 150;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names(&Palette::for_scene(Scene::Shop, &mut world)),
|
names(&Palette::for_scene(Scene::Shop, &mut world)),
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,92 @@ fn a_purchase_dealt_before_entering_sell_is_refused_without_a_press() {
|
||||||
assert!(world.pulses.is_empty());
|
assert!(world.pulses.is_empty());
|
||||||
assert!(machine.running().is_none());
|
assert!(machine.running().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row 55: a purchase the counter's cursor cannot reach is not on the pad.
|
||||||
|
///
|
||||||
|
/// **What was live** (2026-09-22 19:02 UTC, the Pewter mart, ten brain minutes): `BUY ANTIDOTE
|
||||||
|
/// start` / `BUY ANTIDOTE blocked` every 0.8 s, 747 repeats of a sequence of one, nothing else
|
||||||
|
/// starting. Surveyed on the cartridge: the counter stocks seven items and ANTIDOTE is its
|
||||||
|
/// **fourth**, while the buy list's cursor walks rows `0, 1, 2` and then scrolls the window under
|
||||||
|
/// itself -- so the absolute index the script aimed at was above the list's own max and the step
|
||||||
|
/// returned `Blocked` on its first frame, with no button pressed and no byte changed. The offset
|
||||||
|
/// that would name the scrolled position is not a pinned address, so the fourth item and after
|
||||||
|
/// have no index this seam can aim at, and a macro that cannot run is not on the pad.
|
||||||
|
#[test]
|
||||||
|
fn a_purchase_past_the_cursors_reach_is_not_on_the_pad() {
|
||||||
|
let mut world = World::room();
|
||||||
|
world.scene = Scene::Shop;
|
||||||
|
world.list = List::Shop(ShopScreen::Buying);
|
||||||
|
world.cursor_max = 2;
|
||||||
|
// Pewter's counter, in menu order, as `shop_stock` read it off the cartridge at the live
|
||||||
|
// checkpoint: POKE BALL, POTION, ESCAPE ROPE, ANTIDOTE, BURN HEAL, AWAKENING, PARLYZ HEAL.
|
||||||
|
// The four ids the palette has no constant for are the ones no button buys.
|
||||||
|
world.stock = vec![item::POKE_BALL, item::POTION, 29, item::ANTIDOTE, 12, 14, 15];
|
||||||
|
world.money = 9_999;
|
||||||
|
assert_eq!(
|
||||||
|
names(&Palette::for_scene(Scene::Shop, &mut world)),
|
||||||
|
["CONFIRM", "BUY POTION", "BUY BALL", "LEAVE"],
|
||||||
|
"the Antidote is Pewter's fourth item and the cursor cannot reach it"
|
||||||
|
);
|
||||||
|
// The live wallet, where the Antidote was the only thing money allowed: the pad is the two
|
||||||
|
// presses that get out of the counter, and it is not empty.
|
||||||
|
world.money = 104;
|
||||||
|
assert_eq!(
|
||||||
|
names(&Palette::for_scene(Scene::Shop, &mut world)),
|
||||||
|
["CONFIRM", "LEAVE"],
|
||||||
|
"nothing in reach is affordable, so nothing is dealt but the ways out"
|
||||||
|
);
|
||||||
|
assert!(world.pulses.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row 55, the other half: the clerk talking is not a list, so no purchase starts on it.
|
||||||
|
///
|
||||||
|
/// `wListMenuID` keeps `PRICEDITEMLISTMENU` across the mart's own text
|
||||||
|
/// (`docs/design/macros-wram.md` section 7), and the cursor bytes left behind belong to a
|
||||||
|
/// two-option box. A purchase dealt on such a frame navigates a menu that is not on screen, which
|
||||||
|
/// is section 12.11's rule in the one scene it had not reached -- so the seam names the screen and
|
||||||
|
/// the palette deals the presses that *do* advance it.
|
||||||
|
#[test]
|
||||||
|
fn the_clerks_own_text_box_deals_no_purchase_and_reports_no_list() {
|
||||||
|
let mut world = World::room();
|
||||||
|
world.scene = Scene::Shop;
|
||||||
|
world.list = List::Shop(ShopScreen::Talking);
|
||||||
|
world.cursor_max = 1;
|
||||||
|
world.stock = vec![item::POKE_BALL, item::POTION, item::ANTIDOTE];
|
||||||
|
world.money = 9_999;
|
||||||
|
|
||||||
|
for kind in [
|
||||||
|
MacroKind::BuyPotion,
|
||||||
|
MacroKind::BuyBall,
|
||||||
|
MacroKind::BuyAntidote,
|
||||||
|
MacroKind::BuyRepel,
|
||||||
|
] {
|
||||||
|
assert!(!precondition(kind, &mut world), "{kind:?} has no list to navigate");
|
||||||
|
}
|
||||||
|
assert_eq!(names(&Palette::for_scene(Scene::Shop, &mut world)), ["CONFIRM", "LEAVE"]);
|
||||||
|
assert!(listing(&mut world).is_none(), "nothing is accepting list input");
|
||||||
|
assert!(world.pulses.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And a purchase that was dealt before the counter changed under it presses nothing.
|
||||||
|
///
|
||||||
|
/// The same shape as `a_purchase_dealt_before_entering_sell_is_refused_without_a_press`, for the
|
||||||
|
/// reach: the script asks [`stock_index`] again and refuses, rather than aiming a cursor step at
|
||||||
|
/// an index the list cannot hold and reporting `blocked` a frame later.
|
||||||
|
#[test]
|
||||||
|
fn a_purchase_out_of_reach_by_the_time_it_starts_is_refused_without_a_press() {
|
||||||
|
let mut world = World::room();
|
||||||
|
world.scene = Scene::Shop;
|
||||||
|
world.list = List::Shop(ShopScreen::Buying);
|
||||||
|
world.cursor_max = 2;
|
||||||
|
world.stock = vec![item::ANTIDOTE, item::POKE_BALL];
|
||||||
|
world.money = 9_999;
|
||||||
|
let (palette, slot) = pick(&mut world, MacroKind::BuyAntidote);
|
||||||
|
// The counter the fly is standing at turns out to be Pewter's, where the Antidote is fourth.
|
||||||
|
world.stock = vec![item::POKE_BALL, item::POTION, 29, item::ANTIDOTE];
|
||||||
|
|
||||||
|
let mut machine = MacroMachine::new(1);
|
||||||
|
assert!(machine.start(&palette, slot, &mut world).is_err());
|
||||||
|
assert!(world.pulses.is_empty());
|
||||||
|
assert!(machine.running().is_none());
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue