From 617b7d35121d994ec1dbe221d2e4187cd2a72c55 Mon Sep 17 00:00:00 2001 From: dhenry Date: Mon, 27 Jul 2026 23:52:39 -0400 Subject: [PATCH 1/3] fix(#661): a floating body never enters the depenetration net, and a blocked swimmer can duck UNDER an obstruction The qcat pocket strand's measured writer was NOT the swimming step-up the issue named (instrumented: it never fires there) but the depenetration net: its footprint ring probes 1u ABOVE the waterline for a swimmer at the float plane, so pocket-rim air-band contact read the swimmer as embedded, the ring push-out ate its input (the live walker_stalled), and on frame 33 a candidate a fraction outside the .wtr region took Recovery::at_column's water-blind dry-candidate fall-through and beached the still-swimming body onto the dry tile at -55.96875 - on_ground, dry, want_swim inert, one-way. Fix, three-sided, all in src/movement.rs: * the net's door: a body afloat in water never enters (buoyancy owns its vertical, the collided slide its lateral); Recovery::Afloat is deleted and only dry bodies can reach the Grounded constructor; * try_duck_under: the swimming step-up's downward mirror - a blocked swimmer may pass under an obstruction within the same 2.5u envelope the step-up climbs, taken only when diving measures strictly more progress and stays in water, so bank haul-outs (#191) keep the right of way (p1 + a new asset-free bank test both RED if the step-up is disabled); * the underworld guard's recovering arm routed through recover(). The strand test flips to asserting the ESCAPE (pocket plane -> shaft swim plane -44.982, swept over the whole formerly-stranded start band); the test that pinned the dry-candidate beach is inverted; the depenetration corpus over 42 baked zones shows dry-body recoveries byte-identical (changed: dry-body=0) and zero drifters. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV --- src/movement.rs | 372 ++++++++++++++++++++++------ tests/synthetic_scenes/mod.rs | 21 +- tests/synthetic_water_capability.rs | 90 +++---- tests/water_capability.rs | 142 ++++++----- 4 files changed, 439 insertions(+), 186 deletions(-) diff --git a/src/movement.rs b/src/movement.rs index 6ab12003..66916da2 100644 --- a/src/movement.rs +++ b/src/movement.rs @@ -305,57 +305,50 @@ pub fn zone_in_reground(col: &Collision, p: [f32; 3], underworld: Option) - /// /// The net used to write `pos` and `on_ground` inline, which made an illegal state trivially /// expressible and, in qcat, actually expressed: a body IN WATER placed on a FLOOR and marked -/// `on_ground` (#649). A swimmer in a ~12 u flooded pocket fails `footprint_clear` as a matter of -/// course — geometry is within a body radius on every side — which the net read as "embedded in -/// rock" and recovered by hunting the NEAREST floor with -/// `nearest_floor(up = STEP_UP + GROUND_ORIGIN, down = GROUND_DEPTH)`. That search takes whichever -/// floor is closer, not one the character can occupy, so it teleported swimmers in BOTH directions: -/// UP onto the tile floor 2.009 u above the pocket's swim plane (0.009 u above the waterline, hence -/// dry, hence buoyancy never fires again — the live #329 wedge coordinate), and DOWN 10–12 u onto -/// the pool floor from anywhere below it. +/// `on_ground` (#649). The net recovered any "embedded" body by hunting the NEAREST floor with +/// `nearest_floor(up = STEP_UP + GROUND_ORIGIN, down = GROUND_DEPTH)` — whichever floor is closer, +/// not one the character can occupy — so it teleported swimmers in BOTH directions: UP onto the +/// tile floor 2.009 u above the qcat pocket's swim plane (0.009 u above the waterline, hence dry, +/// hence buoyancy never fires again — the live #329 wedge coordinate), and DOWN 10–12 u onto the +/// pool floor from anywhere below it. /// -/// So the state is made unrepresentable instead of guarded: constructing a `Recovery` is the ONLY -/// way the net moves the character, [`Recovery::at_column`] is the ONLY constructor, and it picks the -/// variant from the MEDIUM. A future caller cannot forget a `if in_water` check, because there is no -/// check to forget — `Afloat` carries "feet unsupported" with it and [`CharacterController::recover`] -/// writes the matching flags. +/// #649/#658 answered that with a second, `Afloat` variant: the net still ran for a swimmer, but +/// recovered it at its own depth whenever the ring candidate was still water. **#661 measured the +/// two ways that HALF-measure still failed at the same coordinate** (see +/// [`CharacterController::depenetrate`]) **and removed the swimmer from the net entirely: a body +/// afloat in water never enters the net, so there is no afloat recovery to get wrong and the +/// `Afloat` variant is GONE.** What remains is the invariant in its strongest form: constructing a +/// `Recovery` is the ONLY way the net moves the character, the only constructible recovery is a +/// grounded one, and the only bodies that can reach the constructor are dry. #[derive(Clone, Copy, Debug, PartialEq)] enum Recovery { /// Standing on solid floor at this z. Feet supported: `on_ground = true`. Grounded(f32), - /// Floating in a water column at this z — the body's OWN depth, unchanged. Feet unsupported - /// (`on_ground = false`), so the next frame's swim/buoyancy branch still owns the body and can - /// carry it to the surface or across into the next column. - Afloat(f32), } impl Recovery { - /// The recovery available in the candidate column `(e, n)` for a body whose feet are at `z`. - /// `afloat` is the medium the body being recovered is IN, measured once at its own position. + /// The recovery available in the candidate column `(e, n)` for a DRY body whose feet are at + /// `z`: the nearest floor within the step band above / `GROUND_DEPTH` below. Byte-identical to + /// the behaviour the net has always had for a dry body. /// - /// An afloat body is recovered **at its own depth**, never onto a floor, whenever the candidate - /// column is still water there: a swimmer is not embedded in the sense the net assumes, so the - /// only thing wrong with its position is the horizontal overlap the ring push-out is already - /// resolving. Moving it vertically as well is what produced both #649 symptoms. - /// - /// Everything else — every dry body, and an afloat body whose candidate column is NOT water - /// (it left the water laterally) — takes the original floor search, byte-identical. - /// - /// An `Afloat` candidate must ALSO be non-[`is_embedded`], which for a clear footprint means "a - /// floor exists somewhere below". Without that clause the net hands back a spot it would flag - /// again on the very next frame, and a swimmer over unbounded water drifts one ring-radius per - /// frame for ever (#649 review, finding 1). When no candidate qualifies the ring simply runs out - /// and the existing stuck / last-good machinery takes over, exactly as it does today for a dry - /// body with nowhere to go — an afloat body is never quietly handed the floor search as a - /// consolation prize, because that IS the water-blind behaviour this change removes. - fn at_column(col: &Collision, e: f32, n: f32, z: f32, afloat: bool) -> Option { - if afloat && body_in_water(col, [e, n, z]) { - return (!is_embedded(col, [e, n, z])).then_some(Recovery::Afloat(z)); - } + /// > ### ⚠️ Correction (#661) + /// > Until #661 this took an `afloat` flag, and its doc said an afloat body whose candidate + /// > column was NOT water had "left the water laterally" and so "takes the original floor + /// > search, byte-identical" — and the code did. Both halves were wrong. The body had not left + /// > the water — it was still afloat at its own position; only the RING CANDIDATE was outside + /// > the `.wtr` region's XY extent. Handing that case the water-blind `nearest_floor` hunt was + /// > the #649 defect through a side door, and it was the measured writer of the #661 strand: + /// > at the qcat spawn pocket, wet candidates held the swimmer `Afloat` through 16 push-outs + /// > and then one candidate fell a fraction of a unit outside the water region and the + /// > fall-through beached the still-swimming body onto the tile floor at −55.96875 — 0.009 u + /// > above the waterline, DRY, `on_ground` — where `want_swim` is inert and the transition is + /// > one-way. The `afloat` arm is not "fixed"; the call can no longer happen (see + /// > [`CharacterController::depenetrate`]). + fn at_column(col: &Collision, e: f32, n: f32, z: f32) -> Option { col.nearest_floor(e, n, z, STEP_UP + GROUND_ORIGIN, GROUND_DEPTH).map(Recovery::Grounded) } - fn z(self) -> f32 { match self { Recovery::Grounded(z) | Recovery::Afloat(z) => z } } + fn z(self) -> f32 { match self { Recovery::Grounded(z) => z } } fn on_ground(self) -> bool { matches!(self, Recovery::Grounded(_)) } } @@ -668,18 +661,44 @@ impl CharacterController { // Allow step-up while SWIMMING too, not just when grounded: that's how a character hauls // OUT of water onto the shore (swimming clears on_ground, so without this it just presses // into the bank lip at the surface and can't climb the last few units, #191). + let mut ducked = false; if (self.on_ground || swimming) && low_hit && low_prog + 0.01 < hlen(wish) { - if let Some(step) = self.try_step_up(wish, max_step, col) { - if hlen([step[0] - self.pos[0], step[1] - self.pos[1], 0.0]) > low_prog + 0.05 { - applied = step; - stepped = true; + // #661: a blocked SWIMMER first tries to pass UNDER the obstruction — the exact + // mirror of the step-up below, pointing down, and the reason the two are ordered: + // staying in the water is the reversible move (a swimmer that dives can always + // surface again; a haul-out onto dry ground cannot be swum back through, which is + // the one-way transition #661 is about). The step-up may only haul a swimmer out + // once the water route has been MEASURED shut — `try_duck_under` found no more + // lateral progress at diving depth. At a real bank that measurement fails (the + // bank face is solid all the way down, so diving gains nothing) and the haul-out + // proceeds exactly as before (#191); at qcat's pocket mouth the passage to the + // shaft is open water 2 u below the swim plane, so the duck carries the swimmer + // through instead of letting the lip strand it. + // + // Gated on `wish_vspeed <= 0`: an explicit upward swim wish is the walker's + // haul-out drive (water design §4c) and must never be countermanded by an + // autonomous dive. + if swimming && intent.wish_vspeed <= 0.0 { + if let Some(duck) = self.try_duck_under(wish, col) { + if hlen([duck[0] - self.pos[0], duck[1] - self.pos[1], 0.0]) > low_prog + 0.05 { + applied = duck; + ducked = true; + } + } + } + if !ducked { + if let Some(step) = self.try_step_up(wish, max_step, col) { + if hlen([step[0] - self.pos[0], step[1] - self.pos[1], 0.0]) > low_prog + 0.05 { + applied = step; + stepped = true; + } } } // Step-up couldn't cross it. If nav allows, and we're wedged ~head-on (not sliding // along a wall) against a thin barrier with walkable floor just beyond, hop over it // (a fence has flat floor both sides, so there's nothing to step UP onto). The // airborne collide-and-slide below carries us forward over the rail (#41). - if !stepped + if !stepped && !ducked && intent.hop && self.hop_cooldown <= 0.0 && self.can_hop(wish, col) @@ -696,6 +715,14 @@ impl CharacterController { self.pos[2] = applied[2]; self.vel_z = 0.0; self.on_ground = true; + } else if ducked { + // The dive half of the crossing: feet dropped to the duck depth, still in water. + // The body is mid-water by construction (`try_duck_under` requires the lowered + // start AND the destination to be in water), so support state is the swim + // branch's: not grounded, buoyancy owns the vertical from here. + self.pos[2] = applied[2]; + self.vel_z = 0.0; + self.on_ground = false; } } @@ -928,8 +955,16 @@ impl CharacterController { // unbounded — the ring then re-banks copies of the stale point, 4 → 6 → 8 — // and it is the mechanism the retracted "never banks again" claim in // `forget_recovery_history` got wrong. + // #661 (issue's "second un-`Recovery`'d writer" note): routed through + // `recover` so the fall-through guard shares the net's single + // position+support writer instead of re-stating the flags inline. Ring + // samples are banked only while grounded and non-embedded, so + // `Recovery::Grounded` holds by construction — the same justification as + // the stuck fallback in `depenetrate`. Behaviour-identical: `recover` + // additionally zeroes `stuck_time`, but this arm only runs on frames + // `depenetrate` returned false, which already reset it. let recovered = match self.good.back().copied() { - Some(g) => { self.pos = g; self.on_ground = true; true } + Some(g) => { self.recover(g[0], g[1], Recovery::Grounded(g[2])); true } None => false, // hold current pos; don't sink below underworld }; self.vel_z = 0.0; @@ -1051,6 +1086,26 @@ impl CharacterController { } } + /// The swimming step-up's downward mirror (#661): can a blocked swimmer pass UNDER the + /// obstruction by diving? Sink the feet (collided, via [`Self::swim_sink`] — the dive cannot + /// pass through the pool floor) by up to the same envelope the step-up can climb + /// (`STEP_UP + GROUND_SNAP_TOL` = 2.5 u, the controller's real step capability), then sweep the + /// wish again from the lowered position. `Some(lowered destination)` only when the dive found + /// real room below AND both the lowered start and the destination keep the body's feet in + /// water — a duck is a SWIM move; it may never exit the medium downward or sideways. + /// + /// The caller compares the returned progress against the surface slide's and only takes a duck + /// that measured strictly better — on an ascending bank face (solid to the bottom) the lowered + /// slide gains nothing and the haul-out step-up keeps the right of way (#191). + fn try_duck_under(&self, wish: [f32; 3], col: &Collision) -> Option<[f32; 3]> { + let sink = self.swim_sink(-(STEP_UP + GROUND_SNAP_TOL), col); + if sink >= -1e-3 { return None; } + let lowered = [self.pos[0], self.pos[1], self.pos[2] + sink]; + if !col.in_water(lowered) { return None; } + let (lo, _) = self.slide(lowered, wish, col); + col.in_water(lo).then_some(lo) + } + /// Is the wedged-against barrier a *hoppable* fence — i.e. is there walkable floor `HOP_REACH` /// ahead in the move direction, at roughly the current foot height? True → a low rail with flat /// floor beyond (hop over it). False → no floor in band ahead, meaning a real wall (far floor @@ -1083,7 +1138,37 @@ impl CharacterController { return false; } let p = self.pos; - if !is_embedded(col, p) { + // #661: A BODY AFLOAT IN WATER IS NEVER THE NET'S PROBLEM. The net exists for bodies + // stuck in geometry with gravity pulling them deeper; a floating body's vertical is owned + // by buoyancy and its lateral motion by the collided slide, and every question the net + // asks about it is mis-posed: + // + // * `footprint_clear` probes the ring at `feet + Body::ring` = feet + 3, and a swimmer + // floats at `surface − float_depth` = surface − 2 — so the probe tests the AIR band + // 1 u ABOVE the waterline, where every shoreline's dry geometry lives. Measured at + // the qcat spawn pocket (#661): a swimmer whose route was physically open (its slide + // made full progress on every frame it was allowed to run) read as "embedded" on + // alternate frames purely from above-surface rim contact; the ring push-out then ate + // its input and ping-ponged it in place — `walker_stalled`, live — until one candidate + // fell outside the `.wtr` region's XY extent and the water-blind floor fall-through + // beached the still-swimming body onto the dry tile at −55.96875 (`on_ground`, dry, + // `want_swim` inert: the one-way strand). + // * `ground_below(..).is_none()` reads "fallen out of the world", which is not a state + // a floating body can be in — it is how #664's clear-footprint deep-water swimmer + // was dragged into the net at all. + // * And a MORE water-aware net was measured worse, not better: an intermediate revision + // of this fix probed the footprint at the submerged torso band instead, which made + // the net fire during ordinary bank approaches and TUNNEL the body through the bank + // face to the first clear ring candidate on the far side (walker_sim P1 ended 650 u + // out to sea). There is no probe height at which "near geometry" is an emergency for + // a body that floats. + // + // So the medium decides AT THE DOOR, with the same body probe `step` uses (#649's + // `body_in_water`, pinned by `the_nets_water_probe_is_the_BODY_not_the_feet`): a floating + // body takes the ordinary clear path — stuck-clock reset, good-sample banking (waders, + // standing in shallow water, still bank) — and physics keeps custody. The dry-body net + // below is byte-identical to what it has always been. + if body_in_water(col, p) || !is_embedded(col, p) { self.stuck_time = 0.0; self.good_timer += dt; if self.on_ground && self.good_timer >= GOOD_SAMPLE_SECS { @@ -1093,17 +1178,14 @@ impl CharacterController { } return false; } - // Embedded: try a ring push-out to the nearest clear spot the body can OCCUPY. What - // "occupy" means depends on the medium, which is measured ONCE here, at the body's own - // position, and handed to `Recovery::at_column` — see `Recovery` for why a swimmer must not - // be recovered onto a floor in either direction (#649). - let afloat = body_in_water(col, p); + // Embedded (and dry, per the door above): ring push-out to the nearest clear column with + // a floor the body can stand on. for &r in &PUSHOUT_RADII { for i in 0..PUSHOUT_DIRS { let a = (i as f32) / (PUSHOUT_DIRS as f32) * std::f32::consts::TAU; let (e, n) = (p[0] + a.cos() * r, p[1] + a.sin() * r); if !col.footprint_clear(e, n, p[2], PLAYER_RADIUS, PUSHOUT_DIRS / 2) { continue; } - if let Some(rec) = Recovery::at_column(col, e, n, p[2], afloat) { + if let Some(rec) = Recovery::at_column(col, e, n, p[2]) { self.recover(e, n, rec); tracing::debug!("depenetrate: pushed out from ({:.1},{:.1},{:.1}) to ({:.1},{:.1},{:?})", p[0], p[1], p[2], e, n, rec); @@ -1615,7 +1697,7 @@ mod tests { assert!(ctrl.on_ground, "should be grounded on the pushed-out floor"); } - // ── #649: the depenetration net must not teleport a SWIMMER vertically ────────────────────── + // ── #649/#661: the depenetration net must not touch a SWIMMER at all ──────────────────────── // // A body in water that fails `footprint_clear` is NOT "embedded in rock" in the sense the net // assumes — a swimmer in a narrow flooded pocket has geometry within a body radius as a matter @@ -1624,6 +1706,14 @@ mod tests { // can occupy, and then declared `on_ground = true`. One mechanism, two symptoms, both pinned // below: it MOUNTS a swimmer on a slab above it, and it DROPS one onto the pool floor below. // + // #649/#658 kept the net running for swimmers and made the recovery depth-preserving; #661 + // then measured, at the same qcat coordinate, that the remainder was STILL two defects (the + // dry-candidate beach and the input-eating ping-pong — see `depenetrate`'s door comment), and + // the fix became: a body afloat in water never enters the net. These tests' assertions are + // unchanged in what they FORBID (a swimmer teleported vertically / grounded / dried); how the + // controller satisfies them changed from "the net recovers at own depth" to "the net stays + // out and physics keeps custody". + // // The scene mirrors the qcat spawn pocket at 1/10 scale: a flooded corridor too narrow for a // clear footprint (walls 0.8 u either side vs `PLAYER_RADIUS` 1.0), water to z = 0.5, and — in // the first test — a dry slab 2 u overhead, just inside the 3 u upward search. @@ -1666,8 +1756,23 @@ mod tests { next frame's swim/buoyancy branch never runs again"); assert!(c.in_water(ctrl.pos), "#649: and it must still be IN THE WATER it was swimming in; got {:?}", ctrl.pos); - assert!(c.footprint_clear(ctrl.pos[0], ctrl.pos[1], ctrl.pos[2], PLAYER_RADIUS, 8), - "the push-out must still have resolved the horizontal overlap: {:?}", ctrl.pos); + // ⚠️ #661 REWROTE THE FOURTH ASSERTION. It used to demand the push-out "resolve the + // horizontal overlap" (`footprint_clear` at the end position) — i.e. it required the net + // to ACT on this swimmer. Acting on swimmers is exactly what #661 removed: the ring nudge + // was cosmetic here (a body between two long parallel walls is in a narrow CANAL, not a + // trap) and the same machinery, pointed at qcat, was the strand. The replacement pins what + // actually matters about this "wedged" swimmer: it is not stuck at all — a lateral swim + // wish moves it freely along the corridor, in water, at its own depth, with no net rescue. + let mut swim_north = swim_still(); + swim_north.wish_dir = [0.0, 1.0]; + swim_north.speed = 35.0; + for _ in 0..60 { ctrl.step(swim_north, 1.0 / 60.0, &c); } + assert!(ctrl.pos[1] > 20.0, + "a swimmer between close parallel walls is in a canal, not a trap: one second of swim \ + input must carry it well along the corridor (the net must not eat its input); got {:?}", + ctrl.pos); + assert!(ctrl.pos[2].abs() < 1e-3 && c.in_water(ctrl.pos) && !ctrl.on_ground, + "…still at its own depth, wet, unsupported: {:?}", ctrl.pos); } #[test] @@ -1707,31 +1812,66 @@ mod tests { } #[test] - fn depenetration_grounds_a_swimmer_pushed_out_of_the_water_entirely() { - // The other arm of the medium test: a body that IS afloat but whose only clear neighbour is - // OUTSIDE the water takes the ordinary floor recovery, unchanged. Water is a 4 u-wide box - // around the corridor; the push-out's first clear ring point (east ±2) is outside it. + fn a_swimmer_whose_only_clear_neighbours_are_dry_is_never_beached_by_the_net() { + // ⚠️ #661 INVERTED THIS TEST. Under the name `depenetration_grounds_a_swimmer_pushed_out_ + // of_the_water_entirely` it PINNED the dry-candidate fall-through: "a body that IS afloat + // but whose only clear neighbour is OUTSIDE the water takes the ordinary floor recovery, + // unchanged", asserting the swimmer ends at z=2.0, grounded, dry. That behaviour is the + // MEASURED writer of the #661 strand: at the qcat spawn pocket the ring push-out's + // candidate fell a fraction of a unit outside the `.wtr` region's XY extent while the BODY + // was still afloat in water, the fall-through beached it onto the tile floor 0.009 u above + // the waterline, and — dry, `on_ground`, `want_swim` inert, nothing solid to sink through — + // the transition was one-way: the live soft-lock. "The candidate column is dry" never + // meant "the body left the water"; it usually means the water region's edge is nearby. + // + // The same fixture now pins the opposite: the net does not touch a floating body at all + // (see `depenetrate`'s door), so the swimmer is never beached, stays wet at its own depth, + // and — the part the old behaviour destroyed — remains fully FUNCTIONAL: swim input still + // moves it, because no recovery is eating its frames. let mut c = col(vec![floor(-12.0, -100.0, 100.0), floor(2.0, -100.0, 100.0), wall(0.8, -12.0, 10.0), wall(-0.8, -12.0, 10.0)]); c.set_water(Some(std::sync::Arc::new( crate::region_map::RegionMap::box_below(-100.0, 100.0, -1.0, 1.0, 0.5)))); let mut ctrl = CharacterController::new([0.0, 0.0, 0.0]); assert!(c.in_water([0.0, 0.0, 0.0]) && !c.in_water([2.0, 0.0, 0.0]), - "fixture: afloat at the centre, dry two units east — else this arm is never exercised"); - ctrl.step(swim_still(), 1.0 / 60.0, &c); - assert!((ctrl.pos[2] - 2.0).abs() < 1e-3 && ctrl.on_ground, - "leaving the water laterally must still recover onto a floor and ground: {:?}", ctrl.pos); + "fixture: afloat at the centre, dry two units east — the exact shape the old \ + fall-through beached"); + assert!(!c.footprint_clear(0.0, 0.0, 0.0, PLAYER_RADIUS, 8), + "fixture: the dry predicate must call this body embedded, or the door is never tested"); + + // Two seconds idle: the old code beached it on frame 1 (z=2.0, grounded, dry). + for _ in 0..120 { ctrl.step(swim_still(), 1.0 / 60.0, &c); } + assert!(ctrl.pos[2].abs() < 1e-3 && !ctrl.on_ground && c.in_water(ctrl.pos), + "#661: a floating body must NEVER be recovered onto dry land — the old code put this \ + one at z=2.0, on_ground, dry, where `want_swim` does nothing and the state is a \ + one-way soft-lock; got {:?} on_ground={}", ctrl.pos, ctrl.on_ground); + assert!(ctrl.hold().is_none(), + "…and it is not an emergency either: a swimmer in a narrow water strip is just \ + swimming, not held; got {:?}", ctrl.hold()); + + // And it still answers the driver: swim along the strip. + let mut swim_north = swim_still(); + swim_north.wish_dir = [0.0, 1.0]; + swim_north.speed = 35.0; + for _ in 0..60 { ctrl.step(swim_north, 1.0 / 60.0, &c); } + assert!(ctrl.pos[1] > 20.0, + "swim input must still move the body along the water strip; got {:?}", ctrl.pos); } #[test] fn an_afloat_body_with_no_floor_below_is_never_pushed_out_into_a_drift() { // #649 REVIEW FINDING 1 — a REGRESSION PIN, not a fails-on-main pin: `main` passes this too. // `is_embedded` counts `floor.is_none()` as embedded, so a swimmer in deep water with a - // perfectly CLEAR footprint and no floor within GROUND_DEPTH below still enters the net. The - // first cut of this fix answered that with `Recovery::Afloat` at the first ring candidate — - // which is *equally* embedded, so the next frame re-entered the net from there and the body - // walked east one ring radius per frame (60 u/s), ignoring the wish input, reporting a stale - // `in_water`. A recovery that is itself embedded is not a recovery. + // perfectly CLEAR footprint and no floor within GROUND_DEPTH below used to enter the net. + // The first cut of the #649 fix answered that with an `Afloat` recovery at the first ring + // candidate — which is *equally* embedded, so the next frame re-entered the net from there + // and the body walked east one ring radius per frame (60 u/s), ignoring the wish input, + // reporting a stale `in_water`. A recovery that is itself embedded is not a recovery. + // + // Since #661 the protection is structural: a floating body never enters the net at all + // ("no floor below" is not a state a body that FLOATS can be in an emergency about), so + // there is no recovery to get wrong. This pin stays: it is the test that catches any + // future change re-admitting floaters to a net whose recoveries can drift. // // Geometry: floor only far to the east (so `has_geometry` is true and `ground_below` at the // origin is None), water everywhere. Nothing must move. @@ -1752,6 +1892,80 @@ mod tests { ctrl.pos); } + // ── #661: the swimming duck-under — the step-up's downward mirror ─────────────────────────── + + /// **A swimmer blocked by a hanging face with open water beneath passes UNDER it.** + /// + /// The 1/10-scale qcat pocket mouth: a face whose bottom edge sits a hair below the waterline + /// (z −0.2, surface 0), open water beneath it down to −40. A swimmer at the float plane (−2) + /// is blocked — its chest ray (feet + 4 = +2) hits the face — and before #661 it had no + /// downward answer at all: the step-up could not mount (the face runs 20 u up), so it pressed + /// into the lip for ever. That asymmetry is the issue title: the controller could climb 2.5 u + /// OUT of water but never pass 2.5 u UNDER an obstruction, so every mount was one-way. + /// `try_duck_under` dives the feet up to the same 2.5 u envelope and re-slides; here that + /// clears the face bottom and the swimmer swims through, buoyancy returning it to the plane + /// on the far side. + /// + /// (On the unfixed controller this scene fails twice over: no duck exists, AND the + /// depenetration net — whose footprint ring probes the AIR band a swimmer's plane puts 1 u + /// above the waterline — reads the approach as "embedded" and eats the input.) + #[test] + fn a_swimmer_ducks_under_a_hanging_face_instead_of_stranding_at_it() { + let c = flooded_corridor( + vec![floor(-40.0, -100.0, 100.0), wall(4.0, -0.2, 20.0)], + -40.0, 0.0); + let plane = -2.0; // surface 0 − float_depth 2 + let mut ctrl = CharacterController::new([-5.0, 0.0, plane]); + assert!(c.in_water(ctrl.pos), "fixture: starts afloat at the plane"); + + let mut swim_east = swim_still(); + swim_east.wish_dir = [1.0, 0.0]; + swim_east.speed = 35.0; + for _ in 0..150 { ctrl.step(swim_east, 1.0 / 60.0, &c); } + + assert!(ctrl.pos[0] > 8.0, + "#661: the swimmer must pass UNDER the hanging face at east=4 (open water below its \ + −0.2 bottom edge) — without the duck it presses into the lip for ever; got {:?}", + ctrl.pos); + assert!((ctrl.pos[2] - plane).abs() < 0.1 && c.in_water(ctrl.pos) && !ctrl.on_ground, + "…and be back on its swim plane on the far side, wet, unsupported: {:?}", ctrl.pos); + } + + /// **THE #191 CONTROL: the duck must not override a genuine bank haul-out.** + /// + /// Same shape, but the face is SOLID to the pool floor — a real bank whose lip (surface + /// + 0.1) is inside the swimming step-up's 2.5 u reach. `try_duck_under` measures the water + /// route shut (diving gains no lateral progress against a face that runs to the bottom), so + /// the haul-out keeps the right of way and the swimmer climbs out exactly as before #661. + /// This is the asset-free companion to `walker_sim`'s + /// `p1_haul_out_admission_matches_controller_execution`, which sweeps the full lip-height + /// contract; disabling the swimming step-up turns BOTH red. + #[test] + fn a_swimmer_at_a_solid_bank_still_hauls_out_the_duck_does_not_override_191() { + let c = flooded_corridor( + vec![floor(-40.0, -100.0, 4.0), floor(0.1, 4.0, 100.0), wall(4.0, -40.0, 0.1)], + -40.0, 0.0); + let mut ctrl = CharacterController::new([0.0, 0.0, -2.0]); + assert!(c.in_water(ctrl.pos) && !c.in_water([8.0, 0.0, 0.1]), + "fixture: afloat at the plane; the bank top is dry"); + + let mut swim_east = swim_still(); + swim_east.wish_dir = [1.0, 0.0]; + swim_east.speed = 35.0; + let mut out = false; + for _ in 0..300 { + ctrl.step(swim_east, 1.0 / 60.0, &c); + if ctrl.on_ground && ctrl.pos[0] > 4.0 && (ctrl.pos[2] - 0.1).abs() < 0.6 { + out = true; + break; + } + } + assert!(out, + "#191: a lip 2.1 u above the swim plane (0.1 above the surface) is inside the swimming \ + step-up's reach and must still be mounted — the duck may only win where diving \ + actually gains progress; ended at {:?}", ctrl.pos); + } + #[test] // #730: capitalised on purpose — BODY-not-feet is the exact #649 regression this test pins // (a feet-only probe calls a submerged body dry, see the comment below); renaming would blur it. @@ -1763,6 +1977,11 @@ mod tests { // body can be submerged while its FEET are a hair outside the baked water region, because // the `.wtr` volume does not have to meet the floor. A feet-only probe calls that body dry // and mounts it on the slab above — the #649 defect, reachable again by "simplification". + // + // #661 moved the probe from the recovery choice to the net's DOOR (a floating body never + // enters at all); the mutation this pins is unchanged and this test still catches it: with + // a feet-only door probe, this feet-dry body walks straight into the dry net and is + // beached on the slab at z=2. let mut c = col(vec![floor(-12.0, -100.0, 100.0), floor(2.0, -100.0, 100.0), wall(0.8, -12.0, 10.0), wall(-0.8, -12.0, 10.0)]); // Water from 0.5 up: the feet at z=0 are OUTSIDE it, the chest at z=3 is inside. @@ -1774,9 +1993,16 @@ mod tests { ctrl.step(swim_still(), 1.0 / 60.0, &c); - assert!(ctrl.pos[2].abs() < 1e-3 && !ctrl.on_ground, + // One frame. Fixed: the door sees a wet BODY, the net stays out, and buoyancy's ordinary + // ≤ 0.5 u-per-frame rise begins (the pre-#661 assertion demanded z exactly 0, but that was + // the net FREEZING the frame — "not teleported" and "frozen" are different claims, and the + // frozen half is gone with the net). Mutated (feet-only door probe): the body walks into + // the dry net and is beached on the slab — z = 2.0, `on_ground` — which both halves below + // reject. + assert!(ctrl.pos[2] < 1.0 && !ctrl.on_ground, "a submerged body whose FEET are outside the water volume is still afloat: a feet-only \ - probe in the net calls it dry and mounts it on the slab at z=2 — got {:?}", ctrl.pos); + probe in the net calls it dry and mounts it on the slab at z=2, grounded — got {:?} \ + on_ground={}", ctrl.pos, ctrl.on_ground); } /// **THE DEPENETRATION CORPUS — the blast-radius harness, committed so its numbers are @@ -1821,14 +2047,16 @@ mod tests { None } /// This branch's rule, expressed through the production `Recovery` (no second copy of it). + /// #661: a body afloat in water never enters the net at all, so its "recovery" is None — + /// physics keeps custody (mirrors `depenetrate`'s door, which uses the same body probe). fn new_recovery(col: &Collision, p: [f32; 3]) -> Option<([f32; 3], bool)> { - let afloat = body_in_water(col, p); + if body_in_water(col, p) { return None; } for &r in &PUSHOUT_RADII { for i in 0..PUSHOUT_DIRS { let a = (i as f32) / (PUSHOUT_DIRS as f32) * std::f32::consts::TAU; let (e, n) = (p[0] + a.cos() * r, p[1] + a.sin() * r); if !col.footprint_clear(e, n, p[2], PLAYER_RADIUS, PUSHOUT_DIRS / 2) { continue; } - if let Some(rec) = Recovery::at_column(col, e, n, p[2], afloat) { + if let Some(rec) = Recovery::at_column(col, e, n, p[2]) { return Some(([e, n, rec.z()], rec.on_ground())); } } diff --git a/tests/synthetic_scenes/mod.rs b/tests/synthetic_scenes/mod.rs index 310a8c42..4da2861e 100644 --- a/tests/synthetic_scenes/mod.rs +++ b/tests/synthetic_scenes/mod.rs @@ -26,16 +26,21 @@ //! specific argument, and the control scenes (`sealed_pocket_without_lid`, …) that hold everything //! else fixed and remove only the feature under test. //! -//! # What this layer does NOT cover — the swimming step-up +//! # What this layer does NOT cover — the swimming step-up and its #661 duck-under mirror //! -//! **There is zero synthetic coverage of the swimming step-up** (`movement.rs`'s +//! **There is zero synthetic coverage in THIS layer of the swimming step-up** (`movement.rs`'s //! `if (self.on_ground || swimming) && low_hit …` branch, the one that exists so a swimmer can -//! haul OUT onto a bank, #191). Disabling it leaves every test in this layer green — that is a -//! deliberately-recorded observation about the LID STRAND's mechanism (it is the depenetration -//! push-out, not the step-up), and it is NOT a statement that the step-up is untested: the tree's -//! coverage of it lives in `tests/walker_sim.rs` -//! (`p1_haul_out_admission_matches_controller_execution`), which does go RED when it is disabled. -//! A future reader must not infer from "these tests stayed green" that the step-up is unprotected. +//! haul OUT onto a bank, #191) **or of its #661 downward mirror, `try_duck_under`**. Disabling +//! either leaves every test in this layer green — that is a deliberately-recorded observation +//! about the LID STRAND's mechanism (it was the depenetration net, not the step-up), and it is +//! NOT a statement that either branch is untested: the step-up's coverage lives in +//! `tests/walker_sim.rs` (`p1_haul_out_admission_matches_controller_execution`) and in +//! `movement::tests::a_swimmer_at_a_solid_bank_still_hauls_out_the_duck_does_not_override_191`, +//! both RED when it is disabled; the duck-under's lives in +//! `movement::tests::a_swimmer_ducks_under_a_hanging_face_instead_of_stranding_at_it` and the +//! asset-gated `qcat_pocket_swim_plane_swimmer_escapes_to_the_shaft`, both RED without it. +//! A future reader must not infer from "these tests stayed green" that those branches are +//! unprotected. //! //! # Coordinates //! diff --git a/tests/synthetic_water_capability.rs b/tests/synthetic_water_capability.rs index 12c8959c..4f59eea5 100644 --- a/tests/synthetic_water_capability.rs +++ b/tests/synthetic_water_capability.rs @@ -8,8 +8,8 @@ //! //! **What is pinned here is the PHYSICAL CLAIM, not a shipped zone's coordinates.** The //! asset-gated tests assert things like "the qcat pocket surface is −55.978"; these assert "a -//! swimmer is lifted to the DESTINATION column's swim plane" and (since #658) "the depenetration -//! push-out recovers an afloat swimmer AT ITS OWN DEPTH rather than mounting it onto a lid". Both +//! swimmer is lifted to the DESTINATION column's swim plane" and (since #658/#661) "a swimmer +//! beside dry geometry is never carried onto it — it stays at its own depth, in the water". Both //! are worth having; neither replaces the other. mod synthetic_scenes; @@ -71,44 +71,32 @@ fn try_to_sink(col: &Collision, from: [f32; 3], secs: f32) -> [f32; 3] { // ───────────────────────────── scene 1: the lid mount ───────────────────────────── -/// **#649 FIXED, ON SYNTHETIC GEOMETRY: the push-out holds an afloat swimmer at its own depth -/// instead of mounting it onto the lid.** +/// **#649/#661, ON SYNTHETIC GEOMETRY: a swimmer beside the lid is never carried onto it.** /// /// A sealed flooded chamber whose ceiling quad sits **0.009 u above the waterline**. A swimmer /// floating at the chamber's own swim plane swims into the sealed chamber's east wall (there is -/// nowhere else to go); `footprint_clear` fails there, which the controller's depenetration net -/// reads as embedded, and its ring push-out runs. +/// nowhere else to go) and must simply stop there — at its own depth, in the water. /// -/// Before #658, that push-out hunted for the nearest FLOOR within `STEP_UP + GROUND_ORIGIN = 3.0` -/// u of the body regardless of medium — water-blind — found the lid 2.009 u up, and placed the -/// swimmer there: `on_ground = true`, DRY, buoyancy never firing again. Since #658 the net measures -/// the medium once, at the body's own position (`movement::Recovery::at_column`): an afloat body is -/// recovered **at its own depth** in any ring candidate whose column is still water, never onto a -/// floor. Here every candidate at the swim plane's height is still water (the lid is 2 u higher), -/// so the ring finds a nearby clear spot along the same wall and returns `Recovery::Afloat` at the -/// UNCHANGED z — the swimmer ends up nudged into a corner of the chamber, still floating at its own -/// swim plane, never touching the lid. +/// # How the controller has satisfied this claim, across three revisions /// -/// # How this compares to the baked-`qcat` twin, precisely -/// -/// `tests/water_capability.rs`'s `qcat_pocket_swim_plane_strands_the_swimmer_on_the_tile_floor` -/// (renamed alongside #658) shows the identical push-out fix at the real coordinate — one frame -/// from the qcat swim plane now holds depth (`the_depenetration_push_out_holds_a_qcat_swimmer_at_ -/// its_own_depth`) instead of mounting the tile floor as it did pre-fix. But driven for the full -/// 12 s toward the shaft, qcat's swimmer STILL ends up dry at −55.9687 — not through this -/// mechanism, but through a SECOND, independent one: the swimming step-up (the #191 haul-out -/// branch) climbs the same 2.009 u once the swimmer has drifted onto the tile floor's own -/// footprint. That residual live wedge is tracked separately as **#661**. +/// Before #658, the depenetration net's push-out hunted for the nearest FLOOR within +/// `STEP_UP + GROUND_ORIGIN = 3.0` u of an "embedded" body regardless of medium — water-blind — +/// found the lid 2.009 u up, and placed the swimmer there: `on_ground = true`, DRY, buoyancy never +/// firing again. #658 kept the net running for swimmers but recovered an afloat body at its own +/// depth (`Recovery::Afloat`) whenever the ring candidate was still water. #661 then measured, at +/// the real qcat coordinate, that the half-measure still failed two ways (the dry-candidate beach +/// and the input-eating ping-pong) and removed the swimmer from the net entirely: a floating body +/// never enters it, `Recovery::Afloat` no longer exists, and what stops this swimmer at the wall +/// is the collided slide alone. The assertions below never named the mechanism, only the outcome — +/// own depth, wet, not the lid — and pin all three revisions' shared claim. /// -/// This synthetic chamber has no bank for that second mechanism to reach — it is sealed on all six -/// sides with nothing at swim-plane height but water and the wall it presses into (see -/// `synthetic_scenes`'s module doc for why the swimming step-up has zero coverage in this layer), -/// so this test is not expected to flip again when #661 is fixed. +/// # How this compares to the baked-`qcat` twin, precisely /// -/// The SUB-MECHANISM fixed here was confirmed identical to qcat's by mutation, not by resemblance: -/// removing only the push-out's UPWARD reach (`nearest_floor(e, n, p[2], 0.0, GROUND_DEPTH)` — -/// leaving depenetration otherwise intact) used to turn both this test and its qcat twin red for -/// the same reason; #658's fix is what makes both hold depth instead. +/// `tests/water_capability.rs`'s pocket test shows the same coordinate live: under #658 the +/// swimmer still ended dry at −55.9687 (that residual wedge was #661), and since the #661 fix that +/// test asserts the ESCAPE (`qcat_pocket_swim_plane_swimmer_escapes_to_the_shaft`). This chamber +/// is sealed on all six sides, so there is no escape to assert here — a swimmer that stays wet at +/// its own depth beside the wall is the whole correct outcome. /// /// What this test does NOT pin is qcat's own coordinates — `POCKET_LID_Z` was copied from that file /// (see its doc) purely so this chamber's geometry sits in the same band; the near-agreement of the @@ -141,13 +129,14 @@ fn a_swimmer_at_the_pocket_swim_plane_holds_its_own_depth_not_the_lid() { /// This no longer builds on the test above: since #658, ordinary swimming in this chamber never /// puts a character on the lid (see `a_swimmer_at_the_pocket_swim_plane_holds_its_own_depth_not_ /// the_lid`), so the position here is manually authored rather than reached by driving the -/// controller. What survives is the property that used to make the pre-#658 push-out mount -/// permanent — and would make any FUTURE dry mount on this lid equally permanent, whether from a -/// regression in #658, from #661's separate swimming-step-up mechanism reaching this height in some -/// other geometry, or from a GM teleport: `want_swim` only does anything when `in_water`, so a -/// downward swim wish from a DRY position moves nothing. The live #649 evidence was +/// controller. What survives is the property that made every dry mount permanent — and would make +/// any FUTURE one equally permanent, whether from a regression re-admitting floaters to the net +/// (#661's measured writer was its dry-candidate beach), from a haul-out onto a floor with no way +/// back, or from a GM teleport: `want_swim` only does anything when `in_water`, so a downward swim +/// wish from a DRY position moves nothing. The live #649 evidence was /// `POST /v1/move/manual {"up":-1,"duration_ms":3000}` moving the character **0.00 u**; reproduced -/// here to a tenth of a unit. +/// here to a tenth of a unit. This irreversibility is exactly why #661's fix is shaped as "never +/// mount a swimmer unasked" rather than "recover from a wrong mount" — there is no recovering. #[test] fn a_dry_mount_on_the_lid_is_one_way_a_downward_swim_wish_cannot_recover_it() { let col = scenes::sealed_pocket_with_lid(); @@ -175,14 +164,13 @@ fn a_dry_mount_on_the_lid_is_one_way_a_downward_swim_wish_cannot_recover_it() { /// 1 u above it every time it pressed the wall again — still submerged, but by the same defective /// mechanism in its other direction. /// -/// Since #658 the push-out no longer hunts a floor for an afloat body at all when the ring -/// candidate is still water — it recovers the body AT ITS OWN DEPTH instead (`movement::Recovery:: -/// Afloat`). So removing the lid no longer changes the outcome: measured here, this scene now ends -/// at essentially the SAME z as the with-lid scene above (-57.978, its own swim plane), not at the -/// chamber floor. The control's claim is unchanged in substance — **the character stays submerged -/// and never ends up above the waterline** — but the reason it holds is no longer "the nearer floor -/// happens to be below the surface too"; it is that the push-out stopped moving an afloat body -/// vertically at all. +/// Since #658 the push-out no longer hunted a floor for an afloat body when the ring candidate was +/// still water, and since #661 a floating body never enters the net at all. So removing the lid no +/// longer changes the outcome: this scene ends at essentially the SAME z as the with-lid scene +/// above (−57.978, its own swim plane), not at the chamber floor. The control's claim is unchanged +/// in substance — **the character stays submerged and never ends up above the waterline** — but +/// the reason it holds is no longer "the nearer floor happens to be below the surface too"; it is +/// that the net stopped touching floating bodies entirely. #[test] fn without_the_lid_the_same_pocket_never_strands_the_swimmer_above_the_waterline() { let col = scenes::sealed_pocket_without_lid(); @@ -272,9 +260,11 @@ fn the_stepped_scene_really_has_two_surfaces_and_no_climbable_geometry() { /// that position and picked the next candidate, and so on: the body walked east one ring radius /// (1.0 u) per embedded frame — 60 u/s at 60 fps — ignoring wish input entirely, with `in_water` /// reporting stale-false the whole time (the net's early-return freezes the rest of `step`, so the -/// real water probe never runs). `Recovery::at_column`'s `!is_embedded` guard on the afloat arm is -/// the fix that shipped instead: a recovery must ALSO not be embedded, or by definition it is not a -/// recovery — it is the next frame's starting point for exactly the same failure. +/// real water probe never runs). The #649-era fix was a `!is_embedded` guard on the recovery; +/// since #661 the protection is structural — a floating body never enters the net at all, so +/// there is no recovery whose iteration could drift. (The `embedded` mirror used by the fixture +/// checks below restates the net's DRY predicate; production now applies it only behind the +/// floating-body door, which this scene's body never passes.) /// /// Driven for two input-free seconds (110 frames to let anything that is going to happen settle, /// then 10 more to check it has actually stopped) with a completely idle intent — no wish direction, diff --git a/tests/water_capability.rs b/tests/water_capability.rs index 0ab38486..ecc975e3 100644 --- a/tests/water_capability.rs +++ b/tests/water_capability.rs @@ -20,8 +20,9 @@ //! ``` //! //! Two of them pin capability (what a swimmer CAN do, so a future gate cannot quietly forbid it); -//! the third pins the still-open #329 strand, offline and deterministically, at the coordinate the -//! live client wedges on (its mechanism changed when #649 landed — see its doc comment). +//! the third used to pin the #329/#661 strand at the coordinate the live client wedged on, and +//! since the #661 fix pins the ESCAPE from it instead — same start, same drive, opposite +//! expectation (see its doc comment for the measured mechanism and the flip's history). use eqoxide::assets::ZoneAssets; use eqoxide::movement::CharacterController; @@ -84,11 +85,11 @@ fn swim_plane(col: &Collision, p: [f32; 3]) -> f32 { /// This test used to run from −60, −62, −65 and −68, and its green at −59/−60 DEPENDED on the very /// defect #649 tracked: the water-blind depenetration push-out DROPPED the swimmer ~10 u to the /// pool floor on frame 0, and it crossed into the shaft from down there. With the push-out fixed a -/// swimmer holds its own depth, so from −59/−60 ordinary buoyancy lifts it to the pocket's OWN swim -/// plane (−57.978) *before* it reaches the shaft, where the swimming step-up mounts it on the -/// −55.969 tile floor beside the pocket — the second, separate mechanism at that coordinate, still -/// open (see the strand test below). The start depths were therefore re-derived against the fixed -/// controller, exactly as this note used to instruct. +/// swimmer held its own depth, so from −59/−60 it ended dry on the −55.969 tile floor beside the +/// pocket — the residual #661 wedge (whose measured writer was the net's dry-candidate beach, not +/// the step-up this note used to blame). The start depths were re-derived against the #649-fixed +/// controller then; since the #661 fix the shallow band escapes too, and the escape test below +/// sweeps it directly. /// /// The CAPABILITY claim is unchanged and is what must survive: buoyancy lifts a swimmer to the /// DESTINATION column's swim plane, unbounded by the source column's surface (+17 to +23 u here @@ -149,68 +150,94 @@ fn stepped_canal_surfaces_are_swimmable_between() { } } -/// **THE #329 STRAND, REPRODUCED OFFLINE TO FOUR DECIMALS — STILL OPEN.** -/// -/// Starting at the POCKET's own swim plane (−57.978) the character ends up on the tile floor at -/// **−55.9687**, the coordinate the live client wedges on in the original #329 report. That floor is -/// 0.009 u ABOVE the −55.978 waterline, so `in_water` is false, `swimming` is false, buoyancy never -/// fires again, and it cannot sink back through the slab it is standing on. -/// -/// # The mechanism CHANGED when #649 was fixed — read this before touching it -/// -/// #649 was the water-blind **depenetration push-out**: `footprint_clear` fails for any swimmer in a -/// ~12 u pocket, which the net read as "embedded in rock" and recovered with -/// `nearest_floor(up = STEP_UP + GROUND_ORIGIN, down = GROUND_DEPTH)` — the NEARER floor in either -/// direction. It mounted the character here in ONE frame (dz +2.0092, `on_ground = true`). That is -/// fixed: an afloat body now holds its own depth (`movement::Recovery`), and the frame-1 teleport is -/// gone — verified by driving this controller (frame 1 now holds −57.97798). -/// -/// The end coordinate did **not** change, because a SECOND, independent mechanism reaches it: with -/// the swimmer left in the water, it swims laterally until (frame ~33) the **swimming step-up** — -/// the #191 haul-out branch, `(self.on_ground || swimming) && low_hit → try_step_up` — climbs the -/// 2.009 u onto that tile floor, whose footprint IS clear and which has open headroom. It looks -/// locally identical to hauling out onto a bank, and A* routes out of it to 11 of 12 sampled far -/// goals, so it is not obviously refusable on local geometry alone. That half is deliberately NOT -/// fixed here (it is the issue-body's "angle 1" and needs its own blast-radius pass against #191). -/// -/// So: this test is NOT a pin on #649's defect — the #649 pins are the asset-free -/// `movement::tests::depenetration_never_{mounts,drops}_a_swimmer_*` unit tests, which fail on the -/// unfixed controller. This one pins the remaining #329 live wedge, and when the haul-out half is -/// fixed its expectation flips to asserting the escape. +/// **THE #329/#661 WEDGE COORDINATE, FIXED: the pocket swimmer now ESCAPES to the shaft.** +/// +/// # ⚠️ This test's expectation was FLIPPED by the #661 fix — the history matters, read it +/// +/// Under the name `qcat_pocket_swim_plane_strands_the_swimmer_on_the_tile_floor` this test PINNED +/// the strand: from the pocket's own swim plane (−57.978) the character ended on the tile floor at +/// **−55.9687** — the live #329 wedge coordinate, 0.009 u ABOVE the waterline, dry, `on_ground`, +/// `want_swim` inert, unable to sink back through the slab: a one-way soft-lock. Its assertions +/// were `end z ≈ −55.9687`, `!in_water(end)`, and `> 5 u short of the shaft`. They now assert the +/// ESCAPE instead, exactly the flip its own doc said this fix should make. +/// +/// # The mechanism, as MEASURED on `main` @ 269dbbf (the issue text misattributed it) +/// +/// #661's issue body blames the **swimming step-up** (#191's haul-out branch). Instrumenting both +/// branches showed the step-up NEVER fires on this run — no `low_hit` even occurs before the +/// mount. The measured chain was: +/// +/// 1. the depenetration net's footprint ring probes at `feet + 3` — **1 u above the waterline** +/// for a swimmer at the float plane — so pocket-rim geometry in the AIR band read the swimmer +/// as "embedded" on alternate frames while its route was physically open (its slide made full +/// 0.733 u/frame progress on every frame it was allowed to run); +/// 2. the ring push-out ate the input on those frames and ping-ponged the body in place (the live +/// `walker_stalled`), until frame 33, when the first footprint-clear candidate fell a fraction +/// of a unit outside the `.wtr` region's XY extent — whereupon `Recovery::at_column`'s +/// dry-candidate fall-through ran the water-blind `nearest_floor` hunt and beached the +/// still-swimming body onto the tile (dxy exactly 1.000 = `PUSHOUT_RADII[0]`, dz +2.0092, +/// `Grounded`, dry); +/// 3. and with both of those removed the swimmer STILL could not finish: the passage to the shaft +/// is only open BELOW the swim plane, and the controller had no downward mirror of its 2.5 u +/// step-up — it pressed into the pocket's south face for ever. That asymmetry is the "one-way +/// transition" of the issue title, measured in its true location. +/// +/// The fix is three-sided (`movement.rs`): a floating body never enters the depenetration net; +/// the net's only constructible recovery is `Grounded` and only dry bodies can reach it; and a +/// blocked swimmer tries `try_duck_under` — the step-up's downward mirror — before the haul-out, +/// taking it only when diving measurably gains lateral progress. From the plane the swimmer now +/// ducks under the lip, crosses the flooded corridor, and buoyancy lifts it onto the SHAFT's swim +/// plane (−44.982): the same escape the deep starts (−61 … −63) always performed. +/// +/// The start-depth sweep covers the band #661 measured as STRANDED on the #658 controller +/// (−58.00 … −60.75); every depth in it must now end where the deep starts always did. #[test] #[ignore = "asset-gated: needs baked qcat.glb + qcat.wtr at $EQZONES (#357)"] -fn qcat_pocket_swim_plane_strands_the_swimmer_on_the_tile_floor() { +fn qcat_pocket_swim_plane_swimmer_escapes_to_the_shaft() { let col = zone("qcat"); let pocket_xy = [-42.3f32, 1036.8]; let shaft = [-45.75f32, 1030.0625, -42.98]; let surface = col.water_surface([pocket_xy[0], pocket_xy[1], -60.0]).unwrap(); let plane = surface - PLAYER_BODY.float_depth; + let shaft_plane = swim_plane(&col, [shaft[0], shaft[1], -50.0]); + assert!((plane - (-57.978)).abs() < 0.01, "fixture: pocket swim plane {plane}"); - let end = swim_toward(&col, [pocket_xy[0], pocket_xy[1], plane], shaft, 12.0); - assert!((end[2] - (-55.9687)).abs() < 0.01, - "#329: from the pocket's own swim plane ({plane:.4}) the character ends on the tile floor at \ - −55.9687, the live wedge coordinate — since #649 landed this is the SWIMMING STEP-UP, not \ - the depenetration push-out (see this test's doc comment); got {end:?}"); - assert!(!col.in_water(end), - "#329: and the mounted position is DRY (surface {surface:.5}), which is why buoyancy never \ - recovers it — got in_water=true at {end:?}"); - assert!((end[0] - shaft[0]).hypot(end[1] - shaft[1]) > 5.0, - "#329: it never reaches the shaft — that is the strand; got {end:?}"); + for z in [plane, -59.0, -60.5] { + let end = swim_toward(&col, [pocket_xy[0], pocket_xy[1], z], shaft, 12.0); + assert!((end[2] - shaft_plane).abs() < 0.05, + "#661: from z={z:.3} the swimmer must ESCAPE the pocket and settle on the SHAFT's swim \ + plane {shaft_plane:.4} — the pre-fix controller stranded this start dry on the tile \ + floor at −55.9687, the live #329 wedge coordinate; got {end:?}"); + assert!((end[0] - shaft[0]).hypot(end[1] - shaft[1]) < 1.5, + "#661: and actually arrive at the shaft XY; got {end:?}"); + assert!(col.in_water(end), + "#661: still swimming — the escape must never pass through a dry mount; got {end:?}"); + } } -/// **#649 AT THE REAL COORDINATE: the push-out no longer teleports a swimmer vertically.** +/// **#649/#661 AT THE REAL COORDINATE: the first frame never lifts a swimmer dry.** /// /// One frame, from the exact position the #649 comment thread measured — the qcat pocket swim plane, -/// where `footprint_clear` is FALSE and `ground_below` is `Some(−69.969)`, so the depenetration net -/// runs and (on the unfixed controller) recovers with the NEARER floor: the tile floor 2.009 u ABOVE, -/// producing `(−42.252, 1037.071, −55.96875)`, `on_ground = true`, DRY. Here the same frame must end -/// at the SAME DEPTH it started, still afloat. +/// where the dry embedded predicate is TRUE (`footprint_clear` false) and `ground_below` is +/// `Some(−69.969)`. On the pre-#649 controller the net recovered with the NEARER floor: the tile +/// floor 2.009 u ABOVE, producing `(−42.252, 1037.071, −55.96875)`, `on_ground = true`, DRY. +/// +/// # ⚠️ The #661 fix relaxed this test's frame-1 assertion — deliberately +/// +/// Under #658 this asserted `dz ≈ 0`: the net "held the swimmer at its own depth", which was true +/// but conflated two claims — *not vertically teleported* (the #649 pin) and *frame frozen by the +/// net* (an artefact of the net still handling the frame and eating the wish). #661 removed the +/// net from floating bodies AND gave a blocked swimmer the duck-under, so frame 1 from this +/// wedged-against-the-mouth start now legitimately DIVES: a collided, bounded descent (≤ the +/// 2.5 u duck envelope) in the wish direction, still wet. What #649 forbids is unchanged and +/// still asserted: never lifted toward the dry tile, never dropped to the pool floor 12 u below, +/// never grounded, never dry. /// /// The asset-free pins for this are `movement::tests::depenetration_never_{mounts,drops}_a_swimmer_*` /// (they run in CI); this one proves the same thing on the real baked geometry that produced the bug. #[test] #[ignore = "asset-gated: needs baked qcat.glb + qcat.wtr at $EQZONES (#357)"] -fn the_depenetration_push_out_holds_a_qcat_swimmer_at_its_own_depth() { +fn the_first_frame_never_lifts_a_qcat_pocket_swimmer_toward_the_dry_tile() { let col = zone("qcat"); let start = [-42.634235f32, 1036.1473, -57.977985]; let mut c = CharacterController::new(start); @@ -218,10 +245,13 @@ fn the_depenetration_push_out_holds_a_qcat_swimmer_at_its_own_depth() { // motion observed is the controller's, not a drive's). c.step(MoveIntent { wish_dir: [0.55, -0.83], wish_vspeed: 0.0, jump: false, want_swim: true, speed: 44.0, climb: 0.0, hop: false }, 1.0 / 60.0, &col); - assert!((c.pos[2] - start[2]).abs() < 1e-3, - "#649: frame 1 must hold the swim plane {:.5}; got {:?} (dz {:+.4} — the unfixed push-out \ - mounts the tile floor at −55.96875, dz +2.0092, which is the live #329 wedge coordinate)", - start[2], c.pos, c.pos[2] - start[2]); + let dz = c.pos[2] - start[2]; + assert!(dz < 1e-3, + "#649: frame 1 must never LIFT the swimmer (the unfixed net mounts the tile floor at \ + −55.96875, dz +2.0092, the live #329 wedge coordinate); got {:?} (dz {dz:+.4})", c.pos); + assert!(dz > -2.6, + "#649: nor teleport it downward — any descent is the duck-under's collided ≤2.5 u dive, \ + not the old drop to the pool floor 12 u below; got {:?} (dz {dz:+.4})", c.pos); assert!(!c.on_ground, "#649: a floating body must not be marked grounded: {:?}", c.pos); assert!(col.in_water(c.pos), "#649: and it must still be in the water: {:?}", c.pos); } From b7c33b0ef58c6e8098be900cb7a441d4304e31d5 Mon Sep 17 00:00:00 2001 From: dhenry Date: Tue, 28 Jul 2026 03:06:33 -0400 Subject: [PATCH 2/3] fix(#661) round 2: the duck is re-divable by construction, every guard is pinned RED-when-deleted, and the ring's non-embedded invariant is enforced Review round (PR #767): all three blocking findings accepted and fixed structurally, none by rewording. B1: try_duck_under now requires the landing column's floor to exist at or below the ducked feet (the same ground_below shape as try_step_up's landing check), so a duck can only cross passages whose far side can be dived back into at the passage depth. The reviewer's shallow-far-shelf trap scene is committed as a RED-when-unguarded test, plus a round-trip positive control; the ordering comment now states the property the code has instead of the falsified "can always surface again". B2: each duck refusal clause has an individually-verified RED-when-deleted pin (up-wish gate, destination water, lowered-start water, 2.5u envelope now pinned in CI, and the new B1 floor bound). Pinning the up-wish gate exposed a pre-existing bug: the swim-up clamp parked feet exactly ON the surface, where the strict in_water probe reads the surfaced body dry for a frame and the DRY depenetration net ring-recovered it 40u to the pool floor (measured, one frame, Grounded). The clamp now stops SKIN under the surface, matching its own comment. B3: sample banking now enforces "grounded AND non-embedded" with an explicit is_embedded predicate at the banking site (the widened wet door had silently let an embedded wading body bank restore points); pinned by a test in which the stuck fallback must hold-and-disclose rather than rubber-band into the embedded slot. The three "by construction" comment sites describe the enforcement. Also: flip-4 assertion tightened to the physical bound (one buoyancy step) per review; try_duck_under's deliberate feet-only probes documented (review N3). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV --- src/movement.rs | 351 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 314 insertions(+), 37 deletions(-) diff --git a/src/movement.rs b/src/movement.rs index 66916da2..470d8f38 100644 --- a/src/movement.rs +++ b/src/movement.rs @@ -664,20 +664,31 @@ impl CharacterController { let mut ducked = false; if (self.on_ground || swimming) && low_hit && low_prog + 0.01 < hlen(wish) { // #661: a blocked SWIMMER first tries to pass UNDER the obstruction — the exact - // mirror of the step-up below, pointing down, and the reason the two are ordered: - // staying in the water is the reversible move (a swimmer that dives can always - // surface again; a haul-out onto dry ground cannot be swum back through, which is - // the one-way transition #661 is about). The step-up may only haul a swimmer out - // once the water route has been MEASURED shut — `try_duck_under` found no more - // lateral progress at diving depth. At a real bank that measurement fails (the - // bank face is solid all the way down, so diving gains nothing) and the haul-out - // proceeds exactly as before (#191); at qcat's pocket mouth the passage to the - // shaft is open water 2 u below the swim plane, so the duck carries the swimmer - // through instead of letting the lip strand it. + // mirror of the step-up below, pointing down. The ordering rationale, stated as + // the property the code actually has (#661 review, B1 — the first version of this + // comment claimed "a swimmer that dives can always surface again", and the review + // FALSIFIED that on a shallow far shelf; the sentence was not reworded, the + // missing condition was built): + // + // * a haul-out that lands DRY is irreversible under every driver — `want_swim` + // is inert on dry ground and nothing can sink through a floor — so it must be + // the last resort; + // * a duck is only constructible when its landing column is re-divable + // (`try_duck_under`'s floor bound), so the passage it crosses can be dived + // back through; the duck is therefore the option that KEEPS options. + // + // The step-up may only haul a swimmer out once the water route has been MEASURED + // shut — `try_duck_under` found no more lateral progress at diving depth. At a + // real bank that measurement fails (the bank face is solid all the way down, so + // diving gains nothing) and the haul-out proceeds exactly as before (#191); at + // qcat's pocket mouth the passage to the shaft is open water 2 u below the swim + // plane, so the duck carries the swimmer through instead of letting the lip + // strand it. // // Gated on `wish_vspeed <= 0`: an explicit upward swim wish is the walker's // haul-out drive (water design §4c) and must never be countermanded by an - // autonomous dive. + // autonomous dive (`an_upward_haul_out_drive_is_never_countermanded_by_the_duck` + // goes RED if this gate is deleted). if swimming && intent.wish_vspeed <= 0.0 { if let Some(duck) = self.try_duck_under(wish, col) { if hlen([duck[0] - self.pos[0], duck[1] - self.pos[1], 0.0]) > low_prog + 0.05 { @@ -753,11 +764,24 @@ impl CharacterController { // clamped at the water surface — the feet never leave the water column mid-swim; // a haul-out lip is mounted by the swimming step-up above, not by flying out of // the pool. + // + // The clamp stops `SKIN` UNDER the surface, not exactly ON it (#661 review round, + // found by pinning the duck's up-wish gate): `in_water` is a strict inequality, so + // feet clamped to exactly `surf` read as DRY for the frame — the body probe sees + // feet-at-boundary + chest-in-air and calls the whole body dry — and the DRY + // depenetration net then owns a body that is actually swimming at the surface. + // Measured: a surfaced swimmer pressed against a face inside footprint radius was + // ring-recovered by `nearest_floor` straight to the pool floor 40 u down, + // `Grounded`, in one frame — the #649 teleport shape, alive on `main` too + // (pre-existing; this fix's door made it SUSTAINED rather than new, because the + // up-wish now pins the feet at the clamp indefinitely instead of the net churning + // the body). One `SKIN` of depth keeps the clamped swimmer in its own medium, so + // the sentence above about the water column stays true in the probe's own terms. let want = intent.wish_vspeed * dt; if want > 0.0 { let mut rise = self.swim_rise(want, col); if let Some(surf) = col.water_surface(water_at) { - rise = rise.min((surf - self.pos[2]).max(0.0)); + rise = rise.min((surf - SKIN - self.pos[2]).max(0.0)); } self.pos[2] += rise; } else { @@ -958,11 +982,14 @@ impl CharacterController { // #661 (issue's "second un-`Recovery`'d writer" note): routed through // `recover` so the fall-through guard shares the net's single // position+support writer instead of re-stating the flags inline. Ring - // samples are banked only while grounded and non-embedded, so - // `Recovery::Grounded` holds by construction — the same justification as - // the stuck fallback in `depenetrate`. Behaviour-identical: `recover` - // additionally zeroes `stuck_time`, but this arm only runs on frames - // `depenetrate` returned false, which already reset it. + // samples are banked only while grounded and non-embedded — since #661's + // review (B3) that is enforced by the explicit `!is_embedded` predicate at + // the banking site, not by the control flow's shape (the widened wet door + // briefly made the old shape-argument false, measured) — so + // `Recovery::Grounded` holds by construction, same as the stuck fallback + // in `depenetrate`. Behaviour-identical routing: `recover` additionally + // zeroes `stuck_time`, but this arm only runs on frames `depenetrate` + // returned false, which already reset it. let recovered = match self.good.back().copied() { Some(g) => { self.recover(g[0], g[1], Recovery::Grounded(g[2])); true } None => false, // hold current pos; don't sink below underworld @@ -1090,9 +1117,40 @@ impl CharacterController { /// obstruction by diving? Sink the feet (collided, via [`Self::swim_sink`] — the dive cannot /// pass through the pool floor) by up to the same envelope the step-up can climb /// (`STEP_UP + GROUND_SNAP_TOL` = 2.5 u, the controller's real step capability), then sweep the - /// wish again from the lowered position. `Some(lowered destination)` only when the dive found - /// real room below AND both the lowered start and the destination keep the body's feet in - /// water — a duck is a SWIM move; it may never exit the medium downward or sideways. + /// wish again from the lowered position. Every clause is a refusal condition, each pinned by a + /// RED-when-deleted test (named per clause below): + /// + /// * the dive must find real room below (`sink` actually resolved downward); + /// * the lowered start must keep the feet in water — the duck may not dive out the BOTTOM of + /// its own water volume, even when the destination would be wet again + /// (`a_duck_never_dives_out_the_bottom_of_its_own_water_volume`); + /// * the destination must keep the feet in water — the duck may not exit the medium SIDEWAYS + /// into dry space, where `want_swim` is inert and gravity owns the body + /// (`a_duck_never_exits_the_water_sideways`); + /// * **the destination column must be re-divable (#661 review, B1): its floor must exist and + /// sit at or below the ducked feet.** Without this the duck is itself a one-way transition — + /// the defect class this whole fix exists to remove: over a far shelf shallower than the + /// duck depth, the outbound duck clears the obstruction from deep water while the return + /// duck's sink clamps on the shelf and can never get the chest back under the lip (measured: + /// far floor −3.0 vs duck z −4.5 → crossed once, then converged against the far face for + /// ever, `on_ground=false, in_water=true, hold()=None` — trapped with every observable + /// reading "swimming normally"). Requiring `floor ≤ ducked feet` makes the crossing + /// **reversible by a driven dive, by construction**: the return only needs feet to re-occupy + /// the passage depth, which the floor now provably admits and the destination water check + /// already covers. (Residual, stated exactly: the return's sink stops `SKIN` = 0.05 u above + /// the floor, so a passage whose outbound clearance was under 0.05 u can still shut behind + /// the body; and this is reversibility of the PASSAGE for a driver that dives — a + /// horizontal-only wish still needs the return column deep enough for the autonomous duck, + /// which the same floor bound gives everywhere the two sides' surfaces match.) The probe is + /// the same `ground_below` the step-up's landing check uses, so the two mirrors refuse + /// unoccupiable destinations the same way — the reviewer's structural point, adopted. + /// + /// The feet-only `in_water` probes here are DELIBERATE, not an oversight of the #649 + /// feet-probe lesson (#661 review, N3): a duck is a descent into the medium's interior, so + /// "the FEET themselves are in water" is the required condition — a chest-based probe would + /// accept landings whose lower body is out of the volume. In the one geometry where the feet + /// probe lies (a `.wtr` volume that stops short of the pool floor), these clauses can only + /// REFUSE a duck, never mount anything — the failure is a missed shortcut, not a wrong state. /// /// The caller compares the returned progress against the surface slide's and only takes a duck /// that measured strictly better — on an ascending bank face (solid to the bottom) the lowered @@ -1103,7 +1161,13 @@ impl CharacterController { let lowered = [self.pos[0], self.pos[1], self.pos[2] + sink]; if !col.in_water(lowered) { return None; } let (lo, _) = self.slide(lowered, wish, col); - col.in_water(lo).then_some(lo) + if !col.in_water(lo) { return None; } + // Re-divability (B1): the landing column's floor must admit re-occupying the passage + // depth. `ground_below`'s probe origin is `feet + GROUND_ORIGIN`, so a floor slightly + // above the ducked feet would still be FOUND — hence the explicit `<=` bound, not just + // `is_some()`: a floor above the ducked feet shallows the return sink and shuts the door. + let floor = col.ground_below(lo[0], lo[1], lo[2] + GROUND_ORIGIN, GROUND_DEPTH)?; + (floor <= lo[2]).then_some(lo) } /// Is the wedged-against barrier a *hoppable* fence — i.e. is there walkable floor `HOP_REACH` @@ -1171,7 +1235,17 @@ impl CharacterController { if body_in_water(col, p) || !is_embedded(col, p) { self.stuck_time = 0.0; self.good_timer += dt; - if self.on_ground && self.good_timer >= GOOD_SAMPLE_SECS { + // The ring's invariant — every banked sample is GROUNDED and NON-EMBEDDED — used to + // hold by position in the control flow (this arm was only reachable when + // `!is_embedded`). Widening the door for wet bodies broke that silently: a wading + // body embedded between rocks reached this arm and banked embedded restore points, + // which both ring readers (the stuck fallback and the underworld fall-through guard) + // would then restore a body INTO — the #724 stale-ring re-banking shape (#661 review, + // B3, measured: `banked=4 any_embedded=TRUE` vs main's `banked=0`). So the invariant + // is now enforced where the sample is taken, for every medium; the predicate runs + // only on sample frames (every GOOD_SAMPLE_SECS), and for a dry body it is a + // re-evaluation of the door's own answer. + if self.on_ground && self.good_timer >= GOOD_SAMPLE_SECS && !is_embedded(col, p) { self.good_timer = 0.0; if self.good.len() >= GOOD_RING_LEN { self.good.pop_front(); } self.good.push_back(self.pos); @@ -1199,9 +1273,12 @@ impl CharacterController { match self.good.back().copied() { Some(g) => { tracing::info!("depenetrate: stuck {:.1}s, falling back to last good pos {:?}", self.stuck_time, g); - // The ring buffer only ever samples GROUNDED positions (see the `!embedded` arm - // above), so this fallback is a `Grounded` recovery by construction — routed through - // the same single writer so the net has exactly one place that sets the support flags. + // The ring buffer only ever samples GROUNDED, NON-EMBEDDED positions — enforced + // by the explicit `!is_embedded` predicate at the banking site since #661's + // review (B3; the wet-door widening had silently let an embedded wading body + // bank) — so this fallback is a `Grounded` recovery by construction, routed + // through the same single writer so the net has exactly one place that sets + // the support flags. self.recover(g[0], g[1], Recovery::Grounded(g[2])); } // #724 review B1 — the branch that used not to exist. With an empty ring this arm @@ -1966,6 +2043,203 @@ mod tests { actually gains progress; ended at {:?}", ctrl.pos); } + // ── #661 review: every duck guard is pinned by a RED-when-deleted test ────────────────────── + // + // The review measured that three of the duck's four refusal clauses were pinned by nothing + // (deleting each left the whole tree green), and that the duck itself was a NEW one-way + // transition over a shallow far shelf — the defect class this fix exists to remove. The tests + // below each pin one clause; each was verified RED with only its clause deleted and GREEN on + // the fixed controller. The shared scene is the hanging-face corridor from + // `a_swimmer_ducks_under_a_hanging_face_instead_of_stranding_at_it`, varied one element at a + // time. + + /// Drive the controller east with a constant intent for `frames` frames. + fn drive_east(ctrl: &mut CharacterController, col: &Collision, frames: usize, vspeed: f32) { + let intent = MoveIntent { wish_dir: [1.0, 0.0], wish_vspeed: vspeed, jump: false, + want_swim: true, speed: 44.0, climb: 0.0, hop: false }; + for _ in 0..frames { ctrl.step(intent, 1.0 / 60.0, col); } + } + + /// **B1 — the duck must not be a one-way transition: a far side too shallow to dive back out + /// of is REFUSED.** The reviewer's falsifying scene, adopted as the pin: same lintel, but the + /// far column's floor is at −3.0 — above the −4.5 duck depth — so a body that crossed could + /// never re-sink far enough to get its chest back under the lip (measured pre-guard: crossed + /// once, then converged against the far face for ever, every observable reading "swimming + /// normally"). `try_duck_under`'s floor bound (`floor ≤ ducked feet`, the re-divability + /// condition) refuses the crossing instead; the body presses at the lip — wet, at its plane, + /// still answering input — which nav sees as a stall and can replan around. + #[test] + fn a_duck_never_crosses_into_a_column_it_cannot_dive_back_out_of() { + let c = flooded_corridor( + vec![floor(-40.0, -100.0, 4.0), floor(-3.0, 4.0, 100.0), wall(4.0, -0.2, 20.0)], + -40.0, 0.0); + // Fixture: the far column must NOT be divable to the passage depth (−4.5): its floor sits + // above it, so `ground_below` from the ducked feet finds nothing at or below them. + assert!(c.ground_below(8.0, 0.0, -4.5 + 1.0, 200.0).map_or(true, |f| f > -4.5), + "fixture: the far shelf must be shallower than the duck depth, or this is the \ + round-trip scene"); + let mut ctrl = CharacterController::new([-2.0, 0.0, -2.0]); + drive_east(&mut ctrl, &c, 240, 0.0); + assert!(ctrl.pos[0] < 4.0, + "#661 review B1: the duck must REFUSE a crossing whose far side cannot be dived back \ + out of — crossing here is a one-way trap (measured); got {:?}", ctrl.pos); + assert!(c.in_water(ctrl.pos) && (ctrl.pos[2] - (-2.0)).abs() < 0.3 && !ctrl.on_ground, + "…and the refused swimmer stays wet at its plane, still a swimmer: {:?}", ctrl.pos); + } + + /// **B1's other half — a divable far side is a ROUND TRIP.** Identical scene with the far + /// floor at −4.6, just below the −4.5 duck depth: the crossing is allowed, and driving back + /// west re-ducks under the same lintel and returns. This is the reversibility the ordering + /// comment claims, held as a measurement — and the over-tightening guard on the B1 fix (a + /// refusal keyed on anything stronger than re-divability would turn this red). + #[test] + fn a_duck_across_a_divable_far_side_is_a_round_trip() { + let c = flooded_corridor( + vec![floor(-40.0, -100.0, 4.0), floor(-4.6, 4.0, 100.0), wall(4.0, -0.2, 20.0)], + -40.0, 0.0); + let mut ctrl = CharacterController::new([-2.0, 0.0, -2.0]); + drive_east(&mut ctrl, &c, 240, 0.0); + assert!(ctrl.pos[0] > 8.0, + "fixture/capability: with the far floor below the duck depth the crossing must be \ + allowed; got {:?}", ctrl.pos); + let west = MoveIntent { wish_dir: [-1.0, 0.0], wish_vspeed: 0.0, jump: false, + want_swim: true, speed: 44.0, climb: 0.0, hop: false }; + for _ in 0..360 { ctrl.step(west, 1.0 / 60.0, &c); } + assert!(ctrl.pos[0] < 0.0, + "#661 review B1: the crossing must be a round trip — driving back west must re-duck \ + under the lintel and return; got {:?}", ctrl.pos); + assert!(c.in_water(ctrl.pos) && !ctrl.on_ground, + "…still a swimmer after the round trip: {:?}", ctrl.pos); + } + + /// **The `wish_vspeed <= 0` gate (review M4): an explicit upward drive is never countermanded + /// by an autonomous dive.** The walker's haul-out approach (water design §4c) holds an + /// up-wish; if the duck could fire during it, a bank with any open water under its face would + /// see the controller dive under instead of climbing out. Here the up-wishing swimmer starts + /// at the lintel already within duck range: with the gate it presses and RISES (the §4c + /// shape); with the gate deleted it ducks under on the first blocked frame and crosses — + /// which is this test's failure. + #[test] + fn an_upward_haul_out_drive_is_never_countermanded_by_the_duck() { + let c = flooded_corridor( + vec![floor(-40.0, -100.0, 100.0), wall(4.0, -0.2, 20.0)], + -40.0, 0.0); + let mut ctrl = CharacterController::new([3.0, 0.0, -2.4]); + drive_east(&mut ctrl, &c, 120, 5.0); // an explicit, sustained upward swim wish + assert!(ctrl.pos[0] < 4.0, + "#661 review M4: an up-wishing swimmer must NEVER be taken under the obstruction by \ + the autonomous duck — the up-wish is the walker's haul-out drive; got {:?}", ctrl.pos); + assert!(ctrl.pos[2] > -2.4 + 0.05 && c.in_water(ctrl.pos), + "…and the up-wish must actually be rising it toward the surface: {:?}", ctrl.pos); + } + + /// **The destination water check (review M5): a duck never exits the water SIDEWAYS.** The + /// water region ends exactly at the lintel's plane, so the surface swimmer always stays wet — + /// but a duck's landing would be dry. With the check the duck is refused and the body keeps + /// pressing, wet, at its plane; with the check deleted the duck lands the body dry at depth + /// with `want_swim` inert and gravity in charge — it plummets to the pool floor 38 u below, + /// which is this test's failure. + #[test] + fn a_duck_never_exits_the_water_sideways() { + let mut c = col(vec![floor(-40.0, -100.0, 100.0), wall(4.0, -0.2, 20.0)]); + c.set_water(Some(std::sync::Arc::new( + crate::region_map::RegionMap::box_below(-100.0, 100.0, -100.0, 4.0, 0.0)))); + assert!(c.in_water([3.5, 0.0, -2.0]) && !c.in_water([4.5, 0.0, -4.5]), + "fixture: wet up to the lintel plane, dry beyond it at every depth"); + let mut ctrl = CharacterController::new([-2.0, 0.0, -2.0]); + drive_east(&mut ctrl, &c, 180, 0.0); + assert!(ctrl.pos[0] < 4.05 && (ctrl.pos[2] - (-2.0)).abs() < 0.3, + "#661 review M5: a duck whose landing is dry must be refused — accepting it exits the \ + medium sideways at depth and gravity takes the body to the pool floor; got {:?}", + ctrl.pos); + assert!(c.in_water(ctrl.pos) && !ctrl.on_ground, + "…the refused swimmer is still a swimmer: {:?}", ctrl.pos); + } + + /// **The lowered-start water check (review M6): a duck never dives out the BOTTOM of its own + /// water volume.** The near column's water is only 3.5 u deep over a 40 u-deep passage: the + /// 2.5 u sink from the float plane would put the feet below the volume's floor, transiting + /// dry space even though the far side would be wet again. With the check the duck is refused; + /// with it deleted the body crosses — this test's failure. + #[test] + fn a_duck_never_dives_out_the_bottom_of_its_own_water_volume() { + let mut c = col(vec![floor(-40.0, -100.0, 100.0), wall(4.0, -0.2, 20.0)]); + c.set_water(Some(std::sync::Arc::new(crate::region_map::RegionMap::water_boxes(&[ + [-100.0, 100.0, -100.0, 4.0, -3.5, 0.0], // shallow near volume: bottom −3.5 + [-100.0, 100.0, 4.0, 100.0, -40.0, 0.0], // deep far volume + ])))); + assert!(c.in_water([2.0, 0.0, -2.0]) && !c.in_water([2.0, 0.0, -4.5]) + && c.in_water([6.0, 0.0, -4.5]), + "fixture: the ducked depth is BELOW the near volume's bottom but wet on the far side — \ + exactly the case only the lowered-start check refuses"); + let mut ctrl = CharacterController::new([-2.0, 0.0, -2.0]); + drive_east(&mut ctrl, &c, 180, 0.0); + assert!(ctrl.pos[0] < 4.05 && ctrl.pos[2] > -3.6, + "#661 review M6: the duck must not dive through the floor of the water it is in, even \ + to a wet landing; got {:?}", ctrl.pos); + } + + /// **The 2.5 u envelope (review M7), pinned where CI can see it.** The lintel's underside is + /// at −1.0: passing it needs the chest (feet + 4) below −1.0, i.e. a 3.0 u dive from the + /// float plane — just past the `STEP_UP + GROUND_SNAP_TOL` = 2.5 envelope. The duck must + /// refuse; a widened envelope (the review's 12.0 mutation, previously RED only in + /// `#[ignore]`d asset-gated tests) crosses and turns this red. The envelope is the step + /// capability's mirror, not a free dive — anything deeper is the planner's business + /// (a dive-first route with an explicit down-wish), not the controller's autonomy. + #[test] + fn the_duck_envelope_is_the_step_envelope_not_a_free_dive() { + let c = flooded_corridor( + vec![floor(-40.0, -100.0, 100.0), wall(4.0, -1.0, 20.0)], + -40.0, 0.0); + let mut ctrl = CharacterController::new([-2.0, 0.0, -2.0]); + drive_east(&mut ctrl, &c, 240, 0.0); + assert!(ctrl.pos[0] < 4.0 && ctrl.pos[2] > -4.6, + "#661 review M7: a passage needing a 3.0 u dive is outside the 2.5 u duck envelope and \ + must be refused — a free-dive duck is a new capability nobody approved; got {:?}", + ctrl.pos); + assert!(c.in_water(ctrl.pos) && (ctrl.pos[2] - (-2.0)).abs() < 0.3, + "…the refused swimmer holds its plane: {:?}", ctrl.pos); + } + + /// **The recovery ring never contains an EMBEDDED sample (review B3).** Widening the net's + /// door for wet bodies let a wading body embedded between close rocks reach the banking arm — + /// silently breaking the ring's "grounded and non-embedded by construction" property that + /// both ring readers (the stuck fallback and the underworld guard) rely on for their restore + /// points. The banking site now enforces the predicate explicitly. Here: a body wades + /// embedded in a flooded 1.6 u slot for two full seconds (four banking windows), is then + /// relocated over a floorless dry column, and the stuck fallback must find NOTHING to restore + /// — it must hold and disclose, not rubber-band the body back into the embedded slot. + #[test] + fn an_embedded_wading_sample_never_enters_the_recovery_ring() { + let mut c = col(vec![floor(0.0, -100.0, 100.0), wall(0.8, 0.0, 10.0), wall(-0.8, 0.0, 10.0)]); + // Water only over the slot's east band, so the relocation target is DRY. + c.set_water(Some(std::sync::Arc::new( + crate::region_map::RegionMap::box_below(-100.0, 100.0, -1.5, 1.5, 1.0)))); + let mut ctrl = CharacterController::new([0.0, 0.0, 0.0]); + ctrl.on_ground = true; + assert!(c.in_water([0.0, 0.0, 0.0]) && !c.footprint_clear(0.0, 0.0, 0.0, PLAYER_RADIUS, 8), + "fixture: a WET, GROUNDED, EMBEDDED body — the exact combination the widened door let \ + into the banking arm"); + for _ in 0..120 { ctrl.step(walk(0.0, [0.0, 0.0]), 1.0 / 60.0, &c); } + assert!(ctrl.on_ground && ctrl.pos[0].abs() < 0.1, + "fixture: it waded in place, grounded, for the whole banking period: {:?}", ctrl.pos); + + // Relocate over a column with NO floor below (the floor at z=0 is far above) — dry, clear + // footprint, `ground_below` none → the net's no-floor arm, whose only recovery is the ring. + ctrl.pos = [50.0, 0.0, -49.0]; + assert!(!c.in_water(ctrl.pos) + && c.ground_below(50.0, 0.0, -48.0, GROUND_DEPTH).is_none(), + "fixture: the relocation target is dry with nothing below in probe range"); + for _ in 0..90 { ctrl.step(walk(0.0, [0.0, 0.0]), 1.0 / 60.0, &c); } + assert!((ctrl.pos[0] - 50.0).abs() < 1.5, + "#661 review B3: the ring must hold NO sample from the embedded wade — restoring one \ + rubber-bands the body back into the slot it was standing embedded in; got {:?}", + ctrl.pos); + assert!(matches!(ctrl.hold(), Some(h) if h.reason == ControllerHoldReason::EmbeddedNoRecovery), + "…and with an empty ring the stuck fallback must HOLD and disclose, not invent a \ + restore point; got {:?}", ctrl.hold()); + } + #[test] // #730: capitalised on purpose — BODY-not-feet is the exact #649 regression this test pins // (a feet-only probe calls a submerged body dry, see the comment below); renaming would blur it. @@ -1994,15 +2268,17 @@ mod tests { ctrl.step(swim_still(), 1.0 / 60.0, &c); // One frame. Fixed: the door sees a wet BODY, the net stays out, and buoyancy's ordinary - // ≤ 0.5 u-per-frame rise begins (the pre-#661 assertion demanded z exactly 0, but that was - // the net FREEZING the frame — "not teleported" and "frozen" are different claims, and the - // frozen half is gone with the net). Mutated (feet-only door probe): the body walks into - // the dry net and is beached on the slab — z = 2.0, `on_ground` — which both halves below - // reject. - assert!(ctrl.pos[2] < 1.0 && !ctrl.on_ground, - "a submerged body whose FEET are outside the water volume is still afloat: a feet-only \ - probe in the net calls it dry and mounts it on the slab at z=2, grounded — got {:?} \ - on_ground={}", ctrl.pos, ctrl.on_ground); + // rise begins (the pre-#661 assertion demanded z exactly 0, but that was the net FREEZING + // the frame — "not teleported" and "frozen" are different claims, and the frozen half is + // gone with the net). The bound is buoyancy's own per-frame maximum, `BUOY_RATE * dt` + // = 0.5 u — the PHYSICAL claim, not a midpoint to the mutation (#661 review, flip-4 note: + // `< 1.0` would let a doubled buoyancy rate ship green under this test's name). Mutated + // (feet-only door probe): the body walks into the dry net and is beached on the slab — + // z = 2.0, `on_ground` — which both halves below reject. + assert!(ctrl.pos[2] <= BUOY_RATE * (1.0 / 60.0) + 1e-3 && !ctrl.on_ground, + "a submerged body whose FEET are outside the water volume is still afloat and rises at \ + most one buoyancy step (0.5 u): a feet-only probe in the net calls it dry and mounts \ + it on the slab at z=2, grounded — got {:?} on_ground={}", ctrl.pos, ctrl.on_ground); } /// **THE DEPENETRATION CORPUS — the blast-radius harness, committed so its numbers are @@ -2164,9 +2440,10 @@ mod tests { for _ in 0..20 { ctrl.step(walk(0.0, [0.0, 0.0]), 0.05, &bad); } assert!((ctrl.pos[0]).abs() < 1e-2 && (ctrl.pos[1]).abs() < 1e-2, "should have rubber-banded to the last good grounded position (origin): {:?}", ctrl.pos); - // The ring buffer only ever samples GROUNDED positions, so the fallback recovers a body that - // IS standing — pinned here because #649 routed this write through the shared `recover` - // (`Recovery::Grounded`) and an unpinned refactor is an unnoticed behaviour change. + // The ring buffer only ever samples GROUNDED, NON-EMBEDDED positions (the explicit + // `!is_embedded` gate at the banking site — #661 review B3), so the fallback recovers a + // body that IS standing — pinned here because #649 routed this write through the shared + // `recover` (`Recovery::Grounded`) and an unpinned refactor is an unnoticed behaviour change. assert!(ctrl.on_ground, "the last-good position is a grounded one: {:?}", ctrl.pos); } From 8514aa4d180762cbc70f88d68257c9d09de7d798 Mon Sep 17 00:00:00 2001 From: dhenry Date: Tue, 28 Jul 2026 06:52:10 -0400 Subject: [PATCH 3/3] fix(#661) round 3: re-divability gains its surface axis, the <= floor bound is pinned, and the ordering rationale drops both falsified universals Review round 2 (PR #767): all three findings accepted. R2-B1: try_duck_under adds the reviewer's surface bound - the landing column's own float plane must be inside the duck envelope of the passage depth - so an admitted duck's crossing is autonomously round-trip-capable on BOTH axes. Cost measured before adopting: the bound also refuses the qcat pocket-mouth duck (a ~13u surface mismatch, genuinely one-way for the autonomous driver), and measurement showed that cost is nearly nil against the real system: a driven dive crosses with no duck involved, and the walker's own rise-y steering at that pocket sends an up-wish the vspeed gate already excludes from ducking. The escape test therefore became qcat_pocket_swimmer_escapes_to_the_shaft_under_a_driven_dive (green, swept over the stranded band), with the horizontal driver's honest outcome pinned as a WET STALL (never dry, never one-way) in qcat_pocket_horizontal_wish_alone_stalls_wet_at_the_mouth. The same experiment reproduced the LIVE walker wedge offline for the first time (planner emits a rise-at-destination swim_surface hop, swim_vspeed turns the high carrot into an up-wish, body pins under the lid) - that residue is steering/walker territory, filed separately with the repro. R2-B2: the floor test now sweeps far_floor in {-3.0, -4.0}; the -4.0 arm is inside ground_below's probe band and only `floor <= lo[2]` refuses it (measured RED with the refinement deleted, is_some kept). R2-B3: the ordering comment's two falsified universals are deleted; the replacement states the narrow measured basis (banks self-refuse the duck; an admitted duck is round-trip by the bounds; a haul-out changes medium) and says outright that the ordering is not a reversibility argument. Review N4: the surface clamp has an explicit assertion under the M4 test; reverting the clamp fails it by name. The clamp comment now names the reproducible fixture for the 40u figure (the M4 scene, clamp reverted) instead of an unanchored magnitude, and records the reviewer's independent confirmation of the mechanism on main. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV --- src/movement.rs | 183 +++++++++++++++++++++------- tests/synthetic_scenes/mod.rs | 5 +- tests/synthetic_water_capability.rs | 3 +- tests/water_capability.rs | 85 +++++++++++-- 4 files changed, 214 insertions(+), 62 deletions(-) diff --git a/src/movement.rs b/src/movement.rs index 470d8f38..5dab5581 100644 --- a/src/movement.rs +++ b/src/movement.rs @@ -664,26 +664,25 @@ impl CharacterController { let mut ducked = false; if (self.on_ground || swimming) && low_hit && low_prog + 0.01 < hlen(wish) { // #661: a blocked SWIMMER first tries to pass UNDER the obstruction — the exact - // mirror of the step-up below, pointing down. The ordering rationale, stated as - // the property the code actually has (#661 review, B1 — the first version of this - // comment claimed "a swimmer that dives can always surface again", and the review - // FALSIFIED that on a shallow far shelf; the sentence was not reworded, the - // missing condition was built): + // mirror of the step-up below, pointing down. // - // * a haul-out that lands DRY is irreversible under every driver — `want_swim` - // is inert on dry ground and nothing can sink through a floor — so it must be - // the last resort; - // * a duck is only constructible when its landing column is re-divable - // (`try_duck_under`'s floor bound), so the passage it crosses can be dived - // back through; the duck is therefore the option that KEEPS options. + // The ordering is NOT a reversibility argument. Two earlier versions of this + // comment justified it with universals ("a swimmer that dives can always surface + // again"; "a dry haul-out is irreversible under every driver") and review + // measurement falsified BOTH: a duck could be one-way over a shallow shelf or a + // higher far surface (both now refused by `try_duck_under`'s two re-divability + // bounds), and a hauled-out body can walk back off the very lip it climbed. The + // honest, measured basis for the ordering is narrower: // - // The step-up may only haul a swimmer out once the water route has been MEASURED - // shut — `try_duck_under` found no more lateral progress at diving depth. At a - // real bank that measurement fails (the bank face is solid all the way down, so - // diving gains nothing) and the haul-out proceeds exactly as before (#191); at - // qcat's pocket mouth the passage to the shaft is open water 2 u below the swim - // plane, so the duck carries the swimmer through instead of letting the lip - // strand it. + // * at every measured legitimate bank the duck refuses ITSELF (a face that is + // solid to the bottom gives a dive no extra progress), so trying it first + // costs the haul-out nothing — pinned by + // `a_swimmer_at_a_solid_bank_still_hauls_out_the_duck_does_not_override_191` + // and walker_sim's P1 sweep; + // * where both moves genuinely pass, the duck keeps the body in the medium its + // current driver is steering it through, and an admitted duck is round-trip- + // capable by construction (the bounds in `try_duck_under`), while a haul-out + // changes medium and hands the vertical to gravity. // // Gated on `wish_vspeed <= 0`: an explicit upward swim wish is the walker's // haul-out drive (water design §4c) and must never be countermanded by an @@ -770,13 +769,17 @@ impl CharacterController { // feet clamped to exactly `surf` read as DRY for the frame — the body probe sees // feet-at-boundary + chest-in-air and calls the whole body dry — and the DRY // depenetration net then owns a body that is actually swimming at the surface. - // Measured: a surfaced swimmer pressed against a face inside footprint radius was - // ring-recovered by `nearest_floor` straight to the pool floor 40 u down, - // `Grounded`, in one frame — the #649 teleport shape, alive on `main` too - // (pre-existing; this fix's door made it SUSTAINED rather than new, because the - // up-wish now pins the feet at the clamp indefinitely instead of the net churning - // the body). One `SKIN` of depth keeps the clamped swimmer in its own medium, so - // the sentence above about the water column stays true in the probe's own terms. + // The mechanism is confirmed on `main` too (review round 2: feet reach z = 3.7e-7, + // body reads dry, the dry net takes it) — pre-existing, not introduced here. The + // magnitude is geometry-dependent: in the + // `an_upward_haul_out_drive_is_never_countermanded_by_the_duck` scene (lintel at + // east 4, pool floor −40, `water_slab(-40, 0)`, up-wish 5), the clamp-less frame + // trace measured `f28 z=0.0 wet=0` → `f29 pos=(2.81, 0.38, -40.0) Grounded` — a + // one-frame `nearest_floor` teleport to the pool bottom, because no nearer floor + // exists in any candidate column there; in shallow-slot scenes the net shoves + // laterally instead. One `SKIN` of depth keeps the clamped swimmer in its own + // medium, so the sentence above about the water column stays true in the probe's + // own terms; that scene's test pins the clamp by name. let want = intent.wish_vspeed * dt; if want > 0.0 { let mut rise = self.swim_rise(want, col); @@ -1162,12 +1165,40 @@ impl CharacterController { if !col.in_water(lowered) { return None; } let (lo, _) = self.slide(lowered, wish, col); if !col.in_water(lo) { return None; } - // Re-divability (B1): the landing column's floor must admit re-occupying the passage - // depth. `ground_below`'s probe origin is `feet + GROUND_ORIGIN`, so a floor slightly - // above the ducked feet would still be FOUND — hence the explicit `<=` bound, not just - // `is_some()`: a floor above the ducked feet shallows the return sink and shuts the door. + // Re-divability, floor axis (#661 review B1): the landing column's floor must admit + // re-occupying the passage depth. `ground_below`'s probe origin is `feet + GROUND_ORIGIN`, + // so a floor slightly above the ducked feet would still be FOUND — hence the explicit `<=` + // bound, not just `is_some()`: a floor above the ducked feet shallows the return sink and + // shuts the door (far floor −4.0 vs duck depth −4.5 is a measured one-way trap under + // `is_some()` alone; both values are pinned in + // `a_duck_never_crosses_into_a_column_it_cannot_dive_back_out_of`). let floor = col.ground_below(lo[0], lo[1], lo[2] + GROUND_ORIGIN, GROUND_DEPTH)?; - (floor <= lo[2]).then_some(lo) + if floor > lo[2] { return None; } + // Re-divability, surface axis (#661 review R2-B1): the landing column's own float plane + // must be inside the duck envelope of the passage depth, or the return duck — whose sink + // starts from wherever buoyancy parks the body on the far side — cannot get the chest + // back under the lip (measured: a 2 u surface mismatch across the author's lintel traps + // permanently, `hold()=None`, every observable reading "swimming normally"). With BOTH + // bounds an admitted duck's crossing is autonomously round-trip-capable: the return sink + // can reach the passage depth (this bound), the floor provably admits it (the floor + // bound), and the passage is wet at both ends (the water checks) — up to the SKIN-sized + // clearance residual, which review N1 measured unreachable in practice. + // + // Cost, stated plainly: this also refuses the qcat pocket-mouth duck for a HORIZONTAL-only + // wish (the shaft's surface is ~13 u above the pocket's, so that crossing is genuinely + // one-way for the autonomous driver — the same shape as the trap, distinguishable only by + // global knowledge the controller does not have; the planner has it and can always route + // a dive back). Measured against the real system, the cost is nearly nil: a DRIVEN dive + // (an explicit down-wish, what a dive-first route sends) crosses with no duck involved + // (`qcat_pocket_swimmer_escapes_to_the_shaft_under_a_driven_dive`), and the walker's own + // rise-y steering at this pocket sends an UP-wish, which the `wish_vspeed` gate already + // excludes from ducking — with or without this bound. What the bound actually forecloses + // is a horizontal-wish driver (e.g. `/move/manual`) making a one-way crossing it cannot + // undo; that driver now stalls wet at the mouth instead + // (`qcat_pocket_horizontal_wish_alone_stalls_wet_at_the_mouth`). + let surf = col.water_surface(lo)?; + ((surf - crate::traversability::PLAYER_BODY.float_depth) - lo[2] + <= STEP_UP + GROUND_SNAP_TOL).then_some(lo) } /// Is the wedged-against barrier a *hoppable* fence — i.e. is there walkable floor `HOP_REACH` @@ -2062,29 +2093,74 @@ mod tests { /// **B1 — the duck must not be a one-way transition: a far side too shallow to dive back out /// of is REFUSED.** The reviewer's falsifying scene, adopted as the pin: same lintel, but the - /// far column's floor is at −3.0 — above the −4.5 duck depth — so a body that crossed could - /// never re-sink far enough to get its chest back under the lip (measured pre-guard: crossed - /// once, then converged against the far face for ever, every observable reading "swimming - /// normally"). `try_duck_under`'s floor bound (`floor ≤ ducked feet`, the re-divability - /// condition) refuses the crossing instead; the body presses at the lip — wet, at its plane, - /// still answering input — which nav sees as a stall and can replan around. + /// far column's floor sits above the −4.5 duck depth, so a body that crossed could never + /// re-sink far enough to get its chest back under the lip (measured pre-guard: crossed once, + /// then converged against the far face for ever, every observable reading "swimming + /// normally"). `try_duck_under`'s floor bound (`floor ≤ ducked feet`) refuses the crossing; + /// the body presses at the lip — wet, at its plane, still answering input — which nav sees as + /// a stall and can replan around. + /// + /// Two far-floor values, each killing a different half of the bound (#661 review R2-B2 — + /// round 2 shipped only −3.0, which sits above `ground_below`'s probe origin (−3.5) and so + /// pins the `?` while leaving the `<=` refinement pinned by NOTHING; the reviewer measured + /// the whole lib green with `<=` deleted, and −4.0 crossing into the same permanent trap): + /// + /// * −3.0 — above the probe origin: `ground_below` finds nothing → the `?` refuses; + /// * −4.0 — inside the probe band but ABOVE the ducked feet: found, and only + /// `floor <= lo[2]` refuses it. Its return sink would clamp ~0.5 u short of the passage + /// depth and the chest never gets back under the lip. #[test] fn a_duck_never_crosses_into_a_column_it_cannot_dive_back_out_of() { - let c = flooded_corridor( - vec![floor(-40.0, -100.0, 4.0), floor(-3.0, 4.0, 100.0), wall(4.0, -0.2, 20.0)], - -40.0, 0.0); - // Fixture: the far column must NOT be divable to the passage depth (−4.5): its floor sits - // above it, so `ground_below` from the ducked feet finds nothing at or below them. - assert!(c.ground_below(8.0, 0.0, -4.5 + 1.0, 200.0).map_or(true, |f| f > -4.5), - "fixture: the far shelf must be shallower than the duck depth, or this is the \ - round-trip scene"); + for far_floor in [-3.0f32, -4.0] { + let c = flooded_corridor( + vec![floor(-40.0, -100.0, 4.0), floor(far_floor, 4.0, 100.0), wall(4.0, -0.2, 20.0)], + -40.0, 0.0); + // Fixture: the far column must NOT be divable to the passage depth (−4.5) — either no + // floor within the probe, or a floor above the ducked feet. + assert!(c.ground_below(8.0, 0.0, -4.5 + 1.0, 200.0).map_or(true, |f| f > -4.5), + "fixture: the far shelf ({far_floor}) must be shallower than the duck depth, or \ + this is the round-trip scene"); + let mut ctrl = CharacterController::new([-2.0, 0.0, -2.0]); + drive_east(&mut ctrl, &c, 240, 0.0); + assert!(ctrl.pos[0] < 4.0, + "#661 review B1/R2-B2: the duck must REFUSE a crossing whose far side (floor \ + {far_floor}) cannot be dived back out of — crossing is a one-way trap (measured \ + for both values); got {:?}", ctrl.pos); + assert!(c.in_water(ctrl.pos) && (ctrl.pos[2] - (-2.0)).abs() < 0.3 && !ctrl.on_ground, + "…and the refused swimmer stays wet at its plane, still a swimmer: {:?}", ctrl.pos); + } + } + + /// **R2-B1 — the SURFACE axis of re-divability: a far column whose float plane is beyond the + /// duck envelope above the passage depth is REFUSED.** The round-2 floor bound closed the + /// shelf half of the one-way trap; the reviewer then measured the same trap on the surface + /// axis — floor −40 on BOTH sides (so the floor bound admits) and adjacent water volumes + /// whose surfaces differ by 2 u: the body crosses under the lintel, buoyancy parks it at the + /// FAR column's float plane, and the return duck's 2.5 u sink from there can no longer reach + /// the passage depth. Permanently trapped, `hold()=None`, every observable reading "swimming + /// normally". Adjacent surfaces at different heights are a first-class modelled case + /// (`region_map::tests::water_boxes_gives_each_box_its_own_bounded_surface`), not exotica. + /// `try_duck_under`'s surface bound refuses the crossing; deleting it re-opens the trap and + /// turns this red. + #[test] + fn a_duck_never_crosses_into_a_column_whose_float_plane_is_beyond_the_return_envelope() { + let mut c = col(vec![floor(-40.0, -100.0, 100.0), wall(4.0, -0.2, 20.0)]); + c.set_water(Some(std::sync::Arc::new(crate::region_map::RegionMap::water_boxes(&[ + [-100.0, 100.0, -100.0, 4.0, -40.0, 0.0], // near column: surface 0 + [-100.0, 100.0, 4.0, 100.0, -40.0, 2.0], // far column: surface +2 — the reviewer's trap + ])))); + assert!((c.water_surface([2.0, 0.0, -2.0]).unwrap() - 0.0).abs() < 1e-3 + && (c.water_surface([6.0, 0.0, -2.0]).unwrap() - 2.0).abs() < 1e-3, + "fixture: a 2 u surface mismatch across the lintel — the smallest measured trapping \ + mismatch"); let mut ctrl = CharacterController::new([-2.0, 0.0, -2.0]); drive_east(&mut ctrl, &c, 240, 0.0); assert!(ctrl.pos[0] < 4.0, - "#661 review B1: the duck must REFUSE a crossing whose far side cannot be dived back \ - out of — crossing here is a one-way trap (measured); got {:?}", ctrl.pos); + "#661 review R2-B1: the duck must REFUSE a crossing whose far float plane is beyond \ + the return duck's envelope — measured, crossing at a 2 u mismatch traps permanently \ + and silently; got {:?}", ctrl.pos); assert!(c.in_water(ctrl.pos) && (ctrl.pos[2] - (-2.0)).abs() < 0.3 && !ctrl.on_ground, - "…and the refused swimmer stays wet at its plane, still a swimmer: {:?}", ctrl.pos); + "…and the refused swimmer stays wet at its plane: {:?}", ctrl.pos); } /// **B1's other half — a divable far side is a ROUND TRIP.** Identical scene with the far @@ -2131,6 +2207,19 @@ mod tests { the autonomous duck — the up-wish is the walker's haul-out drive; got {:?}", ctrl.pos); assert!(ctrl.pos[2] > -2.4 + 0.05 && c.in_water(ctrl.pos), "…and the up-wish must actually be rising it toward the surface: {:?}", ctrl.pos); + // The surface CLAMP's own pin, under its own name (#661 review N4 — previously this test + // pinned the clamp only by coupling): a fully-risen up-wishing swimmer parks `SKIN` UNDER + // the waterline, still wet by the strict probe. Reverting the clamp to exactly `surf` + // parks the feet ON the boundary, the body reads dry for the frame, and the DRY + // depenetration net owns a swimming body (in THIS scene, pre-clamp-fix, it ring-recovered + // the body to the pool floor 40 u down — `nearest_floor` at the first clear candidate, + // `dz = -40.0`, `Grounded` — because every floor nearer than the pool bottom is outside + // the candidate columns here; in shallower scenes it shoves laterally instead, the + // reviewer's measured 2 u variant. Same mechanism, geometry-dependent magnitude). + assert!(ctrl.pos[2] <= -SKIN + 1e-4, + "the up-wish clamp must park the feet SKIN under the surface, never ON it — feet at \ + the exact waterline read DRY and hand a swimming body to the dry net; got z={}", + ctrl.pos[2]); } /// **The destination water check (review M5): a duck never exits the water SIDEWAYS.** The diff --git a/tests/synthetic_scenes/mod.rs b/tests/synthetic_scenes/mod.rs index 4da2861e..642874f9 100644 --- a/tests/synthetic_scenes/mod.rs +++ b/tests/synthetic_scenes/mod.rs @@ -37,8 +37,9 @@ //! `tests/walker_sim.rs` (`p1_haul_out_admission_matches_controller_execution`) and in //! `movement::tests::a_swimmer_at_a_solid_bank_still_hauls_out_the_duck_does_not_override_191`, //! both RED when it is disabled; the duck-under's lives in -//! `movement::tests::a_swimmer_ducks_under_a_hanging_face_instead_of_stranding_at_it` and the -//! asset-gated `qcat_pocket_swim_plane_swimmer_escapes_to_the_shaft`, both RED without it. +//! `movement::tests::a_swimmer_ducks_under_a_hanging_face_instead_of_stranding_at_it` (the +//! asset-gated qcat escape now goes through a DRIVEN dive rather than the duck — see +//! `tests/water_capability.rs`'s round-3 note), RED without it. //! A future reader must not infer from "these tests stayed green" that those branches are //! unprotected. //! diff --git a/tests/synthetic_water_capability.rs b/tests/synthetic_water_capability.rs index 4f59eea5..3be3e121 100644 --- a/tests/synthetic_water_capability.rs +++ b/tests/synthetic_water_capability.rs @@ -94,7 +94,8 @@ fn try_to_sink(col: &Collision, from: [f32; 3], secs: f32) -> [f32; 3] { /// /// `tests/water_capability.rs`'s pocket test shows the same coordinate live: under #658 the /// swimmer still ended dry at −55.9687 (that residual wedge was #661), and since the #661 fix that -/// test asserts the ESCAPE (`qcat_pocket_swim_plane_swimmer_escapes_to_the_shaft`). This chamber +/// test asserts the ESCAPE (`qcat_pocket_swimmer_escapes_to_the_shaft_under_a_driven_dive`; the +/// horizontal-only drive now stalls wet by design — see that file's round-3 note). This chamber /// is sealed on all six sides, so there is no escape to assert here — a swimmer that stays wet at /// its own depth beside the wall is the whole correct outcome. /// diff --git a/tests/water_capability.rs b/tests/water_capability.rs index ecc975e3..2edb7ab2 100644 --- a/tests/water_capability.rs +++ b/tests/water_capability.rs @@ -20,9 +20,11 @@ //! ``` //! //! Two of them pin capability (what a swimmer CAN do, so a future gate cannot quietly forbid it); -//! the third used to pin the #329/#661 strand at the coordinate the live client wedged on, and -//! since the #661 fix pins the ESCAPE from it instead — same start, same drive, opposite -//! expectation (see its doc comment for the measured mechanism and the flip's history). +//! the strand test's lineage pins the #329/#661 wedge coordinate: it asserted the strand, then +//! (round 2) the escape under the same horizontal drive, and now (round 3) the escape under a +//! DRIVEN dive plus a wet-stall disclosure for the horizontal drive — each flip's history is in +//! the test docs, because the drive that can honestly escape changed as the duck's re-divability +//! bounds landed. use eqoxide::assets::ZoneAssets; use eqoxide::movement::CharacterController; @@ -185,15 +187,34 @@ fn stepped_canal_surfaces_are_swimmable_between() { /// The fix is three-sided (`movement.rs`): a floating body never enters the depenetration net; /// the net's only constructible recovery is `Grounded` and only dry bodies can reach it; and a /// blocked swimmer tries `try_duck_under` — the step-up's downward mirror — before the haul-out, -/// taking it only when diving measurably gains lateral progress. From the plane the swimmer now -/// ducks under the lip, crosses the flooded corridor, and buoyancy lifts it onto the SHAFT's swim -/// plane (−44.982): the same escape the deep starts (−61 … −63) always performed. +/// taking it only when diving measurably gains lateral progress. In round 2 the duck itself +/// carried the horizontal-only swimmer under the lip to the shaft plane; round 3 changed which +/// drive performs the escape — read on. +/// +/// # ⚠️ Round 3 changed WHICH drive escapes — the history matters, read it +/// +/// Round 2's version of this test escaped under a horizontal-only wish, via the autonomous +/// duck-under crossing from the pocket's swim plane into the shaft's water column. The round-2 +/// review then measured that an unconstrained duck is itself a one-way transition over any +/// surface mismatch beyond the duck envelope (R2-B1) — and the pocket→shaft crossing is a ~13 u +/// mismatch, i.e. genuinely one-way for the autonomous driver. `try_duck_under`'s surface bound +/// now refuses it, so the horizontal-only drive stalls wet at the mouth (the test below this one) +/// and the ESCAPE is executed the way the system actually intends: a DRIVEN dive — the explicit +/// down-wish a dive-first route (or a manual down+lateral drive) sends — which passes under the +/// lip with no duck involved and can always be driven back the same way. +/// +/// (Also measured this round, filed separately: driving the REAL planner route with the REAL +/// steering still wedges at this pocket — the planner emits a rise-at-destination `swim_surface` +/// hop, `swim_vspeed` turns the high carrot into an up-wish, and the up-wish pins the body under +/// the lid. That walker/steering residue is outside this PR's file set; the offline repro is in +/// the follow-up issue. The controller-level capabilities this test and its sibling pin are the +/// substrate that fix needs either way.) /// /// The start-depth sweep covers the band #661 measured as STRANDED on the #658 controller -/// (−58.00 … −60.75); every depth in it must now end where the deep starts always did. +/// (−58.00 … −60.75); every depth in it must reach the shaft under the driven dive. #[test] #[ignore = "asset-gated: needs baked qcat.glb + qcat.wtr at $EQZONES (#357)"] -fn qcat_pocket_swim_plane_swimmer_escapes_to_the_shaft() { +fn qcat_pocket_swimmer_escapes_to_the_shaft_under_a_driven_dive() { let col = zone("qcat"); let pocket_xy = [-42.3f32, 1036.8]; let shaft = [-45.75f32, 1030.0625, -42.98]; @@ -203,11 +224,25 @@ fn qcat_pocket_swim_plane_swimmer_escapes_to_the_shaft() { assert!((plane - (-57.978)).abs() < 0.01, "fixture: pocket swim plane {plane}"); for z in [plane, -59.0, -60.5] { - let end = swim_toward(&col, [pocket_xy[0], pocket_xy[1], z], shaft, 12.0); + // The dive-first driver: an explicit down-wish until below the corridor lid, horizontal + // toward the shaft throughout, neutral once the dive has cleared the passage — the intent + // shape a dive-first route (or `/move/manual` down+lateral) sends. + let mut c = CharacterController::new([pocket_xy[0], pocket_xy[1], z]); + let dt = 1.0 / 60.0; + for _ in 0..(12 * 60) { + let d = [shaft[0] - c.pos[0], shaft[1] - c.pos[1]]; + let l = (d[0] * d[0] + d[1] * d[1]).sqrt(); + let dir = if l > 0.2 { [d[0] / l, d[1] / l] } else { [0.0, 0.0] }; + let vs = if c.pos[2] > -60.0 && l > 4.0 { -20.0 } else { 0.0 }; + c.step(MoveIntent { wish_dir: dir, wish_vspeed: vs, jump: false, want_swim: true, + speed: 44.0, climb: 0.0, hop: false }, dt, &col); + } + let end = c.pos; assert!((end[2] - shaft_plane).abs() < 0.05, - "#661: from z={z:.3} the swimmer must ESCAPE the pocket and settle on the SHAFT's swim \ - plane {shaft_plane:.4} — the pre-fix controller stranded this start dry on the tile \ - floor at −55.9687, the live #329 wedge coordinate; got {end:?}"); + "#661: from z={z:.3} a DRIVEN dive must carry the swimmer out of the pocket and \ + buoyancy must settle it on the SHAFT's swim plane {shaft_plane:.4} — the pre-fix \ + controller stranded these starts dry on the tile floor at −55.9687, the live #329 \ + wedge coordinate; got {end:?}"); assert!((end[0] - shaft[0]).hypot(end[1] - shaft[1]) < 1.5, "#661: and actually arrive at the shaft XY; got {end:?}"); assert!(col.in_water(end), @@ -215,6 +250,32 @@ fn qcat_pocket_swim_plane_swimmer_escapes_to_the_shaft() { } } +/// **The horizontal-only driver's honest outcome at the pocket: a WET STALL, never a strand.** +/// +/// This is what round 2's escape drive now gets, by design: the pocket→shaft crossing is one-way +/// for the autonomous duck (~13 u surface mismatch, beyond the return envelope), so +/// `try_duck_under`'s surface bound refuses it and the body presses at the mouth — wet, +/// unsupported, at its own swim plane, input still honoured. Every part of that end state is the +/// #661 fix: the pre-fix controller turned this exact drive into the dry `on_ground` strand at +/// −55.9687 (via the net's beach) where `want_swim` was inert for ever. A stall is visible to nav +/// (`walker_stalled` → replan); the strand was terminal. +#[test] +#[ignore = "asset-gated: needs baked qcat.glb + qcat.wtr at $EQZONES (#357)"] +fn qcat_pocket_horizontal_wish_alone_stalls_wet_at_the_mouth() { + let col = zone("qcat"); + let pocket_xy = [-42.3f32, 1036.8]; + let shaft = [-45.75f32, 1030.0625, -42.98]; + let surface = col.water_surface([pocket_xy[0], pocket_xy[1], -60.0]).unwrap(); + let plane = surface - PLAYER_BODY.float_depth; + + let end = swim_toward(&col, [pocket_xy[0], pocket_xy[1], plane], shaft, 12.0); + assert!(col.in_water(end) && (end[2] - plane).abs() < 0.05, + "#661: the horizontal-only driver must stall WET at its own swim plane ({plane:.4}) — \ + never the dry −55.9687 strand, never a one-way crossing; got {end:?}"); + assert!((end[2] - (-55.9687)).abs() > 1.0, + "#661: specifically NOT the old strand coordinate; got {end:?}"); +} + /// **#649/#661 AT THE REAL COORDINATE: the first frame never lifts a swimmer dry.** /// /// One frame, from the exact position the #649 comment thread measured — the qcat pocket swim plane,