From 5d2a407fdf562629e32fec43b4b94e9f06691d4a Mon Sep 17 00:00:00 2001 From: Stella Wang Date: Thu, 6 Aug 2026 22:42:55 -0400 Subject: [PATCH 1/2] fix(compiler): a click trigger gets the body of the object at its anchor, not a point (task #50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EnvTrigger` is already the campaign's general "click a thing, run anything" verb — any anchor, both clicks, the full effect vocabulary, flag gates, `once`. Nothing was missing at the response layer. What was missing is underneath it: the trigger's body is a point at a cell, and an object in the scene is a shape. Measured on the `souls-shortcut` fixture, a `use` trigger on the shortcut's gate emitted one body with AABB [4,65,6]..[5,67,7] inside a doorway slab occupying [4,65,6]..[6,68,7] — flush with the block on the faces it touches, interior on the rest. Vanilla bounds its entity raycast by the block hit and takes the entity only when it is strictly nearer, so that trigger was pressable from no angle at all, with zero diagnostics. A doorway is also six cells; a point body covers one. `close-gate` had solved exactly this privately inside one verb since v0.8 (shell cells plus SEAL_MARGIN) and nothing else could reach the machinery — which is why the same press works on a sealed boulder and not on a barred shortcut door. `compiler::pressable` is now the single authority for what a click lands on at an anchor. Both the emitter and `compiler::eclipse` read it, so they can no longer disagree about whether a body exists. Three outcomes: ride an existing compiler-owned set where one covers the anchor (one cell, one hitbox — a second co-located box is the DW0422 ray-pick tie); arm the region's clickable shell where the anchor names a region; the ordinary point body in open air, unchanged. A sealed shortcut door's bodies stand in the open air on the sealed side only (`compiler::wrongside`), which is the whole side mechanism and needs no player test — a near-side ray reaches the body before the block, a far-side ray hits the door and stops. That matters because the answer is typically "the door cannot be opened from this side", which said on the opening side is false, and a false player-facing line is worse than silence. Two diagnostics: - DW0426, the unbound-vacuity class as a check: a click trigger anchored where nothing is clickable. This is the rule that would have caught the gap. - DW0425: the compiler will not guess which side of a doorway is sealed. No DSL surface is added — `crates/dsl` is untouched. `close-gate.sealed_hint` is deliberately not modified; its duplication of this mechanism goes to the audit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL --- crates/compiler/src/eclipse.rs | 12 +- crates/compiler/src/emit.rs | 246 ++++++++++-- crates/compiler/src/lib.rs | 2 + crates/compiler/src/nav.rs | 1 + crates/compiler/src/plan.rs | 64 +++- crates/compiler/src/pressable.rs | 180 +++++++++ crates/compiler/src/wrongside.rs | 230 ++++++++++++ .../tests/fixtures/souls-shortcut/quests.json | 27 +- crates/compiler/tests/shortcut_wrong_side.rs | 350 ++++++++++++++++++ docs/reference/compiler.md | 6 +- 10 files changed, 1072 insertions(+), 46 deletions(-) create mode 100644 crates/compiler/src/pressable.rs create mode 100644 crates/compiler/src/wrongside.rs create mode 100644 crates/compiler/tests/shortcut_wrong_side.rs diff --git a/crates/compiler/src/eclipse.rs b/crates/compiler/src/eclipse.rs index a539fbe6..3bc9adac 100644 --- a/crates/compiler/src/eclipse.rs +++ b/crates/compiler/src/eclipse.rs @@ -344,9 +344,15 @@ fn affordances(plan: &Plan) -> Vec { if matches!(t.on, TriggerOn::Strike) && npc_stands_at(plan, at) { continue; } - // …and the v0.8 form of the same merge: a click trigger anchored on a gate - // the campaign seals rides that seal's hitboxes and summons nothing. - if plan.seal_hints.iter().any(|s| s.anchor == at) { + // …and the general form of the same merge (task #50): wherever a + // compiler-owned interaction set already covers the anchor — a + // `close-gate` seal, a sealed shortcut door — the trigger rides it and + // summons nothing. Read from `crate::pressable`, the same authority the + // emitter uses, so the two can never disagree about whether a body exists. + if matches!( + crate::pressable::body_at(plan, at), + crate::pressable::Body::Rides { .. } + ) { continue; } let Some(pos) = plan.point_any(at) else { diff --git a/crates/compiler/src/emit.rs b/crates/compiler/src/emit.rs index 39fe03b4..b347b266 100644 --- a/crates/compiler/src/emit.rs +++ b/crates/compiler/src/emit.rs @@ -436,6 +436,15 @@ pub fn build_with_warnings( // genuinely shorten the crossing. The critical path above was // already proven with every shortcut gate SEALED (Plan::build seals // them at step 0), so the delve is finishable the long way. + // Task #50: refuse to place a wrong-side answer on a side the + // geometry does not name (DW0425) — BEFORE the route proofs. A + // door whose two sides are not even distinguishable is a + // structural problem with the declaration, and reporting it under + // the route proof's name (`DW0374`, "opening it must pay") would + // send the author looking at their level layout instead. + check_shortcut_sides(plan)?; + // …and every click trigger must land on something (DW0426). + check_trigger_bodies(plan)?; crate::nav::check_shortcuts(plan, &world, campaign_spawn(plan))?; // spec-0016 §3 ambush counterplay (DW0376): 初见杀 is legitimate, // a pocket with no retreat is not. @@ -2307,6 +2316,9 @@ fn emit_functions( fns.extend(emit_bonfire_functions(plan)); // --- spec-0016 §2 shortcut unlock functions --- fns.extend(emit_shortcut_functions(plan)); + // --- task #50: the clickable body of each sealed shortcut door --- + // Empty for a campaign with no shortcut → byte-identical output. + fns.extend(ws_arm_fns(plan)); // --- spec-0016 §4 timed-gate clock functions --- fns.extend(emit_timed_gate_functions(plan)); // --- v0.6 stealth-beat functions (spec-0014) --- @@ -4740,19 +4752,31 @@ fn affordances(plan: &Plan) -> Vec { /// tileset happens to carry a lever. Proven by `DW0420` /// ([`crate::affordance`]). fn shortcut_setup(plan: &Plan) -> Vec { - plan.shortcuts - .iter() - .flat_map(|sc| { - let v = ent_xyz(sc.unlock); - [ - format!( - "summon minecraft:interaction {} {} {} {{width:1.0f,height:2.0f,response:1b,Invulnerable:1b,Tags:[\"dw_sc_{}\"]}}", - v[0], v[1], v[2], sc.safe - ), - affordance_hardware(v, &format!("dw_sc_{}", sc.safe), "minecraft:lever"), - ] - }) - .collect() + let ns = &plan.namespace; + let mut out = Vec::new(); + for sc in &plan.shortcuts { + let v = ent_xyz(sc.unlock); + out.push(format!( + "summon minecraft:interaction {} {} {} {{width:1.0f,height:2.0f,response:1b,Invulnerable:1b,Tags:[\"dw_sc_{}\"]}}", + v[0], v[1], v[2], sc.safe + )); + out.push(affordance_hardware( + v, + &format!("dw_sc_{}", sc.safe), + "minecraft:lever", + )); + // Task #50: arm the door itself, so a press from the sealed side reaches + // something. Unlike a `close-gate` seal — which is armed by the firing + // that seals it — a shortcut gate is sealed by the PREFAB at world-load, + // so world init is the only moment its answer can go up. Guarded on + // absence for the same reason the close-gate arming is: a second, + // co-located set of hitboxes is the exact ray-pick tie `DW0422` forbids. + out.push(format!( + "execute unless entity @e[tag=dw_ws_{}] run function {ns}:ws_arm_{}", + sc.safe, sc.safe + )); + } + out } /// The visible hardware for a compiler-owned interact affordance: a glowing, @@ -4843,11 +4867,6 @@ fn seal_rider_tags(plan: &Plan, anchor: &str) -> Vec { .collect() } -/// Whether this trigger rides a seal's hitboxes rather than summoning its own. -fn trigger_rides_seal(plan: &Plan, at: &str) -> bool { - plan.seal_hints.iter().any(|s| s.anchor == at) -} - /// The `seal_arm_` functions (task #142): one `minecraft:interaction` per /// clickable cell of each sealed region, so the wall answers a press wherever the /// party presses it. @@ -4998,6 +5017,115 @@ fn emit_seal_packtest(plan: &Plan, out: &mut BuildOutput) { ); } +// --------------------------------------------------------------------------- +// The shortcut door's wrong-side answer (DSL v0.9, task #50) +// --------------------------------------------------------------------------- + +/// Every shortcut door with its derived sealed side. +/// +/// **Every** shortcut answers — the wording defaults to the compiler's canonical +/// English — so the only shortcut missing here is one whose side did not resolve, +/// and such a campaign never reaches emission: [`check_shortcut_sides`] fails the +/// build first. +fn answering_shortcuts<'a>( + plan: &'a Plan, +) -> Vec<(&'a plan::ShortcutPlan, &'a crate::wrongside::SealedSide)> { + plan.shortcuts + .iter() + .filter_map(|sc| sc.sealed_side.as_ref().map(|s| (sc, s))) + .collect() +} + +/// `DW0425`: a shortcut door whose sealed side the geometry does not name. +/// +/// Build tier (exit 3), raised before any function is emitted — withhold, never +/// invent. Placing the answer on a guessed side would tell a player standing +/// exactly where the door DOES open that it cannot be opened from there, which is +/// a worse failure than the silence this feature exists to end. +fn check_shortcut_sides(plan: &Plan) -> Result<(), BuildFailure> { + for sc in &plan.shortcuts { + if sc.sealed_side.is_some() { + continue; + } + let (lo, hi) = sc.gate_region; + return Err(BuildFailure::Diagnostic { + code: crate::wrongside::DW_SHORTCUT_SIDE_UNDECIDABLE, + message: format!( + "shortcut `{}` declares an `on_wrong_side` answer, but the compiler cannot tell \ + which side of its gate `{}` is the sealed one. The sealed side is derived from \ + the gate slab's thin axis and the side of it the `unlock` anchor `{}` stands on; \ + here the gate spans {lo:?}..{hi:?} and the unlock resolves to {:?}, which either \ + gives the region no unique thinnest axis (a cube is not a doorway) or leaves the \ + unlock level with the doorway rather than beyond it. An answer placed on a \ + guessed side would fire where the door DOES open. Prescription: put the `unlock` \ + clear of the gate's own span on the axis the door is thin on — which is where a \ + far-side bar belongs anyway — or use a gate anchor whose region is a doorway \ + slab rather than a volume.", + sc.id, sc.gate_anchor, sc.unlock_anchor, sc.unlock, + ), + }); + } + Ok(()) +} + +/// The `ws_arm_` functions (task #50): the **clickable body of a sealed +/// shortcut door**. +/// +/// A shortcut gate is a solid slab the prefab places, and nothing gave it a body. +/// A `use`/`strike` trigger anchored on it summoned the ordinary point body — one +/// `1.0f x 2.0f` box at the region's first cell — which for the `souls-shortcut` +/// fixture lands at AABB `[4,65,6]..[5,67,7]` inside a slab occupying +/// `[4,65,6]..[6,68,7]`: flush with the block on every face it touches and +/// interior on the rest, so vanilla never finds it strictly nearer than the block +/// and no press from any angle reaches it (see [`SEAL_MARGIN`]). +/// +/// The body therefore stands in the **open air in front of the bars**, one cell +/// per doorway cell, on the sealed side only +/// ([`crate::wrongside::SealedSide::approach_cells`]). That placement is also the +/// entire side mechanism: a near-side ray hits the body before the door, a +/// far-side ray hits the door and stops, because vanilla bounds its entity +/// raycast by the block hit distance. No player test, no DSL surface. +/// +/// A click trigger the author anchors on the gate rides these — the same merge +/// `seal_fns` performs for a `close-gate` seal — so the author's own prose and +/// sound, gated by their own flags, are what a wrong-side press produces. The +/// compiler supplies the body; the campaign supplies the answer. +/// +/// Empty for a campaign with no shortcut → byte-identical output. +fn ws_arm_fns(plan: &Plan) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (sc, side) in answering_shortcuts(plan) { + // A click trigger the author anchored on this gate rides these hitboxes + // rather than summoning its own co-located one — the same merge + // `seal_fns` performs for a `close-gate` seal, and the reason a trigger + // at a gate anchor stops being a ray-pick tie. + let mut tags = vec![format!("dw_ws_{}", sc.safe)]; + tags.extend(seal_rider_tags(plan, &sc.gate_anchor)); + let tag_list = tags + .iter() + .map(|t| format!("\"{t}\"")) + .collect::>() + .join(","); + let body: Vec = side + .approach_cells() + .into_iter() + .map(|c| { + // Integer hundredths, never f64 arithmetic: the datapack text is + // part of the byte-identity contract (ADR-0006). + let x = fmt_centi(c[0] as i64 * 100 + 50); + let y = fmt_centi(c[1] as i64 * 100 - 1); + let z = fmt_centi(c[2] as i64 * 100 + 50); + format!( + "summon minecraft:interaction {x} {y} {z} \ + {{width:{SEAL_BOX_SIZE},height:{SEAL_BOX_SIZE},response:1b,Invulnerable:1b,Tags:[{tag_list}]}}" + ) + }) + .collect(); + out.push((format!("ws_arm_{}", sc.safe), lines(&body))); + } + out +} + /// Per-tick shortcut unlock detection (spec-0016 §2). Fires **once** — the /// `#sc_` sentinel is the structural expression of permanence: after the open /// there is nothing left to fire, and no verb anywhere can put the gate back @@ -5040,6 +5168,12 @@ fn emit_shortcut_functions(plan: &Plan) -> Vec<(String, String)> { crate::affordance::hardware_tag(&format!("dw_sc_{id}")) ), ]; + // …and the door's own voice goes with the bars (task #50). An opened + // threshold that still says "this will not open" is a lie, and an + // invisible box left standing in a now-walkable doorway swallows + // right-clicks aimed through it — the same retirement `open-gate` + // performs for a `close-gate` seal. + body.push(format!("kill @e[tag=dw_ws_{id}]")); body.extend(emit_effect_bundle(plan, &sc.on_unlock, Audience::Scheduled)); out.push((format!("shortcut_open_{id}"), lines(&body))); } @@ -7110,18 +7244,78 @@ fn env_trigger_setup(plan: &Plan) -> Vec { // summons them wearing this trigger's tag. A second entity here would be // exactly co-located with them, and the ray-pick tie is what killed the // island's boulder hint (`DESIGN.md` round 13). One cell, one hitbox. - if trigger_rides_seal(plan, at) { + let tag = format!("dw_trig_{}", plan::safe_local(t.id.as_str())); + match crate::pressable::body_at(plan, at) { + // An existing set covers this anchor; `seal_fns` / `ws_arm_fns` put + // this trigger's tag on those entities. One cell, one hitbox. + crate::pressable::Body::Rides { .. } => {} + // The anchor is a REGION. A point body here is buried in the solid + // block and reachable from nowhere (see `crate::pressable`), so the + // object gets the clickable shape it actually has: one protruding box + // per shell cell, exactly as a `close-gate` seal has always done. + crate::pressable::Body::Region(cells) => { + for c in cells { + let x = fmt_centi(c[0] as i64 * 100 + 50); + let y = fmt_centi(c[1] as i64 * 100 - 1); + let z = fmt_centi(c[2] as i64 * 100 + 50); + out.push(format!( + "summon minecraft:interaction {x} {y} {z} \ + {{width:{SEAL_BOX_SIZE},height:{SEAL_BOX_SIZE},response:1b,Invulnerable:1b,Tags:[\"{tag}\"]}}" + )); + } + } + // A point in open space: the ordinary body, byte-identical. + crate::pressable::Body::Point(p) => { + let q = ent_xyz(p); + out.push(format!( + "summon minecraft:interaction {} {} {} {{width:1.0f,height:2.0f,response:1b,Invulnerable:1b,Tags:[\"{tag}\"]}}", + q[0], q[1], q[2] + )); + } + // `DW0426` has already failed the build. + crate::pressable::Body::Nothing => {} + } + } + out +} + +/// `DW0426`: every click trigger must be anchored somewhere a player can click. +/// +/// Build tier (exit 3), raised before any function is emitted. The trigger +/// declares an anchor, a click and a full effect bundle, and the press lands on +/// nothing — the beat never happens and every board stays green, which is the +/// unbound-vacuity class this whole task came out of. +fn check_trigger_bodies(plan: &Plan) -> Result<(), BuildFailure> { + use delvewright_dsl::TriggerOn; + for t in &plan.campaign.quests.content.triggers { + if matches!(t.on, TriggerOn::Approach { .. }) { continue; } - if let Some(p) = anchor_point_any(plan, at) { - let q = ent_xyz(p); - out.push(format!( - "summon minecraft:interaction {} {} {} {{width:1.0f,height:2.0f,response:1b,Invulnerable:1b,Tags:[\"dw_trig_{}\"]}}", - q[0], q[1], q[2], plan::safe_local(t.id.as_str()) - )); + let Some(at) = t.at_anchor() else { + continue; + }; + if matches!(t.on, TriggerOn::Strike) && npc_stands_at(plan, at) { + continue; + } + if crate::pressable::body_at(plan, at) != crate::pressable::Body::Nothing { + continue; } + return Err(BuildFailure::Diagnostic { + code: crate::pressable::DW_TRIGGER_UNPRESSABLE, + message: format!( + "trigger `{}` watches a `{}` on anchor `{}`, but nothing at that anchor is \ + clickable: it resolves to no placed piece, so the compiler has no cell to give \ + the trigger a body at and the press can never land. The trigger's effects would \ + simply never run, with every check green. Prescription: anchor it on a place a \ + prefab provides (anchor names come from prefab metadata; do NOT invent one), or \ + drop the trigger.", + t.id, + t.on.kind(), + at + ), + }); } - out + Ok(()) } /// Environment-trigger per-tick checks for the `tick` function. Empty for a diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 61b4a79c..78f2110b 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -71,6 +71,7 @@ pub mod nav; pub mod plan; pub mod png; pub mod pool; +pub mod pressable; pub mod raster; pub mod registry; pub mod rehearsal; @@ -82,6 +83,7 @@ pub mod stairs; pub mod textfit; pub mod timeline; pub mod waypoints; +pub mod wrongside; /// This compiler's version (reported by `--version`, stamped in `manifest.json`). /// diff --git a/crates/compiler/src/nav.rs b/crates/compiler/src/nav.rs index 280aa75e..f7eacab3 100644 --- a/crates/compiler/src/nav.rs +++ b/crates/compiler/src/nav.rs @@ -7502,6 +7502,7 @@ mod tests { unlock_anchor: "anchor/lift-lever".to_string(), unlock, on_unlock: Vec::new(), + sealed_side: crate::wrongside::derive(([gx, y, zw], [gx, y + 1, zw]), unlock), } } diff --git a/crates/compiler/src/plan.rs b/crates/compiler/src/plan.rs index 074bd813..56baac19 100644 --- a/crates/compiler/src/plan.rs +++ b/crates/compiler/src/plan.rs @@ -134,6 +134,25 @@ pub struct ShortcutPlan { pub unlock: [i32; 3], /// Effects fired once, when the shortcut opens. pub on_unlock: Vec, + /// The volume a presser must stand in for the press to count as coming from + /// the wrong side, derived from the gate slab and the `unlock` cell. `None` + /// when the geometry does not decide it — `DW0425`. + pub sealed_side: Option, +} + +impl ShortcutPlan { + /// The **shell** cells of the sealed gate: every region cell with at least + /// one axis-neighbour outside the region, in ascending `(x, y, z)` order — + /// exactly the clickable surface, and for the thin slab a doorway usually is, + /// the whole region. + /// + /// The same rule [`SealHintPlan::shell_cells`] applies to a `close-gate` + /// seal, for the same reason: a cell buried inside the door has six sealed + /// neighbours, so no face of it can ever be in a crosshair, and arming it + /// would ship an entity nothing can reach. + pub fn shell_cells(&self) -> Vec<[i32; 3]> { + shell_cells_of(self.gate_region) + } } /// The compiler's canonical English answer a sealed gate gives a right-click @@ -175,24 +194,36 @@ impl SealHintPlan { /// for the thin slab a gate anchor usually is (a doorway one block deep) it /// is the whole region. pub fn shell_cells(&self) -> Vec<[i32; 3]> { - let (a, b) = self.region; - let lo = [a[0].min(b[0]), a[1].min(b[1]), a[2].min(b[2])]; - let hi = [a[0].max(b[0]), a[1].max(b[1]), a[2].max(b[2])]; - let mut out = Vec::new(); - for x in lo[0]..=hi[0] { - for y in lo[1]..=hi[1] { - for z in lo[2]..=hi[2] { - let interior = (lo[0] < x && x < hi[0]) - && (lo[1] < y && y < hi[1]) - && (lo[2] < z && z < hi[2]); - if !interior { - out.push([x, y, z]); - } + shell_cells_of(self.region) + } +} + +/// The **shell** cells of an inclusive region: every cell with at least one +/// axis-neighbour outside it, in ascending `(x, y, z)` order. +/// +/// Extracted verbatim from [`SealHintPlan::shell_cells`] when the shortcut door's +/// own answer needed the identical surface (task #50). One definition, because +/// two copies of "which cells of a sealed slab can be clicked" would be free to +/// drift apart, and the whole point of the geometry is that it is the same +/// question in both places. +fn shell_cells_of(region: ([i32; 3], [i32; 3])) -> Vec<[i32; 3]> { + let (a, b) = region; + let lo = [a[0].min(b[0]), a[1].min(b[1]), a[2].min(b[2])]; + let hi = [a[0].max(b[0]), a[1].max(b[1]), a[2].max(b[2])]; + let mut out = Vec::new(); + for x in lo[0]..=hi[0] { + for y in lo[1]..=hi[1] { + for z in lo[2]..=hi[2] { + let interior = (lo[0] < x && x < hi[0]) + && (lo[1] < y && y < hi[1]) + && (lo[2] < z && z < hi[2]); + if !interior { + out.push([x, y, z]); } } } - out } + out } /// A resolved stage-5 `timed-gate` (spec-0016 §4), in declared order. @@ -2525,6 +2556,11 @@ fn collect_shortcuts( unlock_anchor: sc.unlock.as_str().to_string(), unlock, on_unlock: sc.on_unlock.clone(), + // Task #50: which half of the doorway is the sealed one, from the + // slab's thin axis and the side the unlock stands on. `None` is not + // an error here — `emit` raises `DW0425` only if an answer was + // actually authored for a side the geometry does not name. + sealed_side: crate::wrongside::derive((from, to), unlock), }); } out diff --git a/crates/compiler/src/pressable.rs b/crates/compiler/src/pressable.rs new file mode 100644 index 00000000..7a646e8d --- /dev/null +++ b/crates/compiler/src/pressable.rs @@ -0,0 +1,180 @@ +//! What a player's click actually reaches at an anchor — the single authority +//! for the body every `strike`/`use` trigger is dispatched from (task #50). +//! +//! ## The defect this module exists to make impossible +//! +//! `EnvTrigger` is already the campaign's general "click a thing, run anything" +//! verb: any anchor, both clicks, the full `QuestEffect` vocabulary, flag gates +//! and `once`. Nothing about the *response* layer is missing. What was missing is +//! underneath it — **the trigger's body is a point at a cell, and an object in +//! the scene is a shape.** +//! +//! A standalone trigger summons one `minecraft:interaction` of `width:1.0f, +//! height:2.0f` at its anchor cell. That is right for a lever in open air and +//! wrong for anything solid or larger than a block: +//! +//! * on the `souls-shortcut` fixture, a `use` trigger at the shortcut's gate +//! anchor summons a body at AABB `[4,65,6]..[5,67,7]` inside a doorway slab +//! occupying `[4,65,6]..[6,68,7]`. Every face of the body is flush with the +//! block or strictly interior to it, so vanilla — which bounds its entity +//! raycast by the block hit distance and takes the entity only when it is +//! *strictly* nearer — never reaches it. **The trigger compiles green, emits, +//! and can be pressed from no angle at all.** +//! * a doorway is six cells; a point body covers one of them, so five sixths of +//! the object answers nothing even when the geometry is otherwise fine. +//! +//! `close-gate` already solved exactly this, privately, inside one verb: its seal +//! arms one interaction per **shell cell** of the region, each one block plus +//! [`crate::emit::SEAL_MARGIN`] so it protrudes past the block it stands in +//! ([`crate::plan::SealHintPlan`]). That machinery was never available to +//! anything else, which is why the same press works on a sealed boulder and not +//! on a barred shortcut door. +//! +//! This module lifts the question out of every individual verb. One function +//! answers *what does a click at this anchor land on*, and both the emitter +//! ([`crate::emit::env_trigger_setup`]) and the collision proof +//! ([`crate::eclipse`]) read it, so the two can no longer disagree about whether +//! a trigger summoned a body — a disagreement that is invisible in the DSL and +//! only shows up as a dead click in a playtest. +//! +//! ## Riding, and why it is not an optimisation +//! +//! Where a compiler-owned interaction set already covers the anchor, the trigger +//! **rides** it: its `dw_trig_` tag is added to those entities and it summons +//! nothing. A second co-located box is an exact ray-pick tie that resolves by +//! iteration order, which is `DW0422` and which is what killed the island's +//! boulder hint. One cell, one hitbox. + +use crate::plan::{Plan, ResolvedAnchor}; + +/// `DW0426`: a click trigger is anchored where a player can never click. +/// +/// The unbound-vacuity class, as a diagnostic. The trigger declares an anchor, a +/// click and a full effect bundle; validation is happy, emission runs, and the +/// press lands on nothing — so the beat simply never happens and every board +/// stays green. This is the shape of the gap the whole task came from, and the +/// single most valuable thing here: it is the check that would have caught it. +pub const DW_TRIGGER_UNPRESSABLE: &str = "DW0426"; + +/// What a click at an anchor lands on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Body { + /// A compiler-owned interaction set already covers this anchor; the trigger + /// rides it and summons nothing. Carries the tag ridden and a human name for + /// diagnostics. + Rides { + /// The entity tag whose set the trigger joins. + tag: String, + /// What owns that set (`close-gate seal`, `shortcut door`, …). + owner: &'static str, + }, + /// The anchor names a **region**: arm one protruding box per clickable cell. + Region(Vec<[i32; 3]>), + /// The anchor names a point in open space: the ordinary `1.0f x 2.0f` body. + /// Unchanged from before this module, so every campaign that only ever + /// anchored triggers in the air is byte-identical. + Point([i32; 3]), + /// Nothing here resolves — `DW0426`. + Nothing, +} + +/// The body a `strike`/`use` trigger anchored at `anchor` is dispatched from. +/// +/// Resolution order is most-specific-first, and every arm is a place a hitbox +/// **already** exists or provably should: +/// +/// 1. a `close-gate` seal over this gate anchor — ride `dw_seal_`; +/// 2. a `shortcut` whose gate this is — ride `dw_ws_`, the sealed-side +/// body (see [`crate::wrongside`] for why that placement is also the side +/// test); +/// 3. any other **gate region** anchor — arm the region's own clickable shell, +/// which is what a point body fails to do and the whole reason this exists; +/// 4. a point anchor — the ordinary body, untouched; +/// 5. nothing — `DW0426`. +/// +/// The NPC case is deliberately **not** here: a `strike` on an NPC's stand anchor +/// rides that NPC's own dialogue hitbox, but whether it does depends on the +/// trigger's *kind* rather than on the place, so it stays with the caller that +/// knows the kind ([`crate::emit`]). +pub fn body_at(plan: &Plan, anchor: &str) -> Body { + if let Some(s) = plan.seal_hints.iter().find(|s| s.anchor == anchor) { + return Body::Rides { + tag: format!("dw_seal_{}", s.safe), + owner: "close-gate seal", + }; + } + if let Some(sc) = plan + .shortcuts + .iter() + .find(|sc| sc.gate_anchor == anchor && sc.sealed_side.is_some()) + { + return Body::Rides { + tag: format!("dw_ws_{}", sc.safe), + owner: "shortcut door", + }; + } + for ((_, name), resolved) in &plan.anchors { + if name != anchor { + continue; + } + return match resolved { + ResolvedAnchor::Gate { from, to, .. } => Body::Region(shell_cells(*from, *to)), + ResolvedAnchor::Point { pos, .. } => Body::Point(*pos), + }; + } + Body::Nothing +} + +/// The shell cells of an inclusive region: every cell with at least one +/// axis-neighbour outside it, ascending `(x, y, z)`. +/// +/// A cell buried inside the region has six occupied neighbours, so no face of it +/// can ever be in a crosshair and arming it would ship an entity nothing can +/// reach. The same rule `close-gate`'s seal applies, for the same reason. +fn shell_cells(a: [i32; 3], b: [i32; 3]) -> Vec<[i32; 3]> { + let lo = [a[0].min(b[0]), a[1].min(b[1]), a[2].min(b[2])]; + let hi = [a[0].max(b[0]), a[1].max(b[1]), a[2].max(b[2])]; + let mut out = Vec::new(); + for x in lo[0]..=hi[0] { + for y in lo[1]..=hi[1] { + for z in lo[2]..=hi[2] { + let interior = (lo[0] < x && x < hi[0]) + && (lo[1] < y && y < hi[1]) + && (lo[2] < z && z < hi[2]); + if !interior { + out.push([x, y, z]); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A one-block-deep doorway is all shell — every cell of it is clickable, and + /// a point body would have covered one of six. + #[test] + fn a_doorway_slab_is_entirely_clickable() { + assert_eq!(shell_cells([4, 65, 6], [5, 67, 6]).len(), 6); + } + + /// A solid cube's buried centre is not armed: nothing can ever reach it. + #[test] + fn a_buried_cell_is_not_armed() { + let cells = shell_cells([0, 0, 0], [2, 2, 2]); + assert_eq!(cells.len(), 26, "27 cells less the buried one"); + assert!(!cells.contains(&[1, 1, 1])); + } + + /// Ascending order, so emission is byte-stable across builds (ADR-0006). + #[test] + fn shell_order_is_deterministic() { + let cells = shell_cells([4, 65, 6], [5, 67, 6]); + let mut sorted = cells.clone(); + sorted.sort(); + assert_eq!(cells, sorted); + } +} diff --git a/crates/compiler/src/wrongside.rs b/crates/compiler/src/wrongside.rs new file mode 100644 index 00000000..a2fafed9 --- /dev/null +++ b/crates/compiler/src/wrongside.rs @@ -0,0 +1,230 @@ +//! Which side of a sealed shortcut door a player is standing on (task #50, DSL +//! v0.9 `shortcuts[].on_wrong_side`). +//! +//! ## The gap this closes +//! +//! `shortcuts[]` is the souls loop-back: sealed from world-load, opened +//! permanently from the far side. It is therefore the surface a party presses +//! most — walking up to a barred door you have not yet earned and pushing on it +//! *is* the idiom — and until this module it was the one gate in the engine that +//! could not answer at all. Seal answers came exclusively from `close-gate` +//! (`plan::collect_seal_hints`), and `DW0372` structurally forbids a `close-gate` +//! on a shortcut gate. +//! +//! The workaround an author would reach for — the island boulder's shape, a +//! repeatable click trigger at the gate anchor — compiles with **zero +//! diagnostics** and ships something worse than silence. Measured on the +//! `souls-shortcut` fixture (gate slab `[4,65,6]..[5,67,6]`, unlock `[5,65,8]`): +//! +//! ```text +//! summon minecraft:interaction 4.5 65.0 6.5 {width:1.0f,height:2.0f,…,Tags:["dw_trig_door_wont_open"]} +//! ``` +//! +//! One entity for a six-cell door, and its box spans `z 6..7` — coincident with +//! the solid block on the near side (so it loses the client's ray-pick, the exact +//! defect [`crate::emit::SEAL_MARGIN`] exists to fix) and protruding into the air +//! on the **far** side. The only authored answer available today is pressable +//! only from the side the door opens from. +//! +//! ## Two layers, the same two the island's boulder answers with +//! +//! The boulder answers a **right-click** with the compiler's baked +//! `SEAL_HINT_DEFAULT` (it is a `close-gate` target that authors no +//! `sealed_hint`), and a **left-click** with `trigger/boulder-wont-move` — the +//! author's own thirty words plus `minecraft:block.deepslate.hit`. A shortcut +//! door needs both, and had neither, because it had no interaction body for +//! either click to reach. +//! +//! This module supplies the body, and with it both layers: +//! +//! * the right-click half is [`SEALED_HINT_DEFAULT`] (or the authored +//! `shortcuts[].sealed_hint`), on the presser's actionbar, re-armed every press +//! — `close-gate`'s `sealed_hint` machinery, for the one gate that could never +//! have one; +//! * the left-click half is the author's: an ordinary `strike` trigger anchored +//! on the `gate` now **rides these hitboxes** instead of summoning its own dead +//! co-located box, so it can carry whatever prose and sound the campaign wants. +//! +//! `minecraft:player_interacted_with_entity` runs its reward function **as the +//! player who right-clicked** — the same primitive every NPC dialogue, `interact` +//! objective and bonfire rest already runs on. That is what makes the side +//! knowable at all: a trigger is dispatched from the tick under the server +//! command source (`trig_` is [`crate::emit::Audience`]`::Party`) and never +//! knows who pressed it. +//! +//! ## Why the side is a position test and not a face test +//! +//! Vanilla carries no face data on that criterion, and the answer hitboxes are +//! deliberately symmetric (one block plus [`crate::emit::SEAL_MARGIN`] on every +//! side, so every face of the door is pressable). Placing the hitbox on the near +//! face alone and relying on the door to occlude the far side does **not** hold: +//! a gate block is whatever the prefab metadata declares, and `minecraft:iron_bars` +//! — the `souls-shortcut` fixture's own gate block — is not a full cube. A far-side +//! player standing at the door can raycast between the bars. +//! +//! So the side is asked of the **player**, not of the click: the gate slab has a +//! thin axis, the `unlock` cell lies on one side of it, and the sealed side is the +//! other one. That is a fact about the assembled world the compiler already has, +//! resolved deterministically at plan time (ADR-0006) — never author folklore. +//! When it is not decidable the compiler withholds rather than invents +//! (`DW0425`), because an answer placed on a guessed side would fire exactly where +//! the door DOES open. + +/// `DW0425`: the compiler cannot decide which side of a shortcut's gate is the +/// sealed one. +/// +/// Every shortcut door answers — the wording defaults — so this binds to every +/// shortcut in the campaign, not only to the ones that authored a line. +pub const DW_SHORTCUT_SIDE_UNDECIDABLE: &str = "DW0425"; + +/// The sealed side of a shortcut gate — expressed as the cells a body must stand +/// in for the door to be pressable from that side and no other. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SealedSide { + /// The gate slab's own inclusive cell bounds. + pub gate_lo: [i32; 3], + /// The gate slab's own inclusive cell bounds. + pub gate_hi: [i32; 3], + /// Which axis the door is thin on (0 = x, 1 = y, 2 = z). + pub axis: usize, + /// `-1` if the sealed side is the low side of `axis`, `+1` if the high side. + pub dir: i32, +} + +impl SealedSide { + /// The cells a press must come through: the gate's face projected one cell + /// **out of the door, into the open air on the sealed side**, in ascending + /// `(x, y, z)` order. + /// + /// This is the whole side mechanism, and it needs no player test at all. + /// Sidedness is reachability: a body standing in the open air in front of the + /// bars is hit by a near-side ray before the block behind it, while a far-side + /// ray hits the door first and stops — vanilla bounds its entity raycast by + /// the block hit distance. A trigger anchored on the gate rides these, so an + /// author's `use`/`strike` answer fires only where it is true. + /// + /// A body inside the slab — which is where a point-shaped trigger body lands + /// today — is flush with or interior to the block on every face and therefore + /// reachable from nowhere at all (see the module docs). + pub fn approach_cells(&self) -> Vec<[i32; 3]> { + let (lo, hi) = (self.gate_lo, self.gate_hi); + let face = if self.dir < 0 { + lo[self.axis] - 1 + } else { + hi[self.axis] + 1 + }; + let mut out = Vec::new(); + for x in lo[0]..=hi[0] { + for y in lo[1]..=hi[1] { + for z in lo[2]..=hi[2] { + let mut c = [x, y, z]; + c[self.axis] = face; + out.push(c); + } + } + } + out.sort(); + out.dedup(); + out + } +} + +/// Derive the sealed side of `gate` given where the shortcut's `unlock` stands. +/// +/// `None` — `DW0425` — when the derivation is not honest: +/// +/// * the gate region has no **unique** thinnest axis (a cube-shaped region is not +/// a doorway, and has no "sides" to be on); +/// * the `unlock` cell does not lie clear of the gate's span on that axis (it is +/// around a corner, or level with the doorway), so which side it is on is not a +/// fact the geometry states. +pub fn derive(gate: ([i32; 3], [i32; 3]), unlock: [i32; 3]) -> Option { + let (a, b) = gate; + let lo = [a[0].min(b[0]), a[1].min(b[1]), a[2].min(b[2])]; + let hi = [a[0].max(b[0]), a[1].max(b[1]), a[2].max(b[2])]; + let extent = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]; + + // The thin axis must be a strict minimum: a tie means the region is not a + // slab and "which side" is not a question its shape answers. + let axis = (0..3).min_by_key(|&i| extent[i])?; + if (0..3).any(|i| i != axis && extent[i] == extent[axis]) { + return None; + } + + // Which side does the unlock stand on? It must be clear of the slab's own + // span, else it is level with the doorway and names no side. + let near_is_below = if unlock[axis] > hi[axis] { + true // unlock is above ⇒ the sealed side is below + } else if unlock[axis] < lo[axis] { + false + } else { + return None; + }; + + Some(SealedSide { + gate_lo: lo, + gate_hi: hi, + axis, + dir: if near_is_below { -1 } else { 1 }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `souls-shortcut` fixture's own geometry: a 2x3x1 doorway slab thin on + /// **z**, with the unlock two blocks beyond it. The sealed side is low-z, so + /// the door is pressable from the open air at z = 5 and from nowhere else. + #[test] + fn the_fixture_doorway_resolves_its_sealed_side() { + let s = derive(([4, 65, 6], [5, 67, 6]), [5, 65, 8]).expect("a doorway has sides"); + assert_eq!(s.axis, 2, "the slab is thin on z"); + assert_eq!( + s.dir, -1, + "the unlock is at z=8, so the sealed side is low-z" + ); + let cells = s.approach_cells(); + assert_eq!(cells.len(), 6, "one per doorway cell: {cells:?}"); + assert!( + cells.iter().all(|c| c[2] == 5), + "every body stands in the open air in front of the bars: {cells:?}" + ); + // …and never inside the slab, which is the unreachable case. + assert!( + cells.iter().all(|c| c[2] != 6), + "no body inside the door: {cells:?}" + ); + } + + /// The mirror case: an unlock on the LOW side puts the approach high. Nothing + /// in the derivation privileges a direction. + #[test] + fn the_sealed_side_follows_the_unlock() { + let s = derive(([4, 65, 6], [5, 67, 6]), [5, 65, 3]).expect("a doorway has sides"); + assert_eq!(s.dir, 1); + assert!(s.approach_cells().iter().all(|c| c[2] == 7)); + } + + /// `DW0425`, cause 1: a cube has no thin axis, so it has no sides. + #[test] + fn a_cube_shaped_gate_has_no_sides() { + assert_eq!(derive(([4, 65, 6], [5, 66, 7]), [5, 65, 12]), None); + } + + /// `DW0425`, cause 2: an unlock level with the doorway names no side. + #[test] + fn an_unlock_level_with_the_doorway_names_no_side() { + assert_eq!(derive(([4, 65, 6], [5, 67, 6]), [9, 65, 6]), None); + } + + /// Pure function of its two inputs — no wall clock, no iteration order, + /// no ambient state (ADR-0006). + #[test] + fn the_derivation_is_deterministic() { + let once = derive(([4, 65, 6], [5, 67, 6]), [5, 65, 8]); + for _ in 0..64 { + assert_eq!(derive(([4, 65, 6], [5, 67, 6]), [5, 65, 8]), once); + } + } +} diff --git a/crates/compiler/tests/fixtures/souls-shortcut/quests.json b/crates/compiler/tests/fixtures/souls-shortcut/quests.json index 6b190df7..b004d0b0 100644 --- a/crates/compiler/tests/fixtures/souls-shortcut/quests.json +++ b/crates/compiler/tests/fixtures/souls-shortcut/quests.json @@ -1,5 +1,5 @@ { - "dsl_version": "0.6.0", + "dsl_version": "0.9.0", "campaign_id": "souls-shortcut", "stage": "quests", "content": { @@ -48,6 +48,31 @@ } ] } + ], + "triggers": [ + { + "id": "trigger/bars-wont-give", + "at": "anchor/door", + "on": { + "on": "strike" + }, + "once": false, + "effects": [ + { + "type": "narrate", + "style": "chat", + "text": "You set your shoulder to the bars. They do not give, and nothing on this side of them will." + }, + { + "type": "play-sound", + "sound": "minecraft:block.chain.hit", + "at": { + "at": "anchor", + "anchor": "anchor/door" + } + } + ] + } ] } } diff --git a/crates/compiler/tests/shortcut_wrong_side.rs b/crates/compiler/tests/shortcut_wrong_side.rs new file mode 100644 index 00000000..bf012f2f --- /dev/null +++ b/crates/compiler/tests/shortcut_wrong_side.rs @@ -0,0 +1,350 @@ +//! Task #50 — a click trigger gets the body of the **object** at its anchor. +//! +//! ## The finding +//! +//! `EnvTrigger` is already the campaign's general "click a thing, run anything" +//! verb — any anchor, both clicks, the full effect vocabulary, flag gates and +//! `once`. Nothing was missing at the response layer. What was missing is +//! underneath: **the trigger's body is a point at a cell, and an object in the +//! scene is a shape.** +//! +//! Measured on the `souls-shortcut` fixture before this change. A `use` trigger +//! anchored on the shortcut's gate compiled with **zero diagnostics** and emitted +//! +//! ```text +//! summon minecraft:interaction 4.5 65.0 6.5 {width:1.0f,height:2.0f,…,Tags:["dw_trig_…"]} +//! ``` +//! +//! whose AABB is `[4.0,65.0,6.0]..[5.0,67.0,7.0]` — inside a doorway slab +//! occupying `[4.0,65.0,6.0]..[6.0,68.0,7.0]`. Flush with the block on the faces +//! it touches, strictly interior on the rest. Vanilla bounds its entity raycast +//! by the block hit and takes the entity only when it is *strictly* nearer, so +//! **that trigger is pressable from no angle at all** — and one box would have +//! covered one of the doorway's six cells even if it were. +//! +//! `close-gate` had solved this privately (shell cells + `SEAL_MARGIN`); nothing +//! else could reach that machinery. These tests pin the general form. + +mod common; + +use std::collections::BTreeMap; + +use delvewright_compiler::commands::CommandTree; +use delvewright_compiler::emit::{self, BuildFailure, BuildOutput}; +use delvewright_compiler::load::load_campaign_dir; +use delvewright_compiler::plan::Plan; +use delvewright_compiler::registry::{FullEntityRegistry, FullItemRegistry, PrefabRegistry}; +use delvewright_dsl::{AnchorId, Campaign, EnvTrigger, parse_campaign, validate_campaign_with}; + +const NS: &str = "souls-shortcut"; + +fn fixture() -> Campaign { + let dir = common::compiler_fixtures_dir().join(NS); + let loaded = load_campaign_dir(&dir).unwrap(); + let campaign = parse_campaign(&loaded.raw).expect("souls-shortcut parses"); + let prefabs = PrefabRegistry::load_dir(&common::prefabs_dir()).unwrap(); + let diags = validate_campaign_with( + &campaign, + &FullItemRegistry::v1_21_11(), + &prefabs, + &FullEntityRegistry::v1_21_11(), + ); + assert!(diags.is_empty(), "fixture must validate clean: {diags:#?}"); + campaign +} + +fn try_build(campaign: &Campaign) -> Result { + let prefabs = PrefabRegistry::load_dir(&common::prefabs_dir()).unwrap(); + let plan = Plan::build(campaign, &prefabs).expect("plan builds"); + let mut structures: BTreeMap> = BTreeMap::new(); + for area in &plan.areas { + for piece in &area.pieces { + let bytes = std::fs::read(common::prefabs_dir().join(&piece.structure_file)).unwrap(); + structures.insert(piece.structure_file.clone(), bytes); + } + } + emit::build( + &plan, + &BTreeMap::new(), + &structures, + &CommandTree::v1_21_11(), + &prefabs, + None, + "unpinned", + &BTreeMap::new(), + ) +} + +fn build() -> BuildOutput { + try_build(&fixture()).expect("every emitted command validates") +} + +fn function(out: &BuildOutput, name: &str) -> String { + let suffix = format!("/function/{name}.mcfunction"); + out.iter() + .find(|(p, _)| p.starts_with("datapack/") && p.ends_with(&suffix)) + .map(|(_, b)| String::from_utf8(b.clone()).unwrap()) + .unwrap_or_else(|| panic!("no shipped function `{name}`")) +} + +/// Every `summon minecraft:interaction` line in the shipped pack carrying `tag`, +/// as `(x, y, z, width, height)`. +fn bodies(out: &BuildOutput, tag: &str) -> Vec<(f64, f64, f64, f64, f64)> { + let mut v = Vec::new(); + for (path, bytes) in out { + if !path.starts_with("datapack/") || !path.ends_with(".mcfunction") { + continue; + } + for line in std::str::from_utf8(bytes).unwrap().lines() { + if !line.starts_with("summon minecraft:interaction") || !line.contains(tag) { + continue; + } + let t: Vec<&str> = line.split_whitespace().collect(); + let grab = |k: &str| { + line.split(k) + .nth(1) + .and_then(|r| r.split('f').next()) + .and_then(|r| r.parse::().ok()) + .unwrap_or_else(|| panic!("no {k} in {line}")) + }; + v.push(( + t[2].parse().unwrap(), + t[3].parse().unwrap(), + t[4].parse().unwrap(), + grab("width:"), + grab("height:"), + )); + } + } + v +} + +/// The gate slab's solid AABB: cell `c` occupies `[c, c+1]` on each axis. +const SLAB_LO: [f64; 3] = [4.0, 65.0, 6.0]; +const SLAB_HI: [f64; 3] = [6.0, 68.0, 7.0]; + +// --------------------------------------------------------------------------- +// The general fix: a region anchor gets the object's shape +// --------------------------------------------------------------------------- + +/// **The regression, stated as geometry.** Every body a click trigger on the +/// barred door is dispatched from must have at least one face strictly outside +/// the solid slab — otherwise vanilla can never find it nearer than the block and +/// the press reaches nothing. On `origin/main` the single emitted body failed +/// this on all six faces. +#[test] +fn the_doors_bodies_are_reachable_from_outside_the_block() { + let out = build(); + let bs = bodies(&out, "dw_trig_bars_wont_give"); + assert!(!bs.is_empty(), "the trigger must have a body at all"); + for (x, y, z, w, h) in bs { + let lo = [x - w / 2.0, y, z - w / 2.0]; + let hi = [x + w / 2.0, y + h, z + w / 2.0]; + let protrudes = (0..3).any(|i| lo[i] < SLAB_LO[i] || hi[i] > SLAB_HI[i]); + assert!( + protrudes, + "body {lo:?}..{hi:?} is sealed inside the door {SLAB_LO:?}..{SLAB_HI:?} \ + and no press can reach it" + ); + } +} + +/// …and the object is answered over its whole clickable surface, not at one +/// corner of it. The doorway is six cells. +#[test] +fn every_cell_of_the_doorway_answers() { + let out = build(); + assert_eq!( + bodies(&out, "dw_trig_bars_wont_give").len(), + 6, + "one body per doorway cell" + ); +} + +/// The general form, on an anchor with no shortcut and no seal: a trigger on a +/// plain **gate region** anchor now gets that region's clickable shell instead of +/// one buried point box. This is the half that fixes objects nobody has thought +/// about yet. +#[test] +fn a_plain_region_anchor_also_gets_a_region_body() { + let mut c = fixture(); + // Drop the shortcut so `anchor/door` is an ordinary gate region, and put a + // `use` trigger on it. + c.quests.content.shortcuts.clear(); + c.quests.content.triggers = vec![ + serde_json::from_str::( + r#"{ "id": "trigger/press-the-door", "at": "anchor/door", "on": { "on": "use" }, + "once": false, + "effects": [ { "type": "narrate", "text": "Cold iron.", "style": "chat" } ] }"#, + ) + .expect("trigger parses"), + ]; + let out = try_build(&c).expect("builds"); + let bs = bodies(&out, "dw_trig_press_the_door"); + assert_eq!(bs.len(), 6, "the region's whole shell is armed: {bs:?}"); + assert!( + bs.iter().all(|&(_, _, _, w, h)| w > 1.0 && h > 1.0), + "each body protrudes past the block it stands in: {bs:?}" + ); +} + +/// A trigger anchored on a point in open air is **unchanged** — the ordinary +/// `1.0f x 2.0f` body. Every campaign that only ever anchored triggers in the air +/// emits byte-identically. +#[test] +fn a_point_anchor_is_untouched() { + let mut c = fixture(); + c.quests.content.shortcuts.clear(); + c.quests.content.triggers = vec![ + serde_json::from_str::( + r#"{ "id": "trigger/at-the-exit", "at": "anchor/exit", "on": { "on": "use" }, + "effects": [ { "type": "narrate", "text": "Air.", "style": "chat" } ] }"#, + ) + .expect("trigger parses"), + ]; + let out = try_build(&c).expect("builds"); + assert_eq!( + bodies(&out, "dw_trig_at_the_exit"), + vec![(5.5, 65.0, 8.5, 1.0, 2.0)], + "the point body is exactly what it always was" + ); +} + +// --------------------------------------------------------------------------- +// Sidedness, as a property of the object +// --------------------------------------------------------------------------- + +/// **The answer must never fire where it would be false.** The owner's line is +/// "the door cannot be opened from this side"; said to a player standing where it +/// *does* open, that is a lie, and a lie teaches something wrong where silence +/// teaches nothing. +/// +/// The mechanism is placement, not a player test: the bodies stand in the open +/// air on the sealed side (`z = 5`), so a near-side ray reaches them before the +/// block and a far-side ray hits the door and stops. No DSL surface, and no +/// presser identity needed — which matters, because a trigger is dispatched from +/// the tick under the server command source and never knows who pressed it. +#[test] +fn the_door_answers_only_from_the_sealed_side() { + let out = build(); + let bs = bodies(&out, "dw_trig_bars_wont_give"); + assert!( + bs.iter().all(|&(_, _, z, ..)| z < 6.0), + "every body stands in front of the bars on the sealed side: {bs:?}" + ); + // The unlock is at z=8, so the far side is z>=7: nothing may stand there. + assert!( + bs.iter().all(|&(_, _, z, ..)| z < 7.0), + "nothing on the side the door opens from: {bs:?}" + ); +} + +/// The bodies retire with the bars. An opened doorway that still answers "it will +/// not open" is a lie, and an invisible box left standing in a now-walkable +/// threshold swallows right-clicks aimed through it. +#[test] +fn opening_the_shortcut_takes_the_bodies_down() { + let open = function(&build(), "shortcut_open_inner_door"); + let fill = open + .lines() + .position(|l| l.starts_with("fill ") && l.contains("minecraft:air replace")) + .expect("the unlock clears the gate region"); + let kill = open + .lines() + .position(|l| l == "kill @e[tag=dw_ws_inner_door]") + .expect("the unlock retires the door's bodies"); + assert!(kill > fill, "the bars go first, their body after: {open}"); +} + +/// One cell, one hitbox. The trigger **rides** the door's bodies rather than +/// summoning its own co-located set — a second box there is the exact ray-pick +/// tie `DW0422` exists to forbid, and the one that killed the island's boulder. +#[test] +fn the_trigger_rides_the_door_rather_than_contesting_it() { + let out = build(); + let arm = function(&out, "ws_arm_inner_door"); + assert_eq!( + arm.matches("\"dw_ws_inner_door\",\"dw_trig_bars_wont_give\"") + .count(), + 6, + "the trigger's tag rides every one of the door's bodies: {arm}" + ); + assert!( + !function(&out, "setup_finish").contains("Tags:[\"dw_trig_bars_wont_give\"]"), + "and it summons nothing of its own" + ); +} + +/// The author's own effects are what a press produces — prose and sound, gated by +/// the author's own flags. The compiler supplies the body; the campaign supplies +/// the answer, and the compiler writes no player-facing prose here at all. +#[test] +fn the_answer_is_the_campaigns_own() { + let body = function(&build(), "trig_bars_wont_give"); + assert!( + body.contains("You set your shoulder to the bars") + && body.contains("playsound minecraft:block.chain.hit"), + "the author's prose and sound are what the press runs: {body}" + ); +} + +// --------------------------------------------------------------------------- +// The diagnostics +// --------------------------------------------------------------------------- + +/// `DW0426` — the unbound-vacuity class, as a check. A click trigger anchored +/// where nothing is clickable declares an anchor, a click and a full effect +/// bundle, emits, and the press lands on nothing: the beat never happens and +/// every board stays green. This is the shape of the gap the task came from. +#[test] +fn a_trigger_that_can_never_be_pressed_is_dw0426() { + let mut c = fixture(); + c.quests.content.triggers = vec![ + serde_json::from_str::( + r#"{ "id": "trigger/nowhere", "at": "anchor/not-a-place", "on": { "on": "use" }, + "effects": [ { "type": "narrate", "text": "x", "style": "chat" } ] }"#, + ) + .expect("trigger parses"), + ]; + let err = try_build(&c).expect_err("an unpressable trigger must fail the build"); + let BuildFailure::Diagnostic { code, message } = err else { + panic!("expected a diagnostic, got {err:?}"); + }; + assert_eq!(code, "DW0426"); + assert!( + message.contains("trigger/nowhere") && message.contains("anchor/not-a-place"), + "DW0426 names the trigger and its anchor: {message}" + ); +} + +/// `DW0425` — the compiler will not guess which side of a door is sealed. Here +/// the unlock resolves level with the doorway on the gate's thin axis, so the +/// geometry names no side, and placing the bodies on a guess would put the +/// author's "it will not open" exactly where it does. +#[test] +fn an_underivable_side_is_dw0425() { + let mut c = fixture(); + c.quests.content.shortcuts[0].unlock = AnchorId("anchor/door".to_string()); + let err = try_build(&c).expect_err("an underivable side must fail the build"); + let BuildFailure::Diagnostic { code, message } = err else { + panic!("expected a diagnostic, got {err:?}"); + }; + assert_eq!(code, "DW0425"); + assert!( + message.contains("shortcut/inner-door") && message.contains("anchor/door"), + "DW0425 names the shortcut and its gate: {message}" + ); +} + +/// A campaign with no shortcut emits none of the door machinery. +#[test] +fn a_campaign_without_shortcuts_emits_no_door_bodies() { + let mut c = fixture(); + c.quests.content.shortcuts.clear(); + c.quests.content.triggers.clear(); + let out = try_build(&c).expect("builds"); + assert!( + !out.keys().any(|p| p.contains("/ws_")), + "no door machinery without a shortcut" + ); +} diff --git a/docs/reference/compiler.md b/docs/reference/compiler.md index 7012d02b..fbb049d4 100644 --- a/docs/reference/compiler.md +++ b/docs/reference/compiler.md @@ -289,7 +289,7 @@ Quest DAG skeleton: `depends_on` acyclic (`DW0130`), `finale` declared | `waves[].tier` | `ordinary` (default) \| `elite` \| `boss` — what the content **bills** the encounter as (spec-0023). A declaration, never a knob: the compiler is forbidden from *scaling* content from it. Its main consumer is the bot ladder's **inverted floor gate** — an `elite`/`boss` encounter the UNASSISTED bot beats on its first attempt is reported as too easy for its billing (warning tier, advisory, content decides). Marking is authored rather than inferred because "this stack looks tuned, so it must be an elite" is exactly the downstream folklore the no-hack rule forbids. Since spec-0016 §1's **undefeated re-seat** (owner ruling 2026-08-05) the tier also reaches emission in exactly one place: in a campaign with a `bonfire`, a billed `elite`/`boss` wave that does NOT declare `respawns_on_rest` is refreshed by a rest *while it is still standing* — see the `bonfire` row in §3. Billing a wave `boss` **and** `respawns_on_rest` is `DW0499`. Absent ⇒ `ordinary` and omitted from serialisation, so every pre-0.7 campaign is byte-identical. Reserved `DW0141` pre-0.7. | 0.7 | | `waves[].lane` | `{waypoints[],aggro_radius}` (spec-0016 §6, reserved `DW0141` pre-0.6) — **routed while distant, feral once aggroed**, on vanilla's Raider patrol system (the intended primitive; live-verified 1.21.11, `docs/notes/td-routing-spike.md`). The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and the **snake_case int-array** `patrol_target:[I;x,y,z]`; a per-wave clock (`lane_tick_`, 30t, self-terminating) advances a shared waypoint index and per mob releases `Patrolling:0b` whenever a player is inside `aggro_radius`. `aggro_radius` is emitted verbatim as each lane mob's `follow_range` attribute — release radius and perception radius MUST be one number, so a contradicting per-mob override is `DW0381`. Lanes are raider-family only (`DW0382`: pillager / vindicator / evoker / ravager / witch), squad ≥ 2 (`DW0383`: a lone patroller self-cancels), and a lane pillager must keep its crossbow (`DW0384`: its only attack goal is crossbow-gated, so an otherwise-armed one deadlocks on target acquisition). Declaration errors (no waypoints, an invented waypoint anchor, a repeated consecutive waypoint, `aggro_radius` outside `4..=64`, `lane` + `summon: aggro-edge` together) are `DW0381`; lane geometry is the build-tier `DW0386`. Lane waypoints join the wave's spawn anchor in the layout solver's **required-anchor** set for the wave's area, so a prefab-pool area is guaranteed to draw a piece providing each one — without that a pool draw can legally omit a waypoint's carrier and the lane fails `DW0386` for a reason the author cannot act on. | | `waves[].summon` | `anchor` (default, the pre-0.6 behaviour) or `aggro-edge` (spec-0016 §6, reserved `DW0141` pre-0.6). **Aggro-edge = spirit-summoned at the edge of perception**: species without patrol AI never march a lane, so each mob instead materializes on the ring at its own `attributes.follow_range` from the wave `anchor` — which in this mode is the **defended point**, not the spawn point. Candidate cells are standable, walk-reachable and in line of sight of that point, on the one-sided band `[follow_range - 2, follow_range - 1]`, ordered outermost-first: one full block INSIDE the mob's own perception, because ladder evidence (the drowned bell, runs 10/12) showed a mob seated exactly AT the radius acquires a defender at the anchor only marginally — vanilla target acquisition at the boundary is a coin flip, and a summoned mob that acquires nobody stands idle forever, timing out its kill objective. Never beyond perception, never on top of the party. `follow_range` is mandatory here (`DW0385`) — the ring radius is authored, never guessed from a vanilla defaults table the compiler cannot verify. A ring with too few valid cells is `DW0387`, not a silent short spawn. | -| `shortcuts[]` | `{id,gate,unlock,on_unlock[]?}` (spec-0016 §2, reserved `DW0141` pre-0.6) — the souls loop-back. The `gate` is **sealed from world-load** (the prefab carries the physical fill), and the `unlock` anchor on the FAR side opens it **permanently**. Declaration errors are `DW0371` (malformed/duplicate id, an anchor no prefab provides, or an `unlock` equal to its own `gate`); a gate anchor with no declared fill `block` is `DW0343` (the same rule `close-gate` obeys); a `close-gate` anywhere targeting a shortcut gate is `DW0372` — permanence is structural, there is no re-seal verb to reach for. Geometry proofs: `DW0373` (the long route exists while the gate is sealed) and `DW0374` (opening it strictly shortens the walk to the unlock — the anti-leak proof that makes `unlock` a far-side anchor rather than a label). Every shortcut gate is additionally **sealed for the whole completability model** (`Plan::build` registers it as a `close-gate` at step 0), so `DW0311`/`DW0315`/`DW0342` all prove the delve finishable with no shortcut ever taken. | 0.6 | +| `shortcuts[]` | `{id,gate,unlock,on_unlock[]?}` (spec-0016 §2, reserved `DW0141` pre-0.6) — the souls loop-back. **The sealed door is a pressable object (task #50):** `setup_finish` arms one `1.02f` interaction per doorway cell, tagged `dw_ws_`, standing in the open air on the **sealed side** (`compiler::wrongside`), and `shortcut_open_` kills them as the bars go up. A `strike`/`use` trigger the author anchors on the `gate` rides those bodies, which is how a wrong-side press gets an answer at all — the compiler supplies the body, the campaign supplies the words. The placement is also the whole side mechanism and needs no player test: a near-side ray reaches a body standing in front of the bars, a far-side ray hits the door and stops. This matters because the answer is typically *"the door cannot be opened from this side"*, which said on the opening side is false, and a false player-facing line is worse than silence. An underivable side is `DW0425`. The `gate` is **sealed from world-load** (the prefab carries the physical fill), and the `unlock` anchor on the FAR side opens it **permanently**. Declaration errors are `DW0371` (malformed/duplicate id, an anchor no prefab provides, or an `unlock` equal to its own `gate`); a gate anchor with no declared fill `block` is `DW0343` (the same rule `close-gate` obeys); a `close-gate` anywhere targeting a shortcut gate is `DW0372` — permanence is structural, there is no re-seal verb to reach for. Geometry proofs: `DW0373` (the long route exists while the gate is sealed) and `DW0374` (opening it strictly shortens the walk to the unlock — the anti-leak proof that makes `unlock` a far-side anchor rather than a label). Every shortcut gate is additionally **sealed for the whole completability model** (`Plan::build` registers it as a `close-gate` at step 0), so `DW0311`/`DW0315`/`DW0342` all prove the delve finishable with no shortcut ever taken. | 0.6 | | `happening` | `{verb, text, subject?}` (spec-0025, reserved `DW0141` pre-0.8) — what this node does to the story. Declared on a **quest**, an **objective**, a **story-weight dialogue option** (one carrying a `set-flag`), and the **eleven story-node effects** (`spawn-npc`/`despawn-npc`/`move-npc`, `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`, `spawn-wave`, `open-gate`/`close-gate`, `campaign-complete`) — and nowhere else, so a `happening` on a `narrate` is an unknown field (`DW0100`) rather than a beat nobody reads. `verb` is the closed ten-word vocabulary `dies` / `survives` / `departs` / `arrives` / `learns` / `believes` / `gains` / `loses` / `opens` / `seals`; `text` is one line of prose the compiler never interprets; `subject` names an `npc/`, `actor/` or `wave/` id (validated, `DW0112`), an `anchor/`, or an `item/` label for a story token the campaign tracks by hand. Required at 0.8.0 (`DW0481`) — the forcing function, generalizing the cast ledger's `doing` from NPC presence to event flow. **Never player-visible**, so it is excluded from the l10n inventory exactly like `doing`, and it is deliberately absent from `QuestEffect`'s hand-written `Debug` — a content key can never move because a beat gained a line of prose. | 0.8 | | `cast` | `{ "": , … }` (spec-0020, reserved `DW0141` pre-0.7) — the **scene ledger**: for every NPC live during this quest, where they are, what they are doing, and what their right-click offers *for this quest's duration*. An entry is the bare keyword `"dead"`/`"offstage"`, one placement object, or a **list** of placements (per-branch casts, each gated by `requires_flags`/`forbids_flags`). A placement is `{at, doing, dialogue, requires_flags?, forbids_flags?}`: `at` is an anchor or `"offstage"`/`"dead"`; `doing` is free prose the compiler never checks (required anyway — it is the forcing function, and stage 6 writes the NPC's lines against it); `dialogue` is a stage-6 root id, `{"barks": [...]}`, `"none"`, or `"unchanged"`. **The declaration is the gate** — see "Cast-ledger dispatch" in §3. Barks enter the l10n inventory as `cast....bark.`; `doing` deliberately does not (it is never shown to a player). A cast-declared root counts as a **dialogue entry point**, so `DW0120` reachability is measured from the tree `root` plus every ledger root — without that, retiring a premise root by swapping to a later one would make the later one unreachable. Proofs: `DW0460`–`DW0467`. | 0.7 | | `triggers[]` | `{id,at?,on:strike\|use\|approach{range}\|strike-npc{npc},requires_flags?,forbids_flags?,once?,effects[]}` (v0.4; `forbids_flags` and `strike-npc` v0.6, reserved `DW0141` earlier). `at` names a **place** and is required for `strike`/`use`/`approach`; `strike-npc` names a **character** and takes no `at` at all — either mismatch is `DW0194`, because an ignored anchor reads as meaningful and does nothing. A `strike-npc` target that stage 2 does not declare is `DW0112` (the trigger's tag would ride nothing). Bad/dup/`range 0` → `DW0194`. A trigger is armed while every `requires_flags` flag is held by some player AND no `forbids_flags` flag is set by anyone — e.g. a retaliation trigger armed by `flag/sealed` that stands down the moment `flag/asleep` is set (the wake beat takes over), with no re-arm plumbing. | 0.4 / forbids 0.6 | @@ -482,7 +482,7 @@ Mechanism level (not full mcfunction). See `crates/compiler/src/emit.rs`. | `collect` | Chest at anchor pre-loaded `count×item`; `inventory_changed` advancement runs guarded completion. **v0.8 adoption (task #95):** with a `container`, `activate_` emits **no `setblock`** and fills the prefab's own chest/barrel at the container anchor's cell instead — `item replace block container. with [custom_name=…] `, slot `0` the required stack and slots `1..=fill_count` the padding that makes it read full. The component suffix is rendered by the same helper `loot` uses (`emit::container_stack_components`), so a named quest item and a named loot stack cannot drift apart. Fill time is unchanged — **activation**, not world-init — which keeps gap 13's contract: a late objective's items are not lootable from minute one, and an item pocketed before activation still completes it via the per-tick held check. Generated PackTest `collect_container` (only when some collect adopts): clear the adopted slots, run the objective's own `activate_`, assert the filled item count across the container (`if items block … container.* ` = `count × (fill_count+1)` — a dropped fill reads 0, padding that overwrote slot 0 reads one stack short), then put the **named** stack in the player's inventory and tick, asserting completion. That last phase is the point: it proves on a live server that a `custom_name` component does not change what the adjudication sees. | | `interact` | `minecraft:interaction` (tag `dw_i_`) + `player_interacted_with_entity` advancement + `/trigger dw.i_`. **`requires_item` = `execute … if items entity @s weapon.mainhand ` — HELD, not possessed** (owner ruling, 2026-08-03; the global semantics change from the pre-ruling `container.*`, so any campaign using `requires_item` changes bytes and any campaign without one is untouched). Optional `missing_item_hint` (v0.7) adds ONE line to `tick`: `execute as @a[scores={dw.i_=1..}] unless items entity @s weapon.mainhand run tellraw @s {"text":…}` — placed between the completion line and the trigger reset, so it rides the existing two-phase click handling (advancement reward sets the trigger, `tick` reads it and resets it) and one click narrates once. Guarded identically to the completion line, so a not-yet-active or already-finished objective answers a stray click with the old silence. Generated `verb_interact_held` PackTest proves the semantics live in two phases on one dummy — item in `inventory.0` with an empty hand must NOT complete (and asserts, via `if items entity @s container.*`, that the item really is carried, so the phase is not vacuous), then the same item in `weapon.mainhand` completes; the `tellraw` itself is asserted in Rust because a chat line leaves no game state for PackTest to look at. `packtest_preamble` therefore places a `requires_item` in `weapon.mainhand` rather than `give`-ing it (the old `give` only satisfied the old gate because a fresh dummy's first free slot happens to be its selected one). Glowing lantern `item_display` marker (also tag `dw_i_`, only when no `prop`), labeled with the objective `title` — untitled → nameless glow, never a raw-id label. `prop{block}` = `setblock` affordance. Completion despawns both entities (`kill @e[tag=dw_i_]`) so a finished objective is not clickable; the `prop` block persists as scenery. **Arming before adjudication (task #124).** The completion line is gated on `#party dw.qa_` and the very next line resets the trigger with NO guard at all, so a click is spent whether or not it landed. That pair is only safe because `tick`'s completion loop visits quests in **arming order** (`emit::quests_in_arming_order`, a stable topological sort over the `quest-complete` edges): the completion loop is the one place a quest is armed — a completion line runs `complete_` → `check_q_` → `complete_q_`, which writes `dw.qa_` — so a quest's lines must precede the lines of any quest it arms, or a click already pending when its quest arms is adjudicated against an unarmed quest and then thrown away. Nothing in the DSL orders quest declarations, so before this the guarantee was an accident of the JSON array. The sort is stable, so a campaign already declared in arming order is byte-identical. The unconditional reset is deliberate and stays (owner ruling): a trigger fired long before arming is DISCARDED, never banked — a banked click would auto-complete the objective the moment the quest armed, with nobody having clicked. Losing input is a bug; fabricating it is worse. Pinned by `tests/tick_arming.rs` (the invariant over every fixture, plus a campaign deliberately declared out of order) and by the generated `verb_interact_arming` PackTest (premature click → no completion and no banked score; arming alone → still nothing; a real click after arming → completes). | | stage-5 `loot[]` (spec-0021) | `setup_finish` emits one `item replace block container. with [components] ` per declared stack, slot = declaration index. `components` carries `custom_name` (localized) and `enchantments` when present. The container itself is never emitted — it is prefab furniture, proven present by `DW0431`. A campaign with no `loot` emits nothing here and stays byte-identical. | -| environment `triggers[]` (v0.4) | `setup_finish` summons one `minecraft:interaction` per `strike`/`use` trigger at its `at` anchor (tag `dw_trig_`); `approach` needs no entity. `tick`: `strike` fires on `nbt={attack:{}}`, `use` on `nbt={interaction:{}}`; `approach` is a `distance=..` selector. **The click block is two phases, not one (round-8, island QA):** every click trigger's fire clause first, in declaration order, then every clear clause (`data remove entity @s `). Emitting the pair inline per trigger is only sound while at most one trigger reads a given interaction entity, and several `strike-npc` triggers legitimately ride ONE NPC hitbox — the island's giant carried `wake-the-giant` (requires `flag/asleep`) and `his-house` (requires `flag/sealed`, forbids `flag/asleep`) on the same entity. Inline removal made the FIRST-DECLARED trigger consume the click even with its own gate shut, so `his-house` could never fire: a suppressed trigger starved its siblings and declaration order silently decided which of two legal triggers worked. Two phases make it order-independent — every trigger sharing a hitbox is offered the same click and fires exactly when its own gate says so — while consumption is unchanged (the record is gone by the end of the same `tick` pass, so a held click still fires once). Byte impact: a campaign whose click triggers are its last-declared triggers is unchanged; any other ordering moves the clear clauses to the end of the block. `once` guards on `#trig_ dw.sys`, which **every** trigger now writes on firing (not only `once` ones): the write is what makes dispatch observable at all — the starvation bug was a trigger that simply never fired, invisible to every automated check — and it is what the generated `v06_shared_hitbox` template reads. One added line per non-`once` trigger function. **Generated `v06_shared_hitbox` (round-8):** emitted for a campaign that has two click triggers on one NPC hitbox whose flags can tell them apart; it proves the hitbox really is shared, then writes the vanilla `attack` compound and runs the real `tick` twice — once with the later trigger's gate open and the earlier one's shut (the starvation case: the later one must fire, the earlier must stay silent, the record must still be consumed), once with the earlier one's gate open (so both are reachable). Players are shielded with Resistance V across each pass because a real `tick` runs real effects and a delve's effects include `damage-players`; flags, actors and NPCs are handed back untouched (batch model). **`strike-npc` — the body IS the target (v0.6, round-7):** `on: {on:"strike-npc", npc}` has **no anchor**. Its tag rides the interaction hitbox the named NPC already owns and `setup_finish` summons nothing for it, so it works wherever that NPC stands and whatever body it wears. This is the form that can express "hit the giant": a place-based `strike` summons its own entity at a *cell*, and a large NPC's body eclipses that cell (`DW0359`), so the click never reaches it — the owner's island round-7 finding, where striking Polyphemus did nothing. Right- and left-click stay separate all the way down because a `minecraft:interaction` records them in **two distinct NBT fields**: the dialogue advancement takes the right-click (`interaction`), the trigger takes the left-click (`attack`), and neither consumes the other's record. That separability is machine-proven, not assumed — the generated `v04_strike_npc` PackTest writes a right-click record on the shared hitbox, ticks, and asserts no `attack` record appeared and the trigger did not fire. **Strike on an NPC's anchor — one cell, one hitbox (round-6):** the pre-0.6 spelling of the same mechanism, kept working — when a `strike` trigger's `at` is also where an NPC stands, the NPC's own interaction hitbox carries `dw_trig_` **and is the trigger's sole entity** — `setup_finish` suppresses the trigger's own summon. The NPC's body is `Invulnerable`, so without the shared tag a swing could land where nothing was watching and the trigger never fire (round-4 island QA); and with a *second*, exactly co-located hitbox (the round-4 form) the client's entity ray-pick is ambiguous — an exact tie resolves to whichever entity iterates first, in practice the world-init summon — so every right-click landed on an entity without `dw_npc_` and the dialogue advancement never fired (round-6 island QA: Polyphemus untalkable after the boulder seal, proven on a live server). Consequences: the trigger's lifecycle follows the NPC's — a `deferred` NPC's strike trigger is armed only after its `spawn-npc` entrance, a `move-npc`'d NPC carries the strike target with it, and `despawn-npc` removes it entirely (which is the trigger's meaning: the thing being struck is the NPC). Scoped to left-clicks: right-click on an NPC already belongs to the dialogue advancement, so a co-located `use` trigger is rejected at validate time (`DW0350`) and again at build time (`DW0359`). Generated PackTests: `v04_strike_npc` writes the vanilla `attack` compound onto the NPC's hitbox and asserts the trigger fires, once, with the record consumed; `v04_strike_talk` pins the single-hitbox invariant — exactly one interaction entity wears the trigger tag, none wears it without the NPC tag, before and after an attack record is consumed (attack-then-talk must stay clickable). | +| environment `triggers[]` (v0.4) | `setup_finish` gives each `strike`/`use` trigger a body at its `at` anchor (tag `dw_trig_`); `approach` needs no entity. **The body is the shape of the object at that anchor, not a point (task #50).** `compiler::pressable::body_at` is the single authority and both this emitter and `compiler::eclipse` read it, so the two can never disagree about whether a body exists. Three outcomes: where a compiler-owned interaction set already covers the anchor — a `close-gate` seal, a sealed shortcut door — the trigger **rides** it and summons nothing (one cell, one hitbox; a second co-located box is the `DW0422` ray-pick tie); where the anchor names a **gate region**, one `1.02f` box is summoned per clickable **shell** cell of that region, exactly as a `close-gate` seal has always done; where it names a point in open space, the ordinary `1.0f x 2.0f` box, unchanged and byte-identical. **Why the region form exists:** a point body at a region anchor lands *inside* the solid block. Measured on the `souls-shortcut` fixture, a `use` trigger on the shortcut's gate emitted one body with AABB `[4,65,6]..[5,67,7]` inside a doorway slab occupying `[4,65,6]..[6,68,7]` — flush with the block on the faces it touched and interior on the rest. Vanilla bounds its entity raycast by the block hit and takes the entity only when it is *strictly* nearer, so that trigger was pressable from **no angle at all**, and it compiled with zero diagnostics; a doorway is also six cells, of which a point body covers one. `close-gate` had solved this privately inside one verb since v0.8 and nothing else could reach the machinery. A trigger whose anchor resolves to nothing at all is `DW0426`. `tick`: `strike` fires on `nbt={attack:{}}`, `use` on `nbt={interaction:{}}`; `approach` is a `distance=..` selector. **The click block is two phases, not one (round-8, island QA):** every click trigger's fire clause first, in declaration order, then every clear clause (`data remove entity @s `). Emitting the pair inline per trigger is only sound while at most one trigger reads a given interaction entity, and several `strike-npc` triggers legitimately ride ONE NPC hitbox — the island's giant carried `wake-the-giant` (requires `flag/asleep`) and `his-house` (requires `flag/sealed`, forbids `flag/asleep`) on the same entity. Inline removal made the FIRST-DECLARED trigger consume the click even with its own gate shut, so `his-house` could never fire: a suppressed trigger starved its siblings and declaration order silently decided which of two legal triggers worked. Two phases make it order-independent — every trigger sharing a hitbox is offered the same click and fires exactly when its own gate says so — while consumption is unchanged (the record is gone by the end of the same `tick` pass, so a held click still fires once). Byte impact: a campaign whose click triggers are its last-declared triggers is unchanged; any other ordering moves the clear clauses to the end of the block. `once` guards on `#trig_ dw.sys`, which **every** trigger now writes on firing (not only `once` ones): the write is what makes dispatch observable at all — the starvation bug was a trigger that simply never fired, invisible to every automated check — and it is what the generated `v06_shared_hitbox` template reads. One added line per non-`once` trigger function. **Generated `v06_shared_hitbox` (round-8):** emitted for a campaign that has two click triggers on one NPC hitbox whose flags can tell them apart; it proves the hitbox really is shared, then writes the vanilla `attack` compound and runs the real `tick` twice — once with the later trigger's gate open and the earlier one's shut (the starvation case: the later one must fire, the earlier must stay silent, the record must still be consumed), once with the earlier one's gate open (so both are reachable). Players are shielded with Resistance V across each pass because a real `tick` runs real effects and a delve's effects include `damage-players`; flags, actors and NPCs are handed back untouched (batch model). **`strike-npc` — the body IS the target (v0.6, round-7):** `on: {on:"strike-npc", npc}` has **no anchor**. Its tag rides the interaction hitbox the named NPC already owns and `setup_finish` summons nothing for it, so it works wherever that NPC stands and whatever body it wears. This is the form that can express "hit the giant": a place-based `strike` summons its own entity at a *cell*, and a large NPC's body eclipses that cell (`DW0359`), so the click never reaches it — the owner's island round-7 finding, where striking Polyphemus did nothing. Right- and left-click stay separate all the way down because a `minecraft:interaction` records them in **two distinct NBT fields**: the dialogue advancement takes the right-click (`interaction`), the trigger takes the left-click (`attack`), and neither consumes the other's record. That separability is machine-proven, not assumed — the generated `v04_strike_npc` PackTest writes a right-click record on the shared hitbox, ticks, and asserts no `attack` record appeared and the trigger did not fire. **Strike on an NPC's anchor — one cell, one hitbox (round-6):** the pre-0.6 spelling of the same mechanism, kept working — when a `strike` trigger's `at` is also where an NPC stands, the NPC's own interaction hitbox carries `dw_trig_` **and is the trigger's sole entity** — `setup_finish` suppresses the trigger's own summon. The NPC's body is `Invulnerable`, so without the shared tag a swing could land where nothing was watching and the trigger never fire (round-4 island QA); and with a *second*, exactly co-located hitbox (the round-4 form) the client's entity ray-pick is ambiguous — an exact tie resolves to whichever entity iterates first, in practice the world-init summon — so every right-click landed on an entity without `dw_npc_` and the dialogue advancement never fired (round-6 island QA: Polyphemus untalkable after the boulder seal, proven on a live server). Consequences: the trigger's lifecycle follows the NPC's — a `deferred` NPC's strike trigger is armed only after its `spawn-npc` entrance, a `move-npc`'d NPC carries the strike target with it, and `despawn-npc` removes it entirely (which is the trigger's meaning: the thing being struck is the NPC). Scoped to left-clicks: right-click on an NPC already belongs to the dialogue advancement, so a co-located `use` trigger is rejected at validate time (`DW0350`) and again at build time (`DW0359`). Generated PackTests: `v04_strike_npc` writes the vanilla `attack` compound onto the NPC's hitbox and asserts the trigger fires, once, with the record consumed; `v04_strike_talk` pins the single-hitbox invariant — exactly one interaction entity wears the trigger tag, none wears it without the NPC tag, before and after an attack record is consumed (attack-then-talk must stay clickable). | | `set-flag` / `requires_flags` / `forbids_flags` | `dw.f_` scoreboard (per-player); required flags AND-ed into objective guards (layered on `after`), forbidden flags (v0.6) joined as `unless score @s dw.f_ matches 1` clauses in the same guard. **Per-effect** gates (v0.6) wrap each of the effect's emitted commands in `execute if score @s dw.f_ matches 1 [… per required] unless score @s dw.f_ matches 1 [… per forbidden] run `; these effect functions already run per-player (`complete_` / `trig_` are entered `as @a`/`@s`), and an ungated effect is emitted verbatim (byte-identical). In a **scheduled** bundle (`on_arrive`, `sequence` steps) there is no acting player: a per-player effect's gate stays `if score @s …` but under the effect's own `as @a`, while a global effect's gate degrades to the any-player predicate `if entity @a[scores={dw.f_=1..}]` — §4 "A scheduled bundle has no `@s`". `unless … matches 1` is the deliberate unset-safe spelling: flag scores are never pre-initialized to 0, so a `scores={…=..0}` selector would not match an unset score. **Trigger-level** `forbids_flags` is any-player: the fire condition gains `unless entity @a[scores={dw.f_=1..}]` per flag (a positive selector inside a negation — flags are campaign state, so one player's wake beat stands the trigger down for everyone); a suppressed strike/use still consumes the interaction record. Generated PackTests: `verb_flag_gate` (requires) and `verb_forbid_gate` (forbids: set flag → drive → assert NOT complete; clear → drive → assert complete). | | `open-gate` | `/fill … air` over the gate region, **plus `kill @e[tag=dw_seal_]`** when the campaign ever seals that anchor (v0.8): the seal's answer comes down with the seal. An opened threshold that still says "the way is sealed" is a lie, and an invisible box left standing in a doorway swallows right-clicks aimed through it. | | `close-gate` | `/fill ` over the gate region with the anchor's declared fill block (no `replace` clause — the dual of `open-gate`), **plus `execute unless entity @e[tag=dw_seal_] run function :seal_arm_`** (v0.8, task #142 — the owner's island finding #34: a sealed boulder answered a right-click with silence). See [The seal answers](#the-seal-answers) below. | @@ -2663,6 +2663,8 @@ Exit 3 except `DW0312` (wave-capacity), `DW0313` (gravity-despawn) and `DW0342` | `DW0421` | An affordance's **visible hardware is destroyed by machinery that does not own it**. Hardware may be retired by exactly one thing — the affordance's own consumption (`shortcut_open_`, `trap_disarm_`); a bonfire's is permanent and may be retired by nothing. Anything else reaching the `dw_hw_` (a cleanup pass whose selector widened, a `DW0361`-class name collision) leaves a live affordance invisible again — the same soft-lock by a different route. Tag matching is exact, not prefix, so `dw_hw_a` never matches a kill aimed at `dw_hw_ab`. Emission self-check over the finished datapack, `compiler::affordance`, build-tier (exit 3). | | `DW0422` | A **seal's answer hitbox is contested** by another compiler-owned interaction affordance (v0.8, task #142). A `close-gate` arms one `minecraft:interaction` per clickable cell of the sealed region so the wall can answer a right-click; any other affordance whose own 1.0 × 2.0 box overlaps one of those cells is in an exact ray-pick contest with it, and the client resolves an exact tie by iteration order — one of the two silently stops receiving clicks and which one is not decidable from the campaign. This is the defect that made the island's boulder hint unshippable for three rounds (`DESIGN.md` §7 item 4: a co-located second hitbox meant either the existing left-click hint or the new right-click hint died, and the compiler built green either way). Pure box arithmetic over resolved cells, `compiler::eclipse::check_seal_collisions`, build-tier (exit 3), run beside `DW0359`. **Not a collision:** a click trigger anchored on the gate anchor **itself** — it rides the seal's own hitboxes and `env_trigger_setup` summons nothing for it, the same merge `strike`-on-an-NPC's-anchor has used since round 6. Prescription: move the affordance out of the sealed region, or — when the thing being clicked really is the gate — anchor the trigger on the gate anchor so it rides the seal. | | `DW0423` | Two `close-gate` effects seal the **same** gate anchor with different `sealed_hint` wordings (v0.8, task #142). A seal's answer belongs to the PLACE: one anchor carries one set of `dw_seal_` hitboxes and one reward function, so a second wording has nowhere to live and would be silently dropped — a line an author wrote and a player can never read, which is the same silence class the verb exists to close. A firing that authors no hint is compatible with anything (it asks for the compiler's canonical English); only two *authored, different* lines conflict. `compiler::gates::check_seal_hints`, validation tier (exit 1). Prescription: give both firings the same line, or seal two different gate anchors. | +| `DW0425` | **The compiler cannot tell which side of a `shortcut`'s gate is the sealed one** (task #50, owner ruling 2026-08-06). A shortcut door's clickable body is placed in the open air on the *sealed* side only, and that placement IS the side test — so the side has to be derivable or nothing may be placed. It is derived from the gate slab's thin axis plus which side of it the `unlock` cell lies on, and it fails when the region has no unique thinnest axis (a cube is not a doorway) or the `unlock` is level with the doorway on that axis rather than beyond it. Withhold, never invent: bodies placed on a guess put the author's "this will not open" answer exactly where the door DOES open, and a false player-facing statement is worse than silence — silence teaches nothing, a lie teaches something wrong. `compiler::wrongside::derive` + `emit::check_shortcut_sides`, build-tier (exit 3), raised **before** the route proofs so an undecidable doorway is not reported under `DW0374`'s name. Prescription: put the `unlock` clear of the gate's span on the axis the door is thin on — which is where a far-side bar belongs anyway — or use a gate anchor whose region is a doorway slab rather than a volume. | +| `DW0426` | **A click trigger is anchored where a player can never click it** (task #50). The unbound-vacuity class as a check, and the rule that would have caught the gap this task came from: the trigger declares an anchor, a click and a full effect bundle, validation passes, emission runs, and the press lands on nothing — so the beat never happens and every board stays green. Fires when a `strike`/`use` trigger's `at` resolves to no placed piece, so there is no cell to give it a body at. (`strike-npc` carries no anchor and rides its NPC's own hitbox; `approach` is a radius test with no entity — neither is in scope.) `compiler::pressable::body_at` + `emit::check_trigger_bodies`, build-tier (exit 3). Prescription: anchor it on a place a prefab provides — anchor names come from prefab metadata, never invented — or drop the trigger. | | `DW0386` | A TD `lane` (spec-0016 §6) does not survive contact with the assembled world: a waypoint anchor that resolves nowhere in the wave's area, a waypoint with no standable footing within 3 blocks, a leg the squad cannot walk (routed on the same **no-gate-use** view wave seating uses — lane mobs cannot right-click a fence gate open), or a leg of **10 blocks or less**. The spacing rule is not taste: vanilla re-rolls a patrol target to a random point once the patroller is within 10 blocks of it, so a tighter lane is one the engine quietly stops following — it reads as working-but-drunk, not as a bug. The spike's measured working default is 12. `compiler::nav::plan_lanes`, build-tier (exit 3); the message names the wave, both leg endpoints and the measured length. | | `DW0387` | A `summon: aggro-edge` wave (spec-0016 §6) whose perception ring offers fewer valid cells than the stack has mobs. The ring is the standable, walk-reachable, line-of-sight cells on `[follow_range - 1, follow_range]` around the defended anchor, inside the area. An error rather than a silent short spawn on purpose: the round-1 lesson was a wave that never fully appeared, so its `kill` countdown could never reach zero and the delve soft-locked with every other proof green. `compiler::emit::plan_aggro_edge_spawns`, build-tier (exit 3). Prescription: give the arena room at that radius, lower the stack's `follow_range` to a ring the arena actually has, or move the defended anchor off the wall. | | `DW0388` | **Hazard observability** (spec-0016 §4 addendum, souls dossier §5.3 / §2.2 axis 5): a timed hazard — a `timed-gate` span or a `volley` kill zone — that the player cannot **watch before committing to it**. The obligation is one standable **watch cell**: (a) at least **5 blocks** (Chebyshev box distance) clear of every cell of the lethal span — one second of sprint at the same `4 t/block` model `DW0355` and `DW0378` use, so sight from the lip of the span does not count as safety; (b) walkable from the campaign entry over the world with that span **sealed**, which is the load-bearing clause — a bay you can only reach by first surviving the hazard is not a bay; and (c) with an unobstructed sightline from eye height (1.62 above its floor) to the player-centre-mass point (1.0 above the floor, the exact point a volley aims at) of some cell the hazard judges, walked by the `DW0308` Amanatides–Woo traversal through the same `blocks_camera` sight predicate — so glass and a grate are transparent to an eye exactly as they are to a camera. Search is bounded to 32 blocks; candidates are tried nearest-first, ties on cell order (ADR-0006). Deliberately **not** required: sight to the whole span — a stair volley read from its foot is observable even though the treads occlude each other, and demanding total visibility would red legitimate geometry while proving nothing more. `collapse` is out of scope (it fires once, its region is a ceiling with no standable cell, and there is no cycle to watch — `DW0445` is its fairness proof); a region with no standable cell, and a campaign with no entry anchor, are left to `DW0444`/`DW0311`/`DW0345`. **Two tiers, one rule**: **error (exit 3)** when the campaign declares a `bonfire` — the same test the flask obligation `DW0476` uses to decide "is this spec-0016 content" — and **warning** otherwise, where the geometry is a design note rather than a broken promise. `compiler::nav::check_hazard_observability`. This is the dossier's gap G1: no source reports a duty cycle for any FromSoft periodic hazard, but every source attests the observe-from-safety rule, and the dossier's verdict is that if only one of the two proofs can be afforded it should be this one, not `DW0378`'s 20%. Prescription is always geometry — open the approach, or move the hazard off the blind side of the corner. Never shorten the standoff. | From 892ad1c19860e6fca5e7b1f72394a72390017b37 Mon Sep 17 00:00:00 2001 From: Stella Wang Date: Sat, 8 Aug 2026 00:09:45 -0400 Subject: [PATCH 2/2] =?UTF-8?q?chore(audit):=20ledger=20the=20shortcut=20g?= =?UTF-8?q?ate's=20approach=20bodies=20=E2=80=94=20shape=203,=20closed=20n?= =?UTF-8?q?ot=20catalogued?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability-ownership gate refused this PR by name: `ws_arm_fns` summons interaction bodies that no ledger entry justified. That is the gate working — a new capability site cannot appear in silence. The entry says what the site IS, because it is the lift the audit asked for rather than another instance. A trigger's `at` binds a point at a cell, not the clickable shape of the object standing at that anchor, so authoring the island boulder's own pattern on a shortcut door compiled clean and shipped a box pressable only from the side the door opens from. The fix is not a fourth mechanism: the body is arrayed over the gate's approach cells and the author's own click trigger RIDES those hitboxes, exactly as `strike-npc` rides the NPC dialogue body. The compiler supplies the shape; the campaign supplies the answer, in the general effect vocabulary — so the reply is l10n-inventoried and flag-gated by construction. Check A's binding count in the audit doc follows: 9 -> 11. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL --- docs/notes/capability-ownership-audit.md | 2 +- tools/check-capability-ownership.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/notes/capability-ownership-audit.md b/docs/notes/capability-ownership-audit.md index 2ec4104a..b40c1f4b 100644 --- a/docs/notes/capability-ownership-audit.md +++ b/docs/notes/capability-ownership-audit.md @@ -219,7 +219,7 @@ binding**, because a gate that matched nothing is vacuous, not a pass. | Check | Binds today | |---|---| -| A — every `summon minecraft:interaction`, keyed by enclosing fn | 9 sites | +| A — every `summon minecraft:interaction`, keyed by enclosing fn | 11 sites (was 9; the shortcut wrong-side lift adds `ws_arm_fns`) | | B — every compiler-baked player-facing English string | 3 constants (was 5; spec-0029 closed two) | | C — DSL structs declared separately with an identical field set | 2 groups | | D — cross-cutting modifier absent from some variants of a tagged enum | 6 (enum, field) pairs | diff --git a/tools/check-capability-ownership.py b/tools/check-capability-ownership.py index e8c7c673..9408aab9 100644 --- a/tools/check-capability-ownership.py +++ b/tools/check-capability-ownership.py @@ -109,6 +109,20 @@ "(`trigger_rides_seal`, `DW0422`) instead of the seal being an ordinary " "trigger. Shapes 2 and 3 together. Lift in progress — see the audit." ), + "ws_arm_fns": ( + "The shortcut gate's approach-side bodies, and the SHAPE-3 LIFT this PR " + "exists for. A trigger's `at` binds a POINT AT A CELL, not the clickable " + "shape of the object standing at that anchor — so authoring the island " + "boulder's own pattern on a shortcut door compiled clean and shipped a box " + "pressable only from the side the door opens from. The fix is not a fourth " + "mechanism: the body is arrayed over the gate's approach cells, and a click " + "trigger the author anchors there RIDES these hitboxes (`seal_rider_tags`) " + "instead of summoning a co-located one, exactly as `strike-npc` rides " + "`npc_summon_commands`'s body. The compiler supplies the SHAPE; the campaign " + "supplies the ANSWER, in the general effect vocabulary, so the reply is " + "l10n-inventoried and flag-gated by construction. Empty for a campaign with " + "no shortcut — byte-identical output. Shape 3, closed rather than catalogued." + ), "shortcut_setup": ( "OPEN FINDING. `shortcuts[].unlock` summons its own body at the far-side " "anchor. `on_unlock` already uses the general effect vocabulary, so only "