diff --git a/.claude/skills/new-delve/SKILL.md b/.claude/skills/new-delve/SKILL.md index 40da48e6..c1c06a80 100644 --- a/.claude/skills/new-delve/SKILL.md +++ b/.claude/skills/new-delve/SKILL.md @@ -514,6 +514,13 @@ Symptom → tool: - **Terrain/visual fixes beyond swapping prefabs**: `delvec edit` — the spec-0017 map editor loop (edit script batch → replay → snapshot). Never hand-patch `.nbt` or invent block edits outside it. +- **Ground the campaign means to sit at the waterline** (an ocean `horizon` + shoreline that wades, a bank rolled down past sea level): the stage-7 `flood` + verb (spec-0030, world-edits `dsl_version` 0.9.0) — declare the envelope the + ambient sea is admitted into and the compiler computes and builds the reach. + It is a claim, not a switch: `DW0364` still refuses every cell the sea does + not reach, an envelope the water never enters is `DW0394`, and water that + runs on past it is `DW0395`. Never author water by hand to clear a `DW0364`. - **Handing a build to the owner to play**: mention the playtest note flow (spec-0006: `/trigger dw.note` in-game, then `delve-harvest` → `playtest-report.json`) — one line, human-optional. diff --git a/crates/compiler/src/edit.rs b/crates/compiler/src/edit.rs index dd78de8b..42f1d301 100644 --- a/crates/compiler/src/edit.rs +++ b/crates/compiler/src/edit.rs @@ -92,6 +92,22 @@ pub const DW_EDIT_GATE_REGION: &str = "DW0353"; /// placed (a declared minimum-light guarantee, not decoration). pub const DW_EDIT_SUPPORT: &str = "DW0354"; +/// A `flood` envelope the ambient water never enters (spec-0030): the horizon +/// has no ambient water at all, or the declared region holds no cell the sea +/// can reach — every cell of it sits above the flood level, is already solid, +/// or is walled off from the sea. The declaration binds nothing, so it proves +/// nothing: an unbound gate is a finding, never a pass (CLAUDE.md). Build-tier +/// (exit 3). +pub const DW_FLOOD_INERT: &str = "DW0394"; + +/// The water a `flood` admitted does not stop inside the declared envelope +/// (spec-0030): from a wetted cell it flows on — sideways at the same level, or +/// down — into an air cell of a **placed piece** that no `flood` region covers, +/// at or below the flood level. The shoreline is then not where the author said +/// it is, and the model would ship dry ground the sea takes. Build-tier +/// (exit 3). +pub const DW_FLOOD_ESCAPES: &str = "DW0395"; + /// A failed edit replay: a stable diagnostic code plus a message that names the /// offending batch. #[derive(Debug)] @@ -185,6 +201,13 @@ fn replay_with( // cells a `relight` verb placed (those are declared lighting, error tier). let mut support_watch: BTreeMap<[i32; 3], String> = BTreeMap::new(); let mut fixture_cells: BTreeSet<[i32; 3]> = BTreeSet::new(); + // spec-0030 tideline bookkeeping, cumulative across batches: every `flood` + // declaration (batch, region, envelope cells) and the union of their + // envelopes. The tideline invariant re-proves over ALL of them after every + // batch, so a later batch that re-cuts the bank — opening a new channel or + // drying a wetted cell — is red where it happened, not silently shipped. + let mut flood_decls: Vec<(String, String, BTreeSet<[i32; 3]>)> = Vec::new(); + let mut flood_envelopes: BTreeSet<[i32; 3]> = BTreeSet::new(); for batch in &env.content.batches { let bid = batch.id.as_str(); @@ -255,6 +278,49 @@ fn replay_with( write_cell(&mut assembled, &mut batch_writes, cell, "minecraft:air"); } } + WorldEdit::Flood { region } => { + let cells = used_region(bid, ®ions, region.as_str())?.clone(); + // No ambient water anywhere in this world — the verb has + // nothing to admit, and a no-op declaration is a finding. + let tide = Tide::of_plan(plan).ok_or_else(|| EditError { + code: DW_FLOOD_INERT, + message: format!( + "world-edits batch `{bid}`: `flood` on region `{region}` in a world \ + whose `horizon` has no ambient water at all — there is no sea to \ + admit, so the declaration binds nothing. `flood` states that a \ + stretch of ground is deliberately at the waterline; a world with \ + no waterline cannot host one. Declare an ocean `horizon`, or drop \ + the verb" + ), + })?; + let wet = flood_reach(&tide, &assembled.blocks, &cells); + if wet.is_empty() { + let above = cells.iter().filter(|c| c[1] > tide.flood).count(); + return Err(EditError { + code: DW_FLOOD_INERT, + message: format!( + "world-edits batch `{bid}`: `flood` on region `{region}` admits \ + the sea into NO cell — the declaration binds nothing, so it \ + proves nothing (a zero binding is a finding, never a pass). Of \ + its {} cell(s), {above} sit above the flood level (y={}) where \ + water never rises, and the rest are outside every placed piece \ + (where the generator's sea already is), already solid, or \ + walled off from the ambient water. `flood` does not place \ + water: it admits the sea and the compiler computes the reach. \ + Extend the envelope down to the shoreline the sea actually \ + touches, or drop the verb — do NOT reach for it to quiet an \ + unrelated diagnostic", + cells.len(), + tide.flood, + ), + }); + } + flood_envelopes.extend(cells.iter().copied()); + flood_decls.push((bid.to_string(), region.as_str().to_string(), cells)); + for &cell in &wet { + write_cell(&mut assembled, &mut batch_writes, cell, "minecraft:water"); + } + } WorldEdit::Morph { region, op } => { let cells = used_region(bid, ®ions, region.as_str())?; morph(&mut assembled, &mut batch_writes, cells, op, seed, bid)?; @@ -385,6 +451,7 @@ fn replay_with( // relight re-entry (spec-0010), walkability re-proof, boundary safety // (spec-0017). Each failure names the batch. if enforce { + check_tideline(plan, &assembled, bid, &flood_decls, &flood_envelopes)?; check_batch_invariants(plan, &assembled, bid, &batch_writes)?; check_support( &assembled, @@ -413,6 +480,80 @@ fn replay_with( })) } +/// **The tideline invariant** (spec-0030), re-proved after every batch over +/// every `flood` declaration made so far. +/// +/// A `flood` is a claim about the delivered world — *the sea comes in exactly +/// here* — and the claim has to survive the rest of the script. Two halves: +/// +/// 1. **The water is where the model says it is.** Every cell the ambient sea +/// reaches inside a declared envelope holds water in the current model. The +/// verb materialized them; a later batch that filled one back in with rock +/// without shrinking the envelope has changed the shoreline, and this is +/// where that shows up. +/// 2. **The water stops where the author said.** No wetted cell has a +/// downstream air neighbour, at or below the flood level, inside a placed +/// piece and outside every declared envelope ([`DW_FLOOD_ESCAPES`]). +/// +/// Runs only for a script that declares at least one `flood`: an ocean campaign +/// that declares none keeps `DW0364`'s absolute rule and its exact pre-spec-0030 +/// output. This is deliberate — the strict default is that no standable cell may +/// sit under the waterline at all, and `flood` is the only way to trade a piece +/// of that ground for water, at the price of these two proofs. +fn check_tideline( + plan: &Plan, + assembled: &Assembled, + bid: &str, + decls: &[(String, String, BTreeSet<[i32; 3]>)], + envelopes: &BTreeSet<[i32; 3]>, +) -> Result<(), EditError> { + if decls.is_empty() { + return Ok(()); + } + let Some(tide) = Tide::of_plan(plan) else { + return Ok(()); // unreachable: the verb itself rejects a waterless world + }; + for (decl_batch, region, envelope) in decls { + let wet = flood_reach(&tide, &assembled.blocks, envelope); + if let Some(dry) = wet.iter().find(|c| { + !assembled + .blocks + .get(*c) + .is_some_and(|b| assembled::is_water(b)) + }) { + return Err(EditError { + code: DW_FLOOD_ESCAPES, + message: format!( + "after world-edits batch `{bid}`: the sea reaches [{}, {}, {}] inside the \ + `flood` envelope `{region}` (declared in batch `{decl_batch}`) but the \ + model has no water there — a later batch changed the shoreline out from \ + under the declaration. Re-cut the bank before the `flood`, or move the \ + `flood` after it: a tideline the script contradicts is exactly the \ + model-says-dry divergence `DW0364` exists to refuse", + dry[0], dry[1], dry[2] + ), + }); + } + if let Some(leak) = flood_escape(&tide, &assembled.blocks, &wet, envelopes) { + return Err(EditError { + code: DW_FLOOD_ESCAPES, + message: format!( + "after world-edits batch `{bid}`: the water admitted by `flood` on region \ + `{region}` (batch `{decl_batch}`) does not stop inside it — it flows on \ + into [{}, {}, {}], an air cell of a placed piece at or below the flood \ + level (y={}) that no `flood` envelope covers. The delivered world wets \ + that cell while every compile-time proof reads it as dry air (the #149 \ + class, `DW0364`). A `flood` envelope is a claim about where the shoreline \ + ENDS: widen it to the cells the sea really takes, or dam the channel — \ + never leave the overflow undeclared", + leak[0], leak[1], leak[2], tide.flood + ), + }); + } + } + Ok(()) +} + /// Re-prove the post-edit invariants over the current assembled world, naming /// `bid` in any failure. Mirrors the final build's pass order: relight first /// (its colliding fixtures join the nav world), then the walkability proofs, @@ -775,6 +916,163 @@ pub fn anchor_starts(plan: &Plan) -> Vec<[i32; 3]> { /// removes the cell (absent = air) and always clears any open-gate marking; a /// non-air write over an authored open gate likewise closes it (the runtime /// `setblock` replaces the whole block, state included). +/// The ambient-water geometry a `flood` verb reasons against (spec-0030): the +/// horizon's flood level, the lowest ambient water layer, and the placed-piece +/// AABBs `/place template` overwrites (inside a box the piece decides what is +/// there — outside it, and below it, the generator's water does). +/// +/// `None` for a horizon with no ambient water (`void`, `flatland`): there is no +/// sea to admit, which is why `flood` in such a world is `DW0394` rather than a +/// silent no-op. +struct Tide { + /// Y of the topmost ambient water block (`crate::horizon::SEA_LEVEL`). + flood: i32, + /// Y of the topmost ambient solid block; water occupies `floor_top+1..=flood`. + floor_top: i32, + /// Inclusive world AABBs of every placed piece, in plan order. + boxes: Vec<([i32; 3], [i32; 3])>, +} + +impl Tide { + fn of_plan(plan: &Plan) -> Option { + let h = crate::horizon::of_campaign(plan.campaign); + let flood = crate::horizon::flood_level(&h)?; + Some(Tide { + flood, + floor_top: crate::horizon::SEA_FLOOR_TOP_Y, + boxes: plan + .areas + .iter() + .flat_map(|a| a.pieces.iter().map(|p| p.bbox())) + .collect(), + }) + } + + /// Whether `c` falls inside a placed piece's AABB. + fn covered(&self, c: [i32; 3]) -> bool { + self.boxes + .iter() + .any(|(lo, hi)| (0..3).all(|a| lo[a] <= c[a] && c[a] <= hi[a])) + } + + /// Whether water can occupy `c` at all: the cell holds no block, or holds + /// water already (a cell an earlier `flood`, or the prefab itself, + /// authored). Every other block dams the flow, exactly as vanilla has it + /// ([`assembled::occupancy_of`]). + fn waterable(&self, blocks: &BTreeMap<[i32; 3], String>, c: [i32; 3]) -> bool { + blocks.get(&c).is_none_or(|b| assembled::is_water(b)) + } + + /// Whether `c` is **ambient** water: inside the generator's water layers and + /// not inside a placed piece's box. + fn ambient_water(&self, blocks: &BTreeMap<[i32; 3], String>, c: [i32; 3]) -> bool { + c[1] > self.floor_top && c[1] <= self.flood && !self.covered(c) && self.waterable(blocks, c) + } + + /// The cells water at `c` flows **into**: the four cardinal neighbours at the + /// same level, and the cell directly below. (Vanilla's drop-seeking + /// direction preference is omitted — spreading every way only ever wets + /// more, which is the safe direction, exactly as [`assembled`]'s flood + /// model argues.) + fn downstream(c: [i32; 3]) -> [[i32; 3]; 5] { + [ + [c[0] - 1, c[1], c[2]], + [c[0] + 1, c[1], c[2]], + [c[0], c[1], c[2] - 1], + [c[0], c[1], c[2] + 1], + [c[0], c[1] - 1, c[2]], + ] + } + + /// The cells water can arrive at `c` **from**: the four cardinal neighbours + /// at the same level, and the cell directly above (the inverse of + /// [`Tide::downstream`]). + fn upstream(c: [i32; 3]) -> [[i32; 3]; 5] { + [ + [c[0] - 1, c[1], c[2]], + [c[0] + 1, c[1], c[2]], + [c[0], c[1], c[2] - 1], + [c[0], c[1], c[2] + 1], + [c[0], c[1] + 1, c[2]], + ] + } +} + +/// Which cells of `envelope` the ambient sea reaches (spec-0030) — the +/// **computed** half of a `flood` declaration: the author names an envelope, the +/// compiler answers what the water does inside it. +/// +/// Seeded from ambient water bordering the envelope, propagated through +/// waterable envelope cells at or below the flood level, cardinal-sideways and +/// downward. Deterministic (`BTreeSet` frontier, fixed neighbour order). +/// +/// Confined to the envelope **by design**: the author's declaration is the +/// bound, and water that would carry on past it is not silently swallowed — it +/// is [`DW_FLOOD_ESCAPES`]. +fn flood_reach( + tide: &Tide, + blocks: &BTreeMap<[i32; 3], String>, + envelope: &BTreeSet<[i32; 3]>, +) -> BTreeSet<[i32; 3]> { + // Only cells a placed piece TOOK from the sea are in play: outside every + // piece box, below the flood level, the generator already puts water there + // and no model of ours claims otherwise. That is also exactly `DW0364`'s + // domain, so `flood` governs precisely the cells `DW0364` guards — never a + // cell more. + let admits = |c: [i32; 3]| { + c[1] <= tide.flood && envelope.contains(&c) && tide.covered(c) && tide.waterable(blocks, c) + }; + let mut wet: BTreeSet<[i32; 3]> = BTreeSet::new(); + let mut queue: std::collections::VecDeque<[i32; 3]> = std::collections::VecDeque::new(); + for &c in envelope { + if !admits(c) { + continue; + } + if Tide::upstream(c) + .iter() + .any(|&n| tide.ambient_water(blocks, n)) + && wet.insert(c) + { + queue.push_back(c); + } + } + while let Some(c) = queue.pop_front() { + for n in Tide::downstream(c) { + if admits(n) && wet.insert(n) { + queue.push_back(n); + } + } + } + wet +} + +/// The first cell (deterministic order) the admitted water would flow on into +/// **outside** every declared envelope while still inside a placed piece, at or +/// below the flood level ([`DW_FLOOD_ESCAPES`]). +/// +/// Cells outside every piece box are not escapes: below the flood level they +/// *are* the ambient sea, which is where the water came from. +fn flood_escape( + tide: &Tide, + blocks: &BTreeMap<[i32; 3], String>, + wet: &BTreeSet<[i32; 3]>, + declared: &BTreeSet<[i32; 3]>, +) -> Option<[i32; 3]> { + let mut leaks: BTreeSet<[i32; 3]> = BTreeSet::new(); + for &c in wet { + for n in Tide::downstream(c) { + if n[1] <= tide.flood + && !declared.contains(&n) + && tide.covered(n) + && blocks.get(&n).is_none() + { + leaks.insert(n); + } + } + } + leaks.into_iter().next() +} + fn write_cell( assembled: &mut Assembled, batch_writes: &mut BTreeMap<[i32; 3], String>, diff --git a/crates/compiler/src/emit.rs b/crates/compiler/src/emit.rs index d738c00c..898b43b3 100644 --- a/crates/compiler/src/emit.rs +++ b/crates/compiler/src/emit.rs @@ -259,6 +259,23 @@ pub fn build_with_warnings( // spec-0016 §7 pacing lints, filled in by the nav stage below. let mut pacing: Vec = Vec::new(); + // spec-0026 flood-level proof (DW0364): in a horizon with a flood level + // (ocean), every standable cell of every placed piece must sit above it — + // empirical assembled geometry, no `waterline_y` exemption (the #149 + // class). Runs off the EDITED model when an edit script exists, since a + // stage-7 batch can carve floor. No-op (and no occupancy model built) for + // a floodless horizon, so void/flatland builds are untouched. + if crate::horizon::flood_level(&crate::horizon::of_campaign(plan.campaign)).is_some() { + let world = match &edit_replay { + Some(er) => crate::nav::World::from_occupancy(crate::assembled::occupancy_of( + er.assembled.blocks.clone(), + &er.assembled.open_gates, + )), + None => crate::nav::World::from_plan(plan, structures), + }; + crate::nav::check_flood_level(plan, &world)?; + } + // spec-0021 container proof (DW0431). Runs off the assembled world — over // the EDITED model when an edit script exists, since a stage-7 batch can // legitimately be what puts the barrel there. Independent of nav, because a @@ -14623,22 +14640,20 @@ fn emit_server(plan: &Plan, out: &mut BuildOutput) { "easy" } }); - // Horizon (DSL v0.6, spec-0013). `void` (default/absent) keeps the empty-layer - // superflat + `the_void` biome, byte-identical to v0.5. `ocean` swaps in a - // pinned bedrock/stone/water superflat: from the -64 build floor, 1+118+8 - // layers top the water at y=62 (= sea level); areas are placed on that datum - // (`plan::OCEAN_BASE_Y` = 60) so island pieces read as land ringed by the sea. No structures (generate-structures=false) or mobs (gamerule - // spawn_mobs false); the sea is pure backdrop. The string is a fixed literal, - // so both horizons stay deterministic (ADR-0006). - let ocean = matches!( - plan.campaign.world.content.horizon, - Some(delvewright_dsl::Horizon::Ocean) - ); - let generator_settings = if ocean { - "{\"biome\":\"minecraft:ocean\",\"layers\":[{\"block\":\"minecraft:bedrock\",\"height\":1},{\"block\":\"minecraft:stone\",\"height\":118},{\"block\":\"minecraft:water\",\"height\":8}]}" - } else { - "{\"biome\":\"minecraft:the_void\",\"layers\":[]}" - }; + // Horizon (DSL v0.6 spec-0013; horizon library spec-0026). `void` + // (default/absent) keeps the empty-layer superflat + `the_void` biome, + // byte-identical to v0.5. `ocean` swaps in a pinned bedrock/stone/water + // superflat (1+118+8 layers from the -64 floor, water top y=62 = sea + // level); `flatland` a pinned bedrock/dirt/grass superflat (1+126+1 + // layers, grass top y=63 — one block under the scene walk plane by the §2 + // datum equation), `minecraft:plains` biome for vanilla's own grass tint. + // Areas are placed on the per-area datum (`plan::area_base_y`: + // walk_ref_y − the tileset's declared walk_y). No structures + // (generate-structures=false) or mobs (gamerule spawn_mobs false); the + // ambient is pure backdrop. The strings are fixed literals in + // `crate::horizon`, so every horizon stays deterministic (ADR-0006). + let horizon = crate::horizon::of_campaign(plan.campaign); + let generator_settings = crate::horizon::generator_settings(&horizon); // server.properties (keys sorted for determinism). let props: BTreeMap<&str, String> = BTreeMap::from([ ("allow-nether", "false".to_string()), @@ -14660,12 +14675,14 @@ fn emit_server(plan: &Plan, out: &mut BuildOutput) { "# Generated by delvec for campaign {} (spec-0002 world strategy).\n", plan.namespace )); - if ocean { - text.push_str( + match horizon.base { + delvewright_dsl::HorizonBase::Ocean => text.push_str( "# Ocean superflat (spec-0013 backdrop) + fixed seed; created on first boot.\n", - ); - } else { - text.push_str("# Void/superflat + fixed seed; the world is created on first boot.\n"); + ), + delvewright_dsl::HorizonBase::Flatland => text.push_str( + "# Flatland grass superflat (spec-0026 horizon) + fixed seed; created on first boot.\n", + ), + _ => text.push_str("# Void/superflat + fixed seed; the world is created on first boot.\n"), } for (k, v) in &props { text.push_str(&format!("{k}={v}\n")); @@ -14680,12 +14697,20 @@ The server jar is NOT shipped (ADR-0010); it is fetched by version at run time.\ .to_vec(), ); - let horizon_bullet = if ocean { - "- `level-type=minecraft:flat` + a pinned bedrock/stone/water `generator-settings`\n\ + let horizon_bullet = match horizon.base { + delvewright_dsl::HorizonBase::Ocean => { + "- `level-type=minecraft:flat` + a pinned bedrock/stone/water `generator-settings`\n\ (sea level y=62, `minecraft:ocean` biome) ⇒ an island backdrop (spec-0013).\n" - } else { - "- `level-type=minecraft:flat` + `generator-settings` with an empty layer list and\n\ + } + delvewright_dsl::HorizonBase::Flatland => { + "- `level-type=minecraft:flat` + a pinned bedrock/dirt/grass `generator-settings`\n\ + (grass top y=63, `minecraft:plains` biome) ⇒ a bare grass plain flush with the\n\ + scene walk plane (spec-0026).\n" + } + _ => { + "- `level-type=minecraft:flat` + `generator-settings` with an empty layer list and\n\ the `minecraft:the_void` biome ⇒ a void world.\n" + } }; out.insert( "server/README.md".to_string(), diff --git a/crates/compiler/src/horizon.rs b/crates/compiler/src/horizon.rs new file mode 100644 index 00000000..15a4161c --- /dev/null +++ b/crates/compiler/src/horizon.rs @@ -0,0 +1,184 @@ +//! The horizon model (spec-0026): one place where a campaign's resolved +//! stage-1 `horizon` becomes **declared physical facts** the rest of the +//! compiler reads — the placement datum, the flood level, the analytic ambient +//! and the emitted world generator. Nothing outside this module matches on +//! [`HorizonBase`] to decide world physics. +//! +//! ## The world model (spec-0026 §2) +//! +//! Each horizon declares: +//! +//! - **`walk_ref_y`** — the world y a placed piece's *walk plane* must land at. +//! The per-area placement datum is `walk_ref_y − walk_y`, where `walk_y` is +//! the tileset walk-plane convention declared in prefab metadata +//! ([`crate::registry::PrefabMeta::walk_y`]; missing in a non-void horizon is +//! `DW0367`, `crate::plan`). This supersedes spec-0013's single global +//! `OCEAN_BASE_Y` datum — the datum that made the #149 flooded-interior class +//! possible (an island-tileset constant applied to every tileset). +//! - **`flood_level`** — the world y at or below which a standable cell is +//! under the ambient water. Proved *empirically* against the assembled +//! geometry after placement (`DW0364`, [`crate::nav::check_flood_level`]) — +//! declarations position, proofs read reality. +//! - **ambient** — what a column the compiler modelled nothing into actually +//! contains ([`crate::nav::Ambient`]); the premise of the `DW0322` +//! boundary-safety proof. +//! - the emitted `server.properties` world generator (`level-type` + +//! `generator-settings`), the one channel the toolserver/delve images +//! consume (task #84 parity). +//! +//! ## Plug-in contract for surround generators (W-B/W-C slices) +//! +//! A surround-bearing base (`valley`, `summit`, the flatland seam band, the +//! sky archipelago solver hooks) plugs in here, not across the codebase: +//! +//! 1. implement the generator in its **own module** (e.g. `crate::valley`), +//! consuming the [`delvewright_dsl::ResolvedHorizon`] params and the +//! campaign seed, and emitting placed prefab tiles like any other piece; +//! 2. flip that base's arm in [`base_implemented`] to `true` — the DSL-layer +//! reserved-base rejection (`DW0141`, `delvewright_dsl::validate`) keys off +//! the same slice status and is deleted in the same PR; +//! 3. extend [`generator_settings`] / [`walk_ref_y`] / `nav::Ambient` with the +//! base's ambient facts (one `match` arm each — the compiler refuses to +//! build an unimplemented base long before those arms are reachable). +//! +//! Everything else (per-area datum, DW0364/DW0367, server emission plumbing) +//! is already base-generic and needs no edits. + +use delvewright_dsl::{Campaign, HorizonBase, ResolvedHorizon, resolved_horizon}; + +/// The resolved horizon of a campaign (defaults applied; `void` when absent). +pub fn of_campaign(campaign: &Campaign) -> ResolvedHorizon { + resolved_horizon(&campaign.world.content.horizon) +} + +/// The resolved base of a campaign's horizon. +pub fn base_of(campaign: &Campaign) -> HorizonBase { + of_campaign(campaign).base +} + +/// Whether this delvec slice implements the base end-to-end (spec-0026 +/// foundation: `void`, `ocean`, `flatland`). The DSL validation layer rejects +/// the others (`DW0141` reserved), so compiler paths may treat an +/// unimplemented base as unreachable-after-validation. +pub fn base_implemented(base: HorizonBase) -> bool { + match base { + HorizonBase::Void | HorizonBase::Ocean | HorizonBase::Flatland => true, + HorizonBase::Sky | HorizonBase::Valley | HorizonBase::Summit => false, + } +} + +/// Sea level of the `ocean` horizon superflat (spec-0013): the pinned +/// bedrock/stone/water layer stack (1 + 118 + 8 from the -64 build floor) tops +/// the water at y=62. Emission pins the same stack in `generator-settings`. +pub const SEA_LEVEL: i32 = 62; + +/// Height of the `ocean` horizon superflat's water layer (spec-0013) — the `8` +/// in the pinned `generator-settings` stack emission writes +/// (`emit::emit_server`). Ambient water occupies `SEA_LEVEL - 7 ..= SEA_LEVEL`. +pub const OCEAN_WATER_LAYERS: i32 = 8; + +/// Y of the topmost ambient **solid** block of the `ocean` horizon superflat: +/// the sea floor (stone) directly under the water layers, at 54. The ambient +/// model boundary safety reasons about (`nav::Sea`) starts here — below it the +/// world is stone all the way to bedrock, which is why an ocean world has no +/// void column anywhere. +pub const SEA_FLOOR_TOP_Y: i32 = SEA_LEVEL - OCEAN_WATER_LAYERS; + +/// Y of the `flatland` horizon's grass surface (spec-0026 §1): the pinned +/// bedrock/dirt/grass superflat (1 + 126 + 1 layers from the -64 build floor) +/// tops its grass at y=63 — **exactly one block under the scene walk plane** +/// ([`FLATLAND_WALK_REF_Y`]), the §3 zero-height-difference seam by datum +/// equation, never by blending. +pub const FLATLAND_SURFACE_Y: i32 = 63; + +/// The `ocean` walk reference (spec-0026 §2): sea level + 1, the vanilla-normal +/// beach relationship — a walk plane one block above the top water block. +pub const OCEAN_WALK_REF_Y: i32 = SEA_LEVEL + 1; + +/// The `flatland` walk reference: the grass surface + 1. Numerically equal to +/// `plan::BASE_Y` (64), so a walk_y=0 tileset sits exactly where `void` puts +/// every area — flatland relocates nothing, it just fills the world in. +pub const FLATLAND_WALK_REF_Y: i32 = FLATLAND_SURFACE_Y + 1; + +/// The world y a placed piece's walk plane must land at under this horizon +/// (spec-0026 §2), or `None` for `void` — the one base with no physical +/// reference plane, where areas keep the historical `plan::BASE_Y` origin. +/// +/// `sky` reads its `float_y` param; `valley`/`summit` derive from their +/// surround geometry and land with their generator slices (unreachable behind +/// [`base_implemented`] until then). +pub fn walk_ref_y(h: &ResolvedHorizon) -> Option { + match h.base { + HorizonBase::Void => None, + HorizonBase::Ocean => Some(OCEAN_WALK_REF_Y), + HorizonBase::Flatland => Some(FLATLAND_WALK_REF_Y), + HorizonBase::Sky => Some(h.float_y), + // Valley: gap floor + 1; Summit: plateau top + 1. The gap floor / + // plateau shell land with their surround generators (W-B/W-C); until + // then validation refuses these bases (`base_implemented`). + HorizonBase::Valley | HorizonBase::Summit => None, + } +} + +/// The world y at or below which a standable cell is flooded by the ambient +/// (spec-0026 §2 hazard facts): `ocean` 62, every other base none. Proved +/// empirically by `DW0364` (`crate::nav::check_flood_level`) with **no +/// waterline exemption** — the check reads assembled geometry, never metadata. +pub fn flood_level(h: &ResolvedHorizon) -> Option { + match h.base { + HorizonBase::Ocean => Some(SEA_LEVEL), + _ => None, + } +} + +/// The pinned `generator-settings` JSON for the horizon's ambient superflat +/// (fixed literals — deterministic emission, ADR-0006). The `void` literal is +/// byte-identical to every pre-0.6 campaign's; `ocean` to every v0.6 one. +pub fn generator_settings(h: &ResolvedHorizon) -> &'static str { + match h.base { + HorizonBase::Ocean => { + "{\"biome\":\"minecraft:ocean\",\"layers\":[{\"block\":\"minecraft:bedrock\",\"height\":1},{\"block\":\"minecraft:stone\",\"height\":118},{\"block\":\"minecraft:water\",\"height\":8}]}" + } + HorizonBase::Flatland => { + "{\"biome\":\"minecraft:plains\",\"layers\":[{\"block\":\"minecraft:bedrock\",\"height\":1},{\"block\":\"minecraft:dirt\",\"height\":126},{\"block\":\"minecraft:grass_block\",\"height\":1}]}" + } + // `sky` will emit its declared backdrop's generator (spec-0026 §4, the + // sky slice); until then it is unreachable behind `base_implemented`. + // `valley`/`summit` ambients are void below their tile skirts. + HorizonBase::Void | HorizonBase::Sky | HorizonBase::Valley | HorizonBase::Summit => { + "{\"biome\":\"minecraft:the_void\",\"layers\":[]}" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use delvewright_dsl::horizon_defaults; + + /// The flatland datum equation (spec-0026 §3): grass top is exactly one + /// block under the walk reference, and the layer arithmetic agrees with + /// the emitted literal (1 + 126 + 1 layers from the −64 floor → top 63). + #[test] + fn flatland_datum_equation() { + assert_eq!(FLATLAND_SURFACE_Y, -64 + 1 + 126 + 1 - 1); + assert_eq!(FLATLAND_WALK_REF_Y, FLATLAND_SURFACE_Y + 1); + assert_eq!(FLATLAND_WALK_REF_Y, crate::plan::BASE_Y); + } + + /// The ocean facts are the spec-0013 pins, unchanged by the datum rework. + #[test] + fn ocean_facts_unchanged() { + let h = ResolvedHorizon::of_base(HorizonBase::Ocean); + assert_eq!(walk_ref_y(&h), Some(63)); + assert_eq!(flood_level(&h), Some(62)); + assert_eq!(SEA_FLOOR_TOP_Y, 54); + } + + /// Sky's walk reference is its `float_y` param (default 160). + #[test] + fn sky_walk_ref_is_float_y() { + let h = ResolvedHorizon::of_base(HorizonBase::Sky); + assert_eq!(walk_ref_y(&h), Some(horizon_defaults::FLOAT_Y)); + } +} diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 61b4a79c..d98b1a10 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -62,6 +62,7 @@ pub mod edit; pub mod emit; pub mod flow; pub mod gates; +pub mod horizon; pub mod integrity; pub mod light; pub mod load; diff --git a/crates/compiler/src/main.rs b/crates/compiler/src/main.rs index 096ad798..025e5a67 100644 --- a/crates/compiler/src/main.rs +++ b/crates/compiler/src/main.rs @@ -939,8 +939,8 @@ fn edited_assembled( /// The `ocean`-horizon sea level to draw as a background plane, or `None` for a /// `void`-horizon campaign (see `snapshot::SEA_PLANE_NOTE`). fn sea_level_of(campaign: &delvewright_dsl::Campaign) -> Option { - match campaign.world.content.horizon { - Some(delvewright_dsl::Horizon::Ocean) => Some(delvewright_compiler::plan::SEA_LEVEL), + match delvewright_compiler::horizon::base_of(campaign) { + delvewright_dsl::HorizonBase::Ocean => Some(delvewright_compiler::plan::SEA_LEVEL), _ => None, } } diff --git a/crates/compiler/src/nav.rs b/crates/compiler/src/nav.rs index 280aa75e..3067a9c1 100644 --- a/crates/compiler/src/nav.rs +++ b/crates/compiler/src/nav.rs @@ -595,6 +595,12 @@ pub enum Ambient { Void, /// `horizon: ocean` — the pinned bedrock/stone/water superflat ([`Sea`]). Ocean(Sea), + /// `horizon: flatland` (spec-0026) — the pinned bedrock/dirt/grass + /// superflat: solid ground under **every** column up to + /// `crate::horizon::FLATLAND_SURFACE_Y` (63). Nothing can fall out of a + /// flatland world and there is no water to be stranded in; the boundary + /// clock (required, `DW0320`) owns wanderers. + Flatland, } /// The ocean horizon's ambient sea: a global water plane topping out at @@ -634,11 +640,13 @@ impl Sea { } impl Ambient { - /// The ambient a campaign's `horizon` declares (spec-0013), with the placed - /// pieces of `plan` as the covered region. + /// The ambient a campaign's `horizon` declares (spec-0013, generalized by + /// spec-0026 — one branch per horizon base), with the placed pieces of + /// `plan` as the covered region. pub fn of_plan(plan: &Plan) -> Ambient { - match plan.campaign.world.content.horizon { - Some(delvewright_dsl::Horizon::Ocean) => Ambient::Ocean(Sea { + use delvewright_dsl::HorizonBase; + match crate::horizon::base_of(plan.campaign) { + HorizonBase::Ocean => Ambient::Ocean(Sea { level: crate::plan::SEA_LEVEL, floor_top: crate::plan::SEA_FLOOR_TOP_Y, covered: plan @@ -647,6 +655,10 @@ impl Ambient { .flat_map(|a| a.pieces.iter().map(|p| p.bbox())) .collect(), }), + HorizonBase::Flatland => Ambient::Flatland, + // Void; and the not-yet-landed bases (sky/valley/summit), which + // validation refuses long before an ambient is consulted + // (`crate::horizon::base_implemented`). _ => Ambient::Void, } } @@ -4711,7 +4723,7 @@ pub fn verify_pov_cameras(world: &World, cameras: &[(String, [i32; 3])]) -> Resu /// * `horizon: ocean` — a reachable walkable cell borders **water the player /// cannot get out of**: the pinned superflat puts bedrock under every column, /// so nothing can fall out of an ocean world and the void premise is vacuous; -/// the real hazard the ocean horizon introduced (`plan::OCEAN_BASE_Y`) is +/// the real hazard the ocean horizon introduced (its sea-level datum) is /// *stranding* — a player who ends up in the sea with no shoreline to climb /// back onto is out of the delve just as permanently as one who fell out of a /// void world. See [`verify_boundary_safety`] for the exact model. @@ -4732,6 +4744,110 @@ const BOUNDARY_LIST_LIMIT: usize = 6; /// every direction, so every body that reaches it is one and the same sea. const OPEN_SEA_MARGIN: i32 = 2; +/// `DW0364` (spec-0026 §2): a placed piece's **empirical** walk cell sits at or +/// below the horizon's flood level — the delivered world drowns it on boot, and +/// every proof derived from the placement (walkability, lighting, checkpoints, +/// POV, PackTest) is derived from a model that says dry. Build-tier (exit 3). +/// +/// This closes the `DW0344` gap that made the #149 class invisible: `DW0344` +/// reads a piece's *declared* `waterline_y` and exempts pieces that declare +/// none, so an interior piece whose walk plane landed under sea level was +/// never looked at. This check reads the **assembled geometry** — standable +/// cells over real blocks — and exempts nothing. `DW0344` is retained for +/// waterline-declaring shore pieces (sea mating at sea level is a different +/// fact than walk-cell dryness). +pub const DW_WALK_FLOODED: &str = "DW0364"; + +/// Empirical walk-cell flood proof ([`DW_WALK_FLOODED`], spec-0026 §2). +/// +/// For a horizon with a declared `flood_level` (`ocean`: 62), every standable +/// cell inside every placed piece's AABB must have its feet **above** the +/// flood level. A standable cell at or below it is authored dry air the +/// ambient water owns: at best the player wades at the sea surface, at worst +/// (the #149 tide-mill class) an interior room floods on first boot. Cells the +/// piece authors as water are already impassable in the model (`flooded`) and +/// are not standable, so a shore piece's underwater seabed does not fire this +/// — only genuinely-standable-but-drowned cells do. +/// +/// **Declarations position; proofs read reality**: the check never reads +/// `walk_y`/`waterline_y` to decide, only to *explain* — the message names the +/// area, prefab, placed y and the datum-equation term that disagrees. +/// Aggregated like `DW0322` ([`BOUNDARY_LIST_LIMIT`]). +pub fn check_flood_level(plan: &Plan, world: &World) -> Result<(), NavError> { + let h = crate::horizon::of_campaign(plan.campaign); + let Some(flood) = crate::horizon::flood_level(&h) else { + return Ok(()); + }; + let walk_ref = crate::horizon::walk_ref_y(&h).unwrap_or(crate::plan::BASE_Y); + // (area, piece index, wet standable cells) in deterministic plan order. + let mut hits: Vec<(&str, &crate::plan::PiecePlacement, Vec<[i32; 3]>)> = Vec::new(); + let mut total = 0usize; + for area in &plan.areas { + for piece in &area.pieces { + let (lo, hi) = piece.bbox(); + let mut wet: Vec<[i32; 3]> = Vec::new(); + for x in lo[0]..=hi[0] { + for z in lo[2]..=hi[2] { + // Only cells at or below the flood level can violate; the + // piece box above it is irrelevant, so don't scan it. + for y in lo[1]..=hi[1].min(flood) { + let c = [x, y, z]; + if world.is_standable(c) { + wet.push(c); + } + } + } + } + if !wet.is_empty() { + total += wet.len(); + hits.push((area.area_id.as_str(), piece, wet)); + } + } + } + if hits.is_empty() { + return Ok(()); + } + let mut listing = String::new(); + let mut listed = 0usize; + 'outer: for (area_id, piece, wet) in &hits { + for c in wet { + if listed == BOUNDARY_LIST_LIMIT { + break 'outer; + } + listing.push_str(&format!( + "\n - {c:?} (area `{area_id}`, prefab `{}` placed at y={})", + piece.prefab_id, piece.pos[1] + )); + listed += 1; + } + } + if total > listed { + listing.push_str(&format!("\n - … and {} more", total - listed)); + } + let (area_id, piece, wet) = &hits[0]; + let feet = wet[0][1]; + let meta_note = format!( + "the datum equation (spec-0026 §2) places this piece's tileset walk plane at world \ + y={walk_ref}; these cells stand at y={feet}, {} block(s) under the flood line, so the \ + term that disagrees is the piece's own geometry vs its declared `walk_y` — either the \ + declaration is wrong for what the piece really authors, or the piece authors walkable \ + floor below its own convention", + flood - feet + 1, + ); + Err(NavError { + code: DW_WALK_FLOODED, + message: format!( + "flood-level proof (spec-0026): {total} standable cell(s) in placed pieces sit at or \ + below the ambient flood level (y={flood}) — the delivered world floods them on \ + first boot while every compile-time proof derives from a model that says dry \ + (the #149 class). First: area `{area_id}`, prefab `{}` placed at y={}:{listing}\n\ + {meta_note}. Fix the piece's `walk_y`/geometry so every standable cell lands above \ + y={flood}; never re-datum by hand to sidestep the proof", + piece.prefab_id, piece.pos[1], + ), + }) +} + /// Assert the reachable walk region's boundary is safe (spec-0017 boundary /// safety; [`DW_EDIT_BORDERS_VOID`]). `starts` are the reachability roots (the /// plan's resolved anchors — the same roots the relight pass floods from). Run @@ -4801,6 +4917,12 @@ pub fn verify_boundary_safety(world: &World, starts: &[[i32; 3]]) -> Result<(), match &world.ambient { Ambient::Void => boundary_void(world, &reachable), Ambient::Ocean(sea) => boundary_ocean(world, &reachable, sea), + // Flatland (spec-0026): solid ground under every column at the grass + // surface, no water, and the scene walk plane is flush with the + // ambient by the §2 datum equation — one step off the proven ground is + // a step onto walkable grass. Nothing to fall out of, nothing to be + // stranded in; the required boundary clock (`DW0320`) owns wanderers. + Ambient::Flatland => Ok(()), } } diff --git a/crates/compiler/src/plan.rs b/crates/compiler/src/plan.rs index f2b5cbf6..5d5f9dab 100644 --- a/crates/compiler/src/plan.rs +++ b/crates/compiler/src/plan.rs @@ -4,11 +4,17 @@ //! ## Coordinate scheme (deterministic) //! //! Each stage-1 area is placed at origin `[index * AREA_SPACING, base_y, 0]` -//! (M1 has one area → `[0, 64, 0]`). The origin Y is fixed per **horizon** -//! (spec-0013): `void` → [`BASE_Y`] (64), `ocean` → [`OCEAN_BASE_Y`] (60), which is -//! `sea_level - island waterline` so authored island water meets the world ocean. -//! A prefab's local anchor position resolves to `origin + local`. All coordinates -//! are integers; no randomness is used in v0. +//! (M1 has one area → `[0, 64, 0]`). The origin Y is a **per-area datum** +//! (spec-0026 §2, superseding spec-0013's single global ocean datum): +//! `void` keeps [`BASE_Y`] (64); every other horizon computes +//! `walk_ref_y − walk_y`, where `walk_ref_y` is the horizon's declared walk +//! reference ([`crate::horizon::walk_ref_y`]; ocean 63, flatland 64) and +//! `walk_y` is the area's tileset walk-plane convention declared in prefab +//! metadata (island 3 → base 60, byte-identical to the old global datum; a +//! keep interior 1 → base 62, dry with no author action). A placed piece +//! without `walk_y` in a non-void horizon is [`DW_WALK_Y_MISSING`] (`DW0367`). +//! A prefab's local anchor position resolves to `origin + local`. All +//! coordinates are integers; no randomness is used in v0. //! //! ## Naming scheme (scoreboard/function-safe) //! @@ -35,46 +41,98 @@ pub const AREA_SPACING: i32 = 256; /// The Y of every area origin under `horizon: void` (structures carry their own /// floor at local y=0). Also the fallback Y for an unresolvable position. pub const BASE_Y: i32 = 64; -/// Sea level of the `ocean` horizon superflat (spec-0013): the pinned -/// bedrock/stone/water layer stack (1 + 118 + 8 from the -64 build floor) tops the -/// water at y=62. Emission pins the same stack in `generator-settings`. -pub const SEA_LEVEL: i32 = 62; -/// Height of the `ocean` horizon superflat's water layer (spec-0013) — the `8` -/// in the pinned `generator-settings` stack emission writes -/// (`emit::emit_server`). Ambient water occupies `SEA_LEVEL - 7 ..= SEA_LEVEL`. -pub const OCEAN_WATER_LAYERS: i32 = 8; -/// Y of the topmost ambient **solid** block of the `ocean` horizon superflat: the -/// sea floor (stone) directly under the water layers, at 54. The ambient model -/// boundary safety reasons about (`nav::Sea`) starts here — below it the world is -/// stone all the way to bedrock, which is why an ocean world has no void column -/// anywhere. -pub const SEA_FLOOR_TOP_Y: i32 = SEA_LEVEL - OCEAN_WATER_LAYERS; -/// The island tileset's authored waterline (`prefabs/island-tileset.md`): every -/// island piece puts its top water block at **local y=2**, with the walkable land -/// plane one block above it at local y=3. -/// -/// Assumption (documented in `docs/reference/compiler.md`): the tileset convention -/// is a *library* constant, not a per-piece one — prefab metadata may *declare* its -/// waterline (`waterline_y`), which [`check_ocean_waterline`] then verifies against -/// sea level, but placement itself uses this single convention height so that every -/// area of an ocean world sits on one deterministic datum. -pub const ISLAND_WATERLINE_Y: i32 = 2; -/// The Y of every area origin under `horizon: ocean`: the piece base sits at -/// `SEA_LEVEL - ISLAND_WATERLINE_Y` (= 60) so the authored waterline (local y=2) -/// meets the world ocean (y=62) and the walk plane (local y=3) is the vanilla-normal -/// one block above the sea. Placing ocean areas at [`BASE_Y`] instead floats the -/// island ~4 blocks above the sea: a player who falls into open water cannot climb -/// ashore. -pub const OCEAN_BASE_Y: i32 = SEA_LEVEL - ISLAND_WATERLINE_Y; - -/// The area-origin Y for a campaign's horizon (spec-0013). `void` (default/absent) -/// keeps [`BASE_Y`], so every pre-0.6 / void campaign stays byte-identical; `ocean` -/// uses [`OCEAN_BASE_Y`] so the island waterline convention holds. -pub fn base_y(campaign: &Campaign) -> i32 { - match campaign.world.content.horizon { - Some(delvewright_dsl::Horizon::Ocean) => OCEAN_BASE_Y, - _ => BASE_Y, +// The ambient sea facts live with the rest of the horizon model +// (spec-0026); re-exported here because the sea is also a *placement* fact +// (`DW0344`/`DW0364`) and half the codebase reads them via `plan::`. +pub use crate::horizon::{OCEAN_WATER_LAYERS, SEA_FLOOR_TOP_Y, SEA_LEVEL}; + +/// `DW0367`: a non-void horizon must place every piece on the per-area datum +/// `walk_ref_y − walk_y` (spec-0026 §2), and this piece's prefab metadata +/// declares no `walk_y` — the compiler has no tileset walk-plane convention to +/// place the area with, and guessing one is how the #149 flooded-interior +/// class happened. Build error (exit 3). +pub const DW_WALK_Y_MISSING: &str = "DW0367"; + +/// The per-area origin Y (spec-0026 §2, superseding the spec-0013 global ocean +/// datum): `void` → [`BASE_Y`] unconditionally (byte-identical to every +/// pre-0.9 campaign); any other horizon → `walk_ref_y − walk_y`, where +/// `walk_y` is the **datum piece's** declared tileset walk plane — the bound +/// `prefab` of a single-prefab area, or the `entry`-role member(s) of a pool +/// area (the piece the solver seats at the origin; every other piece chains +/// from it by socket mating). Pools whose entry members disagree about the +/// convention have no single datum: also [`DW_WALK_Y_MISSING`]. +fn area_base_y( + campaign: &Campaign, + area: &delvewright_dsl::Area, + prefabs: &PrefabRegistry, +) -> Result { + let h = crate::horizon::of_campaign(campaign); + let Some(walk_ref) = crate::horizon::walk_ref_y(&h) else { + return Ok(BASE_Y); + }; + let area_id = area.id.as_str(); + let datum_walk_y = |prefab_id: &str| -> Result { + let Some(meta) = prefabs.get(prefab_id) else { + // Missing metadata is reported as DW0300 by the placement pass; + // fall back so THAT (more fundamental) error is the one that fires. + return Ok(walk_ref - BASE_Y); + }; + meta.walk_y.ok_or_else(|| { + PlanError::new( + DW_WALK_Y_MISSING, + format!( + "area `{area_id}` is placed in a `{}` horizon, whose per-area datum is \ + `walk_ref_y ({walk_ref}) − walk_y`, but prefab `{prefab_id}` declares no \ + `walk_y` in its metadata. Declare the tileset's walk-plane convention (the \ + local y a player's feet occupy on the piece's reference floor — island \ + tileset 3, keep 1) as `walk_y` in `{}.json`; never guess a datum for it \ + (spec-0026 §2 — the #149 flooded-interior class)", + h.base.token(), + meta.structure.id, + ), + ) + }) + }; + if let Some(prefab) = &area.prefab { + return Ok(walk_ref - datum_walk_y(prefab.as_str())?); + } + if let Some(pool) = &area.prefab_pool { + let pool_id = pool.as_str(); + let entries: Vec<&str> = prefabs + .pool(pool_id) + .unwrap_or(&[]) + .iter() + .filter(|m| m.role == "entry") + .map(|m| m.prefab.as_str()) + .collect(); + // No entry member is the solver's DW0301; let it report. + let mut walk_y: Option<(i32, &str)> = None; + for prefab_id in entries { + let w = datum_walk_y(prefab_id)?; + match walk_y { + None => walk_y = Some((w, prefab_id)), + Some((prev, prev_id)) if prev != w => { + return Err(PlanError::new( + DW_WALK_Y_MISSING, + format!( + "area `{area_id}` binds pool `{pool_id}`, whose entry-role members \ + disagree about the tileset walk-plane convention: `{prev_id}` \ + declares walk_y={prev} but `{prefab_id}` declares walk_y={w}. A \ + pool area sits on ONE per-area datum (`walk_ref_y − walk_y`, \ + spec-0026 §2) — align the entry pieces' authored walk planes, or \ + split the pool" + ), + )); + } + Some(_) => {} + } + } + if let Some((w, _)) = walk_y { + return Ok(walk_ref - w); + } } + // Neither binding: DW0160 upstream; keep the void fallback. + Ok(BASE_Y) } /// A resolved `set-checkpoint` effect (DSL v0.6, spec-0012), collected in @@ -1016,16 +1074,15 @@ pub const ENTRY_ANCHOR_NAMES: [&str; 2] = ["spawn", "entry"]; /// every one of them derives from the very placement that is wrong. /// /// Pieces that declare no `waterline_y` (interior keep/cave pieces, `hello-room`) -/// are not island pieces and are not checked. +/// author no sea and are not checked **here** — their walk cells are still +/// proved dry empirically by `DW0364` (`nav::check_flood_level`), which has no +/// exemption (spec-0026 §2, closing the #149 gap this clause used to be). fn check_ocean_waterline( campaign: &Campaign, areas: &[AreaPlacement], prefabs: &PrefabRegistry, ) -> Result<(), PlanError> { - if !matches!( - campaign.world.content.horizon, - Some(delvewright_dsl::Horizon::Ocean) - ) { + if crate::horizon::base_of(campaign) != delvewright_dsl::HorizonBase::Ocean { return Ok(()); } for area in areas { @@ -1047,22 +1104,70 @@ fn check_ocean_waterline( } else { ("below", "is drowned — the walk plane sits under the sea") }; + let walk_y = meta + .walk_y + .map(|v| v.to_string()) + .unwrap_or_else(|| "undeclared".to_string()); return Err(PlanError::new( DW_OCEAN_WATERLINE, format!( "area `{}` places prefab `{}` at y={} with a declared waterline of local \ y={w}, putting its waterline at world y={placed} — {} blocks {dir} the \ ocean sea level (y={SEA_LEVEL}). The piece {verb}. Prefab metadata and \ - placement disagree about the island datum: either declare the waterline \ - the piece really authors (`waterline_y` in `{}.json`, the local y of its \ - top water block — the island tileset convention is {ISLAND_WATERLINE_Y}), \ - or rebuild the piece against that convention. Ocean areas are placed at \ - y={OCEAN_BASE_Y} (= sea level - {ISLAND_WATERLINE_Y}); a piece with a \ - different waterline cannot share that datum", + placement disagree about the datum equation (spec-0026 §2: area base = \ + walk_ref_y {} − walk_y {walk_y} = {}; the disagreeing term is this \ + piece's `waterline_y`): a shore piece authored to its tileset convention \ + has `waterline_y = walk_y − 1`. Declare the waterline the piece really \ + authors (`waterline_y` in `{}.json`, the local y of its top water \ + block), or rebuild the piece against its tileset's declared walk plane", area.area_id, piece.prefab_id, piece.pos[1], delta.abs(), + crate::horizon::OCEAN_WALK_REF_Y, + piece.pos[1], + meta.structure.id, + ), + )); + } + } + } + Ok(()) +} + +/// Per-area datum honesty (`DW0367`, spec-0026 §2): in a non-void horizon, +/// every **placed** piece must declare its tileset walk plane (`walk_y`) in +/// prefab metadata. The area origin only consumes the datum piece's value +/// ([`area_base_y`] already failed if that one is missing), but requiring the +/// declaration on every placed piece keeps the tileset migration honest and +/// gives the empirical proofs (`DW0344`/`DW0364`) the datum term their +/// messages name. +fn check_walk_y_declared( + campaign: &Campaign, + areas: &[AreaPlacement], + prefabs: &PrefabRegistry, +) -> Result<(), PlanError> { + let h = crate::horizon::of_campaign(campaign); + if crate::horizon::walk_ref_y(&h).is_none() { + return Ok(()); // void: areas sit on BASE_Y, no datum to declare. + } + for area in areas { + for piece in &area.pieces { + let Some(meta) = prefabs.get(&piece.prefab_id) else { + continue; // missing metadata is already DW0300 upstream + }; + if meta.walk_y.is_none() { + return Err(PlanError::new( + DW_WALK_Y_MISSING, + format!( + "area `{}` places prefab `{}` in a `{}` horizon, but its metadata \ + declares no `walk_y` — the tileset walk-plane convention every piece \ + of a non-void horizon must state (spec-0026 §2: area base = \ + walk_ref_y − walk_y). Declare the local y a player's feet occupy on \ + the piece's reference floor as `walk_y` in `{}.json`", + area.area_id, + piece.prefab_id, + h.base.token(), meta.structure.id, ), )); @@ -1097,12 +1202,18 @@ impl<'a> Plan<'a> { // Socket doorways severed by `rewire-socket sealed`, per area — the // DW0306 connectivity graph must not count those edges. let mut severed: BTreeMap> = BTreeMap::new(); - // Origin Y is a per-horizon datum (spec-0013): void keeps 64, ocean drops to - // sea_level-2 so the island waterline convention holds. - let base_y = base_y(campaign); for (i, area) in campaign.world.content.areas.iter().enumerate() { let area_id = area.id.as_str().to_string(); - let origin = [i as i32 * AREA_SPACING, base_y, 0]; + // Origin Y is a per-area datum (spec-0026 §2): void keeps BASE_Y; + // any other horizon computes walk_ref_y − the datum piece's + // declared tileset walk plane, so every tileset lands its walk + // plane on the horizon's reference — not on an island-tileset + // constant (the #149 class). + let origin = [ + i as i32 * AREA_SPACING, + area_base_y(campaign, area, prefabs)?, + 0, + ]; let placement = if let Some(prefab) = &area.prefab { // Single-prefab area (the M1 degenerate assembly): one piece at @@ -1293,6 +1404,14 @@ impl<'a> Plan<'a> { )?; } + // ---- per-area datum honesty (DW0367, spec-0026 §2) ---- + // Every placed piece of a non-void horizon must declare its tileset + // walk plane. The datum itself only reads the entry piece's, but a + // chained piece without one has an unauditable relationship to the + // datum — and the empirical flood proof (DW0364) needs the declared + // term to name what disagreed. + check_walk_y_declared(campaign, &areas, prefabs)?; + // ---- ocean waterline invariant (DW0344) ---- check_ocean_waterline(campaign, &areas, prefabs)?; diff --git a/crates/compiler/src/registry.rs b/crates/compiler/src/registry.rs index 83c65f30..361b6cff 100644 --- a/crates/compiler/src/registry.rs +++ b/crates/compiler/src/registry.rs @@ -258,9 +258,21 @@ pub struct PrefabMeta { /// ocean-horizon placement invariant (`DW0344`, `plan::check_ocean_waterline`): /// in a `horizon: ocean` world the declared waterline must land at world sea /// level. Absent for pieces that author no sea (interiors, keep/cave tilesets), - /// which are then not checked. + /// which are then not checked by `DW0344` — their walk cells are still proved + /// dry empirically (`DW0364`, spec-0026 §2, no exemption). #[serde(default)] pub waterline_y: Option, + /// The tileset **walk-plane convention** this piece is authored against + /// (spec-0026 §2): the local y a player's feet occupy on the piece's + /// reference floor — the floor its lowest socket opens onto, or for a + /// socketless piece its authored ground plane (island tileset 3, keep 1, + /// cave 2). The per-area placement datum is `walk_ref_y − walk_y` (the + /// datum piece is the area's bound prefab / the pool's entry member), and + /// every piece placed in a non-void horizon must declare it (`DW0367`, + /// `plan::check_walk_y_declared`). Optional so void-only metadata still + /// loads; a shore piece's `waterline_y` is `walk_y − 1` by construction. + #[serde(default)] + pub walk_y: Option, /// keep-socket-v1 connectors (jigsaw sockets). Empty for single-piece prefabs /// (e.g. `hello-room`); the layout solver (`crate::solver`) mates these when /// assembling a `prefab_pool` area (M2 task #9). Optional so single-piece diff --git a/crates/compiler/src/render_plan.rs b/crates/compiler/src/render_plan.rs index c93d56c0..e343b21c 100644 --- a/crates/compiler/src/render_plan.rs +++ b/crates/compiler/src/render_plan.rs @@ -65,7 +65,7 @@ //! amplified noise), so `delve-render scene` uses the stamp to apply its //! documented night-vision review emulation to exactly those shots and no others. -use delvewright_dsl::{AreaMitigation, Campaign, Horizon, LightingProfile, Objective}; +use delvewright_dsl::{AreaMitigation, Campaign, HorizonBase, LightingProfile, Objective}; use serde_json::{Value, json}; use crate::nav::LegRoute; @@ -699,13 +699,35 @@ pub fn render_plan(plan: &Plan, prefabs: &PrefabRegistry, pov: &[PovShot]) -> Va /// /// A void horizon emits **no key at all** (not `null`), so every campaign that /// declares nothing keeps a byte-identical `render-plan.json`. +/// +/// `flatland` (spec-0026) deliberately emits no key **yet**. Its ambient does +/// change what a renderer must draw (a grass plane, not nothing), but +/// `render-plan.json`'s `horizon` is an externally-tagged enum on the consumer +/// side (`delvewright_render::scene::Horizon`, which knows only `ocean`), so a +/// `{"kind": "flatland", …}` this PR made up would be an unparseable plan on +/// the render side rather than a better picture. The wire shape belongs to +/// spec-0026 §6's render work (the per-horizon establishing vista shot), which +/// lands with the surround slices; inventing it here is exactly the downstream +/// folklore the no-hack doctrine forbids. Until then a flatland delve renders +/// as it did before the horizon library existed. +/// +/// The match is exhaustive on [`HorizonBase`] on purpose: a base added later +/// must decide this question explicitly instead of falling through to "no +/// ambient". fn horizon_fact(c: &Campaign) -> Option { - match c.world.content.horizon { - Some(Horizon::Ocean) => Some(json!({ + match crate::horizon::base_of(c) { + HorizonBase::Ocean => Some(json!({ "kind": "ocean", "sea_level": crate::plan::SEA_LEVEL, })), - Some(Horizon::Void) | None => None, + // No render-side wire shape defined (see above). `valley`/`summit`/ + // `sky` are additionally refused at validation in this slice, so those + // arms are unreachable from a build that gets this far. + HorizonBase::Void + | HorizonBase::Flatland + | HorizonBase::Valley + | HorizonBase::Summit + | HorizonBase::Sky => None, } } diff --git a/crates/compiler/tests/cli.rs b/crates/compiler/tests/cli.rs index f3f42325..a50b2a52 100644 --- a/crates/compiler/tests/cli.rs +++ b/crates/compiler/tests/cli.rs @@ -33,9 +33,12 @@ fn version_line() { s.contains(&format!("delvec {}", env!("CARGO_PKG_VERSION"))), "{s}" ); - // Task #179 raised the implemented DSL to 0.9.0: declared drops on an - // elite/boss (`drops[]`) and the `collect` `dropped_by` that sources a quest - // item off a body instead of out of a box. + // DSL 0.9.0 carries two independent surfaces, on two stages: task #179's + // declared drops on an elite/boss (`drops[]` + the `collect` `dropped_by` + // that sources a quest item off a body instead of out of a box), and + // spec-0026's stage-1 horizon library (the `horizon` object form + the new + // bases). Either one alone raises the implemented DSL to 0.9.0; the line + // below is what `--version` reports for both. assert!(s.contains("dsl 0.9.0"), "{s}"); assert!(s.contains("mc 1.21.11"), "{s}"); } @@ -1455,11 +1458,16 @@ fn v06_actor_datapack_emits_the_mechanics() { ); } -/// spec-0013 sea-level datum: an `ocean` world places its areas at -/// `sea_level - island waterline` (y=60) so the island tileset's authored -/// waterline (local y=2) meets the world ocean (y=62) and its walk plane (local -/// y=3) is the vanilla-normal one block above the sea. A `void` world is +/// spec-0026 per-area datum (superseding spec-0013's global y=60): an `ocean` +/// world places each area at `walk_ref_y (63) − walk_y`, the tileset's declared +/// walk-plane convention. `hello-room` declares `walk_y: 1` (interior floor at +/// local 0, feet at 1), so its base is y=62 and its walk plane lands at 63 — +/// one block above the sea, DRY. Under the old island-constant datum (y=60) the +/// same piece's walk plane sat at 61, one block UNDER sea level: the #149 +/// flooded-interior class, live in this very fixture. A `void` world is /// unchanged at y=64 — the byte-identity guarantee for every existing campaign. +/// (The island tileset's own `walk_y: 3` → base 60, byte-identical to the old +/// datum — asserted in `spec0026_horizon.rs`.) #[test] fn ocean_areas_sit_on_the_sea_level_datum_void_unchanged() { let pf = common::prefabs_dir(); @@ -1497,8 +1505,8 @@ fn ocean_areas_sit_on_the_sea_level_datum_void_unchanged() { let ocean = place_line(Some("ocean"), "datum-ocean"); assert!( - ocean.contains("place template hello-world:hello-room 0 60 0"), - "ocean areas must sit at sea_level-2 (y=60):\n{ocean}" + ocean.contains("place template hello-world:hello-room 0 62 0"), + "ocean areas must sit on the per-area datum (walk_ref 63 − walk_y 1 = 62):\n{ocean}" ); let void = place_line(None, "datum-void"); assert!( @@ -1552,9 +1560,10 @@ fn ocean_waterline_off_sea_level_exits_3_with_dw0344() { let stdout = String::from_utf8_lossy(&b.stdout); assert!(stdout.contains("DW0344"), "expected DW0344:\n{stdout}"); - // The same piece declaring the island convention (local y=2) lands its - // waterline exactly at sea level and builds clean. - meta["waterline_y"] = serde_json::json!(2); + // The same piece declaring the waterline consistent with its own datum + // (spec-0026: `waterline_y = walk_y − 1`; hello-room declares walk_y 1, so + // waterline 0 → base 62 + 0 = sea level) builds clean. + meta["waterline_y"] = serde_json::json!(0); std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap(); let out_ok = tmp("dw0344-out-ok"); let ok = delvec(&[ diff --git a/crates/compiler/tests/edit.rs b/crates/compiler/tests/edit.rs index c94132ee..490bc31f 100644 --- a/crates/compiler/tests/edit.rs +++ b/crates/compiler/tests/edit.rs @@ -313,12 +313,27 @@ fn edit_select_only_batch_on_an_ocean_horizon_is_green() { /// The ocean horizon's *replacement* invariant (`DW0322`): the same wall breach /// that is a void drop under `horizon: void` is a **stranding** hazard under -/// `horizon: ocean` — the room's floor sits below sea level, so a player who -/// walks out of the breach is in open water with no shoreline at the waterline -/// to climb back onto. The code is the same, the premise and the prescription -/// are the horizon's. +/// `horizon: ocean` — when the walk plane sits high enough over the sea that a +/// swimmer cannot climb back in through the breach. +/// +/// Fixture note (spec-0026): before the per-area datum, hello-room's walk +/// plane landed BELOW sea level (base 60, walk 61 — the #149 flooded-interior +/// class, now `DW0364`), which incidentally made any breach a stranding. On +/// the corrected datum (walk_y 1 → base 62, walk 63) a breach is a canonical +/// beach — escapable, correctly green. The stranding hazard this test guards +/// needs a genuinely lipped tileset: a private prefabs copy declaring +/// `walk_y: 0` places the room one block higher (walk 64, two over the sea), +/// and the breach lip is then a wall to a swimmer. #[test] fn edit_ocean_breach_strands_the_player_dw0322() { + let prefabs = tmp("edits-ocean-breach-prefabs"); + common::copy_dir_all(&common::prefabs_dir(), &prefabs); + let meta_path = prefabs.join("hello-room.json"); + let mut meta: serde_json::Value = + serde_json::from_slice(&std::fs::read(&meta_path).unwrap()).unwrap(); + meta["walk_y"] = serde_json::json!(0); + std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap(); + let dir = edits_copy("edits-ocean-breach"); set_ocean_horizon(&dir); set_batches( @@ -343,7 +358,7 @@ fn edit_ocean_breach_strands_the_player_dw0322() { "-o", out.to_str().unwrap(), "--prefabs", - &prefabs_arg(), + prefabs.to_str().unwrap(), ]); assert_eq!(r.status.code(), Some(3), "build-tier failure"); let stdout = format!( diff --git a/crates/compiler/tests/spec0026_horizon.rs b/crates/compiler/tests/spec0026_horizon.rs new file mode 100644 index 00000000..e4efaa0e --- /dev/null +++ b/crates/compiler/tests/spec0026_horizon.rs @@ -0,0 +1,303 @@ +//! spec-0026 foundation fixtures: the per-area `walk_y` placement datum +//! (`DW0367`), the empirical flood-level proof (`DW0364` — the #149 tide-mill +//! class as a permanent red fixture), the flatland ambient emission, and the +//! double-build byte-identity gates for the landed horizon kinds (acceptance +//! criteria 1/2/3). + +mod common; + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_delvec"); + +fn delvec(args: &[&str]) -> Output { + Command::new(BIN).args(args).output().expect("run delvec") +} + +fn code(out: &Output) -> i32 { + out.status.code().unwrap_or(-1) +} + +fn tmp(name: &str) -> std::path::PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn read_tree(root: &Path) -> BTreeMap> { + let mut map = BTreeMap::new(); + fn walk(base: &Path, dir: &Path, map: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(base, &path, map); + } else { + let rel = path + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + map.insert(rel, std::fs::read(&path).unwrap()); + } + } + } + walk(root, root, &mut map); + map +} + +/// Materialize hello-world with a patched stage-1 world document. `horizon` is +/// raw JSON (string or object form); `version` is the world stage's +/// `dsl_version`. +fn campaign_with_horizon(name: &str, version: &str, horizon: &str) -> std::path::PathBuf { + let camp = tmp(name); + common::copy_dir_all(&common::hello_world_dir(), &camp); + let mut world: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(camp.join("world.json")).unwrap()).unwrap(); + world["dsl_version"] = serde_json::json!(version); + let content = world["content"].as_object_mut().unwrap(); + content.insert("horizon".into(), serde_json::from_str(horizon).unwrap()); + content.insert("boundary".into(), serde_json::json!({ "margin": 20 })); + std::fs::write( + camp.join("world.json"), + serde_json::to_string_pretty(&world).unwrap(), + ) + .unwrap(); + camp +} + +fn build_into(camp: &Path, out: &Path, prefabs: &Path) -> Output { + delvec(&[ + "build", + camp.to_str().unwrap(), + "-o", + out.to_str().unwrap(), + "--prefabs", + prefabs.to_str().unwrap(), + "--json", + ]) +} + +/// Set (or remove) `walk_y` in one prefab metadata file of a private copy. +fn set_walk_y(prefabs: &Path, prefab: &str, walk_y: Option) { + let path = prefabs.join(format!("{prefab}.json")); + let mut meta: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + match walk_y { + Some(v) => { + meta["walk_y"] = serde_json::json!(v); + } + None => { + meta.as_object_mut().unwrap().remove("walk_y"); + } + } + std::fs::write(&path, serde_json::to_string_pretty(&meta).unwrap()).unwrap(); +} + +/// `DW0367` (spec-0026 §2): a piece placed in a non-void horizon whose prefab +/// metadata declares no `walk_y` is a build error — the compiler refuses to +/// guess a tileset datum (the #149 folklore that flooded the tide mill). +#[test] +fn missing_walk_y_in_ocean_exits_3_with_dw0367() { + let prefabs = tmp("dw0367-prefabs"); + common::copy_dir_all(&common::prefabs_dir(), &prefabs); + set_walk_y(&prefabs, "hello-room", None); + + let camp = campaign_with_horizon("dw0367-camp", "0.6.0", "\"ocean\""); + let out = tmp("dw0367-out"); + let b = build_into(&camp, &out, &prefabs); + assert_eq!(code(&b), 3, "missing walk_y should exit 3"); + let stdout = String::from_utf8_lossy(&b.stdout); + assert!(stdout.contains("DW0367"), "expected DW0367:\n{stdout}"); + + // The same library in a VOID world needs no datum: builds clean. + let void_camp = tmp("dw0367-void-camp"); + common::copy_dir_all(&common::hello_world_dir(), &void_camp); + let out_void = tmp("dw0367-void-out"); + let v = build_into(&void_camp, &out_void, &prefabs); + assert_eq!( + code(&v), + 0, + "void world needs no walk_y: {}", + String::from_utf8_lossy(&v.stdout) + ); +} + +/// The #149 tide-mill class as a permanent fixture (spec-0026 acceptance +/// criterion 2): an interior piece mis-datumed by tileset folklore — `walk_y` +/// declared as the island convention (3) while its real walk plane is local 1, +/// and **no `waterline_y`**, so `DW0344` never looks — lands its standable +/// cells at world y=61, one block under the sea. Before spec-0026 this built +/// GREEN and flooded on first boot; now the empirical flood proof rejects it +/// (`DW0364`, exit 3, no exemption). Correcting the datum declaration to what +/// the piece really authors (walk_y=1 → base 62) makes the SAME content green +/// and dry, and the emitted base y is asserted (criterion 2's red→green pair). +#[test] +fn flooded_interior_is_dw0364_and_corrected_datum_is_green_and_dry() { + let prefabs = tmp("dw0364-prefabs"); + common::copy_dir_all(&common::prefabs_dir(), &prefabs); + // hello-room's metadata declares no waterline_y (interior piece) — assert + // that stays true, because the DW0344 gap this fixture guards is exactly + // "no waterline, so nothing ever looked". + let meta: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(prefabs.join("hello-room.json")).unwrap()) + .unwrap(); + assert!( + meta.get("waterline_y").is_none(), + "fixture premise: the piece declares no waterline_y" + ); + set_walk_y(&prefabs, "hello-room", Some(3)); + + let camp = campaign_with_horizon("dw0364-camp", "0.6.0", "\"ocean\""); + let out = tmp("dw0364-out"); + let b = build_into(&camp, &out, &prefabs); + assert_eq!(code(&b), 3, "flooded interior should exit 3"); + let stdout = String::from_utf8_lossy(&b.stdout); + assert!(stdout.contains("DW0364"), "expected DW0364:\n{stdout}"); + + // Correct the datum declaration: the piece's real walk plane is local 1. + set_walk_y(&prefabs, "hello-room", Some(1)); + let out_ok = tmp("dw0364-out-ok"); + let ok = build_into(&camp, &out_ok, &prefabs); + assert_eq!( + code(&ok), + 0, + "corrected walk_y must build dry: {}", + String::from_utf8_lossy(&ok.stdout) + ); + // The emitted base y is the datum equation's: walk_ref 63 − walk_y 1 = 62, + // landing the walk plane at 63 — one block above the sea. + let place = std::fs::read_to_string( + out_ok.join("datapack/data/hello-world/function/place_all.mcfunction"), + ) + .unwrap(); + assert!( + place.contains("place template hello-world:hello-room 0 62 0"), + "corrected datum must place at y=62:\n{place}" + ); +} + +/// Acceptance criterion 3: the ocean ISLAND datum is unchanged — a tileset +/// declaring the island convention (`walk_y: 3`, the real island/tk-shore +/// value) keeps base y = 60, exactly the old global `OCEAN_BASE_Y`. The datum +/// rework must not move the island. Asserted against the real content +/// library's `island-beach-camp` (walk_y 3) via a plan-level build of a +/// minimal campaign binding it. +#[test] +fn island_walk_y_3_keeps_base_60() { + use delvewright_compiler::plan::Plan; + use delvewright_compiler::registry::PrefabRegistry; + + let world = r#"{ + "dsl_version": "0.6.0", + "campaign_id": "island-datum", + "stage": "world", + "content": { + "title": "Datum probe", + "theme": "An island that must not move.", + "premise": "Placement is a proof, not a habit.", + "seed": 20260804, + "target_minutes": 5, + "horizon": "ocean", + "boundary": { "margin": 20 }, + "areas": [ + { "id": "area/island", "name": "The Island", "prefab": "prefab/island-beach-camp" } + ] + } + }"#; + let stage = |stage: &str, content: &str| -> String { + format!( + r#"{{ "dsl_version": "0.2.0", "campaign_id": "island-datum", + "stage": "{stage}", "content": {content} }}"# + ) + }; + let raw = delvewright_dsl::RawCampaign { + world: world.to_string(), + npcs: stage("npcs", r#"{ "npcs": [] }"#), + classes: stage("classes", r#"{ "classes": [] }"#), + quest_plan: stage("quest-plan", r#"{ "quests": [], "finale": "quest/none" }"#), + quests: stage("quests", r#"{ "quests": [] }"#), + dialogue: stage("dialogue", r#"{ "dialogues": [] }"#), + world_edits: None, + }; + let campaign = delvewright_dsl::parse_campaign(&raw).expect("parse"); + let prefabs = PrefabRegistry::load_dir(&common::prefabs_dir()).expect("prefabs"); + let plan = Plan::build(&campaign, &prefabs).expect("plan"); + assert_eq!( + plan.areas[0].pieces[0].pos, + [0, 60, 0], + "island walk_y=3 must keep the historical base y=60 (walk_ref 63 − 3)" + ); +} + +/// The flatland foundation (spec-0026 §1/§3 ambient half): builds clean at +/// 0.9.0, ships the pinned bedrock/dirt/grass `generator-settings` with the +/// plains biome, places the scene on the datum (walk_ref 64 − walk_y 1 = 63, +/// so the walk plane sits exactly one block over the grass), and a double +/// build is byte-identical (acceptance criterion 1 for the flatland kind). +#[test] +fn flatland_builds_on_the_datum_byte_identical() { + let pf = common::prefabs_dir(); + let camp = campaign_with_horizon("flatland-camp", "0.9.0", "\"flatland\""); + let out_a = tmp("flatland-a"); + let out_b = tmp("flatland-b"); + for out in [&out_a, &out_b] { + let r = build_into(&camp, out, &pf); + assert_eq!( + code(&r), + 0, + "flatland build: {}", + String::from_utf8_lossy(&r.stdout) + ); + } + let a = read_tree(&out_a); + let b = read_tree(&out_b); + assert_eq!(a.keys().collect::>(), b.keys().collect::>()); + for (path, bytes) in &a { + assert_eq!(bytes, &b[path], "flatland byte mismatch in {path}"); + } + + let props = String::from_utf8(a["server/server.properties"].clone()).unwrap(); + assert!( + props.contains( + "generator-settings={\"biome\":\"minecraft:plains\",\"layers\":[{\"block\":\"minecraft:bedrock\",\"height\":1},{\"block\":\"minecraft:dirt\",\"height\":126},{\"block\":\"minecraft:grass_block\",\"height\":1}]}" + ), + "flatland must ship the pinned grass superflat:\n{props}" + ); + let place = + String::from_utf8(a["datapack/data/hello-world/function/place_all.mcfunction"].clone()) + .unwrap(); + assert!( + place.contains("place template hello-world:hello-room 0 63 0"), + "flatland datum: walk_ref 64 − walk_y 1 = 63:\n{place}" + ); +} + +/// The object form of an already-landed base emits byte-identically to its +/// string shorthand (`{base:"ocean"}` ≡ `"ocean"`), so the v0.9 surface adds +/// zero emission drift for existing horizons (criterion 1's fence half). +#[test] +fn ocean_object_form_emits_byte_identical_to_the_string() { + let pf = common::prefabs_dir(); + let camp_s = campaign_with_horizon("ocean-str", "0.9.0", "\"ocean\""); + let camp_o = campaign_with_horizon("ocean-obj", "0.9.0", "{ \"base\": \"ocean\" }"); + let out_s = tmp("ocean-str-out"); + let out_o = tmp("ocean-obj-out"); + for (camp, out) in [(&camp_s, &out_s), (&camp_o, &out_o)] { + let r = build_into(camp, out, &pf); + assert_eq!(code(&r), 0, "{}", String::from_utf8_lossy(&r.stdout)); + } + let s = read_tree(&out_s); + let o = read_tree(&out_o); + assert_eq!(s.keys().collect::>(), o.keys().collect::>()); + for (path, bytes) in &s { + // The stage documents themselves are build inputs recorded into the + // manifest; the world.json literally differs (that IS the two forms). + if path == "manifest.json" { + continue; + } + assert_eq!(bytes, &o[path], "object-form drift in {path}"); + } +} diff --git a/crates/compiler/tests/spec0030_flood.rs b/crates/compiler/tests/spec0030_flood.rs new file mode 100644 index 00000000..58dc99bf --- /dev/null +++ b/crates/compiler/tests/spec0030_flood.rs @@ -0,0 +1,331 @@ +//! spec-0030: the `flood` verb — declaring that a stretch of ground is +//! deliberately at the waterline, and the two proofs that keep the declaration +//! from being an exemption with a nicer name. +//! +//! The load-bearing fixture is [`flood_cannot_silence_the_149_class`]: the +//! spec-0026 tide-mill red with a `flood` declared straight over it stays red — +//! it just changes which red. `flood` is not a suppression switch; it is a claim +//! that the sea reaches those cells, and the sea cannot reach a sealed interior. + +mod common; + +use std::path::Path; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_delvec"); + +fn delvec(args: &[&str]) -> Output { + Command::new(BIN).args(args).output().expect("run delvec") +} + +fn code(out: &Output) -> i32 { + out.status.code().unwrap_or(-1) +} + +/// Everything `delvec` said: diagnostics are split across stdout (advisories) +/// and stderr (the fatal one), and these fixtures assert on both. +fn said(out: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +fn tmp(name: &str) -> std::path::PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A private prefab copy with `hello-room`'s `walk_y` set to `walk_y` — the +/// spec-0026 datum knob that decides which world y the room's floor lands at. +fn prefabs_with_walk_y(name: &str, walk_y: i64) -> std::path::PathBuf { + let dir = tmp(name); + common::copy_dir_all(&common::prefabs_dir(), &dir); + let path = dir.join("hello-room.json"); + let mut meta: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + meta["walk_y"] = serde_json::json!(walk_y); + std::fs::write(&path, serde_json::to_string_pretty(&meta).unwrap()).unwrap(); + dir +} + +/// hello-world with a patched `horizon` (raw JSON; `null` leaves the stage-1 +/// document untouched, i.e. the default `void` world) and an optional stage-7 +/// edit script. +fn campaign( + name: &str, + horizon: Option<&str>, + edits: Option, +) -> std::path::PathBuf { + let camp = tmp(name); + common::copy_dir_all(&common::hello_world_dir(), &camp); + if let Some(h) = horizon { + let mut world: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(camp.join("world.json")).unwrap()) + .unwrap(); + world["dsl_version"] = serde_json::json!("0.6.0"); + let content = world["content"].as_object_mut().unwrap(); + content.insert("horizon".into(), serde_json::from_str(h).unwrap()); + content.insert("boundary".into(), serde_json::json!({ "margin": 20 })); + std::fs::write( + camp.join("world.json"), + serde_json::to_string_pretty(&world).unwrap(), + ) + .unwrap(); + } + if let Some(batches) = edits { + let doc = serde_json::json!({ + "dsl_version": "0.9.0", + "campaign_id": "hello-world", + "stage": "world-edits", + "content": { "batches": batches }, + }); + std::fs::write( + camp.join("world-edits.json"), + serde_json::to_string_pretty(&doc).unwrap(), + ) + .unwrap(); + } + camp +} + +fn build(camp: &Path, out: &Path, prefabs: &Path) -> Output { + delvec(&[ + "build", + camp.to_str().unwrap(), + "-o", + out.to_str().unwrap(), + "--prefabs", + prefabs.to_str().unwrap(), + ]) +} + +/// A `select` of a piece-local box of hello-world's single `hello-room` piece. +fn select(name: &str, min: [i32; 3], max: [i32; 3]) -> serde_json::Value { + serde_json::json!({ + "verb": "select", + "name": name, + "shape": { + "kind": "box", + "frame": { "kind": "piece-local", "piece": 0, "prefab": "prefab/hello-room" }, + "min": min, + "max": max, + } + }) +} + +/// The shore-notch fixture's edits: build a seabed shelf under the room's west +/// wall line, then cut the wall down to the waterline over `notch_z`, so the +/// room gains standable cells whose feet sit exactly at the ocean line (y=62) +/// and which the open sea can flow into. `envelope_z` is the z-range the +/// `flood` envelope covers — narrower than `notch_z` is a shoreline that does +/// not stop where it was declared to. +fn shore_notch(notch_z: (i32, i32), envelope_z: Option<(i32, i32)>) -> serde_json::Value { + let mut edits = vec![ + select("region/seabed", [0, -1, notch_z.0], [0, -1, notch_z.1]), + serde_json::json!({ + "verb": "fill", + "region": "region/seabed", + "recipe": { "blocks": [{ "block": "minecraft:stone", "weight": 1.0 }], "scale": 0.3 } + }), + select("region/notch", [0, 0, notch_z.0], [0, 1, notch_z.1]), + serde_json::json!({ "verb": "carve", "region": "region/notch" }), + ]; + if let Some((lo, hi)) = envelope_z { + edits.push(select("region/shore", [0, 0, lo], [0, 0, hi])); + edits.push(serde_json::json!({ "verb": "flood", "region": "region/shore" })); + } + serde_json::json!([{ "id": "batch/shore", "area": "area/keep", "edits": edits }]) +} + +fn water_lines(out: &Path) -> Vec { + let f = out.join("datapack/data/hello-world/function/world_edits.mcfunction"); + std::fs::read_to_string(f) + .unwrap() + .lines() + .filter(|l| l.contains("minecraft:water")) + .map(str::to_string) + .collect() +} + +/// **The test this whole feature exists to pass.** The spec-0026 #149 fixture — +/// an interior room mis-datumed onto world y=61, a block under the sea — with a +/// `flood` declared straight over the whole piece. It does NOT go green: the +/// room is sealed, the sea reaches none of it, and the declaration is `DW0394` +/// (it binds nothing). Drop the declaration and the original `DW0364` is back. +/// +/// A declaration that merely suppressed `DW0364` would have turned this build +/// green, which is exactly the hole spec-0026 closed. +#[test] +fn flood_cannot_silence_the_149_class() { + let prefabs = prefabs_with_walk_y("flood-149-prefabs", 3); + + // Without any declaration: the tide-mill red. + let bare = campaign("flood-149-bare", Some("\"ocean\""), None); + let b = build(&bare, &tmp("flood-149-bare-out"), &prefabs); + let stdout = said(&b); + assert_eq!(code(&b), 3, "the #149 fixture must be red:\n{stdout}"); + assert!(stdout.contains("DW0364"), "expected DW0364:\n{stdout}"); + + // With `flood` declared over the whole drowned piece: still red, and red + // about the DECLARATION, not silenced. + let edits = serde_json::json!([{ + "id": "batch/wishful", + "area": "area/keep", + "edits": [ + select("region/everything", [0, 0, 0], [10, 5, 10]), + { "verb": "flood", "region": "region/everything" }, + ] + }]); + let camp = campaign("flood-149-declared", Some("\"ocean\""), Some(edits)); + let d = build(&camp, &tmp("flood-149-declared-out"), &prefabs); + let stdout = said(&d); + assert_eq!( + code(&d), + 3, + "a declared flood must not make #149 green:\n{stdout}" + ); + assert!(stdout.contains("DW0394"), "expected DW0394:\n{stdout}"); +} + +/// Red → green over the same content: a shoreline notch cut to the ocean line is +/// `DW0364` (standable ground under the waterline); declaring the sea into it +/// builds clean AND emits the water, so the model and the delivered world agree +/// about that cell instead of leaving it to first-boot fluid ticks. +#[test] +fn shore_notch_is_dw0364_until_the_sea_is_declared_into_it() { + let prefabs = prefabs_with_walk_y("flood-shore-prefabs", 1); + + let red = campaign( + "flood-shore-red", + Some("\"ocean\""), + Some(shore_notch((5, 5), None)), + ); + let r = build(&red, &tmp("flood-shore-red-out"), &prefabs); + let stdout = said(&r); + assert_eq!( + code(&r), + 3, + "an undeclared shore notch must be red:\n{stdout}" + ); + assert!(stdout.contains("DW0364"), "expected DW0364:\n{stdout}"); + + let green = campaign( + "flood-shore-green", + Some("\"ocean\""), + Some(shore_notch((5, 5), Some((5, 5)))), + ); + let out = tmp("flood-shore-green-out"); + let g = build(&green, &out, &prefabs); + assert_eq!( + code(&g), + 0, + "a declared shoreline must build:\n{}", + said(&g) + ); + // The declaration is materialized, not merely believed: the notch cell ships + // as water, so nothing downstream depends on vanilla flow arriving. + assert_eq!( + water_lines(&out).len(), + 1, + "exactly the reached cell is emitted as water: {:?}", + water_lines(&out) + ); +} + +/// ADR-0006 over the new verb: the flooded fixture builds byte-identically +/// twice. The reach is a `BTreeSet` walk over a fixed neighbour order, so it +/// carries no iteration-order dependence into the emitted `setblock` list. +#[test] +fn flood_build_is_byte_identical_twice() { + let prefabs = prefabs_with_walk_y("flood-det-prefabs", 1); + let camp = campaign( + "flood-det", + Some("\"ocean\""), + Some(shore_notch((5, 5), Some((5, 5)))), + ); + let (a, b) = (tmp("flood-det-a"), tmp("flood-det-b")); + for out in [&a, &b] { + let r = build(&camp, out, &prefabs); + assert_eq!(code(&r), 0, "{}", said(&r)); + } + let fa = a.join("datapack/data/hello-world/function/world_edits.mcfunction"); + let fb = b.join("datapack/data/hello-world/function/world_edits.mcfunction"); + assert_eq!( + std::fs::read(&fa).unwrap(), + std::fs::read(&fb).unwrap(), + "the flood materialization must be byte-stable" + ); +} + +/// `DW0395`: the notch is two cells wide and the envelope covers one of them. +/// The water the declaration admits does not stop inside it — it flows on into +/// an undeclared air cell of the piece, which the model would then ship as dry. +#[test] +fn flood_that_overflows_its_envelope_is_dw0395() { + let prefabs = prefabs_with_walk_y("flood-escape-prefabs", 1); + let camp = campaign( + "flood-escape", + Some("\"ocean\""), + Some(shore_notch((5, 6), Some((5, 5)))), + ); + let r = build(&camp, &tmp("flood-escape-out"), &prefabs); + let stdout = said(&r); + assert_eq!(code(&r), 3, "an overflowing flood must be red:\n{stdout}"); + assert!(stdout.contains("DW0395"), "expected DW0395:\n{stdout}"); +} + +/// `DW0394`: a `flood` in a world whose horizon has no ambient water at all. +/// There is no sea to admit, so the verb is a no-op — and a no-op declaration is +/// a finding, never a silent pass. +#[test] +fn flood_in_a_waterless_horizon_is_dw0394() { + let edits = serde_json::json!([{ + "id": "batch/dry", + "area": "area/keep", + "edits": [ + select("region/void-shore", [0, 0, 0], [10, 1, 10]), + { "verb": "flood", "region": "region/void-shore" }, + ] + }]); + let camp = campaign("flood-void", None, Some(edits)); + let r = build(&camp, &tmp("flood-void-out"), &common::prefabs_dir()); + let stdout = said(&r); + assert_eq!(code(&r), 3, "flood in a void world must be red:\n{stdout}"); + assert!(stdout.contains("DW0394"), "expected DW0394:\n{stdout}"); + assert!( + stdout.contains("no ambient water at all"), + "the message must name the real cause:\n{stdout}" + ); +} + +/// The v0.9 version fence (`DW0141`): declaring `flood` in a world-edits stage +/// below 0.9.0 is a reserved-feature rejection, so a pre-0.9 script keeps +/// emitting byte-for-byte what it emitted before this verb existed. +#[test] +fn flood_below_v09_is_reserved() { + let camp = campaign( + "flood-fence", + Some("\"ocean\""), + Some(shore_notch((5, 5), Some((5, 5)))), + ); + // Drop the stage back to 0.6.0, where every other verb in this script lives. + let path = camp.join("world-edits.json"); + let mut doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + doc["dsl_version"] = serde_json::json!("0.6.0"); + std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap(); + + let prefabs = prefabs_with_walk_y("flood-fence-prefabs", 1); + let r = build(&camp, &tmp("flood-fence-out"), &prefabs); + let stdout = said(&r); + assert_eq!( + code(&r), + 1, + "a reserved verb is a validation rejection:\n{stdout}" + ); + assert!(stdout.contains("DW0141"), "expected DW0141:\n{stdout}"); +} diff --git a/crates/dsl/src/diagnostic.rs b/crates/dsl/src/diagnostic.rs index 8619c8f4..481cee74 100644 --- a/crates/dsl/src/diagnostic.rs +++ b/crates/dsl/src/diagnostic.rs @@ -275,6 +275,13 @@ pub mod codes { /// (v0.6) `boundary.margin` outside the `0..=64` range (spec-0013): /// validation-tier (exit 1). pub const BOUNDARY_MARGIN: &str = "DW0321"; + /// (v0.9, spec-0026) A horizon param is out of range for its declared base + /// (`valley.ratio` outside `2..=3`, `summit.min_drop` < 100, a world-y + /// param outside the −64..320 build range), or a param is declared on a + /// base it does not belong to (`blend_width` on `ocean`). Validation-tier + /// (exit 1). Grouped in the DW036x horizon family beside the build-tier + /// placement codes (`DW0364`/`DW0367`). + pub const HORIZON_PARAM: &str = "DW0366"; /// (v0.6) A `sequence` effect is nested inside another `sequence` (directly, or /// reachable via a nested `move-actor` `on_arrive`) — timelines do not recurse /// (spec-0014). Flatten the inner steps into the outer timeline. diff --git a/crates/dsl/src/envelope.rs b/crates/dsl/src/envelope.rs index ef2052d0..ab808e91 100644 --- a/crates/dsl/src/envelope.rs +++ b/crates/dsl/src/envelope.rs @@ -28,7 +28,12 @@ pub const SUPPORTED_DSL_VERSION: &str = "0.9.0"; /// (spec-0025) adds declared stage-4 `branch_points`, the per-node `happening` /// declaration and the named `campaign-complete` `ending`, and (spec-0016 §1 /// owner rulings) the bonfire rest interaction — the `bonfire` effect's -/// authorable option strings and the class-kit `flask`. +/// authorable option strings and the class-kit `flask`; v0.9 carries two +/// independent surfaces on two different stages — (task #179, stage 5) +/// declared **drops** on an elite/boss, and (spec-0026, stage 1) the +/// horizon-library surface: the `horizon` object form `{base, …params}` and +/// the new base/shorthand names (`sky`, `flatland`, `valley`, `summit`, +/// `cherry-valley`). /// Older campaigns remain valid and compile byte-identically. A construct /// introduced in a later version is rejected with `DW0141` in an earlier one. pub const SUPPORTED_DSL_VERSIONS: &[&str] = &[ @@ -122,14 +127,27 @@ pub fn is_v08(version: &str) -> bool { ordinal(version) >= 8 } -/// True if `version` enables the DSL v0.9 surface (task #179, owner ruling -/// 2026-08-04): declared **drops** on an elite/boss — the `drops[]` list on a -/// wave mob and on an actor, and the `collect` `dropped_by` that turns a boss's -/// quest token into a proved link in the quest graph. Additive over v0.8: a -/// campaign that declares none of it compiles byte-identically (every -/// undeclared slot keeps drop chance `0.0`, which is exactly what pre-0.9 -/// emission wrote), and any use of the surface in an earlier campaign is -/// rejected with `DW0141`. +/// True if `version` enables the DSL v0.9 surface. v0.9 carries **two +/// independent surfaces**, landed by two different slices and fenced on two +/// different stages — this one predicate answers for both, because a +/// `dsl_version` is one number per stage document, not one per feature: +/// +/// - **Stage 5, declared drops** (task #179, owner ruling 2026-08-04): the +/// `drops[]` list on a wave mob and on an actor, and the `collect` +/// `dropped_by` that turns a boss's quest token into a proved link in the +/// quest graph. Additive over v0.8: every undeclared slot keeps drop chance +/// `0.0`, which is exactly what pre-0.9 emission wrote. +/// - **Stage 1, the horizon library** (spec-0026): the `horizon` **object +/// form** `{base, …params}` and the new base/shorthand names (`sky`, +/// `flatland`, `valley`, `summit`, `cherry-valley`). The two v0.6 string +/// shorthands (`"void"`, `"ocean"`) stay valid at 0.6.0+ and byte-identical. +/// +/// Because the fences are per stage, raising one stage document to 0.9.0 opens +/// only that stage's half: a campaign whose `quests` stage is 0.9.0 and whose +/// `world` stage is 0.8.0 gets drops and is still `DW0141` on the horizon +/// object form, and vice versa. Additive over v0.8 either way — a campaign +/// declaring none of the new surface compiles byte-identically, and any use of +/// it in an earlier campaign is rejected with `DW0141`. pub fn is_v09(version: &str) -> bool { ordinal(version) >= 9 } diff --git a/crates/dsl/src/lib.rs b/crates/dsl/src/lib.rs index cf0489e6..a296f3c9 100644 --- a/crates/dsl/src/lib.rs +++ b/crates/dsl/src/lib.rs @@ -53,7 +53,8 @@ pub use stages::{ CastDialogueKeyword, CastEntry, CastPlace, CastPlacement, Class, ClassesContent, DamageKind, DespawnStyle, DialogueContent, DialogueEffect, DialogueNode, DialogueOption, EffectSite, EnchantedItem, EncounterTier, EnvTrigger, EquipItem, EquipSlot, Facing, Fixture, Happening, - HappeningVerb, Horizon, ItemDrop, KitItem, Lethality, Loot, LootItem, MAX_POTION_AMPLIFIER, + HappeningVerb, Horizon, HorizonBase, HorizonFall, HorizonFlora, HorizonName, HorizonPalette, + HorizonSpec, ItemDrop, KitItem, Lethality, Loot, LootItem, MAX_POTION_AMPLIFIER, MAX_POTION_DURATION_TICKS, MobAttributes, MobDrop, MobEffect, MobEquipment, NarrateStyle, Npc, NpcDialogue, NpcSkin, NpcsContent, Objective, Persona, Pieces, PlannedQuest, PotionContents, PotionEffect, Prop, Quest, QuestEffect, QuestPlanContent, QuestsContent, Relationship, Role, @@ -65,4 +66,5 @@ pub use stages::{ EditBatch, EditFrame, FragmentRotation, MorphOp, PaletteBlock, PaletteRecipe, RegionShape, SocketState, TreeKind, WorldEdit, WorldEditsContent, }; +pub use stages::{ResolvedHorizon, horizon_base, horizon_defaults, resolved_horizon}; pub use validate::{validate_campaign, validate_campaign_with}; diff --git a/crates/dsl/src/stages.rs b/crates/dsl/src/stages.rs index 023b020d..fadc61b1 100644 --- a/crates/dsl/src/stages.rs +++ b/crates/dsl/src/stages.rs @@ -77,11 +77,16 @@ pub struct WorldContent { /// implicit `easy` must be redone. #[serde(default, skip_serializing_if = "Option::is_none")] pub difficulty: Option, - /// Scenic horizon (DSL v0.6, spec-0013). Absent or `void` = the void world - /// (byte-identical to v0.5). `ocean` swaps the world generator for a - /// deterministic superflat sea (bedrock/stone/water, sea level y=62) and drops - /// the area datum to y=60 (`sea_level-2`) so island pieces meet the sea at their - /// authored waterline. No structures or mobs either way. + /// Scenic horizon (DSL v0.6 `"void"`/`"ocean"` strings, spec-0013; + /// generalized at v0.9 into the horizon library, spec-0026). Absent or + /// `void` = the void world (byte-identical to v0.5). `ocean` swaps the + /// world generator for a deterministic superflat sea (bedrock/stone/water, + /// sea level y=62). v0.9 accepts the object form `{base, …params}` and the + /// `sky`/`flatland`/`valley`/`summit`/`cherry-valley` bases. Placement rides + /// the per-area datum: area base y = the horizon's walk reference y minus + /// the tileset's declared `walk_y` (spec-0026 §2; `DW0367` when a placed + /// piece declares none in a non-void horizon). No structures or natural + /// mobs under any base. #[serde(default, skip_serializing_if = "Option::is_none")] pub horizon: Option, /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler @@ -277,20 +282,302 @@ impl WorldDifficulty { } } -/// A scenic horizon (DSL v0.6, spec-0013). `void` is the default and is -/// byte-identical to v0.5 (empty-layer superflat, `minecraft:the_void` biome); -/// `ocean` selects a pinned bedrock/stone/water superflat with sea level y=62, -/// pure backdrop with no structures or mobs. The compiler owns the exact -/// generator-settings; this enum only picks which one. +/// A scenic horizon (DSL v0.6 `void`/`ocean` strings, spec-0013; generalized at +/// v0.9 into the horizon library, spec-0026). A horizon is a **composition of +/// orthogonal axes**, not an enum of monoliths: a **base** (what surrounds the +/// scene) plus base params. `horizon` accepts a plain string shorthand +/// ([`HorizonName`], byte-identical to the v0.6 surface for `"void"`/`"ocean"`) +/// or the object form [`HorizonSpec`] `{base, …params}`. Consumers never match +/// this wire enum directly — [`Horizon::resolved`] desugars both forms into one +/// [`ResolvedHorizon`] with the pinned defaults applied. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum Horizon { + /// String shorthand (`"ocean"` ≡ `{base:"ocean"}`; `"cherry-valley"` ≡ + /// `{base:"valley", flora:"cherry", palette:"stone-petal"}`). + Name(HorizonName), + /// The object form `{base, …params}` (DSL v0.9, spec-0026). + Spec(HorizonSpec), +} + +/// A horizon string shorthand. `void`/`ocean` are the v0.6 surface (spec-0013) +/// and stay valid at 0.6.0+; the rest are v0.9 (spec-0026, `DW0141` earlier). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] -pub enum Horizon { +pub enum HorizonName { /// The void world (default) — no sky-filling geometry. #[default] Void, - /// A superflat sea backdrop; areas are placed on the sea-level datum (y=60) so - /// island pieces read as land ringed by the ocean. + /// A superflat sea backdrop; islands ringed by an infinite ocean. + Ocean, + /// A floating-island archipelago over a declared backdrop (v0.9). + Sky, + /// An infinite bare grass plain flush with the scene walk plane (v0.9). + Flatland, + /// A mountain-ringed valley floor (v0.9). + Valley, + /// `{base:"valley", flora:"cherry", palette:"stone-petal"}` (v0.9) — a + /// parameter row, not a base: the compiler holds no cherry code path. + CherryValley, + /// A high plateau over gorges — every surround crest below the scene (v0.9). + Summit, +} + +/// The six horizon bases (spec-0026 §1). What surrounds the scene; each base +/// carries its own param schema on [`HorizonSpec`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum HorizonBase { + /// Void superflat; no surround. The default (byte-identical to v0.5). + #[default] + Void, + /// Pinned water superflat, sea level 62; no surround. Ocean, + /// Scene rooms become a floating-island archipelago; void ambient below. + Sky, + /// Grass superflat whose surface tops exactly one block under the scene + /// walk plane (zero height difference by the §2 datum equation). + Flatland, + /// Mountain annulus around a flat gap floor; void ambient below the skirt. + Valley, + /// Flat-topped plateau under the scene, surround range + gorges all below + /// the scene walk plane. + Summit, +} + +impl HorizonBase { + /// The kebab wire name. + pub fn token(self) -> &'static str { + match self { + HorizonBase::Void => "void", + HorizonBase::Ocean => "ocean", + HorizonBase::Sky => "sky", + HorizonBase::Flatland => "flatland", + HorizonBase::Valley => "valley", + HorizonBase::Summit => "summit", + } + } +} + +/// The consequence of falling below a `sky` scene's region floor (spec-0026 +/// §4). Either way the spec-0013 boundary clock owns the faller — the backdrop +/// is unreachable by invariant, never by fall damage. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum HorizonFall { + /// The catch applies lethal damage; vanilla death fires and the respawn + /// re-seat lands on the armed checkpoint (full souls death costs). Default. + #[default] + Lethal, + /// Plain teleport back to the last checkpoint (the flatland behavior). + Return, +} + +/// The tree/biome layer a `valley` surround plants (spec-0026 §1): selects +/// biome paint and tree-template species together. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum HorizonFlora { + /// Oak trees over `minecraft:forest`-family tint. Default. + #[default] + Oak, + /// Cherry templates over `minecraft:cherry_grove` tint. + Cherry, +} + +/// The surface palette of a `valley` surround (spec-0026 §1). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum HorizonPalette { + /// Grey stone crests over grass slopes. Default. + #[default] + StoneGrass, + /// The cherry-valley dressing: stone crests over petal understory. + StonePetal, +} + +/// The `horizon` object form (DSL v0.9, spec-0026 §1): a `base` plus that +/// base's params, all optional with pinned defaults. Params foreign to the +/// declared base are rejected (`DW0366`), so the flat shape stays one schema +/// without letting an ocean declare a `blend_width`. +/// +/// The `sky` `backdrop`/`placement` axis (spec-0026 §4) is deliberately **not +/// on this struct yet** — it lands with the sky slice, and until then the +/// schema rejects the field outright rather than freezing a wire shape early. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct HorizonSpec { + /// The base (what surrounds the scene). + pub base: HorizonBase, + /// `sky`: world y of the archipelago walk plane (default 160). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub float_y: Option, + /// `sky`: consequence of falling below the region floor (default `lethal`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fall: Option, + /// `flatland`: width of the seam material-dither band (default 6). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blend_width: Option, + /// `valley`: total surround footprint as a multiple of the scene's + /// (`2..=3`, default 2.5; else `DW0366`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ratio: Option, + /// `valley`: rim crest height over the gap floor (default 48). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rim_height: Option, + /// `valley`: tree/biome layer (default `oak`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flora: Option, + /// `valley`: surround surface palette (default `stone-grass`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub palette: Option, + /// `summit`: world y of the plateau top (default 208). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plateau_y: Option, + /// `summit`: radius of the generated vista ring (default 176). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vista_radius: Option, + /// `summit`: minimum gorge drop along the vista ring (≥ 100, default 120; + /// else `DW0366`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_drop: Option, +} + +/// Pinned horizon param defaults (spec-0026 §1). One table so the doc comments, +/// the resolver and the diagnostics cannot drift. +pub mod horizon_defaults { + /// `sky.float_y`. + pub const FLOAT_Y: i32 = 160; + /// `flatland.blend_width`. + pub const BLEND_WIDTH: u16 = 6; + /// `valley.ratio`. + pub const RATIO: f64 = 2.5; + /// `valley.rim_height`. + pub const RIM_HEIGHT: i32 = 48; + /// `summit.plateau_y`. + pub const PLATEAU_Y: i32 = 208; + /// `summit.vista_radius`. + pub const VISTA_RADIUS: i32 = 176; + /// `summit.min_drop`. + pub const MIN_DROP: i32 = 120; +} + +/// A horizon with both wire forms desugared and every default applied — the +/// only view downstream code (validation, the compiler) reads. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedHorizon { + /// The base. + pub base: HorizonBase, + /// `sky.float_y`. + pub float_y: i32, + /// `sky.fall`. + pub fall: HorizonFall, + /// `flatland.blend_width`. + pub blend_width: u16, + /// `valley.ratio`. + pub ratio: f64, + /// `valley.rim_height`. + pub rim_height: i32, + /// `valley.flora`. + pub flora: HorizonFlora, + /// `valley.palette`. + pub palette: HorizonPalette, + /// `summit.plateau_y`. + pub plateau_y: i32, + /// `summit.vista_radius`. + pub vista_radius: i32, + /// `summit.min_drop`. + pub min_drop: i32, +} + +impl Default for ResolvedHorizon { + fn default() -> Self { + ResolvedHorizon { + base: HorizonBase::Void, + float_y: horizon_defaults::FLOAT_Y, + fall: HorizonFall::Lethal, + blend_width: horizon_defaults::BLEND_WIDTH, + ratio: horizon_defaults::RATIO, + rim_height: horizon_defaults::RIM_HEIGHT, + flora: HorizonFlora::Oak, + palette: HorizonPalette::StoneGrass, + plateau_y: horizon_defaults::PLATEAU_Y, + vista_radius: horizon_defaults::VISTA_RADIUS, + min_drop: horizon_defaults::MIN_DROP, + } + } +} + +impl ResolvedHorizon { + /// The resolved horizon of `base` with every param at its pinned default. + pub fn of_base(base: HorizonBase) -> Self { + ResolvedHorizon { + base, + ..Default::default() + } + } +} + +impl Horizon { + /// Desugar either wire form to the one resolved view, defaults applied. + /// `"cherry-valley"` resolves exactly as `{base:"valley", flora:"cherry", + /// palette:"stone-petal"}` — same struct, same values (spec-0026 + /// acceptance criterion 6's DSL half). + pub fn resolved(&self) -> ResolvedHorizon { + match self { + Horizon::Name(HorizonName::Void) => ResolvedHorizon::of_base(HorizonBase::Void), + Horizon::Name(HorizonName::Ocean) => ResolvedHorizon::of_base(HorizonBase::Ocean), + Horizon::Name(HorizonName::Sky) => ResolvedHorizon::of_base(HorizonBase::Sky), + Horizon::Name(HorizonName::Flatland) => ResolvedHorizon::of_base(HorizonBase::Flatland), + Horizon::Name(HorizonName::Valley) => ResolvedHorizon::of_base(HorizonBase::Valley), + Horizon::Name(HorizonName::CherryValley) => ResolvedHorizon { + flora: HorizonFlora::Cherry, + palette: HorizonPalette::StonePetal, + ..ResolvedHorizon::of_base(HorizonBase::Valley) + }, + Horizon::Name(HorizonName::Summit) => ResolvedHorizon::of_base(HorizonBase::Summit), + Horizon::Spec(s) => ResolvedHorizon { + base: s.base, + float_y: s.float_y.unwrap_or(horizon_defaults::FLOAT_Y), + fall: s.fall.unwrap_or_default(), + blend_width: s.blend_width.unwrap_or(horizon_defaults::BLEND_WIDTH), + ratio: s.ratio.unwrap_or(horizon_defaults::RATIO), + rim_height: s.rim_height.unwrap_or(horizon_defaults::RIM_HEIGHT), + flora: s.flora.unwrap_or_default(), + palette: s.palette.unwrap_or_default(), + plateau_y: s.plateau_y.unwrap_or(horizon_defaults::PLATEAU_Y), + vista_radius: s.vista_radius.unwrap_or(horizon_defaults::VISTA_RADIUS), + min_drop: s.min_drop.unwrap_or(horizon_defaults::MIN_DROP), + }, + } + } + + /// The resolved base. + pub fn base(&self) -> HorizonBase { + self.resolved().base + } + + /// True when this declaration needs the v0.9 surface: the object form, or + /// any shorthand beyond the v0.6 `"void"`/`"ocean"` pair. + pub fn needs_v09(&self) -> bool { + !matches!( + self, + Horizon::Name(HorizonName::Void) | Horizon::Name(HorizonName::Ocean) + ) + } +} + +/// The resolved base of an optional stage-1 `horizon` field — `Void` when +/// absent (the pre-0.6 world). The one helper every downstream consumer +/// (compiler placement, ambient model, emission) goes through. +pub fn horizon_base(horizon: &Option) -> HorizonBase { + horizon.as_ref().map(|h| h.base()).unwrap_or_default() +} + +/// The resolved view of an optional stage-1 `horizon` field (defaults for +/// absent). +pub fn resolved_horizon(horizon: &Option) -> ResolvedHorizon { + horizon.as_ref().map(|h| h.resolved()).unwrap_or_default() } /// The default boundary `margin` (blocks of horizontal breathing room added @@ -5382,6 +5669,31 @@ pub enum WorldEdit { /// The region (an earlier `select` in this batch) to clear. region: RegionId, }, + /// Admit the horizon's **ambient water** into a region (spec-0030): the + /// stretch of ground the campaign means to sit at the waterline. + /// + /// This is not "fill with water". The author supplies an **envelope** and + /// the compiler computes what the ambient sea actually reaches inside it — + /// every air cell of the region at or below the horizon's flood level that + /// the sea can flow to (cardinal at the same level, or downward) from the + /// ambient water outside the placed pieces — and materializes exactly + /// those cells as water, in the model AND in the emitted world. What the + /// sea cannot reach stays dry, and stays subject to `DW0364` unchanged: a + /// `flood` can never make a standable cell under the waterline legal, only + /// make it *stop being ground*. + /// + /// Two obligations the compiler proves, both of which fail a false + /// declaration loudly rather than silently: the sea must actually arrive + /// (`DW0394` — an envelope the water never enters binds nothing), and it + /// must stop inside the envelope (`DW0395` — water that would flow on into + /// an undeclared cell of a placed piece means the shoreline is not where + /// the author said it is). + Flood { + /// The region (an earlier `select` in this batch) the ambient water is + /// admitted into: an **envelope**, not a cell list. Cells above the + /// flood level, and cells the sea cannot reach, are simply not wetted. + region: RegionId, + }, /// Reshape terrain surface within a region (raise / lower / smooth). Morph { /// The region (an earlier `select` in this batch) whose columns to diff --git a/crates/dsl/src/validate.rs b/crates/dsl/src/validate.rs index 12db1bfc..743f7e8c 100644 --- a/crates/dsl/src/validate.rs +++ b/crates/dsl/src/validate.rs @@ -1218,14 +1218,214 @@ fn reserved_v06_effect_flags(c: &Campaign, d: &mut Vec) { } } +/// Stage-1 `horizon` rules (spec-0026, DSL v0.9; generalizing spec-0013). +/// Runs only for a world stage at 0.6.0+ (the pre-0.6 gate rejects the field +/// wholesale in [`reserved_v06_world`]). +/// +/// 1. **v0.9 fence**: the object form and every base/shorthand beyond +/// `"void"`/`"ocean"` are `DW0141` below 0.9.0. +/// 2. **Not-yet-landed bases**: `valley`/`summit`/`sky` (and the +/// `cherry-valley` shorthand) parse at 0.9.0 but their surround generators +/// have not landed in this delvec slice — `DW0141`, per the reserved-value +/// precedent (npc `vendor`/`boss`), so the surface is reserved rather than +/// silently mis-emitted. The follow-up slices delete these arms. +/// 3. **`DW0366`**: params foreign to the declared base, and params out of +/// their spec-0026 range. +/// 4. **`DW0320` generalized** (spec-0026 §5): every non-void base has an +/// enterable ambient or a lateral fall hazard, so every one requires a +/// `boundary`. +fn horizon_rules(c: &Campaign, d: &mut Vec) { + use crate::stages::{Horizon, HorizonBase, horizon_defaults}; + + let Some(h) = &c.world.content.horizon else { + return; + }; + let path = "/content/horizon".to_string(); + if h.needs_v09() && !is_v09(c.world.dsl_version.as_str()) { + d.push(Diagnostic::error( + codes::RESERVED, + "world", + path, + "this `horizon` form requires dsl_version 0.9.0 (spec-0026): below it, `horizon` \ + accepts only the strings `\"void\"` and `\"ocean\"`. Raise the world stage's \ + `dsl_version` to 0.9.0, or use one of those strings" + .to_string(), + )); + return; + } + let r = h.resolved(); + + // Bases whose surround generator has not landed in this engine slice. + if matches!( + r.base, + HorizonBase::Sky | HorizonBase::Valley | HorizonBase::Summit + ) { + d.push(Diagnostic::error( + codes::RESERVED, + "world", + path.clone(), + format!( + "horizon base `{}` is reserved and not implemented in this delvec yet — the \ + spec-0026 foundation ships `void`, `ocean` and `flatland`; the \ + `valley`/`cherry-valley`, `summit` and `sky` surround generators land in \ + following slices. Use a landed base, or hold this campaign until the engine \ + ships the base (raising `dsl_version` further will not enable it)", + r.base.token() + ), + )); + } + + // DW0366: params foreign to the declared base (object form only — the + // shorthands cannot carry params). + if let Horizon::Spec(s) = h { + let foreign: &[(&str, bool, HorizonBase)] = &[ + ("float_y", s.float_y.is_some(), HorizonBase::Sky), + ("fall", s.fall.is_some(), HorizonBase::Sky), + ( + "blend_width", + s.blend_width.is_some(), + HorizonBase::Flatland, + ), + ("ratio", s.ratio.is_some(), HorizonBase::Valley), + ("rim_height", s.rim_height.is_some(), HorizonBase::Valley), + ("flora", s.flora.is_some(), HorizonBase::Valley), + ("palette", s.palette.is_some(), HorizonBase::Valley), + ("plateau_y", s.plateau_y.is_some(), HorizonBase::Summit), + ( + "vista_radius", + s.vista_radius.is_some(), + HorizonBase::Summit, + ), + ("min_drop", s.min_drop.is_some(), HorizonBase::Summit), + ]; + for (name, present, owner) in foreign { + if *present && *owner != s.base { + d.push(Diagnostic::error( + codes::HORIZON_PARAM, + "world", + format!("/content/horizon/{name}"), + format!( + "horizon param `{name}` belongs to base `{}`, not `{}` — remove it, or \ + change the base it was meant for", + owner.token(), + s.base.token() + ), + )); + } + } + } + + // DW0366: spec-0026 param ranges (only the params of the declared base — + // a foreign param already got its own diagnostic above). + let mut range = |name: &str, msg: String| { + d.push(Diagnostic::error( + codes::HORIZON_PARAM, + "world", + format!("/content/horizon/{name}"), + msg, + )); + }; + match r.base { + HorizonBase::Valley => { + if !(2.0..=3.0).contains(&r.ratio) { + range( + "ratio", + format!( + "`ratio` = {} is out of range — the valley surround footprint is \ + `2..=3`× the scene's (default {})", + r.ratio, + horizon_defaults::RATIO + ), + ); + } + if r.rim_height < 1 { + range( + "rim_height", + format!( + "`rim_height` = {} is not a rim — declare a positive crest height \ + (default {})", + r.rim_height, + horizon_defaults::RIM_HEIGHT + ), + ); + } + } + HorizonBase::Summit => { + if r.min_drop < 100 { + range( + "min_drop", + format!( + "`min_drop` = {} is below the spec-0026 floor — a summit gorge drop is \ + ≥ 100 blocks (default {})", + r.min_drop, + horizon_defaults::MIN_DROP + ), + ); + } + if r.plateau_y > 319 || r.plateau_y - r.min_drop < -64 { + range( + "plateau_y", + format!( + "`plateau_y` = {} overflows the −64..320 build range once the ≥ {} gorge \ + drop is carved under it — everything a summit generates must fit in \ + −64..320 (default plateau {})", + r.plateau_y, + r.min_drop, + horizon_defaults::PLATEAU_Y + ), + ); + } + } + HorizonBase::Sky => { + if !(-63..=319).contains(&r.float_y) { + range( + "float_y", + format!( + "`float_y` = {} is outside the −64..320 build range — the archipelago \ + walk plane must be a buildable y (default {})", + r.float_y, + horizon_defaults::FLOAT_Y + ), + ); + } + } + HorizonBase::Void | HorizonBase::Ocean | HorizonBase::Flatland => {} + } + + // DW0320 generalized (spec-0026 §5): any horizon whose ambient is + // enterable (ocean, flatland, valley, summit) requires `boundary`; `sky` + // requires it too (the lateral clock is also the fall catch). + if r.base != HorizonBase::Void && c.world.content.boundary.is_none() { + let hazard = match r.base { + HorizonBase::Ocean => "an infinite swimmable sea with no return rule", + HorizonBase::Flatland => "an infinite walkable plain with no return rule", + HorizonBase::Valley => "a walkable gap floor ringing the scene with no return rule", + HorizonBase::Summit => "a walkable plateau rim with no return rule", + HorizonBase::Sky => { + "a fall past the islands with no catch (the boundary clock IS the fall consequence)" + } + HorizonBase::Void => unreachable!(), + }; + d.push(Diagnostic::error( + codes::OCEAN_NO_BOUNDARY, + "world", + path, + format!( + "`horizon` base `{}` needs a `boundary` — {hazard} lets players leave the map. \ + Add a `boundary` (a bare `{{}}` uses the default margin), or set `horizon` to \ + `void`", + r.base.token() + ), + )); + } +} + /// Stage-1 `horizon`/`boundary` gating + validation (spec-0013), plus the /// stage-5 v0.6 gating of `actors` and the effect verbs (spec-0012/0014: /// `set-checkpoint`, `begin-stealth`/`end-stealth`, `play-sound`, `narrate art`, /// and the actor staging verbs; gated on the quests stage). The per-effect /// `requires_flags` gate is handled separately in [`reserved_v06_effect_flags`]. fn reserved_v06_world(c: &Campaign, d: &mut Vec) { - use crate::stages::Horizon; - // --- Stage 1: horizon / boundary (spec-0013), gated on the world stage --- if !is_v06(c.world.dsl_version.as_str()) { if c.world.content.horizon.is_some() { @@ -1318,20 +1518,10 @@ fn reserved_v06_world(c: &Campaign, d: &mut Vec) { .to_string(), )); } - // `horizon: "ocean"` without a return rule strands wanderers in an infinite sea. - if matches!(c.world.content.horizon, Some(Horizon::Ocean)) - && c.world.content.boundary.is_none() - { - d.push(Diagnostic::error( - codes::OCEAN_NO_BOUNDARY, - "world", - "/content/horizon".to_string(), - "`horizon: \"ocean\"` needs a `boundary` — an infinite swimmable sea with no \ - return rule lets players wander off the map. Add a `boundary` (a bare `{}` uses \ - the default margin), or set `horizon` to `void`" - .to_string(), - )); - } + // The horizon library surface (spec-0026): v0.9 fence, per-base param + // validation (DW0366), the not-yet-landed-base rejections, and the + // generalized DW0320 boundary requirement. + horizon_rules(c, d); // `margin` range check (0..=64). if let Some(b) = &c.world.content.boundary && !(0..=64).contains(&b.margin) @@ -5224,6 +5414,24 @@ fn world_edits_checks(c: &Campaign, blocks: &dyn BlockRegistry, d: &mut Vec { check_region_ref(d, stage, ®ions, format!("{epath}/region"), region); } + WorldEdit::Flood { region } => { + check_region_ref(d, stage, ®ions, format!("{epath}/region"), region); + // v0.9 surface (spec-0030). Declaring it below 0.9.0 is + // `DW0141`, the same asymmetry every version ledger uses: + // a script that never floods emits byte-for-byte what + // pre-0.9 emission wrote. + if !is_v09(env.dsl_version.as_str()) { + d.push(Diagnostic::error( + codes::RESERVED, + stage, + epath.clone(), + "the `flood` verb (admit the horizon's ambient water into a declared \ + envelope — spec-0030) requires dsl_version 0.9.0 — raise this \ + stage's `dsl_version` to 0.9.0, or remove the verb" + .to_string(), + )); + } + } WorldEdit::Morph { region, op } => { check_region_ref(d, stage, ®ions, format!("{epath}/region"), region); match op { diff --git a/crates/dsl/tests/v09_horizon.rs b/crates/dsl/tests/v09_horizon.rs new file mode 100644 index 00000000..310e59b2 --- /dev/null +++ b/crates/dsl/tests/v09_horizon.rs @@ -0,0 +1,227 @@ +//! DSL v0.9 (spec-0026 horizon library): the stage-1 `horizon` object form +//! `{base, …params}` and the new base/shorthand names. +//! +//! - The v0.6 strings (`"void"`/`"ocean"`) stay valid and byte-identical. +//! - The new surface is reserved (`DW0141`) below 0.9.0. +//! - `valley`/`summit`/`sky` (and `cherry-valley`) parse at 0.9.0 but are +//! reserved-not-implemented in this engine slice (`DW0141`, the npc +//! `vendor`/`boss` precedent) — the surround-generator slices delete that. +//! - `DW0366`: params out of range, and params foreign to the declared base. +//! - `DW0320` generalizes: every non-void base requires a `boundary`. +//! - `"cherry-valley"` desugars exactly to `{base:"valley", flora:"cherry", +//! palette:"stone-petal"}` (acceptance criterion 6, DSL half). + +mod common; + +use delvewright_dsl::{ + Horizon, HorizonBase, HorizonFlora, HorizonPalette, RawCampaign, check_campaign, +}; + +/// A v0.9 stage-1 world document with a pluggable `horizon` value and an +/// always-on boundary. +fn world_doc(version: &str, horizon_json: &str, boundary: bool) -> String { + let boundary_line = if boundary { + "\"boundary\": { \"margin\": 24 },\n " + } else { + "" + }; + format!( + r#"{{ + "dsl_version": "{version}", + "campaign_id": "hello-world", + "stage": "world", + "content": {{ + "title": "The Keeper's Door", + "theme": "A lonely keep at the edge of the moor.", + "premise": "One locked door stands between you and the road home.", + "seed": 20260729, + "target_minutes": 5, + "horizon": {horizon_json}, + {boundary_line}"areas": [ + {{ "id": "area/keep", "name": "The Keep", "prefab": "prefab/hello-room" }} + ] + }} +}}"# + ) +} + +fn campaign_with_world(world: String) -> RawCampaign { + RawCampaign { + world, + npcs: common::read_valid("npcs.json"), + classes: common::read_valid("classes.json"), + quest_plan: common::read_valid("quest-plan.json"), + quests: common::read_valid("quests.json"), + dialogue: common::read_valid("dialogue.json"), + world_edits: None, + } +} + +fn diags_for( + version: &str, + horizon_json: &str, + boundary: bool, +) -> Vec { + check_campaign(&campaign_with_world(world_doc( + version, + horizon_json, + boundary, + ))) +} + +/// The v0.6 string shorthands stay valid at 0.6.0 — no new obligation. +#[test] +fn ocean_string_still_validates_at_0_6() { + let diags = diags_for("0.6.0", "\"ocean\"", true); + assert!(diags.is_empty(), "{diags:#?}"); +} + +/// The object form — even for a base 0.6 already ships — is v0.9 surface: +/// `DW0141` below it. +#[test] +fn object_form_is_reserved_below_0_9() { + let diags = diags_for("0.8.0", "{ \"base\": \"ocean\" }", true); + assert!( + diags.iter().any(|d| d.code == "DW0141"), + "object form must be DW0141 under 0.8.0: {diags:#?}" + ); +} + +/// A new shorthand name below 0.9.0 is `DW0141` too. +#[test] +fn flatland_string_is_reserved_below_0_9() { + let diags = diags_for("0.8.0", "\"flatland\"", true); + assert!( + diags.iter().any(|d| d.code == "DW0141"), + "flatland must be DW0141 under 0.8.0: {diags:#?}" + ); +} + +/// `flatland` (with a boundary) is the landed new base: clean at 0.9.0, both +/// as a string and as the object form with its param. +#[test] +fn flatland_validates_clean_at_0_9() { + for h in [ + "\"flatland\"", + "{ \"base\": \"flatland\", \"blend_width\": 8 }", + ] { + let diags = diags_for("0.9.0", h, true); + assert!(diags.is_empty(), "{h}: {diags:#?}"); + } +} + +/// `DW0320` generalizes (spec-0026 §5): a flatland with no `boundary` is an +/// infinite walkable plain with no return rule. +#[test] +fn flatland_without_boundary_is_dw0320() { + let diags = diags_for("0.9.0", "\"flatland\"", false); + assert!( + diags.iter().any(|d| d.code == "DW0320"), + "flatland without boundary must be DW0320: {diags:#?}" + ); +} + +/// `valley`/`summit`/`sky` parse at 0.9.0 but their surround generators have +/// not landed in this slice: reserved (`DW0141`), never silently mis-emitted. +#[test] +fn unlanded_bases_are_reserved_at_0_9() { + for h in [ + "\"valley\"", + "\"cherry-valley\"", + "\"summit\"", + "\"sky\"", + "{ \"base\": \"valley\" }", + "{ \"base\": \"summit\" }", + "{ \"base\": \"sky\" }", + ] { + let diags = diags_for("0.9.0", h, true); + assert!( + diags.iter().any(|d| d.code == "DW0141"), + "{h} must be reserved-not-implemented (DW0141): {diags:#?}" + ); + } +} + +/// `DW0366`: params out of their spec-0026 ranges. +#[test] +fn out_of_range_params_are_dw0366() { + for h in [ + // valley ratio outside 2..=3 + "{ \"base\": \"valley\", \"ratio\": 3.5 }", + "{ \"base\": \"valley\", \"ratio\": 1.0 }", + // summit min_drop below the 100 floor + "{ \"base\": \"summit\", \"min_drop\": 80 }", + // summit plateau overflowing the build range after the gorge drop + "{ \"base\": \"summit\", \"plateau_y\": 400 }", + // sky walk plane outside the build range + "{ \"base\": \"sky\", \"float_y\": 400 }", + ] { + let diags = diags_for("0.9.0", h, true); + assert!( + diags.iter().any(|d| d.code == "DW0366"), + "{h} must be DW0366: {diags:#?}" + ); + } +} + +/// `DW0366`: a param declared on a base it does not belong to. +#[test] +fn foreign_params_are_dw0366() { + for h in [ + "{ \"base\": \"ocean\", \"blend_width\": 4 }", + "{ \"base\": \"flatland\", \"ratio\": 2.5 }", + "{ \"base\": \"valley\", \"float_y\": 160 }", + ] { + let diags = diags_for("0.9.0", h, true); + assert!( + diags.iter().any(|d| d.code == "DW0366"), + "{h} must be DW0366 (foreign param): {diags:#?}" + ); + } +} + +/// `"cherry-valley"` is a parameter row, not a base (spec-0026 acceptance +/// criterion 6, DSL half): it resolves to exactly the same view as +/// `{base:"valley", flora:"cherry", palette:"stone-petal"}`. +#[test] +fn cherry_valley_desugars_to_valley_params() { + let shorthand: Horizon = serde_json::from_str("\"cherry-valley\"").unwrap(); + let object: Horizon = serde_json::from_str( + "{ \"base\": \"valley\", \"flora\": \"cherry\", \"palette\": \"stone-petal\" }", + ) + .unwrap(); + assert_eq!(shorthand.resolved(), object.resolved()); + let r = shorthand.resolved(); + assert_eq!(r.base, HorizonBase::Valley); + assert_eq!(r.flora, HorizonFlora::Cherry); + assert_eq!(r.palette, HorizonPalette::StonePetal); + // …and the plain valley differs ONLY in those two fields. + let plain: Horizon = serde_json::from_str("\"valley\"").unwrap(); + let p = plain.resolved(); + assert_eq!( + delvewright_dsl::ResolvedHorizon { + flora: HorizonFlora::Cherry, + palette: HorizonPalette::StonePetal, + ..p + }, + r + ); +} + +/// Both wire forms round-trip through serde unchanged — the string shorthand +/// stays a string (the byte-identity half of the fence: a v0.6 document +/// re-serialized carries the same `"ocean"` it declared). +#[test] +fn wire_forms_round_trip() { + for (json, want) in [ + ("\"ocean\"", "\"ocean\""), + ("\"cherry-valley\"", "\"cherry-valley\""), + ( + "{\"base\":\"flatland\",\"blend_width\":8}", + "{\"base\":\"flatland\",\"blend_width\":8}", + ), + ] { + let h: Horizon = serde_json::from_str(json).unwrap(); + assert_eq!(serde_json::to_string(&h).unwrap(), want); + } +} diff --git a/docs/reference/compiler.md b/docs/reference/compiler.md index 83e3634e..b9713413 100644 --- a/docs/reference/compiler.md +++ b/docs/reference/compiler.md @@ -87,7 +87,8 @@ same PR (CLAUDE.md Methodology; CI enforces the DW-code subset — see | 4 | l10n sidecar coverage + reserved marker channel | `dsl::validate_l10n`, `dsl::validate_marker_channel` | `DW0180`/`DW0181`/`DW0182` (exit 1) | | 5 | Analyze (branch-coherent quest/dialogue reachability + critical-path replay) | `compiler::analyze` over `compiler::flow` | `DW02xx` (exit 2) | | 6 | Solve jigsaw layout (per `prefab_pool` area, from seed); then read the settled draw back and report a pool that seats the same anchor-bearing prefab twice (`DW0498`, `compiler::pool`) | `compiler::solver`, `compiler::pool` | `DW030x` (exit 3); advisory `DW0498` | -| 7 | Assemble world model (placed pieces → voxel grid; ocean sea-level datum check) | `compiler::plan` | `DW030x`/`DW0344` (exit 3) | +| 7 | Assemble world model (placed pieces → voxel grid; per-area `walk_y` datum + declaration check; ocean waterline check) | `compiler::plan` | `DW030x`/`DW0344`/`DW0367` (exit 3) | +| 7b | Empirical flood-level proof over the assembled (or edited) model: every placed piece's standable cells above the horizon's flood level, no `waterline_y` exemption (spec-0026 §2) | `compiler::nav::check_flood_level` (invoked from `compiler::emit`) | `DW0364` (exit 3) | | 8 | Replay the stage-7 edit script over the assembled model (spec-0017; per-batch invariant re-proofs — trap-hardware integrity, gravity, relight, walkability, boundary safety, block support; plus the advisory gate-region check). Skipped entirely for a campaign without one (byte-identical). | `compiler::edit` | `DW0322`/`DW0323`/`DW0352`/`DW0354` + reused invariant codes, batch-attributed (tier per code); advisory `DW0353`/`DW0354` | | 9 | Assembled-light + relight (measure, place fixtures; over the **edited** model when a script exists) | `compiler::light` | `DW0210`/`DW0211` (**exit 2**) | | 10 | Nav checks (A* `move-npc`/`move-actor` (footprint-aware, each walk routed over its **own timeline's** gate state), cutscene clip (authored polyline + rendered keyframe chords) + angular budget, critical-path walkability — incl. relight fixtures + water flood, and **per reachable branch** over each branch's own path under its own gate-seal step space (task #117); talk-to endpoint snap; waypoint self-check (critical path + per branch); POV camera clear-eye self-check; v0.6 checkpoint no-stranding/placement + stealth-zone/onset + trap completability proofs; spec-0016 §6 TD lane polylines; spec-0016 §1 bonfire safe zone) — all over the **edited** model when a script exists | `compiler::nav` + `compiler::timeline` | `DW0307`/`DW0308`/`DW0311`/`DW0314`/`DW0315`/`DW0316`/`DW0325`/`DW0327`/`DW0342`/`DW0347`/`DW0355`/`DW0386`/`DW0410`/`DW0430`/`DW0478`/`DW0488`/`DW0724` (exit 3; `DW0342` → exit 2) | @@ -176,15 +177,15 @@ exported via `delvec schema`). Introduced-by column cites the spec. | `seed` (u64) | Sole downstream randomness (layout PRNG). | 0.1 | | `target_minutes` | Informational (pacing). | 0.1 | | `languages[]` (opt) | BCP-47 codes; `en` implicit/never listed; drives l10n coverage + `--lang`. | 0.3 i18n | -| `areas[]` | 1..N. Each binds **exactly one** of `prefab` or `prefab_pool`+`pieces{min,max}` (else `DW0160`). Area origin = `[i·256, base_y, 0]`, where `base_y` is the **horizon datum**: `void` → 64, `ocean` → 60 (see `horizon`). | 0.1 / pool 0.2 | +| `areas[]` | 1..N. Each binds **exactly one** of `prefab` or `prefab_pool`+`pieces{min,max}` (else `DW0160`). Area origin = `[i·256, base_y, 0]`, where `base_y` is the **per-area datum** (spec-0026 §2): `void` → 64; any other horizon → `walk_ref_y − walk_y` (the datum piece's declared tileset walk plane; ocean island 3 → 60, keep 1 → 62; `DW0367` when undeclared). | 0.1 / pool 0.2 / per-area datum 0.9 | | `areas[].lighting {fixture,min_light}` (opt) | spec-0010: relight pass guarantees `min_light` (1..=14, default 7; `DW0196` out of range) over reachable walkable cells by placing `fixture` (`torch`/`lantern`/`campfire`/`shroomlight`), else `DW0211`. | 0.5 | | `areas[].mitigation` (opt) | `night-vision` — the first-class darkness declaration (v0.6). The compiler emits a self-rescheduling **1 s (20t)** `night_vision_tick` that runs `effect give @a[] minecraft:night_vision 0 true` (amplifier 0, particles hidden). The lease is `max(12, longest camera + 10 + 1)` seconds — **the camera-coverage guarantee** (owner ruling 2026-08-04): a granted vision effect must outlast any authored camera it can overlap, plus vanilla's 10 s wind-down, so it can never begin ramping down on screen. 12 s is the floor and is what a campaign with no cutscene still emits (byte-identical to pre-0.6.1). The longest camera is measured from the ticks `camera::shot_ticks` really emits, and the campaign-wide max is used because the compiler cannot know which cutscene a player who steps out of a mitigated area will land in — the island's ending transports the party from the mitigated island to `area/open-sea` and immediately plays a 15 s camera, which the old 12 s lease could not survive. A player who leaves the area keeps sight for ≤ the lease: deliberate, since no vanilla primitive strips one effect on region exit without stripping effects the story granted, and the alternative is a visible flicker. Independent of `lighting`. This declaration is the **sole** `DW0210` night-vision mitigation. | 0.6 | | `time` (opt) | `day`/`noon`/`dusk`/`night`/`midnight`/`dawn` (default `noon`; `sunrise` is accepted as a synonym of `dawn`). Dimension-global initial state, emitted in the sealing baseline. Vanilla's `/time set` takes either a keyword or a raw tick count, so the DSL is not limited to the four keywords: the four vanilla states emit their **keyword verbatim** (`time set night`) and the two added by the 2026-08-03 owner ruling emit the equivalent **tick form** (`dusk` -> `time set 12000`, `dawn` -> `time set 23000`). One table maps every state to its argument and its `time query daytime` read-back (`WorldTime::spec`), so the sealed-state PackTest asserts the right value and no shipped campaign's bytes move. `dusk` is the **sunset onset** (12000, the sky visibly going orange), deliberately not 13000 — 13000 is the sun already down, which is exactly what the `night` keyword sets, so it would make `dusk` a synonym rather than its own beat; `dawn` (23000) is the sunrise onset. `dusk`/`dawn` count as night for the sky-light model (`DW0210`), which is the conservative direction (both skies are in fact brighter than midnight). Same enum for the `set-time` effect. | 0.5 / dusk+dawn 0.5 | | `weather` (opt) | `clear`/`rain`/`thunder` (default `clear`; `clear` emits nothing — byte-identical to pre-0.5). Dimension-global, emitted after sealing (`weather `). Rain/thunder attenuate the assembled-light sky term. | 0.5 | | `difficulty` (opt) | The delve's combat difficulty (owner ruling 2026-08-03): `easy` / `normal` / `hard`. Absent = the compiler's historical **derivation** — `easy` when the campaign fields any wave, `peaceful` when it fields none — which is what keeps every pre-0.6 campaign byte-identical. Declaring it overrides the derivation in BOTH places a difficulty comes from: `server/server.properties` (what the shipped image and every compose profile boot from, via `validation/world-settings-entrypoint.sh`) and a `/difficulty ` appended to the sealing baseline, so the declaration also holds when the datapack alone is dropped into another world. A declaring campaign also emits the `declared_difficulty` PackTest, which asserts the live world's difficulty via the bare `/difficulty` query command (vanilla returns `Difficulty#getId()`: peaceful 0, easy 1, normal 2, hard 3) — so properties, sealing and declaration are proven to agree on a real server. `peaceful` is refused (`DW0468`); fighting actors with no waves and no declaration is the advisory `DW0469`. **Retuning warning:** every combat number in every campaign written before this field was tuned under the implicit `easy`, which HALVES incoming player damage (`min(dmg / 2 + 1, dmg)`) — content that declares `normal` or `hard` must redo that arithmetic, not merely flip the keyword. | 0.6 | -| `horizon` (opt) | spec-0013: `void` (default/absent, byte-identical to v0.5) or `ocean` — a pinned bedrock/stone/water superflat (sea level y=62), no structures/mobs. Drives `generator-settings` **and the area-origin datum**: ocean areas are placed at y=60 = `sea_level − 2`, so an island piece's authored waterline (local y=2) meets the world ocean and its walk plane (local y=3) is the vanilla-normal one block above the sea. Enforced by `DW0344`. | 0.6 | +| `horizon` (opt) | spec-0013 strings / spec-0026 horizon library. `void` (default/absent, byte-identical to v0.5) or `ocean` (pinned bedrock/stone/water superflat, sea level y=62) as v0.6 strings, unchanged. v0.9 adds the **object form** `{base, …params}` and the bases `flatland` (pinned grass superflat, top y=63, plains biome — landed) and `sky`/`valley`/`summit` + the `cherry-valley` shorthand (`{base:"valley", flora:"cherry", palette:"stone-petal"}`) — parsed and param-validated (`DW0366`), but **reserved** (`DW0141`) until their surround-generator slices land. Drives `generator-settings` **and the per-area datum** (`walk_ref_y − walk_y`; see `areas[]`). Enforced by `DW0344` (declared waterlines), `DW0364` (empirical flood proof), `DW0367` (missing `walk_y`). Every non-void base requires `boundary` (`DW0320`). | 0.6 / library 0.9 | | `min_players` (opt, 1..=4) | spec-0018: the party size the delve **requires**. Absent = 1 (a party of one is always legal; every pre-0.6 campaign reads as 1). `>= 2` emits the **lobby gate**: `tick` recomputes the live count into `#lobby dw.sys`, the class-selection dialog driver is prefixed `if score #lobby dw.sys matches ..` (so the delve cannot START short-handed), and unclassed players get a self-updating `x / n` actionbar (`{"score":{"name":"#lobby","objective":"dw.sys"}}` — one emitted line, no per-count strings; a compiler default, not an l10n key). Out of range = `DW0356`; a mandatory-n declaration with no n-way division of labour = `DW0358`. `min_players: 1` emits **nothing** (byte-identical). | 0.6 | -| `boundary {margin?,message?}` (opt) | spec-0013: declares a **derived** playable region (union of final placed-piece AABBs, inflated horizontally by `margin` (`0..=64`, default 16; else `DW0321`), unbounded up, floor = lowest placed block − 8). A 1s clock returns any player outside it to the last checkpoint (`dw:cp`) with an actionbar `message` (l10n `world.boundary.message`, English default when absent) + a soft sound; no damage, no item loss. `horizon:"ocean"` without a `boundary` = `DW0320`. | 0.6 | +| `boundary {margin?,message?}` (opt) | spec-0013: declares a **derived** playable region (union of final placed-piece AABBs, inflated horizontally by `margin` (`0..=64`, default 16; else `DW0321`), unbounded up, floor = lowest placed block − 8). A 1s clock returns any player outside it to the last checkpoint (`dw:cp`) with an actionbar `message` (l10n `world.boundary.message`, English default when absent) + a soft sound; no damage, no item loss. Any non-void `horizon` base without a `boundary` = `DW0320` (spec-0026 §5 generalization; horizon-agnostic — the derivation and clock never branch on horizon kind). | 0.6 | ### Stage 2 — `npcs` (casting sheets, stationary) @@ -397,6 +398,7 @@ from l10n (no stage-7 string is player-visible). | Frames | `piece-local` (`piece` placement index + `prefab` drift-guard — mismatch is `DW0323`) or `anchor-relative` (a resolved anchor of the batch's area) — never raw world coordinates, so a script survives placement moves. | | `fill` / `replace` | Seeded palette-recipe write over a region (`replace` only rewrites cells whose base id is in `matching`). A recipe is weighted `blocks[]` (+ optional noise `scale`, default 0.35 blocks⁻¹) sampled by smooth value noise — picks cluster into strata/patches, never a uniform fill. Block ids validate against the pinned registry with optional verbatim blockstate suffix (`DW0193`); weights/scale finite > 0 (`DW0162`). | | `carve` | Clear a region to air. Sealing-aware by construction: the carved region re-enters relight + walkability + boundary proofs. | +| `flood` (v0.9, spec-0030) | `{region}` — admit the horizon's **ambient water** into a region: the DSL's way to say *this stretch of ground is deliberately at the waterline*. The region is an **envelope, not a cell list**: the compiler computes which cells the sea actually reaches inside it (seeded from ambient water bordering the envelope, propagated cardinal-sideways and downward through waterable cells at or below the flood level, confined to cells **inside a placed piece** — outside them the generator's sea is already there) and materializes exactly those as `minecraft:water`, in the model AND in the emitted `world_edits`, so the delivered shoreline is a function of the DSL rather than of first-boot fluid ticks. Everything the sea does not reach stays dry and stays under `DW0364` **unchanged** — `flood` can never make a standable cell under the waterline legal, only make it stop being ground. Two proofs, re-run after every later batch (the **tideline invariant**): the water must arrive (`DW0394`, a zero binding is a finding) and it must stop inside the envelope (`DW0395`). Over-declaring is not a way out: a wider envelope wets more of your map, and an envelope that swallows a corridor turns nav red. Requires the world-edits stage at `dsl_version` 0.9.0 (`DW0141` below it); a script with no `flood` emits byte-for-byte what it emitted before the verb existed. | | `morph` | Surface reshape per region column: `raise{by,recipe}`, `lower{by}`, `smooth{passes,recipe}` (±1/pass relaxation toward the cardinal-neighbour mean). The region gives the footprint + where the surface is read; `raise`/`smooth` may add cells above the region top. | | `scatter` (PR 2) | Seeded dressing over a region's **standable** cells (air over an occupied cell): weighted `items[]` (blockstate suffixes allowed), per-candidate white-noise `density` gate in `(0, 1]` (dressing wants speckle, not the fill verbs' clustered patches), keep-clear `avoid[]` region envelopes (matched by `(x, z)` column), optional both-axes `spacing` rule and `limit` cap taken in descending noise order — the greenfield generator's spread idiom, ported. | | `plant` (PR 2) | Structural flora via the #121 **lean-or-grow** canopy rules (ported from the island terrain generator): up to `count` trees on the region's highest-noise standable cells (both-axes `spacing`, default 4; trunks never on `avoid[]` columns). A canopy that would cover an `avoid` column leans one block directly away; if that still covers it, the tree grows tall instead — its whole ball arched 3 above the trunk's floor. **No leaf is ever sliced**; leaves write only into air, so near walls/ceilings the ball may extend past them — review via the batch snapshot. `tree: oak` (per-species rule sets, extensible). | @@ -980,29 +982,54 @@ and `minecraft:`-prefixed forms both rejected). Emitted sealing commands water×8]}`: from the −64 build floor the top water block lands at **y=62** (sea level). Still no structures/mobs (`generate-structures=false` + gamerule `spawn_mobs false`). `void`/absent is byte-identical to v0.5. -- **Sea-level datum (ocean).** An ocean world places its areas at **y=60** - (`plan::OCEAN_BASE_Y` = `SEA_LEVEL − ISLAND_WATERLINE_Y`), not at the void - datum 64. The island tileset (`prefabs/island-tileset.md`) authors every piece - with its waterline — the top authored water block — at **local y=2** and its - walkable land plane at local y=3; placing the base at `sea_level−2` makes the - authored water one body with the world ocean and puts the shore exactly one - block above the sea, the vanilla-normal beach a swimming player can climb. - Placed at 64 instead, the whole island floats ~4 blocks above the sea: the - authored water pocket hangs in the air and open water becomes an inescapable - moat. *Assumption:* the waterline height is a **library** constant, not a - per-piece one — placement uses the single tileset convention (2) so every area - of an ocean world shares one datum, and prefab metadata's optional - `waterline_y` is a *declaration checked against* that datum (`DW0344`), never - an input that moves it. Everything downstream (nav/critical path, boundary - region, checkpoint storage, POV shots, PackTests) derives from placement and - simply follows the new Y. The water-flood model is unaffected: it seeds only - from authored `minecraft:water` cells inside placed pieces and never climbs, - so the walk plane one block above the waterline stays dry by construction — - the world ocean is backdrop, not a flood source. +- `horizon:"flatland"` (v0.9, spec-0026) is a pinned grass superflat + `{"biome":"minecraft:plains","layers":[bedrock×1, dirt×126, grass_block×1]}`: + grass top at **y=63**, exactly one block under the scene walk plane by the + datum equation below — the §3 zero-height seam is a config equation, never a + blend. Bare by design (no vegetation); `minecraft:plains` gives vanilla's own + grass tint. The seam material-dither band (`blend_width`) is a surround + generator and lands in a following slice. +- **Per-area `walk_y` datum (spec-0026 §2, supersedes the spec-0013 global + ocean datum).** Every non-void horizon declares a **walk reference y** + (`compiler::horizon::walk_ref_y`: ocean 63 = sea level + 1, flatland 64 = + grass top + 1, sky = `float_y`), and each area's base y is + `walk_ref_y − walk_y`, where `walk_y` is the tileset walk-plane convention + declared in prefab metadata — the local feet-y of the piece's placement + datum (island 3 → base 60, exactly the retired `OCEAN_BASE_Y`; keep 1 → + base 62, dry with no author action; cave 2; tidal-keep shore 3 / plinth 11). + The **datum piece** is the area's bound `prefab`, or the pool's + `entry`-role member(s) — which must agree — for a pool area; every other + piece chains from it by socket mating. A placed piece without `walk_y` in a + non-void horizon is `DW0367` (build, exit 3): the compiler never guesses a + tileset datum — a guessed island constant applied to an interior tileset is + exactly the #149 flooded-room class. `void` keeps the historical `BASE_Y` + (64) unconditionally, so every pre-0.9/void campaign is byte-identical. + Everything downstream (nav/critical path, boundary region, checkpoint + storage, POV shots, PackTests) derives from placement and simply follows the + per-area Y. +- **Declarations position; proofs read reality (spec-0026 §2).** After + assembly (and after the stage-7 edit replay when a script exists) the + compiler checks **empirical geometry**: every placed piece's standable cells + must sit strictly above the horizon's flood level (ocean: 62) — a walk cell + at or below it is `DW0364` (build, exit 3), with **no exemption for pieces + that declare no `waterline_y`**. This closes the gap that made #149 + invisible: `DW0344` reads declarations and skipped non-declaring pieces, so + a drowned interior walk plane was never looked at while every downstream + proof derived from the flooded placement and stayed green. Authored water + cells are impassable in the model (not standable), so a shore's underwater + seabed never fires this — only genuinely-standable-but-drowned cells do. - Prefab metadata may declare **`waterline_y`** (optional, integer, local y of - the piece's top authored water block). Island-tileset pieces declare `2`; - pieces that author no sea (keep/cave interiors, `hello-room`) omit it and are - not checked. Consumed only by `DW0344`. + the piece's top authored water block; `walk_y − 1` by construction for a + piece authored to its tileset convention, so the island tileset's `walk_y: 3` + pieces declare `2`). Pieces that author no sea (keep/cave interiors, + `hello-room`) omit it and are not checked by `DW0344` — their dryness is + proven empirically by `DW0364` instead. Consumed only by `DW0344`. +- Prefab metadata **`walk_y`** (integer; required for every piece placed in a + non-void horizon, `DW0367`): the tileset walk-plane convention — the local + feet-y of the piece's reference floor (the floor its lowest socket opens + onto; the authored ground plane for a socketless piece). Backfilled across + the whole library (content-repo migration, spec-0026), emitted by every + tileset generator, and verified empirically per piece by `DW0364`. - Prefab metadata's `lighting.profile` takes a fourth value, **`unmeasured`** (spec-0027 §2): a *generated* prefab places blocks, not photons, so it declares that a probe is owed rather than fabricating one. It is distinct from an absent @@ -1013,6 +1040,12 @@ and `minecraft:`-prefixed forms both rejected). Emitted sealing commands carrying either is refused too. Nothing gates on the profile (`DW0210` measures the assembled world); its one consumer is the interior shot's reviewer line, where `unmeasured` reads "verify readability", never "mitigation expected". +- The horizon model lives in **`compiler::horizon`** (spec-0026): resolved + params, `walk_ref_y`, `flood_level`, the pinned `generator-settings` + literals, and the slice status (`base_implemented`) that keys the DSL-layer + reserved-base rejection. Surround generators (valley/cherry, summit, sky + archipelago, flatland seam band) plug in per its module-doc contract; until + a base's generator lands, validation refuses the base (`DW0141` reserved). - `boundary` (v0.6, spec-0013) emits, in `setup_finish`: a `dw:region bounds` storage mirror (readable region contract), a `dw:cp pos` init to the spawn cell (shared with spec-0012 checkpoints — the last-checkpoint mirror the return @@ -2182,7 +2215,7 @@ standards: `DW0312`, `DW0210`/`DW0211`, `DW0304`, `DW0306`. | `DW0132` | `finale` is not the convergent sink (some quest is not a transitive dependency of finale). | | `DW0133` | Non-mandatory quest (`mandatory:false`), reserved until M3. | | `DW0140` | Objective `after` cycle. | -| `DW0141` | Reserved enum value/field for the campaign's `dsl_version`. **This row is the single enumerated list of reserved surface** — §2 deliberately does not restate it (npc `vendor`/`boss`; under 0.2.0 the v0.3 verbs/effects; under pre-0.4 the v0.4 surface; under pre-0.5 the v0.5 surface: `time`/`weather`/`lighting`, `set-time`/`set-weather`; under pre-0.6 the v0.6 surface: area `mitigation`, `close-gate`, `damage-players`, `set-checkpoint`, `begin-stealth`/`end-stealth`, `horizon`/`boundary`, the `play-sound` effect + `narrate` `style: art`, per-effect `requires_flags`, `forbids_flags` at every site, `move-npc.on_arrive`, stage-2 npc `deferred` + the `spawn-npc` effect, stage-5 `actors` + `spawn`/`despawn`/`move`/`unleash-actor`, `sequence`, the `traps[]` section, the `bonfire` effect, wave `respawns_on_rest`, wave `equipment`, `waves[].lane` / `waves[].summon`, the `shortcuts[]` / `ambushes[]` / `timed_gates[]` sections, the `loot[]` section, actor `equipment`, and the spec-0022 trap `payload` surface + its `volley` / `collapse` effects; under pre-0.7 the v0.7 surface: the stage-5 `cast` ledger, wave `tier`; under pre-0.8 the v0.8 surface: the stage-4 `branch_points` section, the per-node `happening` on a quest / objective / dialogue option / staging-or-gate-or-ending effect, and the named `campaign-complete` `ending` (spec-0025); the class-kit `flask`, a kit item's potion `contents` and the `bonfire` rest-dialog labels (spec-0016 §1); actor `tier` (spec-0023); the stage-6 dialogue-option `tooltip` (owner design 2026-08-04); the `close-gate` `sealed_hint` (task #142); and the `collect` container-adoption trio `container` / `item_name` / `fill_count` (task #95, owner ruling from island playtest rounds 1-2 — each field is reserved independently, and an explicit `fill_count: 0` declares nothing since it is the default)). | +| `DW0141` | Reserved enum value/field for the campaign's `dsl_version`. **This row is the single enumerated list of reserved surface** — §2 deliberately does not restate it (npc `vendor`/`boss`; under 0.2.0 the v0.3 verbs/effects; under pre-0.4 the v0.4 surface; under pre-0.5 the v0.5 surface: `time`/`weather`/`lighting`, `set-time`/`set-weather`; under pre-0.6 the v0.6 surface: area `mitigation`, `close-gate`, `damage-players`, `set-checkpoint`, `begin-stealth`/`end-stealth`, `horizon`/`boundary`, the `play-sound` effect + `narrate` `style: art`, per-effect `requires_flags`, `forbids_flags` at every site, `move-npc.on_arrive`, stage-2 npc `deferred` + the `spawn-npc` effect, stage-5 `actors` + `spawn`/`despawn`/`move`/`unleash-actor`, `sequence`, the `traps[]` section, the `bonfire` effect, wave `respawns_on_rest`, wave `equipment`, `waves[].lane` / `waves[].summon`, the `shortcuts[]` / `ambushes[]` / `timed_gates[]` sections, the `loot[]` section, actor `equipment`, and the spec-0022 trap `payload` surface + its `volley` / `collapse` effects; under pre-0.7 the v0.7 surface: the stage-5 `cast` ledger, wave `tier`; under pre-0.8 the v0.8 surface: the stage-4 `branch_points` section, the per-node `happening` on a quest / objective / dialogue option / staging-or-gate-or-ending effect, and the named `campaign-complete` `ending` (spec-0025); the class-kit `flask`, a kit item's potion `contents` and the `bonfire` rest-dialog labels (spec-0016 §1); actor `tier` (spec-0023); the stage-6 dialogue-option `tooltip` (owner design 2026-08-04); the `close-gate` `sealed_hint` (task #142); and the `collect` container-adoption trio `container` / `item_name` / `fill_count` (task #95, owner ruling from island playtest rounds 1-2 — each field is reserved independently, and an explicit `fill_count: 0` declares nothing since it is the default)); under pre-0.9 the v0.9 surface, which two independent slices landed on two different stages: the stage-5 declared `drops[]` on a wave mob / actor and the `collect` `dropped_by` (task #179), and the stage-1 `horizon` **object form** `{base, …params}` plus every base/shorthand beyond the v0.6 `"void"`/`"ocean"` strings (spec-0026). Additionally, **at 0.9.0** the not-yet-landed horizon bases `valley`/`cherry-valley`/`summit`/`sky` are parse-accepted but reserved — the vendor/boss pattern — until their surround-generator slices land; raising `dsl_version` further does not enable them). | | `DW0142` | Anchor not provided by the area's bound prefab. | | `DW0143` | Item id not in the pinned 1.21.11 registry (kit / `collect` / `interact.requires_item` / `give-item`). | | `DW0150` | Planned quest (stage 4) has no stage-5 expansion. | @@ -2209,8 +2242,9 @@ standards: `DW0312`, `DW0210`/`DW0211`, `DW0304`, `DW0306`. | `DW0197` | A stage-2 NPC declares `deferred: true` but **no** `spawn-npc` effect anywhere (quest, trigger, nested timeline, or dialogue) summons it — the NPC never enters the world, so its dialogue tree and any `talk-to` on it are unreachable content. v0.6; the staging dual of `DW0195`. Prescription: add the `spawn-npc` at the entrance beat, or drop `deferred`. (0197/0198 were *reserved* by spec-0011's draft and released when it renumbered to `DW0340`/`DW0341`; no code ever emitted them.) | | `DW0198` | A `talk-to` on a `deferred` NPC provably activates before the NPC exists: every `spawn-npc` for it fires in a quest that is a **strict DAG descendant** of the objective's quest. Conservative by construction — a spawn from a trigger, from dialogue, or from the objective's own quest is not DAG-ordered and suppresses the proof rather than risking a false positive (see the gap note below). v0.6. | | `DW0199` | A `cutscene` effect's shape is invalid: it mixes the multi-shot `shots` list with the single-shot `path`/`seconds` fields, declares neither, omits `seconds` on a single shot, or gives a shot with an empty camera `path`. The two spellings normalize to one shot list, so this is where the shape is policed and emission may then assume a well-formed, non-empty list. v0.6. | -| `DW0320` | `horizon:"ocean"` declared without a `boundary` (an infinite swimmable sea with no return rule). v0.6, spec-0013. Numbered in the 032x world/region family but **validation-tier (exit 1)**, not a DW03x build code. | +| `DW0320` | A non-void `horizon` base declared without a `boundary`. v0.6 (spec-0013) for `ocean` (an infinite swimmable sea with no return rule); generalized by spec-0026 §5 to **every** non-void base — ocean/flatland/valley/summit have an enterable ambient, and `sky`'s lateral clock is also its fall catch. The message names the base's hazard. Numbered in the 032x world/region family but **validation-tier (exit 1)**, not a DW03x build code. | | `DW0321` | `boundary.margin` outside `0..=64`. v0.6, spec-0013. Validation-tier (exit 1). | +| `DW0366` | A horizon param is out of range for its declared base (v0.9, spec-0026): `valley.ratio` outside `2..=3`; `summit.min_drop` < 100; `summit.plateau_y` overflowing the −64..320 build range once its `min_drop` gorge is carved under it; `sky.float_y` outside the build range. **Also** a param declared on a base it does not belong to (`blend_width` on `ocean`, `ratio` on `flatland`, …) — the object form is one flat schema, so param-to-base ownership is a validation fact, not a parse fact. Validation-tier (exit 1), `dsl::validate::horizon_rules`. Two ranges the spec states are **not yet enforced here**: `summit.vista_radius` ≥ 192 and `flatland.blend_width` `1..=16`. The foundation slice reported both as spec ambiguities rather than invent an answer, and the spec-0026 amendment of 2026-08-04 (PR #261) resolved them — `vista_radius` measured outward from the scene bounding box, floor 192 (= the shipped summit `view-distance` 12 × 16), default **208** (the pre-amendment 176 sat below its own floor); `blend_width` `1..=16`, since 0 is the hard material edge the §3 interpenetration ruling forbids. Both params belong to surrounds this slice does not emit — the valley slice (task #155 W-B) carries the enforcement and the corrected default, so this row gains them when that lands. Prescription: bring the param into its documented range, or move it to the base it was meant for. | | `DW0340` | Trap declaration structurally invalid (v0.6, spec-0011): a malformed/duplicate `trap/`, an `at`/`disarm.via` that no area's prefab provides, or a `disarm.via` that collides with the trap's own trigger anchor. Renumbered off the spec's stale reserved number (0197). | | `DW0341` | A trap `dispense` payload item id is not in the pinned 1.21.11 registry (v0.6, spec-0011; mirrors `DW0143`). Renumbered off the spec's stale reserved number (0198). | | `DW0343` | A verb that needs a gate anchor's **fill block** targets an anchor that declares none (or is not a gate region at all): `close-gate` (v0.6), which fills the region back in, or a stage-5 `shortcut` (spec-0016 §2), whose unlock clears exactly that block and whose gate is sealed by it from world-load. Compiler-side (needs prefab metadata the DSL anchor registry does not carry), reported at **validation tier (exit 1)** like the atmos `DW032x` checks; scan is over every prefab (gate anchors resolve globally like `open-gate`), and **all** region-providers of the anchor must declare a `block`. Prescription: declare `block` on the gate anchor, or remove the `close-gate`. | @@ -2667,8 +2701,10 @@ Exit 3 except `DW0312` (wave-capacity), `DW0313` (gravity-despawn) and `DW0342` | `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. | | `DW0393` | A `timed-gate`'s `disarm` affordance (task #184) is not usable **before** the gate is committed to: its `via` cell is not walkable from the campaign entry over the world with the gate span **sealed**. Same load-bearing clause as `DW0388`(b) and `DW0373`, stated for the third rung of the hazard ladder (souls dossier §5.2 — readable, avoidable, *disable-able*): a jam lever the party can only reach by first surviving the crossing disables nothing, it is a trophy for having beaten the hazard dressed as counterplay. Endpoints are snapped on the SEALED world (radius 3) so neither can land inside the span; a gate with no `disarm`, an unstandable entry or `via` cell, and a campaign with no entry anchor are left to the proofs that own them (`DW0345`, the anchor checks). `compiler::nav::check_timed_gate_disarms`, build-tier (exit 3). Prescription is geometry — put the lever on ground the approach already touches (the stair head above the run, the alcove beside the doorway) — or drop the `disarm`. Never open the gate at world-load to silence it. | +| `DW0394` | A `flood` declaration (v0.9, spec-0030) the ambient water never enters: the campaign's `horizon` has no ambient water at all (`void`/`flatland` — there is no waterline for a shoreline to sit on), or the declared envelope holds no cell the sea can reach — every cell above the flood level where water never rises, outside every placed piece (where the generator's sea already is), already solid, or walled off from the ambient water. **A zero binding is a finding, never a pass** (CLAUDE.md): a declaration that binds nothing proves nothing, and this is what stops `flood` from being reached for to quiet an unrelated red. In particular it is what the #149 tide-mill fixture gets when a `flood` is declared over it: a sealed interior room a block under the sea is not a shoreline, so the build stays red — it merely changes which red. Build-tier (exit 3), `compiler::edit`, raised at the verb. The message names the envelope's cell count and how many sit above the flood level. Prescription: extend the envelope down to the shoreline the sea actually touches, or drop the verb. | +| `DW0395` | The **tideline invariant** (v0.9, spec-0030), re-proved after every batch over every `flood` declared so far. Either (a) the water admitted by a `flood` does not stop inside its envelope — from a wetted cell it flows on, sideways at the same level or down, into an air cell of a **placed piece** at or below the flood level that no `flood` envelope covers, so the delivered world wets a cell every compile-time proof reads as dry air (the #149 class `DW0364` refuses); or (b) a later batch changed the shoreline out from under an earlier declaration, leaving a cell the sea reaches with no water in the model. Build-tier (exit 3), `compiler::edit::check_tideline`; the message names the declaring batch, the region, and the first offending cell in deterministic order. Prescription: widen the envelope to the cells the sea really takes, dam the channel, or re-order the `flood` after the batch that cuts the bank — never leave the overflow undeclared. | | `DW0324` | An L2 massing verb cannot apply to the solved layout (v0.6, spec-0017 PR 3): the target area binds a single `prefab` (no jigsaw layout to mass), a `piece` index / `prefab` guard mismatches the placement (layout drift), a `swap-piece`/`reseed-piece` candidate cannot re-mate every mated socket without overlap (or the pool has no compatible variant), an `insert-piece` socket is already mated or nothing attaches without overlap, a `remove-piece` targets the entry piece or a non-leaf, or a `rewire-socket` names an out-of-range connector / seals an already-sealed (opens an already-open) socket. `compiler::massing`, build-tier (exit 3); every message names the batch and prescribes re-inspecting the layout with `delvec snapshot` — never deleting the drift guard or the sockets. | -| `DW0322` | Post-edit boundary safety (v0.6, spec-0017 invariant 4): after a world-edits batch, the reachable walk region fails "one step off the proven ground is survivable **and recoverable**". `nav::verify_boundary_safety`, run after every edit batch (never on the no-edit path, whose worlds provide the guarantee physically). One code, one rule — *stated against the world-generator ambient* (`nav::Ambient`, spec-0013 `horizon`), because what an unmodelled column contains is the generator's property, not the content's. **`horizon: void`**: a reachable walkable cell borders a **void drop** — a horizontally adjacent column the player can step (or open a gate) into with no fall-arrest of any kind below (no solid, no fence/wall/gate top, no water); one step off the proven ground falls out of the world. Prescription: extend the terrain under the exposed edge (fill/morph a slope or outcrop) or reinstate a barrier shape. **`horizon: ocean`**: the pinned bedrock/stone/water superflat puts ground under *every* column, so nothing can fall out of an ocean world and the void premise is vacuous — the rule is the **stranding** invariant instead (the hazard `plan::OCEAN_BASE_Y` already names): a reachable walkable cell lets the player into a body of water with no climb-out back into the reachable walk region. Prescription: give the shoreline a step at the waterline (a beach or a bank), or wall the edge so the water cannot be entered there. Both branches **aggregate**: one report per run listing up to 6 violations plus a total, so the scale of a breach (one cell vs. the whole coastline) is visible without re-probing. Build-tier (exit 3); the message names the batch. Numbered in the 032x world/region family beside the spec-0013 boundary pair (`DW0320`/`DW0321` are validation-tier; this one is build-tier — it needs the edited geometry). Never weaken the check or reroute the path around it. | +| `DW0322` | Post-edit boundary safety (v0.6, spec-0017 invariant 4): after a world-edits batch, the reachable walk region fails "one step off the proven ground is survivable **and recoverable**". `nav::verify_boundary_safety`, run after every edit batch (never on the no-edit path, whose worlds provide the guarantee physically). One code, one rule — *stated against the world-generator ambient* (`nav::Ambient`, spec-0013 `horizon`), because what an unmodelled column contains is the generator's property, not the content's. **`horizon: void`**: a reachable walkable cell borders a **void drop** — a horizontally adjacent column the player can step (or open a gate) into with no fall-arrest of any kind below (no solid, no fence/wall/gate top, no water); one step off the proven ground falls out of the world. Prescription: extend the terrain under the exposed edge (fill/morph a slope or outcrop) or reinstate a barrier shape. **`horizon: ocean`**: the pinned bedrock/stone/water superflat puts ground under *every* column, so nothing can fall out of an ocean world and the void premise is vacuous — the rule is the **stranding** invariant instead: a reachable walkable cell lets the player into a body of water with no climb-out back into the reachable walk region. Prescription: give the shoreline a step at the waterline (a beach or a bank), or wall the edge so the water cannot be entered there. **`horizon: flatland`** (v0.9, spec-0026): solid grass under every column at the surface the scene walk plane sits flush over (the §2 datum equation) — one step off the proven ground is a step onto walkable ambient grass; nothing to fall out of, nothing to be stranded in, so the branch is vacuously green and the required `boundary` clock owns wanderers. The void/ocean branches **aggregate**: one report per run listing up to 6 violations plus a total, so the scale of a breach (one cell vs. the whole coastline) is visible without re-probing. Build-tier (exit 3); the message names the batch. Numbered in the 032x world/region family beside the spec-0013 boundary pair (`DW0320`/`DW0321` are validation-tier; this one is build-tier — it needs the edited geometry). Never weaken the check or reroute the path around it. | | `DW0323` | A stage-7 edit fails to **resolve** against the solved layout (v0.6, spec-0017): a piece-local frame's `piece` index is out of range or its `prefab` guard mismatches the placed piece (layout drift — the loud alternative to a silently misplaced edit), an `anchor-relative` frame names an anchor the batch's area does not resolve, or a verb's target region resolves to **zero cells** (a silent no-op is always a defect: the select drifted off the content it targeted). Also the `fragment` verb's own resolution failures: a prefab outside the admitted library, one decoding to zero non-air cells, and a `rotation` other than `none` on a prefab carrying yaw-dependent blockstate — rotate-aware stamping is not implemented, so the compiler refuses the stamp instead of shipping unrotated facings (see the stage-7 `fragment` row). `compiler::edit`, build-tier (exit 3); the message names the batch and prescribes re-inspecting the layout with `delvec snapshot` — never deleting the prefab guard or leaving a dead edit. | | `DW0352` | A world-edits batch writes into a cell a trap's hardware occupies (v0.6, spec-0017 + spec-0011): its trigger/hazard cell, its dispenser socket, or its disarm-affordance cell. `setup_finish` runs `world_edits` **before** `trap_setup`, so the edit lands first and the trap is loaded into a block that is no longer there — vanilla's `item replace block … container.0` on a non-container fails with **no output**, so the delve ships a dead trap with every proof green (`DW0342` proves the *planned* hazard, not the surviving hardware; no geometry proof models "is this still a dispenser"). `compiler::edit`, checked first in the per-batch invariants, build-tier (exit 3). The message names the batch, the cell, the trap and which role the cell plays; prescription is to move the region off the trap's cells or re-anchor the trap — never to assume the edit leaves the redstone intact. | | `DW0354` | A support-dependent block the edit script placed has no valid support in the post-batch world (v0.6, spec-0017): a torch/lantern/campfire/rail-family block with **nothing below it** after a later batch carved its support away, or flora rooted in a block flowers cannot stand on (a `scatter` over bare stone). Vanilla pops such a block off as an item on the first chunk tick, so the write silently vanishes from the delivered world while every snapshot still shows it. **Two tiers, one code**: advisory (exit 0) for decoration, aggregated per reason + block with a count and one example cell; **error (exit 3)** when the popped block is a fixture the script's own `relight` verb placed — that is a declared `min_light` guarantee the `DW0211` proof accepted, and losing it re-darkens the region. `compiler::edit`, evaluated at every batch close over the cumulative placement set. Deliberately conservative: blocks supported sideways or from above (`wall_torch`, `hanging=true` lanterns) are classified as needing no support, and "support removed" means removed to **air** — the check never guesses about a block it cannot classify. | @@ -2677,8 +2713,10 @@ Exit 3 except `DW0312` (wave-capacity), `DW0313` (gravity-despawn) and `DW0342` | `DW0355` | A **punishing** `begin-stealth` beat whose grace window cannot be beaten (spec-0014 + spec-0016): from a position a player legally occupies the instant the session arms, no zone is reachable within `grace_ticks` at sprint speed over the assembled geometry. DW0327 proves cover exists and is *connected*; this proves it is reachable **in time** — the gap that shipped the island's blinding beat, where the beat armed under the player's feet at the fire-pit and killed every player (bot and human alike) ~2 s later, on a first honest ladder run. Start positions: the activating objective's anchor **and** every `set-checkpoint` reigning inside the beat's active window `[fire_step, end_step]` — a respawn point that cannot beat the window makes the retry loop non-terminating rather than a souls retry. Cost model: 4 ticks/block (vanilla sprint 0.2806 blocks/tick, rounded up — no sprint-jump credit) + 6 ticks per block climbed + 10 ticks of standing-start reaction; routed over the same per-leg geometry DW0311/DW0315 use (gates causally sealed by the firing step forced solid). Build-tier (exit 3), `compiler::nav`. The message names the beat, the start, the nearest zone cell, the measured flee time and the tick deficit. Scope: only beats whose `on_caught` tree actually punishes (`damage-players` / `spawn-wave`) — a narrate-only beat has nothing to escape. Prescription: raise `grace_ticks` to at least the measured need plus a tension margin, put a zone within reach of where the beat starts, move the checkpoint into/beside a zone, or arm the beat from a less exposed objective. **Delaying the arm does not discharge it** (a `sequence` step buys drama, not proof: the clock still starts with the player free to be standing at the start cell), and deleting the `on_caught` consequence is explicitly not a fix. Numbered `DW0355`, not `DW0352`: this rule and the map editor's trap-hardware check were developed on parallel branches, each picking the next free code against its own branch point, and collided on merge — `tools/check-dw-codes.py` now gates one-code-one-rule so that class fails CI instead of shipping. | | `DW0329` | A `sequence` effect is nested inside another `sequence` (directly, or reachable via a nested `move-actor` `on_arrive`) — timelines do not recurse (spec-0014). Validation-tier (exit 1), `dsl::validate`. Flatten the inner steps into the outer timeline (shift their `at_ticks`). | | `DW0342` | A **lethal** trap (spec-0011) whose trigger cell lies on the forced critical path with no discharge — not avoidable (the trigger cell is a required path cell), not survivable (`rearm`, so a respawn walk-back re-triggers it → soft-loop), and not disarmable (no disarm affordance reachable before it, over the world with the trap cell blocked). The player is provably killed or soft-looped. **Analysis-tier: exit 2**, like `DW0312` — a content-design mistake, not a geometry defect; the message names the trap and prescribes moving it off the path, setting `reset: once`, or adding a reachable `disarm`. Renumbered off the spec's stale reserved number (0314 — since taken by the waypoint self-check). | -| `DW0344` | In a `horizon: ocean` world, a placed piece whose prefab metadata declares `waterline_y` does not land that waterline at sea level (`piece.y + waterline_y ≠ 62`) — the piece floats above the sea (its shore an unclimbable cliff, its authored water pocket hanging in the air) or is drowned under it. Build-tier (exit 3), `compiler::plan`, checked after placement. Nothing downstream can catch this: nav, boundary, POV and PackTest all derive from the very placement that is wrong, so a mis-datumed island validates green and ships unplayable. The message names the area, prefab, placed y and the signed offset, and prescribes correcting the declared `waterline_y` (the local y of the piece's top water block; the island convention is 2) or rebuilding the piece against the convention — ocean areas are placed at y=60 and a piece with a different waterline cannot share that datum. Pieces declaring no `waterline_y` author no sea and are not checked. | +| `DW0344` | In a `horizon: ocean` world, a placed piece whose prefab metadata declares `waterline_y` does not land that waterline at sea level (`piece.y + waterline_y ≠ 62`) — the piece floats above the sea (its shore an unclimbable cliff, its authored water pocket hanging in the air) or is drowned under it. Build-tier (exit 3), `compiler::plan`, checked after placement. Nothing downstream can catch this: nav, boundary, POV and PackTest all derive from the very placement that is wrong, so a mis-datumed island validates green and ships unplayable. The message names the area, prefab, placed y, the signed offset, and the datum-equation term that disagrees (spec-0026 §2: a shore piece authored to its tileset convention has `waterline_y = walk_y − 1`), and prescribes correcting the declared `waterline_y` (the local y of the piece's top water block) or rebuilding the piece against its tileset's declared walk plane. Pieces declaring no `waterline_y` author no sea and are not checked **here** — their walk-cell dryness is proven empirically by `DW0364` (spec-0026 closed the exemption gap; this row keeps the sea-**mating** half: only a declared waterline can be proven to meet the world ocean). | +| `DW0364` | **Empirical flood-level proof** (v0.9, spec-0026 §2 — closes the #149 tide-mill class). In a horizon with a declared flood level (`ocean`: 62), a placed piece has a standable cell (feet passable + head passable + solid below, over the assembled — and, when a stage-7 script exists, edited — model) at or below it: the delivered world drowns that cell on first boot while every compile-time proof (walkability, lighting, checkpoints, POV, PackTest) derives from a model that says dry. **No exemption for pieces that declare no `waterline_y`** — that exemption is exactly what made #149 invisible (an interior piece placed under sea level, no declaration, so nothing ever looked). Authored water cells are impassable in the model and never standable, so shore seabeds don't fire; only genuinely-standable-but-drowned cells do. Aggregated like `DW0322` (up to 6 cells + a total); the message names area, prefab, placed y and the datum-equation term that disagrees (the piece's geometry vs its declared `walk_y`). Build-tier (exit 3), `compiler::nav::check_flood_level`, invoked from `compiler::emit` for every flood-capable build. Prescription: fix the piece's `walk_y`/geometry so every standable cell lands above the flood level; never re-datum by hand to sidestep the proof. There is still **no exemption** and no flag that turns this off: the only way a cell under the waterline leaves this check is to stop being ground — a stage-7 `flood` (spec-0030) may hand it back to the sea, which makes it water and therefore not standable, and only where the compiler itself proves the sea reaches it (`DW0394`/`DW0395`). | | `DW0345` | The assembled world resolves **no entry anchor** — no placed piece declares any of the entry-anchor names (`spawn`, `entry`; see §4 "First-join placement"). The compiler then has no cell to call the campaign's start: no `setworldspawn`, no class-apply teleport, no first-join placement, no `dw:cp` seed. Build-tier (exit 3), `compiler::emit`. Silent before — the delve compiled clean and fell back to the vanilla spawn search, which a **dedicated** server resolves to the surface (so every rung of the validation ladder stayed green) and the **integrated singleplayer** server resolves to the build floor, i.e. inside solid stone. Prescription: give the pool's entry-role prefab an entry anchor in its metadata `anchors`, or bind the area to a prefab that has one. | +| `DW0367` | A piece placed in a **non-void horizon** whose prefab metadata declares no `walk_y` (v0.9, spec-0026 §2) — or a pool whose `entry`-role members disagree about it, leaving the area with no single datum. The per-area placement datum is `walk_ref_y − walk_y`; without the declaration the compiler would have to guess a tileset convention, and a guessed island constant applied to an interior tileset is exactly the #149 flooded-room class the datum rework retired. Build-tier (exit 3), `compiler::plan` (`area_base_y` for the datum piece; `check_walk_y_declared` for every placed piece, so chained pieces keep the migration honest and give `DW0344`/`DW0364` messages their datum term). `void` areas keep `BASE_Y` (64) and need no declaration. Prescription: declare the tileset's walk-plane convention (the local feet-y of the piece's reference floor — island 3, keep 1, cave 2, tidal-keep shore 3 / plinth 11) as `walk_y` in the piece's metadata; the generators emit it. | | `DW0346` | A prefab metadata `*.json` (or `pools.json`) in the prefabs dir failed to read or parse (task #62). The canonical trigger is an **older delvec meeting newer metadata**: `deny_unknown_fields` rejects a field this delvec predates. Previously a silent skip — the prefab vanished from the registry and the run failed much later as a baffling `DW0300` "prefab not found" (or a `DW0160` binding error) with no hint of why. Now `PrefabRegistry::load_dir` records a per-file diagnostic naming the file and the serde error, folded into every `validate`/`analyze`/`build` at **validation tier (exit 1)**; loading continues for the other files (report-all, not fail-fast). Prescription: upgrade delvec, or fix the named field. | | `DW0347` | A `cutscene` shot's aim sweeps faster than the angular budget: over 6°/tick (120°/s) peak on the exact eased path — at 20 Hz that reads as a spin, not a shot (the camera dossier's comfortable band is ≤ 2°/tick; thresholds are the dossier's proposal — the spike rig has no rendering client to calibrate against footage). Typical cause: a `look_at` subject too close to a fast dolly, or a sharp travel-aim corner. Build-tier (exit 3), `compiler::nav` (task #64). An **error**, not a warning: the shot is provably nauseating before it ships, and the fix is always available — more camera distance, a longer `seconds`, or splitting the move into two shots (the hard cut between shots is the idiomatic fast reframe). | @@ -3159,6 +3197,7 @@ this doc is current behavior). | Souls-mode pacing lints: retry cost `DW0379`, optional-elite bypass `DW0380` (both warning tier) (v0.6) | spec-0016 §7 | | Souls-mode bonfires: `bonfire{anchor,on_rest?}`, wave `respawns_on_rest`, `DW0370` (v0.6); the two-option rest dialog + authored labels, the class-kit `flask` + `DW0476`, the flask's potion `contents` + `DW0486`/`DW0487`, the critical path's `rest` step (v0.8, owner rulings/directive 2026-08-03); the stationed re-seat + the bonfire safe zone `DW0478`, whose lane term includes the measured marching drift (owner rulings 2026-08-04); the **undefeated re-seat** — a still-standing actor elite / billed wave is deleted and re-seated fresh at its origin on rest and on death-respawn, a defeated one stays defeated, `DW0489` (owner ruling 2026-08-05) | spec-0016 §1 | | The map editor: stage-7 `world-edits.json`, the full L3 verb set (`select`/`fill`/`replace`/`carve`/`morph`/`scatter`/`plant`/`fragment`/`relight`), the L2 massing verbs (`swap`/`insert`/`remove`/`rewire-socket`/`reseed`; `resize` excluded — no size primitive), per-batch invariant re-proofs, `DW0162`/`DW0322`/`DW0323`/`DW0324`, `delvec edit apply|preview` (all v0.6) | spec-0017 (PRs 1–3) | +| Deliberate shorelines: the stage-7 `flood` verb (DSL **v0.9**), ambient-water reach computed and materialized, the tideline invariant `DW0394`/`DW0395` re-proved after every batch | spec-0030 | | Map-editor audit fixes: trap-hardware integrity `DW0352`, gate-region + block-support advisories `DW0353`/`DW0354`, out-of-bbox edit-chunk load convergence + forceload release, `edit` running the full build-tier proof set, blockstate-preserving `fragment` stamps | map-editor audit (post-#145/#146/#149) | | Party-shared progression: the `#party` holder, party-addressed UI, `world.min_players` + lobby gate, `give-item`/kit `carrier`, the n-agent division proof and the n-dummy `party_join_` PackTests, `DW0356`/`DW0357`/`DW0358` (all v0.6) | spec-0018 (landed) | | The NPC scene ledger: stage-5 `cast` (DSL **v0.7**), the four build proofs `DW0460`–`DW0462`, the forcing function `DW0463`, dangling refs `DW0464`, the pre-0.7 deprecation window `DW0465`, the `"unchanged"` sugar `DW0466` and the staleness lint `DW0467`; the `dw.cast` scene dispatch + bark pools; cast roots as dialogue entry points | spec-0020 (+ owner amendment 2026-08-03) | diff --git a/docs/specs/README.md b/docs/specs/README.md index 7b192f77..216aa610 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -37,3 +37,4 @@ machine-checkable assertions — each criterion maps to a CI check. | [spec-0026](spec-0026-horizon-library.md) | Horizon library — five pseudo-open-world bases | Proposed | | [spec-0027](spec-0027-grammar-prefab-backend.md) | Box-split grammar prefab back end | Proposed | | [spec-0028](spec-0028-ref-image-intent-alignment.md) | Reference-image intent alignment (optional prefab-chain step) | Proposed | +| [spec-0030](spec-0030-deliberate-shorelines.md) | Deliberate shorelines — the `flood` verb and the tideline invariant | Proposed | diff --git a/docs/specs/spec-0030-deliberate-shorelines.md b/docs/specs/spec-0030-deliberate-shorelines.md new file mode 100644 index 00000000..71b2d6c0 --- /dev/null +++ b/docs/specs/spec-0030-deliberate-shorelines.md @@ -0,0 +1,153 @@ +# spec-0030 — Deliberate shorelines (`flood`) + +Status: Proposed · Owner ruling: 2026-08-06 · DSL: world-edits stage v0.9.0 + +## Problem + +`DW0364` (spec-0026 §2) refuses any standable cell at or below a non-void +horizon's flood line, **with no exemption** — that exemption is precisely what +made defect #149 (the drowned tide mill) invisible for a release cycle. + +Applied to `nobodys-cave-island`, the flagship released campaign, it fires on +**26 standable cells at world y=62**, the ocean line, and the build stops. The +prefab is innocent (`island-greenfield.nbt` has zero standable cells at its +declared walk plane − 1). The cells come from two stage-7 edit batches, +`batch/west-bank-falls` and `east-bank-falls`, whose `morph lower` rolls the +meadow berms down past the waterline — the shoreline the campaign is *supposed* +to have. + +**Owner ruling, 2026-08-06: this is intended.** A shoreline that gets its feet +wet is the design. It needs a first-class declaration. + +## The trap this spec has to avoid + +A declaration that merely suppresses `DW0364` is the same hole with a nicer +name. `shallows: true` on a batch, an area or a prefab reopens #149 the first +time an author typos it, copy-pastes it, or reaches for it to silence an +unrelated red. + +So the declaration must make the compiler check something **different**, not +something **less**. + +## Decision + +Add one stage-7 edit verb: + +```json +{ "verb": "flood", "region": "region/" } +``` + +**It does not place water and it does not skip a check. It admits the horizon's +ambient sea into a declared envelope, and the compiler computes what the sea +does there.** + +1. The author names a **region** — an envelope, not a cell list. +2. The compiler computes the sea's **reach** inside it: seeded from ambient + water bordering the envelope, propagated cardinal-sideways and downward + through waterable cells at or below the flood level, confined to cells + **inside a placed piece** (outside them the generator's sea is already + there, and `DW0364` does not look there either). +3. Every reached cell is **materialized** as `minecraft:water` — in the model + *and* in the emitted `world_edits` function. +4. Everything the sea does not reach stays dry, and stays under `DW0364` + **unchanged**. + +### Why this is not an exemption + +`DW0364` is untouched. A declared cell stops being a `DW0364` violation only by +**ceasing to be ground**: water cells are impassable and never standable in the +occupancy model (task #45), so there is nothing left to stand on. The verb can +only ever *remove* walkable cells, never add one — it is fail-closed by +construction, and using it costs the author the ground it takes. + +Three consequences follow, and they are the whole safety argument: + +- **You cannot silence a drowned room with it.** A sealed interior a block under + the sea is not reachable by the sea, so the declaration binds nothing and is + `DW0394`. The #149 fixture with a `flood` declared straight over it stays red; + it merely changes which red. +- **You cannot over-declare your way out.** A wider envelope wets more of your + own map. An envelope that swallows a corridor floods the corridor and turns + nav red. There is no envelope that silences anything. +- **You cannot under-declare.** Water that would flow on past the envelope into + an undeclared air cell of a placed piece is `DW0395`: the shoreline is not + where the author said it is. + +### Why the water is materialized rather than modelled + +A compiler that models water it did not build commits the exact sin `DW0364` +punishes — a model that says one thing while the delivered world does another, +in the opposite direction. Today the island's shoreline is whatever vanilla +fluid ticking does on first boot; after this the waterline is a deterministic +function of the DSL (ADR-0006), 25 emitted source blocks, and the model and the +world agree cell for cell. + +### Why the declaration lives on the edit script + +The cells come from stage-7 world edits, not from the prefab, and the verb sits +in the same language, frame and region vocabulary that sculpted the bank. + +| Alternative | What it would have cost | +|---|---| +| Flag on the **edit batch** | Batch-wide scope over a dozen verbs; the claim would cover cells no one meant it to, and batches that touch the same band later would be silently included. | +| Field on the **area** (stage 1) | Effectively campaign-wide. This is the "nicer name for an exemption" shape: one word, no locality, nothing computed. | +| Field on **prefab metadata** | Wrong owner. The prefab is innocent, is placed at different `y` in different campaigns, and "at the waterline" is a placement-and-edit-time fact, not a property of the `.nbt`. | +| **Nothing** — make the ambient sea a global flood source in `assembled` | The truthful model needs no declaration at all, and this was seriously considered. Rejected: it silently changes every ocean campaign's model and every released world's bytes with no declaration to point at, it makes authorial intent invisible in the DSL, and it converts `DW0364`'s strict default (*no ground under the sea, ever*) into a physics question by default rather than by explicit, proof-carrying exception. | +| **Nothing** — author the water with the existing `fill`/`replace` | Expressible today (`intersect` a surface band with a y=62 box, then `fill` water), and it clears `DW0364`. Rejected as the *primary* answer because it makes the author hand-compute the sea's reach — the downstream folklore CLAUDE.md forbids: the ambient sea is a vanilla primitive the DSL was making content simulate by hand, and nothing checked the result. | + +### Timing + +The verb applies at its position in the batch (so `delvec edit`'s per-batch +snapshots show the shoreline the author is looking at), and the **tideline +invariant** re-proves over every declaration so far after **every** batch — the +same cadence as boundary safety. A later batch that re-cuts the bank is red +where it happened. + +## Diagnostics + +| Code | Meaning | +|---|---| +| `DW0394` | The declaration binds nothing: the horizon has no ambient water at all, or the sea reaches no cell of the envelope. A zero binding is a finding, never a pass. Build-tier (exit 3). | +| `DW0395` | The tideline invariant: the admitted water does not stop inside the envelope, or a later batch left a cell the sea reaches with no water in the model. Build-tier (exit 3). | + +`DW0364` keeps its exact behaviour and its exact domain. `DW0141` fences the +verb below world-edits `dsl_version` 0.9.0. + +## Acceptance criteria + +1. `nobodys-cave-island`, with a `flood` declared over its bank bands, **builds + green** (`delvec build`, exit 0) on the spec-0026 engine + the `walk_y` + content backfill. +2. The declaration's **binding count is non-zero and reported**: the island's + `flood` wets exactly **25** cells, all at world y=62, and exactly 25 + `minecraft:water` cells appear in the emitted `world_edits` function. +3. **`DW0364` still fires where it should**, with the declaration in place: + removing only the one-cell bank-pocket fill from the island's tideline batch + — leaving the `flood` envelope covering that cell — is `DW0364` on exactly + **1** cell. Removing only the `flood` verb is `DW0364` on **25**. +4. The spec-0026 #149 fixture with a `flood` declared over the whole drowned + piece is **`DW0394`, never green** (`crates/compiler/tests/spec0030_flood.rs`). +5. A shoreline notch cut to the ocean line is `DW0364` without a declaration and + **exit 0 with exactly one emitted water cell** with one — red→green over + identical geometry. +6. A `flood` whose envelope covers part of a wider notch is **`DW0395`**. +7. A `flood` in a horizon with no ambient water is **`DW0394`**, naming that + cause. +8. A `flood` in a world-edits stage below `dsl_version` 0.9.0 is **`DW0141`**, + exit 1. +9. **Determinism (ADR-0006)**: a flooded campaign builds byte-identically twice, + and the island does too. +10. A campaign that declares no `flood` builds **byte-identically** to its + pre-spec-0030 output. + +## Non-goals + +- No change to `DW0364`'s rule, tier, domain or message. +- No global change to the ambient's participation in the assembled flood model. +- No modelling of *wading*: a materialized shoreline cell is water, and water is + impassable in the occupancy model. In the delivered world the party can wade + through a one-deep tideline; the model declines to route through it. That is + the conservative direction (it can only lose walkable cells, never invent + one), and player confinement is owned by the `boundary` clock (`DW0320`), not + by nav impassability. A first-class wade cell would be a third collision class + through every proof in `nav` and is deliberately out of scope. diff --git a/prefabs/cave-generator/src/main.rs b/prefabs/cave-generator/src/main.rs index 136d0d87..b31b4d89 100644 --- a/prefabs/cave-generator/src/main.rs +++ b/prefabs/cave-generator/src/main.rs @@ -478,12 +478,20 @@ struct LicenseJson { struct MetaJson { prefab_id: String, structure: StructureJson, + /// The cave tileset walk-plane convention (spec-0026 §2): substrate + floor + /// top local y=1, feet at local y=2 — the `cave:socket` datum. Consumed by + /// the compiler's per-area placement datum (`walk_ref_y − walk_y`; missing + /// in a non-void horizon is `DW0367`). + walk_y: i32, anchors: BTreeMap, connectors: Vec, lighting: LightingJson, license: LicenseJson, } +/// The cave walk plane (feet cell) local y — see [`MetaJson::walk_y`]. +const WALK_Y: i32 = 2; + fn a_pos(pos: [i32; 3], facing: Option<&str>) -> AnchorJson { AnchorJson { pos: Some(pos), @@ -1655,6 +1663,7 @@ fn write_piece(out: &Path, spec: &Spec) { let meta = MetaJson { prefab_id: format!("prefab/{}", spec.id), + walk_y: WALK_Y, structure: StructureJson { file: format!("{}.nbt", spec.id), id: spec.id.into(), diff --git a/prefabs/cave-tileset.md b/prefabs/cave-tileset.md index b59c9ccb..ccddeb37 100644 --- a/prefabs/cave-tileset.md +++ b/prefabs/cave-tileset.md @@ -19,6 +19,16 @@ pieces mate with the same machinery as keep pieces. `connectors[]` in each metadata JSON records `local_pos`, `facing`, `opening`, `joint`. The stair (`cave-descent`) carries the low/high y-offset sockets exactly like `keep-stair`. +**Walk-plane datum (spec-0026 §2)**: the cave convention is substrate + floor +top local y=1, feet at local **y=2** — declared as `walk_y: 2` in every piece's +metadata (generator-emitted; `DW0367` without it in a non-void horizon). +`cave-shore`'s beach walks flush at local 2 with its authored water topping at +the same y (an ankle-deep wading shore) — under an ocean horizon its datum +places that walk plane at 63, one above sea level, and its authored pond then +sits one block above the world sea; it declares no `waterline_y`, so `DW0344` +does not demand sea mating (the piece predates the island convention and no +tracked ocean campaign binds it). + > Known solver interaction: unmated sockets are sealed by the solver with > `minecraft:stone_bricks` (hard-coded in `solver.rs`), so an unused cave doorway > gets a smooth stone-brick patch at assembly. Per-piece renders are unaffected; diff --git a/prefabs/generator/src/main.rs b/prefabs/generator/src/main.rs index cd94ceb1..5c7ced2b 100644 --- a/prefabs/generator/src/main.rs +++ b/prefabs/generator/src/main.rs @@ -177,12 +177,20 @@ struct LicenseJson { struct MetaJson { prefab_id: String, structure: StructureJson, + /// The keep tileset walk-plane convention (spec-0026 §2): floor top local + /// y=0, feet at local y=1 — the socket datum every keep piece is authored + /// against. Consumed by the compiler's per-area placement datum + /// (`walk_ref_y − walk_y`; missing in a non-void horizon is `DW0367`). + walk_y: i32, anchors: BTreeMap, connectors: Vec, lighting: LightingJson, license: LicenseJson, } +/// The keep walk plane (feet cell) local y — see [`MetaJson::walk_y`]. +const WALK_Y: i32 = 1; + /// Declarative piece spec. struct Spec { id: &'static str, @@ -449,6 +457,7 @@ fn write_piece(out: &Path, spec: &Spec) { data_version: DATA_VERSION, generator: GENERATOR.into(), }, + walk_y: WALK_Y, anchors, connectors, lighting: LightingJson { diff --git a/prefabs/island-generator/src/main.rs b/prefabs/island-generator/src/main.rs index bc81b694..345576f2 100644 --- a/prefabs/island-generator/src/main.rs +++ b/prefabs/island-generator/src/main.rs @@ -399,6 +399,11 @@ struct LicenseJson { struct MetaJson { prefab_id: String, structure: StructureJson, + /// The island tileset walk-plane convention (spec-0026 §2): feet at local + /// y=3, one above the waterline — the `island:socket` datum. Consumed by + /// the compiler's per-area placement datum (`walk_ref_y − walk_y`; missing + /// in a non-void horizon is `DW0367`). + walk_y: i32, /// Local y of the top authored water block (the island convention's waterline). /// The compiler's ocean-horizon placement invariant (`DW0344`) requires this to /// land at world sea level (y=62) — see `prefabs/island-tileset.md`. @@ -1085,6 +1090,7 @@ fn write_piece(out: &Path, spec: &Spec) { data_version: DATA_VERSION, generator: GENERATOR.into(), }, + walk_y: WATERLINE_Y + 1, waterline_y: WATERLINE_Y, anchors, connectors, diff --git a/prefabs/island-terrain-generator/src/main.rs b/prefabs/island-terrain-generator/src/main.rs index 9a614943..ad9e64b8 100644 --- a/prefabs/island-terrain-generator/src/main.rs +++ b/prefabs/island-terrain-generator/src/main.rs @@ -484,6 +484,11 @@ struct LicenseJson { struct MetaJson { prefab_id: String, structure: StructureJson, + /// The island tileset walk-plane convention (spec-0026 §2): feet at local + /// y=3, one above the waterline — the `island:socket` datum. Consumed by + /// the compiler's per-area placement datum (`walk_ref_y − walk_y`; missing + /// in a non-void horizon is `DW0367`). + walk_y: i32, /// Local y of the island waterline datum (see [`WATERLINE_Y`]); the compiler /// pins it to world sea level when placing an ocean-horizon area (`DW0344`). waterline_y: i32, @@ -1909,6 +1914,7 @@ fn write_piece(out: &Path, spec: &Spec) { data_version: DATA_VERSION, generator: GENERATOR.into(), }, + walk_y: WATERLINE_Y + 1, waterline_y: WATERLINE_Y, anchors, connectors, diff --git a/prefabs/island-tileset.md b/prefabs/island-tileset.md index 2de75d5f..328c0168 100644 --- a/prefabs/island-tileset.md +++ b/prefabs/island-tileset.md @@ -14,9 +14,13 @@ contiguous walkable island (design brief §1, §5). ## Island convention (shared — greenfield/mountain align to this) The world horizon is `ocean` (spec-0013): a superflat with **sea level y=62**. -The compiler places every area of an ocean world with its base at **y=60** -(`sea_level - 2`), the datum this convention assumes — so the piece's walk plane -(local y=3) lands at world y=63, one block above the sea, exactly like a vanilla +The compiler places an island area at **y=60** via the per-area datum +(spec-0026 §2: `walk_ref_y (63) − walk_y (3)` — every island piece declares +`walk_y: 3` in its metadata, generator-emitted; `DW0367` without it). The old +global `sea_level − 2` constant this convention used to assume is retired: the +island's placement is unchanged, but other tilesets now land on their own +declared walk planes instead of the island's. The piece's walk plane (local +y=3) lands at world y=63, one block above the sea, exactly like a vanilla beach. Every island piece authors its own local geometry against these fixed local heights: diff --git a/prefabs/keep-tileset.md b/prefabs/keep-tileset.md index caad579d..7362be2b 100644 --- a/prefabs/keep-tileset.md +++ b/prefabs/keep-tileset.md @@ -20,6 +20,11 @@ whole library: | `final_state` | `minecraft:air` | | `joint` | `aligned` | +**Walk-plane datum (spec-0026 §2)**: the keep convention is floor top local +y=0, feet at local **y=1** — declared as `walk_y: 1` in every piece's metadata +(generator-emitted). The compiler places a non-void-horizon area at +`walk_ref_y − walk_y` and refuses a piece without the declaration (`DW0367`). + - **Symmetric.** `name == target == keep:socket`, so every socket is both a receiver and an initiator: any two sockets connect. Which sockets expand (draw a new piece) vs. which are consumed as the incoming connection is decided by diff --git a/prefabs/tidal-keep-generator/src/common.rs b/prefabs/tidal-keep-generator/src/common.rs index 5e20a6c8..087aaba1 100644 --- a/prefabs/tidal-keep-generator/src/common.rs +++ b/prefabs/tidal-keep-generator/src/common.rs @@ -591,6 +591,12 @@ pub struct LicenseJson { pub struct MetaJson { pub prefab_id: String, pub structure: StructureJson, + /// The piece's authored walk-plane datum (spec-0026 §2): the feet-cell + /// local y of its **lowest** socket floor — shore pieces 3 + /// (`SHORE_FLOOR_Y + 1`), plinth pieces 11 (`KEEP_FLOOR_Y + 1`). Consumed + /// by the compiler's per-area placement datum (`walk_ref_y − walk_y`; + /// missing in a non-void horizon is `DW0367`). + pub walk_y: i32, #[serde(skip_serializing_if = "Option::is_none")] pub waterline_y: Option, pub anchors: BTreeMap, diff --git a/prefabs/tidal-keep-generator/src/main.rs b/prefabs/tidal-keep-generator/src/main.rs index 172acf5b..07b0ec3c 100644 --- a/prefabs/tidal-keep-generator/src/main.rs +++ b/prefabs/tidal-keep-generator/src/main.rs @@ -245,6 +245,16 @@ fn write_piece(out: &Path, spec: &Spec) { data_version: DATA_VERSION, generator: GENERATOR.into(), }, + // The piece's walk-plane datum = its lowest socket floor + 1 (the + // convention the empirical spec-0026 probe verified: shore 3, plinth + // 11; the gatehouse carries both datums and declares its shore side). + walk_y: spec + .doors + .iter() + .map(|d| d.1) + .min() + .expect("piece has doors") + + 1, waterline_y: spec.waterline_y, anchors, connectors, diff --git a/prefabs/tidal-keep-tileset.md b/prefabs/tidal-keep-tileset.md index a6051476..378d4905 100644 --- a/prefabs/tidal-keep-tileset.md +++ b/prefabs/tidal-keep-tileset.md @@ -30,6 +30,15 @@ piece placed at the y=60 datum, so its waterline lands at sea level (`DW0344`). Every other piece omits `waterline_y` on purpose: they stand on the plinth, above the tide, and a declared waterline would demand they sit at sea level. +**Walk-plane datum (spec-0026 §2)**: each piece declares its `walk_y` — the +feet-y of its **lowest** socket floor: shore pieces 3 (`tk-barrow-field`, +`tk-gatehouse`), plinth pieces 11 (everything else); generator-derived from the +piece's door list. The area datum reads the entry piece (`tk-barrow-field`, +walk_y 3 → base `63 − 3 = 60`, byte-identical to the retired global ocean +datum); the plinth pieces' values are declaration honesty for the empirical +flood proof (`DW0364`), which verifies every piece's standable cells sit above +sea level regardless of what anything declares. + **All vertical gain is authored inside a piece.** The solver has no vertical socket (`Facing::parse` accepts cardinals only), so a piece's rise is exactly the difference between its two sockets' local y — the `keep-stair` rule, applied at diff --git a/versions.toml b/versions.toml index 8bc3eaf0..bf9c34c7 100644 --- a/versions.toml +++ b/versions.toml @@ -133,19 +133,24 @@ mineflayer = "4.37.1" # RE-PIN CONVENTION: after a content-repo PR merges, update `sha` to the new # content `main` tip in a normal follow-up PR. repo = "stellarfeline/delvewright-campaigns" -# Content `main` tip after the cave **gravity-floor substrate** regen (content #10) -# — the tileset half of task #42, pairing with the compiler's gravity-settled model -# (#75) and the DW0313 despawn gate (#77). Every enclosed cave piece now lays a -# hidden non-falling `stone` substrate under each sand/gravel floor cell, so the -# sand/gravel surface (a first-class content need) survives the `the_void` world -# instead of despawning. The substrate lifts each piece +1 (size, socket, anchor -# Ys shift together; the solver's mating absorbs the offset). `cave-den`'s -# `anchor/wave` is removed (its interior is all corridor) and `cave-cavern` gains -# one in a valid east alcove; every enclosed `.nbt` body changes, palette unchanged -# (substrate is `stone`, audit-clean). Deterministic (byte-identical regen); -# `nobodys-cave` builds green (DW0313/DW0311/DW0312 clear) with the flock on solid -# floor. Bump on future content merges per the RE-PIN CONVENTION. -sha = "87a8ade1412b6961e1c67081a5c8f9fdbacf6b2e" +# The spec-0026 **walk_y backfill** (content branch +# `worker/spec-0026-walk-y-backfill`, content PR #21), which is content `main` +# 885fd5d plus one additive line per prefab: every prefab metadata file declares +# its tileset walk-plane convention (`walk_y` — keep 1, cave 2, island 3; each +# verified empirically from the .nbt geometry, and the island set re-derived +# over main's REGENERATED island prefabs as `waterline_y + 1`). The compiler's +# per-area placement datum consumes it (base = walk_ref_y - walk_y, spec-0026 +# §2, DW0367). The engine and content changes land as a pair: this delvec +# cannot place a non-void horizon against a library without walk_y, and content +# main carries walk_y on zero of its 36 prefabs. +# +# This is a BRANCH sha, not a content `main` tip, and it is the only kind of pin +# that satisfies both constraints at once — it is a strict descendant of content +# main (nothing this branch's own re-pins landed, up to and including the island +# storybook marker and the v1.0.0 engine re-pin, is rolled back), and it carries +# the metadata this engine requires. RE-PIN to the content `main` tip the moment +# content #21 merges (RE-PIN CONVENTION above). +sha = "5a23d902419e18ff6f0836eb32dd2af3fc987670" [render] # The render layer (spec-0007 rendering infra / spec-0003 visual tier). Renders