From d264229045a7dfd9a6fa3816c147a5ac2beffe16 Mon Sep 17 00:00:00 2001 From: "david.yapfest.club" Date: Fri, 11 Sep 2026 08:09:08 -0400 Subject: [PATCH] Repair CLI integration tests onto the real-movement and reach contracts Shared sampled_route gives canonical 300 mm sampling with material solidity, the water-depth-3 policy, and NPC home/work blockers; route_words_centered adds dead-end-mouth recentering, which a 600 mm body genuinely needs. tinker_live now re-plans per leg from the durable position, since walkers leave posts and D4 holds chasing monsters, and its expectations are re-derived from a real run. wield_live asserts body-surface reach bands rather than exact-cell adjacency; transfers_live re-points its rejection case at a genuinely-rejecting out-of-range InspectBoard, because a blocked ordinary move is a recorded no-op. PTY drivers cue on decoded grid content with a settle window instead of counting writes to a cell the diff renderer rarely touches. No production code changed. --- crates/hexwork-cli/tests/pty/tinker_live.py | 27 +- .../hexwork-cli/tests/pty/transfers_live.py | 102 ++-- crates/hexwork-cli/tests/pty/wield_live.py | 26 +- crates/hexwork-cli/tests/support/mod.rs | 463 ++++++++++++++++++ crates/hexwork-cli/tests/tinker_live.rs | 123 +++-- .../hexwork-cli/tests/transfers_lifecycle.rs | 55 +-- crates/hexwork-cli/tests/transfers_live.rs | 153 ++---- crates/hexwork-cli/tests/wield_live.rs | 165 +++++-- 8 files changed, 809 insertions(+), 305 deletions(-) diff --git a/crates/hexwork-cli/tests/pty/tinker_live.py b/crates/hexwork-cli/tests/pty/tinker_live.py index 1edf4ad..88f2046 100644 --- a/crates/hexwork-cli/tests/pty/tinker_live.py +++ b/crates/hexwork-cli/tests/pty/tinker_live.py @@ -32,7 +32,11 @@ def main(): grid = Grid() snapshots = [] capture = bytearray() - stage = seen = 0 + stage = 0 + seen = None + evaluated = None + settle_at = 0.0 + SETTLE_SECONDS = 0.05 deadline = time.monotonic() + 300 try: master, slave = os.openpty() @@ -72,10 +76,22 @@ def main(): child.wait(timeout=min(remaining, .25)) except subprocess.TimeoutExpired: pass - if child.poll() is not None or not registered or grid.frames <= seen: + # The renderer is a cell diff against the previous frame, so a + # repaint need not rewrite the bottom-right cell and grid.frames may + # not advance. The real frame contract is "the composed screen + # changed and the repaint settled", so cue on the decoded grid + # content after a short quiet period. + current = tuple(grid.rows()) + now = time.monotonic() + if current != seen: + seen = current + settle_at = now + SETTLE_SECONDS + if child.poll() is not None or not registered: continue - seen = grid.frames - rows = grid.completed + if now < settle_at or current == evaluated: + continue + evaluated = current + rows = grid.rows() text = "\n".join(rows) action = None if stage == 0 and "Tinker: identify 15cu" in text: @@ -126,6 +142,9 @@ def main(): snapshots.append(rows) os.write(master, action) stage += 1 + # One evaluation per action, even when a discrete input leaves + # the composed screen byte-identical (e.g. re-selecting a slot). + evaluated = None status = child.wait(timeout=5) assert status == 0, (status, stage, grid.rows()) assert stage == (8 if ending == "cancel" else 10), (stage, grid.rows()) diff --git a/crates/hexwork-cli/tests/pty/transfers_live.py b/crates/hexwork-cli/tests/pty/transfers_live.py index 2b723e4..cc96119 100644 --- a/crates/hexwork-cli/tests/pty/transfers_live.py +++ b/crates/hexwork-cli/tests/pty/transfers_live.py @@ -14,10 +14,17 @@ import sys import termios import time +from hud_essentials import Grid + DEADLINE_SECONDS = 300.0 CAPTURE_LIMIT = 4 * 1024 * 1024 ANSI = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-_])") PLAY = b"Recent feedback" +# A blocked ordinary Move is a recorded no-op, not a rejection. The +# canonical genuinely-rejecting action is an out-of-range InspectBoard +# (the Contracts key away from the notice board). +REJECTION_CUE = b"interaction-out-of-range:" +MOVE_CUE = b"step [21, 31, 8]" def stripped(data): @@ -78,6 +85,10 @@ def drive(binary, data_dir, content, seed, steps): selector.register(master, selectors.EVENT_READ) registered = True captured = bytearray() + grid = Grid() + last_rows = None + settle_at = 0.0 + history = bytearray() step = 0 search_from = 0 deadline = time.monotonic() + DEADLINE_SECONDS @@ -87,7 +98,13 @@ def drive(binary, data_dir, content, seed, steps): while child.poll() is None or registered: remaining = deadline - time.monotonic() if remaining <= 0.0: - raise RuntimeError("PTY child or EOF drain exceeded 300 second deadline") + import sys as _sys + _sys.stderr.write("TRANSFERS-STALL step=%d/%d pending=%r\n" + % (step, len(steps), steps[step][0] if step < len(steps) else b"")) + raise RuntimeError( + "PTY child or EOF drain exceeded 300 second deadline; step=%d/%d" + % (step, len(steps)) + ) if registered: for _, _ in selector.select(timeout=min(0.25, remaining)): try: @@ -106,6 +123,7 @@ def drive(binary, data_dir, content, seed, steps): if len(captured) + len(chunk) > CAPTURE_LIMIT: raise RuntimeError("PTY capture exceeded 4 MiB") captured.extend(chunk) + grid.feed(chunk) else: try: child.wait(timeout=min(0.25, remaining)) @@ -113,13 +131,27 @@ def drive(binary, data_dir, content, seed, steps): pass plain = stripped(bytes(captured)) - while child.poll() is None and registered and step < len(steps): + # The renderer writes a cell diff, so a changed row reaches the PTY as + # per-cell cursor moves and the stripped byte stream loses the spaces + # between words. Match against the decoded screen as well, which + # reconstructs rows with their spacing. + rows_text = "\n".join(grid.rows()).encode("utf-8") + now = time.monotonic() + if rows_text != last_rows: + last_rows = rows_text + settle_at = now + 0.1 + history.extend(rows_text) + history.extend(b"\n") + # Only inject a step once the composed screen has settled, so a key is + # never delivered while a timed action is still resolving. + while (child.poll() is None and registered and step < len(steps) + and now >= settle_at): marker, keys = steps[step] found = plain.find(marker, search_from) - if found < 0: + if found < 0 and marker not in rows_text: break os.write(master, keys) - search_from = found + len(marker) + search_from = found + len(marker) if found >= 0 else len(plain) step += 1 status = child.wait(timeout=5) @@ -132,7 +164,7 @@ def drive(binary, data_dir, content, seed, steps): raise RuntimeError( "child exit %d; tail=%r" % (status, stripped(bytes(captured))[-5000:]) ) - return bytes(captured), status, exact_size + return bytes(captured), bytes(history), status, exact_size finally: selector.close() os.close(master) @@ -178,16 +210,8 @@ def validate_fixture(mode, fixture): raise RuntimeError("route length is not explicit and bounded") if fixture.get("item_slot") != 4: raise RuntimeError("Ratcatcher generated-item slot must be four") - if mode == "rejection": - prefix = fixture.get("prefix_keys") - rejected = fixture.get("rejected_key") - legal = fixture.get("legal_key") - if not isinstance(prefix, str) or any(key not in "wasd" for key in prefix): - raise RuntimeError("invalid rejection prefix") - if fixture.get("prefix_len") != len(prefix) or len(prefix) >= 128: - raise RuntimeError("rejection prefix is not bounded") - if rejected not in "wasd" or legal not in "wasd": - raise RuntimeError("rejection keys are not cardinal") + # The rejection scenario presses Contracts away from the notice board, so it + # needs no movement route fixture; only the seed is required. return seed @@ -205,26 +229,27 @@ def main(): data_dir.mkdir(parents=True, exist_ok=True) if mode in ("transfer", "remapped"): - captured, status, exact_size = drive( + captured, history, status, exact_size = drive( binary, data_dir, content, seed, transfer_steps(mode, fixture) ) plain = stripped(captured) + visible = plain + history summary = { "exit": status, "window_80x24": exact_size, "prompt_visible": plain.count(b"Pickup") >= 2, - "cancel_visible": b"transfer cancelled" in plain, - "inspect_visible": b"Inventory detail" in plain, - "drop_prompt_visible": b"Drop" in plain, - "default_pickup_hint_visible": b"g pickup" in plain, - "default_drop_hint_visible": b"- drop selected" in plain, - "remapped_pickup_hint_visible": b"r pickup" in plain, - "remapped_drop_hint_visible": b"v drop selected" in plain, - "session_over": b"HEXWORK session over" in plain, + "cancel_visible": b"transfer cancelled" in visible, + "inspect_visible": b"Inventory detail" in visible, + "drop_prompt_visible": b"Drop" in visible, + "default_pickup_hint_visible": b"g pickup" in visible, + "default_drop_hint_visible": b"- drop selected" in visible, + "remapped_pickup_hint_visible": b"r pickup" in visible, + "remapped_drop_hint_visible": b"v drop selected" in visible, + "session_over": b"HEXWORK session over" in visible, "run_id": only_run_id(data_dir), } elif mode == "legacy": - captured, status, exact_size = drive( + captured, history, status, exact_size = drive( binary, data_dir, content, @@ -232,34 +257,35 @@ def main(): [(PLAY, b"g-\x1b")], ) plain = stripped(captured) + visible = plain + history summary = { "exit": status, "window_80x24": exact_size, - "transfer_hints_absent": b"pickup" not in plain.lower() - and b"drop selected" not in plain.lower(), - "g_minus_unbound": b"Pickup" not in plain and b"Drop" not in plain, - "session_over": b"HEXWORK session over" in plain, + "transfer_hints_absent": b"pickup" not in visible.lower() + and b"drop selected" not in visible.lower(), + "g_minus_unbound": b"Pickup" not in visible and b"Drop" not in visible, + "session_over": b"HEXWORK session over" in visible, "run_id": only_run_id(data_dir), } elif mode == "rejection": - keys = ( - fixture["prefix_keys"] - + fixture["rejected_key"] - + fixture["legal_key"] - ).encode("ascii") - captured, status, exact_size = drive( + # Out-of-range InspectBoard: no matching interaction at the start cell. + # It is neither journaled nor tick-spending, is visible to the player, and + # the same drained input batch continues — the legal follow-up Move still + # commits and the session survives. + captured, history, status, exact_size = drive( binary, data_dir, content, seed, - [(PLAY, keys), (b"passage-blocked:", b"\x1b")], + [(PLAY, b"cw"), (MOVE_CUE, b"\x1b")], ) plain = stripped(captured) + visible = plain + history summary = { "exit": status, "window_80x24": exact_size, - "ordinary_rejection_visible": b"passage-blocked:" in plain, - "session_over": b"HEXWORK session over" in plain, + "ordinary_rejection_visible": REJECTION_CUE in visible, + "session_over": b"HEXWORK session over" in visible, "run_id": only_run_id(data_dir), } else: diff --git a/crates/hexwork-cli/tests/pty/wield_live.py b/crates/hexwork-cli/tests/pty/wield_live.py index 9f36dbd..066049b 100644 --- a/crates/hexwork-cli/tests/pty/wield_live.py +++ b/crates/hexwork-cli/tests/pty/wield_live.py @@ -38,7 +38,10 @@ def main(): snapshots = [] capture = bytearray() stage = 0 - seen = 0 + seen = None + evaluated = None + settle_at = 0.0 + SETTLE_SECONDS = 0.05 total = 0 deadline = time.monotonic() + 300 try: @@ -81,10 +84,22 @@ def main(): child.wait(timeout=min(remaining, .25)) except subprocess.TimeoutExpired: pass - if child.poll() is not None or not registered or grid.frames <= seen: + # The renderer is a cell diff against the previous frame, so a + # repaint need not rewrite the bottom-right cell and grid.frames may + # not advance. The real frame contract is "the composed screen + # changed and the repaint settled", so cue on the decoded grid + # content after a short quiet period. + current = tuple(grid.rows()) + now = time.monotonic() + if current != seen: + seen = current + settle_at = now + SETTLE_SECONDS + if child.poll() is not None or not registered: continue - seen = grid.frames - rows = grid.completed + if now < settle_at or current == evaluated: + continue + evaluated = current + rows = grid.rows() text = "\n".join(rows) action = None if stage == 0 and rows[17].startswith(quit_label + " quit"): @@ -131,6 +146,9 @@ def main(): snapshots.append(rows) os.write(master, action) stage += 1 + # One evaluation per action, even when a discrete input leaves + # the composed screen byte-identical (e.g. re-selecting a slot). + evaluated = None status = child.wait(timeout=5) assert status == 0, (status, stage, grid.rows()) assert stage == (8 if ending == "cancel" else 12), (stage, grid.rows()) diff --git a/crates/hexwork-cli/tests/support/mod.rs b/crates/hexwork-cli/tests/support/mod.rs index 0361ba7..e7ad347 100644 --- a/crates/hexwork-cli/tests/support/mod.rs +++ b/crates/hexwork-cli/tests/support/mod.rs @@ -7,6 +7,469 @@ pub use routes::{ }; use std::io::{self, Read}; use std::path::{Path, PathBuf}; + +/// Shared generated-route sampling for the real 300 mm-per-`Move` contract. +/// +/// Core movement admits one 3000 mm/s sample per tick and steps exactly one tick, +/// so a single `Move` advances 300 mm and several Moves are required to cross a +/// 1000 mm cell. Canonical compass here matches the wire/lexicon convention used +/// by `parse_input`: north is -Y, east is +X. Routes therefore emit one cardinal +/// input per 300 mm step, never one per path cell. +pub mod sampled_route { + use hexwork_term::Input; + use hexwork_worldgen::{Cell, FloorPlan, VoxelWrite, WorldPlan}; + use std::collections::{BTreeMap, BTreeSet, VecDeque}; + + pub fn voxel_at(writes: &[VoxelWrite], cell: Cell) -> hexwork_core::voxel::Voxel { + writes + .binary_search_by_key(&(cell[2], cell[1], cell[0]), |write| { + (write.cell[2], write.cell[1], write.cell[0]) + }) + .ok() + .map(|index| writes[index].voxel) + .unwrap_or_default() + } + + /// Core solidity is `material != AIR`, so routing must read material rather + /// than the legacy `occ` flag. Enterable water depth is the real movement + /// policy (`MovementPolicy::default().max_water_depth == 3`; core `wet` + /// blocks only at `fluid_amt >= 4`). + pub fn passable(writes: &[VoxelWrite], cell: Cell) -> bool { + let air = hexwork_core::voxel::material::AIR; + let body = voxel_at(writes, cell); + let head = voxel_at(writes, [cell[0], cell[1], cell[2] + 1]); + let below = voxel_at(writes, [cell[0], cell[1], cell[2] - 1]); + body.material == air && head.material == air && below.material != air && body.fluid_amt <= 3 + } + + /// NPCs walk to their scheduled `home`/`work` posts every tick, so a static + /// route must treat those posts as blocked too. + pub fn world_blockers(plan: &WorldPlan) -> BTreeSet { + plan.actors + .iter() + .flat_map(|actor| [Some(actor.position), actor.home, actor.work]) + .flatten() + .collect() + } + + pub fn floor_blockers(plan: &FloorPlan) -> BTreeSet { + plan.actors + .iter() + .flat_map(|actor| [Some(actor.position), actor.home, actor.work]) + .flatten() + .collect() + } + + /// Canonical compass word for a discrete input, matching `parse_input`. + pub fn input_word(input: Input) -> &'static str { + match input { + Input::Up => "north", + Input::Down => "south", + Input::Left => "west", + Input::Right => "east", + Input::UpRight => "northeast", + Input::UpLeft => "northwest", + Input::DownRight => "southeast", + Input::DownLeft => "southwest", + Input::Confirm => "confirm", + Input::Pickup => "pickup", + Input::Drop => "drop", + _ => panic!("route contains non-cardinal input: {input:?}"), + } + } + + fn cardinal(delta: (i32, i32)) -> Input { + match delta { + (0, -1) => Input::Up, + (0, 1) => Input::Down, + (-1, 0) => Input::Left, + (1, 0) => Input::Right, + other => panic!("not a cardinal route step: {other:?}"), + } + } + + /// Sample one cardinal leg at 300 mm per Move from an explicit millimetre + /// position, returning the inputs and the resulting position. + fn sample_leg(position: [i64; 2], from: Cell, to: Cell) -> (Vec, [i64; 2]) { + let (dx, dy, input) = match (to[0] - from[0], to[1] - from[1]) { + (0, -1) => (0i64, -1i64, Input::Up), + (0, 1) => (0, 1, Input::Down), + (-1, 0) => (-1, 0, Input::Left), + (1, 0) => (1, 0, Input::Right), + delta => panic!("not a cardinal route step: {delta:?}"), + }; + let mut out = Vec::new(); + let mut pos = position; + // At most four Moves cross one 1000 mm cell; bound the loop so a bug fails loudly. + for _ in 0..5 { + pos[0] += dx * 300; + pos[1] += dy * 300; + out.push(input); + if pos[0].div_euclid(1000) as i32 == to[0] && pos[1].div_euclid(1000) as i32 == to[1] { + break; + } + } + assert!( + pos[0].div_euclid(1000) as i32 == to[0] && pos[1].div_euclid(1000) as i32 == to[1], + "sampled route never reached cell {to:?}" + ); + (out, pos) + } + + /// Sample a whole cell path from its start cell's centre. + fn sample_path(path: &[Cell]) -> Vec { + let mut pos = [ + i64::from(path[0][0]) * 1000 + 500, + i64::from(path[0][1]) * 1000 + 500, + ]; + let mut out = Vec::new(); + for pair in path.windows(2) { + let (leg, end) = sample_leg(pos, pair[0], pair[1]); + out.extend(leg); + pos = end; + } + out + } + + pub fn route_words( + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, + ) -> Vec { + route_words_opt(writes, start, goal, blockers) + .unwrap_or_else(|| panic!("no deterministic route from {start:?} to {goal:?}")) + } + + pub fn route_words_opt( + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, + ) -> Option> { + if start == goal { + return Some(Vec::new()); + } + let mut queue = VecDeque::from([start]); + let mut previous: BTreeMap = BTreeMap::new(); + let mut seen = BTreeSet::from([start]); + while let Some(cell) = queue.pop_front() { + for delta in [(0, -1), (-1, 0), (1, 0), (0, 1)] { + let next = [cell[0] + delta.0, cell[1] + delta.1, cell[2]]; + if seen.contains(&next) + || (blockers.contains(&next) && next != goal) + || !passable(writes, next) + { + continue; + } + previous.insert(next, (cell, cardinal(delta))); + if next == goal { + let mut path = vec![goal]; + let mut cursor = goal; + while cursor != start { + cursor = previous[&cursor].0; + path.push(cursor); + } + path.reverse(); + return Some(sample_path(&path)); + } + seen.insert(next); + queue.push_back(next); + } + } + None + } + + pub fn append_route( + out: &mut String, + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, + ) { + append_route_extra(out, writes, start, goal, blockers, 0); + } + + /// As [`route_words`], but repeat the final cardinal step `extra` times. A + /// blocked sample spends a tick without moving, so real walks can under-run + /// the exact 300 mm model; extra steps push the body through the goal cell and + /// are absorbed by whatever wall bounds it, self-healing that drift. + pub fn route_words_extra( + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, + extra: u8, + ) -> Vec { + let mut words = route_words(writes, start, goal, blockers); + if let Some(last) = words.last().copied() { + for _ in 0..extra { + words.push(last); + } + } + words + } + + pub fn append_route_extra( + out: &mut String, + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, + extra: u8, + ) { + for input in route_words_extra(writes, start, goal, blockers, extra) { + out.push_str("input "); + out.push_str(input_word(input)); + out.push('\n'); + } + } + + /// Player body is 600 mm wide by 1800 mm tall (`BodyExtents::PLAYER`), so a + /// body offset by one 300 mm step clips the flanking wall of a one-cell + /// dead-end mouth (the D4 hymn-card alcove at `(26,6)` is exactly this). The + /// plain 300 mm model cannot see that clip, which is the real cause of the + /// "stops one cell short, x pinned to the cell edge" drift. + pub const PLAYER_HALF_MM: i64 = 300; + + fn perpendicular(input: Input) -> [i64; 3] { + match input { + Input::Up | Input::Down => [300, 0, 0], + _ => [0, 300, 0], + } + } + + fn perpendicular_word(axis: [i64; 3]) -> Input { + if axis[0] != 0 { + if axis[0] > 0 { + Input::Right + } else { + Input::Left + } + } else if axis[1] > 0 { + Input::Down + } else { + Input::Up + } + } + + fn leg_input(from: Cell, to: Cell) -> Input { + match (to[0] - from[0], to[1] - from[1]) { + (0, -1) => Input::Up, + (0, 1) => Input::Down, + (-1, 0) => Input::Left, + (1, 0) => Input::Right, + delta => panic!("not a cardinal route step: {delta:?}"), + } + } + + /// BFS cell path (canonical compass), goal exempt from obstacles so the caller + /// can stand on a posted cell. + pub fn path_to( + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, + ) -> Vec { + if start == goal { + return vec![start]; + } + let mut queue = VecDeque::from([start]); + let mut previous: BTreeMap = BTreeMap::new(); + let mut seen = BTreeSet::from([start]); + while let Some(cell) = queue.pop_front() { + for delta in [(0, -1), (-1, 0), (1, 0), (0, 1)] { + let next = [cell[0] + delta.0, cell[1] + delta.1, cell[2]]; + if seen.contains(&next) + || (blockers.contains(&next) && next != goal) + || !passable(writes, next) + { + continue; + } + previous.insert(next, cell); + if next == goal { + let mut path = vec![goal]; + let mut cursor = goal; + while cursor != start { + cursor = previous[&cursor]; + path.push(cursor); + } + path.reverse(); + return path; + } + seen.insert(next); + queue.push_back(next); + } + } + panic!("no route from {start:?} to {goal:?}"); + } + + pub fn sample_cells(path: &[Cell]) -> Vec { + sample_path(path) + } + + /// Sample a cell path and return the exact modelled end millimetre position. + pub fn sample_path_end(path: &[Cell]) -> (Vec, [i64; 2]) { + let mut pos = [ + i64::from(path[0][0]) * 1000 + 500, + i64::from(path[0][1]) * 1000 + 500, + ]; + let mut out = Vec::new(); + for pair in path.windows(2) { + let (leg, end) = sample_leg(pos, pair[0], pair[1]); + out.extend(leg); + pos = end; + } + (out, pos) + } + + /// Enter `goal` through a one-cell mouth with the body re-centred. + /// + /// The player body is 600 mm wide (`BodyExtents::PLAYER`), so when both + /// perpendicular neighbours of `goal` are solid, the body must be within + /// ±200 mm of the goal column centre or the approach step clips a flanking + /// wall (the D4 hymn-card alcove `(26,6)` and the `(3,10,-39)` landing are + /// both this shape). The plain 300 mm sample can stop one cell short with the + /// body pinned to the cell edge. Because the sample is exact, the modelled + /// perpendicular offset is known, so a short cardinal nudge centred on the + /// goal column is emitted before the approach. Open-cell goals are unchanged. + pub fn route_words_centered( + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, + ) -> Vec { + let path = path_to(writes, start, goal, blockers); + if path.len() < 2 { + return Vec::new(); + } + let pre = path[path.len() - 2]; + let approach = leg_input(pre, goal); + let (mut words, end) = sample_path_end(&path[..path.len() - 1]); + let (full, _) = sample_path_end(&path); + let final_steps = full.len() - words.len(); + let p = perpendicular(approach); + let axis = if p[0] != 0 { 0usize } else { 1usize }; + let mut neg = goal; + neg[axis] -= 1; + let mut pos = goal; + pos[axis] += 1; + let perp_solid = !passable(writes, neg) && !passable(writes, pos); + if perp_solid { + let center = i64::from(goal[axis]) * 1000 + 500; + let delta = center - end[axis]; + let steps = (delta as f64 / 300.0).round() as i64; + let word = if steps >= 0 { + perpendicular_word(p) + } else { + perpendicular_word([-p[0], -p[1], -p[2]]) + }; + for _ in 0..steps.abs() { + words.push(word); + } + } + for _ in 0..final_steps.max(1) { + words.push(approach); + } + // Surplus presses are only safe when the goal is a true dead-end along the + // approach axis too; otherwise they would overshoot into the next cell. + let (move_axis, sign) = match approach { + Input::Up => (1usize, -1i32), + Input::Down => (1, 1), + Input::Left => (0, -1), + Input::Right => (0, 1), + _ => panic!("non-cardinal approach {approach:?}"), + }; + let mut beyond = goal; + beyond[move_axis] += sign; + if perp_solid && !passable(writes, beyond) { + for _ in 0..3 { + words.push(approach); + } + } + words + } +} +/// Shared re-planning driver for the integration tests: every leg is re-planned +/// from the durable position so 300 mm samples, walkers leaving their posts, and +/// chasing monsters cannot strand a frozen route. +pub mod route_driver { + use super::{sampled_route, Harness}; + use hexwork_save::{decode_run, read_file_bounded, DecodedRun, MAX_COMPRESSED_BYTES}; + use hexwork_worldgen::{Cell, VoxelWrite}; + use std::collections::BTreeSet; + + pub fn decode(h: &Harness, run_id: &str) -> DecodedRun { + let pack = hexwork_content::RuntimePack::load(&h.content).unwrap(); + decode_run( + &read_file_bounded( + h.data.join("runs").join(run_id).join("run.save"), + MAX_COMPRESSED_BYTES, + ) + .unwrap(), + pack.hash(), + ) + .unwrap() + } + + pub fn player_cell(run: &DecodedRun, player: u64) -> Cell { + run.current + .actors + .iter() + .find(|a| a.id == player) + .unwrap() + .position + .cell() + } + + /// Run one headless command batch against the managed run. + pub fn step(h: &Harness, run_id: &str, body: &str) { + let script = h.script("route-driver-step.commands", body); + let mut c = h.managed(&script); + c.arg("--load").arg(run_id); + let out = Harness::run(c); + assert!( + out.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + } + + /// Bounded attempt cap: a genuinely unreachable target fails fast. + pub const ATTEMPTS: usize = 8; + + /// One re-planning batch: route the body-aware sampler from the durable cell to + /// `target` and run it. Decodes the run exactly once. + pub fn walk_to( + h: &Harness, + run_id: &str, + writes: &[VoxelWrite], + blockers: &BTreeSet, + target: Cell, + ) { + for _ in 0..ATTEMPTS { + let run = decode(h, run_id); + let current = player_cell(&run, 1); + if current == target { + return; + } + assert_eq!( + current[2], target[2], + "walk_to left its floor: {current:?} -> {target:?}" + ); + let mut body = String::new(); + for word in sampled_route::route_words_centered(writes, current, target, blockers) { + body.push_str("input "); + body.push_str(sampled_route::input_word(word)); + body.push('\n'); + } + body.push_str("save\nquit\n"); + step(h, run_id, &body); + } + panic!("walk_to did not reach {target:?} within {ATTEMPTS} attempts"); + } +} use std::process::{Command, ExitStatus, Output, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{sync_channel, RecvTimeoutError, SyncSender}; diff --git a/crates/hexwork-cli/tests/tinker_live.rs b/crates/hexwork-cli/tests/tinker_live.rs index 3529eb0..79c3e47 100644 --- a/crates/hexwork-cli/tests/tinker_live.rs +++ b/crates/hexwork-cli/tests/tinker_live.rs @@ -9,43 +9,25 @@ use hexwork_save::{ use std::{fs, path::Path, process::Command}; use support::Harness; -// Frozen ordinary seed140 route: Town -> D1 -> D2 -> D3 -> D4 and back. -// Only public movement, connector Confirm and ground Pickup inputs; no save edits. -fn route() -> String { - let mut s = String::new(); - let mut moves = |direction: &str, count: usize| { - for _ in 0..count { - s.push_str(&format!("input {direction}\n")); - } - }; - moves("south", 24); - moves("southeast", 1); - moves("confirm", 1); - for _ in 0..3 { - moves("east", 26); - moves("confirm", 1); - } - moves("east", 20); - moves("southeast", 3); - moves("south", 1); - moves("pickup", 1); - moves("confirm", 1); - moves("north", 1); - moves("west", 20); - moves("northwest", 3); - moves("confirm", 1); - for _ in 0..3 { - moves("west", 26); - moves("confirm", 1); - } - moves("north", 2); - moves("northwest", 2); - moves("west", 8); - moves("northwest", 5); - moves("north", 4); - moves("east", 1); - s +// Ordinary seed140 route: Town -> D1 -> D2 -> D3 -> D4 -> hymn card -> back to the +// tinker. Real movement advances 300 mm per Move, walkers leave their posts, and +// the deep floors hold chasing monsters, so a frozen input list cannot survive the +// trip. Every leg is instead re-planned from the durable cell after the previous +// batch, using only public movement, connector Confirm and ground Pickup inputs. +const SEED: u64 = 140; + + +fn paired<'a>( + connectors: &'a [hexwork_worldgen::ConnectorPlan], + source: &hexwork_worldgen::ConnectorPlan, +) -> &'a hexwork_worldgen::ConnectorPlan { + connectors + .iter() + .find(|c| c.transition_id == source.paired_transition_id) + .expect("paired connector") } + + fn ok(c: Command) -> std::process::Output { let out = Harness::run(c); assert!( @@ -86,8 +68,15 @@ fn loaded(h: &Harness, name: &str, body: &str) -> DecodedRun { } fn prepare(h: &Harness, mode: Mode) -> DecodedRun { assert!(!h.data.exists()); - let script = h.script("route.commands", &format!("{}save\nquit\n", route())); - let mut c = h.managed(&script); + let town = hexwork_worldgen::milthollow(SEED); + let floors = hexwork_worldgen::playable_deep(SEED); + let town_down = town + .connectors + .iter() + .find(|c| c.destination == hexwork_worldgen::PlanDestination::Deep { depth: 1 }) + .unwrap(); + let init = h.script("init.commands", "save\nquit\n"); + let mut c = h.managed(&init); c.args([ "--seed", "140", @@ -102,6 +91,55 @@ fn prepare(h: &Harness, mode: Mode) -> DecodedRun { c.arg("--story"); } ok(c); + let run_id = id(h); + let town_blockers = support::sampled_route::world_blockers(&town); + support::route_driver::walk_to(h, &run_id, &town.writes, &town_blockers, town_down.access); + support::route_driver::step(h, &run_id, "input confirm\nsave\nquit\n"); + let mut prev_down = town_down; + let mut downs = Vec::new(); + for depth in 1..=3usize { + let f = &floors[depth - 1]; + let down = f + .connectors + .iter() + .find(|c| c.destination == hexwork_worldgen::PlanDestination::Deep { depth: (depth + 1) as u8 }) + .unwrap(); + let blockers = support::sampled_route::floor_blockers(f); + support::route_driver::walk_to(h, &run_id, &f.writes, &blockers, down.access); + support::route_driver::step(h, &run_id, "input confirm\nsave\nquit\n"); + prev_down = down; + downs.push(down); + } + let f4 = &floors[3]; + let up4 = paired(&f4.connectors, prev_down); + let item = f4 + .items + .iter() + .find(|i| i.template_id == "hymn_card_blank") + .unwrap(); + let f4_blockers = support::sampled_route::floor_blockers(f4); + support::route_driver::walk_to(h, &run_id, &f4.writes, &f4_blockers, item.position); + support::route_driver::step(h, &run_id, "input pickup\ninput confirm\nsave\nquit\n"); + // Ascending through a floor's up-connector lands the player at the + // down-connector access (29,10) of the floor above, so each floor needs a + // walk across to its up-connector access (3,10) before the next Confirm. + support::route_driver::walk_to(h, &run_id, &f4.writes, &f4_blockers, up4.access); + support::route_driver::step(h, &run_id, "input confirm\nsave\nquit\n"); + for depth in (0..=2usize).rev() { + let f = &floors[depth]; + let base = if depth == 0 { town_down } else { downs[depth - 1] }; + let up = paired(&f.connectors, base); + let blockers = support::sampled_route::floor_blockers(f); + support::route_driver::walk_to(h, &run_id, &f.writes, &blockers, up.access); + support::route_driver::step(h, &run_id, "input confirm\nsave\nquit\n"); + } + let tinker = town + .interactions + .iter() + .find(|i| matches!(&i.kind, hexwork_worldgen::InteractionKind::Service { id } if id == "tinker")) + .unwrap(); + support::route_driver::walk_to(h, &run_id, &town.writes, &town_blockers, tinker.access); + let r = decode(h); assert_eq!( r.identity.content_hash, @@ -134,7 +172,7 @@ fn prepare(h: &Harness, mode: Mode) -> DecodedRun { ) .unwrap(); assert_eq!(r.identity.lineage_id, vault.lineage_id); - assert_eq!(r.events.len(), 260); + assert_eq!(r.events.len(), 1061); assert!(r.events.iter().any(|e| matches!( e.kind, SessionEventKind::Action(CommittedAction::Pickup { item_id: 43 }) @@ -189,7 +227,7 @@ fn transaction(before: &DecodedRun, after: &DecodedRun, paid: bool, ticks: bool) e.kind, SessionEventKind::Action(CommittedAction::TinkerIdentify { item_id: 43 }) )); - assert_eq!(e.pre_tick, e.post_tick); + assert_eq!(e.post_tick - e.pre_tick, 20); } if !ticks { assert_eq!(suffix.len(), usize::from(paid)); @@ -235,7 +273,8 @@ fn tinker_generated_unknown_roundtrip() { ); transaction(&before, &cancelled, false, false); let after = purchase(&h); - transaction(&before, &after, true, false); + // A committed paid identification now spends its authored twenty ticks. + transaction(&before, &after, true, true); } } #[test] @@ -244,7 +283,7 @@ fn tinker_save_reload_binary_replay() { let h = Harness::new("tinker-replay"); let before = prepare(&h, mode); let after = purchase(&h); - transaction(&before, &after, true, false); + transaction(&before, &after, true, true); let restored = loaded(&h, "reload.commands", ""); assert_eq!(restored.identity, after.identity); assert_eq!(restored.events, after.events); diff --git a/crates/hexwork-cli/tests/transfers_lifecycle.rs b/crates/hexwork-cli/tests/transfers_lifecycle.rs index b76b22c..cdce949 100644 --- a/crates/hexwork-cli/tests/transfers_lifecycle.rs +++ b/crates/hexwork-cli/tests/transfers_lifecycle.rs @@ -17,7 +17,7 @@ use hexwork_save::{ RunId, SessionEvent, SessionEventKind, MAX_COMPRESSED_BYTES, MAX_MARKER_BYTES, MAX_VAULT_BYTES, }; use hexwork_worldgen::{Cell, ConnectorPlan, FloorPlan, PlanDestination, VoxelWrite, WorldPlan}; -use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -116,8 +116,8 @@ fn transfer_cycle_item(events: &[SessionEvent]) -> u64 { other => panic!("second durable action was not matching pickup: {other:?}"), }; assert_eq!(pickup_id, drop_id); - assert_eq!(events[0].pre_tick, events[0].post_tick); - assert_eq!(events[1].pre_tick, events[1].post_tick); + assert_eq!(events[0].post_tick - events[0].pre_tick, 10); + assert_eq!(events[1].post_tick - events[1].pre_tick, 10); drop_id } @@ -177,16 +177,6 @@ fn assert_actual_replay(harness: &Harness, replay_path: &Path, expected_hash: u6 ); } -fn input_name(delta: (i32, i32)) -> &'static str { - match delta { - (0, 1) => "north", - (-1, 0) => "west", - (1, 0) => "east", - (0, -1) => "south", - _ => unreachable!("only cardinal route deltas"), - } -} - fn append_route( output: &mut String, writes: &[VoxelWrite], @@ -194,40 +184,7 @@ fn append_route( goal: Cell, blockers: BTreeSet, ) { - if start == goal { - return; - } - let directions = [(0, 1), (-1, 0), (1, 0), (0, -1)]; - let mut queue = VecDeque::from([start]); - let mut previous = BTreeMap::new(); - let mut seen = BTreeSet::from([start]); - while let Some(cell) = queue.pop_front() { - for delta in directions { - let next = [cell[0] + delta.0, cell[1] + delta.1, cell[2]]; - if seen.contains(&next) || blockers.contains(&next) || !passable(writes, next) { - continue; - } - previous.insert(next, (cell, delta)); - if next == goal { - let mut path = Vec::new(); - let mut cursor = goal; - while cursor != start { - let (prior, step) = previous[&cursor]; - path.push(step); - cursor = prior; - } - for step in path.into_iter().rev() { - output.push_str("input "); - output.push_str(input_name(step)); - output.push('\n'); - } - return; - } - seen.insert(next); - queue.push_back(next); - } - } - panic!("no deterministic ordinary route from {start:?} to {goal:?}"); + support::sampled_route::append_route(output, writes, start, goal, &blockers); } fn voxel_at(writes: &[VoxelWrite], cell: Cell) -> hexwork_core::voxel::Voxel { @@ -248,11 +205,11 @@ fn passable(writes: &[VoxelWrite], cell: Cell) -> bool { } fn world_blockers(plan: &WorldPlan) -> BTreeSet { - plan.actors.iter().map(|actor| actor.position).collect() + support::sampled_route::world_blockers(plan) } fn floor_blockers(plan: &FloorPlan) -> BTreeSet { - plan.actors.iter().map(|actor| actor.position).collect() + support::sampled_route::floor_blockers(plan) } fn paired<'a>(connectors: &'a [ConnectorPlan], source: &ConnectorPlan) -> &'a ConnectorPlan { diff --git a/crates/hexwork-cli/tests/transfers_live.rs b/crates/hexwork-cli/tests/transfers_live.rs index dfa9ada..a7b0331 100644 --- a/crates/hexwork-cli/tests/transfers_live.rs +++ b/crates/hexwork-cli/tests/transfers_live.rs @@ -110,10 +110,12 @@ fn input_name(input: Input) -> &'static str { } fn physical_key(input: Input) -> char { - // Routes are canonical (+Y Up); fresh physical cameras face north (-Y). + // Routes are canonical (`translate_input`: north is -Y, east is +X) and the + // shipped keymap binds `up = ["Up","w","k"]` / `down = ["Down","s","j"]`, so + // physical w/s/a/d map straight through to Up/Down/Left/Right. match input { - Input::Up => 's', - Input::Down => 'w', + Input::Up => 'w', + Input::Down => 's', Input::Left => 'a', Input::Right => 'd', _ => panic!("route contains non-cardinal input: {input:?}"), @@ -128,73 +130,18 @@ fn append_route_script(body: &mut String, route: &[Input]) { } } -fn voxel_at(writes: &[VoxelWrite], cell: Cell) -> hexwork_core::voxel::Voxel { - writes - .binary_search_by_key(&(cell[2], cell[1], cell[0]), |write| { - (write.cell[2], write.cell[1], write.cell[0]) - }) - .ok() - .map(|index| writes[index].voxel) - .unwrap_or_default() -} - -fn passable(writes: &[VoxelWrite], cell: Cell) -> bool { - let body = voxel_at(writes, cell); - let head = voxel_at(writes, [cell[0], cell[1], cell[2] + 1]); - let below = voxel_at(writes, [cell[0], cell[1], cell[2] - 1]); - body.occ == 0 && head.occ == 0 && below.occ != 0 && body.fluid_amt <= 2 -} - fn route_between( writes: &[VoxelWrite], start: Cell, goal: Cell, blockers: &BTreeSet, ) -> Option> { - if start == goal { - return Some(Vec::new()); - } - let directions = [ - (0, 1, Input::Up), - (-1, 0, Input::Left), - (1, 0, Input::Right), - (0, -1, Input::Down), - ]; - let mut queue = VecDeque::from([start]); - let mut previous = BTreeMap::new(); - let mut seen = BTreeSet::from([start]); - while let Some(cell) = queue.pop_front() { - for (dx, dy, input) in directions { - let next = [cell[0] + dx, cell[1] + dy, cell[2]]; - if seen.contains(&next) || blockers.contains(&next) || !passable(writes, next) { - continue; - } - previous.insert(next, (cell, input)); - if next == goal { - let mut route = Vec::new(); - let mut cursor = goal; - while cursor != start { - let (prior, direction) = previous[&cursor]; - route.push(direction); - cursor = prior; - } - route.reverse(); - return Some(route); - } - seen.insert(next); - queue.push_back(next); - } - } - None + support::sampled_route::route_words_opt(writes, start, goal, blockers) } fn generated_route(seed: u64) -> GeneratedRoute { let town = hexwork_worldgen::milthollow(seed); - let blockers = town - .actors - .iter() - .map(|actor| actor.position) - .collect::>(); + let blockers = support::sampled_route::world_blockers(&town); town.items .iter() .filter_map(|item| { @@ -211,51 +158,6 @@ fn generated_route(seed: u64) -> GeneratedRoute { .expect("immutable town plan has reachable ordinary generated loot") } -fn blocked_move_route(seed: u64) -> (Vec, Input, Input) { - let town = hexwork_worldgen::milthollow(seed); - let blockers = town - .actors - .iter() - .map(|actor| actor.position) - .collect::>(); - let directions = [ - (0, 1, Input::Up), - (-1, 0, Input::Left), - (1, 0, Input::Right), - (0, -1, Input::Down), - ]; - let mut queue = VecDeque::from([town.start]); - let mut previous = BTreeMap::new(); - let mut seen = BTreeSet::from([town.start]); - while let Some(cell) = queue.pop_front() { - for (dx, dy, rejected) in directions { - let wall = [cell[0] + dx, cell[1] + dy, cell[2]]; - let body = voxel_at(&town.writes, wall); - let head = voxel_at(&town.writes, [wall[0], wall[1], wall[2] + 1]); - if body.occ == 0 && head.occ == 0 { - continue; - } - if let Some((_, _, legal)) = directions.into_iter().find(|(lx, ly, _)| { - let next = [cell[0] + lx, cell[1] + ly, cell[2]]; - next != wall && passable(&town.writes, next) && !blockers.contains(&next) - }) { - let route = route_between(&town.writes, town.start, cell, &blockers).unwrap(); - return (route, rejected, legal); - } - } - for (dx, dy, input) in directions { - let next = [cell[0] + dx, cell[1] + dy, cell[2]]; - if seen.contains(&next) || blockers.contains(&next) || !passable(&town.writes, next) { - continue; - } - previous.insert(next, (cell, input)); - seen.insert(next); - queue.push_back(next); - } - } - panic!("immutable town plan has no ordinary blocked-move witness") -} - fn generated_item_ids(run: &DecodedRun, route: &GeneratedRoute) -> Vec { run.current .ground_items @@ -368,8 +270,8 @@ fn binary_transfer_route_save_load_and_replay() { }) .expect("recorded generated pickup"); assert_eq!( - picked.events[pickup_index].pre_tick, - picked.events[pickup_index].post_tick + picked.events[pickup_index].post_tick - picked.events[pickup_index].pre_tick, + 10 ); let picked_events = picked.events.clone(); @@ -410,8 +312,8 @@ fn binary_transfer_route_save_load_and_replay() { SessionEventKind::Action(CommittedAction::Drop { item_id: actual }) if actual == item_id )); assert_eq!( - dropped.events.last().unwrap().pre_tick, - dropped.events.last().unwrap().post_tick + dropped.events.last().unwrap().post_tick - dropped.events.last().unwrap().pre_tick, + 10 ); let final_hash = dropped.current.try_hash_state().unwrap(); let final_tick = dropped.current.tick; @@ -557,21 +459,17 @@ fn binary_pty_transfer_prompt_cancel_confirm_and_rejection() { assert_eq!(summary["default_drop_hint_visible"], true); assert_pty_transfer_artifact(&transfer_harness, &summary, &route); - // Separate ordinary movement rejection: no curse fixture exists in the - // shipped binary. The rejected wall move adds no event, while the legal key - // in the same drained PTY input batch still commits and the session continues. + // Genuine ordinary rejection: an out-of-range InspectBoard (the Contracts key + // away from the notice board). A blocked ordinary Move is now a recorded + // no-op rather than a rejection (`move_player` spends a tick without transit + // and never rejects), so InspectBoard is the canonical rejecting action. The + // rejection is visible and non-fatal, and the legal follow-up Move in the same + // drained input batch still commits. let rejection_harness = Harness::new("transfers-live-pty-move-rejection"); - let (prefix, rejected, legal) = blocked_move_route(SEED); let rejection = run_pty( &rejection_harness, "rejection", - &serde_json::json!({ - "seed": SEED, - "prefix_keys": prefix.iter().copied().map(physical_key).collect::(), - "rejected_key": physical_key(rejected).to_string(), - "legal_key": physical_key(legal).to_string(), - "prefix_len": prefix.len(), - }), + &serde_json::json!({ "seed": SEED }), ); assert_eq!(rejection["exit"], 0); assert_eq!(rejection["window_80x24"], true); @@ -582,11 +480,16 @@ fn binary_pty_transfer_prompt_cancel_confirm_and_rejection() { rejection["run_id"].as_str().expect("rejection run ID"), ); let actions = action_events(&rejected_run); - assert_eq!(actions.len(), prefix.len() + 1); - assert!(!actions.iter().any(|action| matches!( - action, - CommittedAction::Pickup { .. } | CommittedAction::Drop { .. } - ))); + assert_eq!( + actions.len(), + 1, + "the rejected board inspect is neither journaled nor tick-spending; only the legal follow-up Move is" + ); + assert!( + matches!(actions[0], CommittedAction::Move(_)), + "the legal follow-up must be an ordinary Move, got {:?}", + actions[0] + ); } #[test] diff --git a/crates/hexwork-cli/tests/wield_live.rs b/crates/hexwork-cli/tests/wield_live.rs index dc4a662..44f6ba3 100644 --- a/crates/hexwork-cli/tests/wield_live.rs +++ b/crates/hexwork-cli/tests/wield_live.rs @@ -8,7 +8,7 @@ use hexwork_save::{ }; use hexwork_worldgen::{Cell, PlanDestination, VoxelWrite}; use std::{ - collections::{BTreeMap, BTreeSet, VecDeque}, + collections::BTreeSet, fs, path::Path, process::Command, @@ -56,7 +56,7 @@ fn assert_attack(before: &DecodedRun, after: &DecodedRun, target: u64) { assert!( matches!(event.kind, SessionEventKind::Action(CommittedAction::ResolveAttack { target_actor }) if target_actor == target) ); - assert_eq!(event.pre_tick, event.post_tick); + assert_eq!(event.post_tick - event.pre_tick, 10); } #[test] @@ -129,7 +129,7 @@ fn binary_pty_wield_confirmation_and_loaded_keys() { assert!( matches!(wield_events[0].kind, SessionEventKind::Action(CommittedAction::Wield{item_id}) if item_id == pike) ); - assert_eq!(wield_events[0].pre_tick, wield_events[0].post_tick); + assert_eq!(wield_events[0].post_tick - wield_events[0].pre_tick, 10); } let restored = loaded(&h, "pty-load.commands", ""); assert_eq!(restored.identity, run.identity); @@ -214,45 +214,61 @@ const DIRS: [(i32, i32, &str); 4] = [ (0, -1, "south"), ]; fn route(w: &[VoxelWrite], start: Cell, goal: Cell, blockers: &BTreeSet) -> Option { - let mut queue = VecDeque::from([start]); - let mut seen = BTreeSet::from([start]); - let mut previous = BTreeMap::new(); - while let Some(c) = queue.pop_front() { - assert!(seen.len() < 65536, "bounded generated-floor BFS"); - if c == goal { - let mut steps = Vec::new(); - let mut cursor = goal; - while cursor != start { - let (prior, direction) = previous[&cursor]; - steps.push(direction); - cursor = prior; - } - steps.reverse(); - return Some(steps.into_iter().map(|d| format!("input {d}\n")).collect()); - } - for (dx, dy, d) in DIRS { - let next = [c[0] + dx, c[1] + dy, c[2]]; - if !seen.contains(&next) && !blockers.contains(&next) && clear(w, next) { - seen.insert(next); - previous.insert(next, (c, d)); - queue.push_back(next); - } - } - } - None + support::sampled_route::route_words_opt(w, start, goal, blockers).map(|words| { + words + .into_iter() + .map(|input| format!("input {}\n", support::sampled_route::input_word(input))) + .collect() + }) +} + +/// Body-aware variant used to stand in a melee goal cell: the last approach is +/// re-centred through a one-cell mouth so the player reaches the goal cell +/// rather than stopping on its edge. +fn route_centered( + w: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, +) -> Option { + support::sampled_route::route_words_opt(w, start, goal, blockers)?; + Some( + support::sampled_route::route_words_centered(w, start, goal, blockers) + .into_iter() + .map(|input| format!("input {}\n", support::sampled_route::input_word(input))) + .collect(), + ) +} +/// Squared body-surface gap in mm between two actors, assuming 600 mm wide bodies +/// (`BodyExtents::PLAYER`). This is the quantity the melee reach contract compares +/// against: ordinary reach 1000 mm, polearm reach 2000 mm. +fn reach_gap_squared(r: &DecodedRun, a: u64, b: u64) -> i128 { + let pa = r.current.actors.iter().find(|x| x.id == a).unwrap().position.millimetres(); + let pb = r.current.actors.iter().find(|x| x.id == b).unwrap().position.millimetres(); + (0..2) + .map(|i| (i128::from(pa[i]) - i128::from(pb[i])).abs().saturating_sub(600)) + .map(|gap| gap * gap) + .sum() } -fn approach(r: &DecodedRun, distance: i32, exclude: Option) -> (String, u64, Cell) { + +fn approach_candidates( + r: &DecodedRun, + distance: i32, + exclude: Option, +) -> Vec<(String, u64, Cell)> { let floors = hexwork_worldgen::playable_deep(SEED); let floor = &floors[0]; let p = player(r); let start = cell(r.current.actors.iter().find(|a| a.id == p).unwrap().position); - let blockers: BTreeSet<_> = r + let mut blockers: BTreeSet<_> = r .current .actors .iter() .filter(|a| a.alive && a.id != p) .map(|a| cell(a.position)) .collect(); + blockers.extend(support::sampled_route::floor_blockers(floor)); + let mut candidates = Vec::new(); for enemy in r.current.actors.iter().filter(|a| { a.alive && a.hp > 3 @@ -282,12 +298,53 @@ fn approach(r: &DecodedRun, distance: i32, exclude: Option) -> (String, u64 }) { continue; } - if let Some(path) = route(&floor.writes, start, goal, &blockers) { - return (path, enemy.id, goal); + if let Some(path) = route_centered(&floor.writes, start, goal, &blockers) { + candidates.push((path, enemy.id, goal)); } } } - panic!("no reachable ordinary defender at distance {distance}") + assert!( + !candidates.is_empty(), + "no reachable ordinary defender at distance {distance}" + ); + candidates +} + +/// Walk toward each candidate goal (re-planning from the durable position) until +/// the player lands inside `(min_reach_mm, max_reach_mm]` body-surface reach of a +/// candidate's defender. Returns the run, the chosen defender and its goal. +fn reach_stage( + h: &Harness, + p: u64, + candidates: &[(String, u64, Cell)], + min_reach_mm: i128, + max_reach_mm: i128, + name: &str, +) -> (DecodedRun, u64, Cell) { + let floors = hexwork_worldgen::playable_deep(SEED); + let floor = &floors[0]; + let mut run = decode(h); + for (_, target, goal) in candidates { + for _ in 0..6 { + let gap = reach_gap_squared(&run, p, *target); + if gap <= max_reach_mm * max_reach_mm && gap > min_reach_mm * min_reach_mm { + return (run, *target, *goal); + } + let current = cell(run.current.actors.iter().find(|a| a.id == p).unwrap().position); + let mut blockers: BTreeSet = run + .current + .actors + .iter() + .filter(|a| a.alive && a.id != p) + .map(|a| cell(a.position)) + .collect(); + blockers.extend(support::sampled_route::floor_blockers(floor)); + let path = route_centered(&floor.writes, current, *goal, &blockers) + .unwrap_or_else(|| panic!("no route {current:?} -> {goal:?}")); + run = loaded(h, name, &path); + } + } + panic!("no candidate defender reached the required reach band"); } #[test] fn binary_wield_pike_range_and_replay() { @@ -327,19 +384,19 @@ fn binary_wield_pike_range_and_replay() { let wielded=loaded(&h,"wield.commands",&format!("input inventory\ninput pick{slot}\ninput confirm\ninput confirm\ninput confirm\ninput escape\n")); assert_eq!(wielded.current.packs[&p].items, pack.items); assert_eq!(wielded.current.packs[&p].wielded().unwrap().id, pike); - assert_eq!(wielded.current.tick, baseline.current.tick); + assert_eq!(wielded.current.tick, baseline.current.tick + 10); assert_eq!(wielded.events.len(), 1); assert!( matches!(wielded.events[0].kind,SessionEventKind::Action(CommittedAction::Wield{item_id}) if item_id==pike) ); - assert_eq!(wielded.events[0].pre_tick, wielded.events[0].post_tick); + assert_eq!(wielded.events[0].post_tick - wielded.events[0].pre_tick, 10); let town = hexwork_worldgen::milthollow(SEED); let down = town .connectors .iter() .find(|c| c.destination == PlanDestination::Deep { depth: 1 }) .unwrap(); - let blockers = town.actors.iter().map(|a| a.position).collect(); + let blockers = support::sampled_route::world_blockers(&town); let path = route(&town.writes, town.start, down.access, &blockers).unwrap(); let arrival = loaded(&h, "deep.commands", &format!("{path}input confirm\n")); let floors = hexwork_worldgen::playable_deep(SEED); @@ -349,9 +406,24 @@ fn binary_wield_pike_range_and_replay() { .find(|c| c.transition_id == down.paired_transition_id) .unwrap(); assert_eq!(arrival.current.actors.iter().find(|a| a.id == p).unwrap().position.cell(), up.access); - let (path, first, position) = approach(&arrival, 2, None); - let before = loaded(&h, "range-position.commands", &path); - assert_eq!(before.current.actors.iter().find(|a| a.id == p).unwrap().position.cell(), position); + let candidates = approach_candidates(&arrival, 2, None); + let (before, first, position) = + reach_stage(&h, p, &candidates, 1000, 2000, "range-position.commands"); + // Reach contract, not exact-cell adjacency: the polearm lands at 2000 mm + // body-surface reach while the ordinary defender's own reach is 1000 mm, so + // the player must stand beyond 1000 mm and within 2000 mm of the target. + // A live walker can block the exact goal cell, so the walk aims at it while + // the assertion is the reach band (`position` remains the planned goal). + let gap = reach_gap_squared(&before, p, first); + assert!( + gap <= 2000 * 2000, + "player must be within polearm reach of {first}: goal={position:?} player={:?}", + cell(before.current.actors.iter().find(|a| a.id == p).unwrap().position) + ); + assert!( + gap > 1000 * 1000, + "polearm proof requires standing beyond the defender's ordinary reach: goal={position:?}" + ); assert!(hp(&before, first) > 3); let hit = loaded(&h, "range-hit.commands", "input confirm\n"); assert_eq!(hp(&hit, first), hp(&before, first) - 3); @@ -361,10 +433,17 @@ fn binary_wield_pike_range_and_replay() { before.current.causes.links().len() + 1 ); assert_attack(&before, &hit, first); - let (path, second, position) = approach(&hit, 1, Some(first)); + let candidates = approach_candidates(&hit, 1, Some(first)); + let (before, second, position) = + reach_stage(&h, p, &candidates, 0, 1000, "adjacent-position.commands"); assert_ne!(first, second); - let before = loaded(&h, "adjacent-position.commands", &path); - assert_eq!(before.current.actors.iter().find(|a| a.id == p).unwrap().position.cell(), position); + // Adjacent stage: stand inside the defender's own 1000 mm reach so it + // retaliates (again a reach band, not an exact cell). + assert!( + reach_gap_squared(&before, p, second) <= 1000 * 1000, + "player must be inside the defender's ordinary reach: goal={position:?} player={:?}", + cell(before.current.actors.iter().find(|a| a.id == p).unwrap().position) + ); assert!( hp(&before, second) > 3, "fresh defender must survive outgoing damage" -- 2.51.2