diff --git a/crates/misaligned-core/src/prefab.rs b/crates/misaligned-core/src/prefab.rs index e8cceb51..7b090a8e 100644 --- a/crates/misaligned-core/src/prefab.rs +++ b/crates/misaligned-core/src/prefab.rs @@ -99,10 +99,24 @@ pub struct Layout { pub placements: Vec, /// Named room rects captured from placements (survives corridor carving). pub rooms: Vec, + /// Child layouts composed into this one (zplanes.md criterion 2: "a floor + /// is a Layout of room-prefabs"). Their non-`Rock` tiles overlay this grid + /// at the given offset, so a larger layout is built by composing smaller + /// ones — the same stamping recursion the plane stack uses one level up. + /// Tile composition only; room-rect and plane composition ride the + /// room-namespacing decision (criterion 3). + pub composed: Vec, /// Final carved tiles (placements stamped + corridors); set by builders. cached: Option>, } +/// A child `Layout` placed into a parent at an offset (zplanes.md criterion 2). +pub struct SubLayout { + pub layout: Box, + pub x: i32, + pub y: i32, +} + impl Layout { /// The finished tile grid (row-major): cached carve if present, else a /// fresh stamp of the placements. @@ -110,7 +124,8 @@ impl Layout { self.cached.clone().unwrap_or_else(|| self.stamp()) } - /// Stamp all placements into a flat tile grid (row-major). + /// Stamp all placements into a flat tile grid (row-major), then overlay + /// any composed child layouts (zplanes.md criterion 2). pub fn stamp(&self) -> Vec { let mut tiles = vec![TileType::Rock; (self.width * self.height) as usize]; for p in &self.placements { @@ -128,6 +143,28 @@ impl Layout { } } } + // Overlay composed children. A child stamps itself (recursively), and + // its non-`Rock` tiles land at the child's offset — `Rock` is + // transparent so the child composes onto the parent without erasing it. + for sub in &self.composed { + let child = sub.layout.tiles(); + let cw = sub.layout.width; + let ch = sub.layout.height; + for cy in 0..ch { + for cx in 0..cw { + let tile = child[(cy * cw + cx) as usize]; + if tile == TileType::Rock { + continue; + } + let x = sub.x + cx; + let y = sub.y + cy; + if x < 0 || y < 0 || x >= self.width || y >= self.height { + continue; + } + tiles[(y * self.width + x) as usize] = tile; + } + } + } tiles } } @@ -298,6 +335,7 @@ pub fn basement() -> Layout { prefabs, placements, rooms, + composed: Vec::new(), cached: None, }; let mut tiles = layout.stamp(); @@ -410,6 +448,7 @@ pub fn toy_layout() -> Layout { prefabs, placements, rooms, + composed: Vec::new(), cached: None, }; let tiles = layout.stamp(); @@ -421,6 +460,67 @@ pub fn toy_layout() -> Layout { mod tests { use super::*; + /// A one-prefab floor: a 4x3 room whose interior is Floor, walls '#'. + fn toy_floor() -> Layout { + Layout { + width: 4, + height: 3, + prefabs: vec![Prefab { + name: "cell", + rows: &["####", "#..#", "####"], + }], + placements: vec![Placement { + prefab: 0, + x: 0, + y: 0, + }], + rooms: Vec::new(), + composed: Vec::new(), + cached: None, + } + } + + #[test] + fn composed_layouts_stamp_at_their_offsets() { + // zplanes.md criterion 2: a two-floor toy building built by composing + // floor-Layouts stamps correctly. Two identical floor-Layouts are + // composed into one taller grid at different y-offsets; each floor's + // interior Floor tiles must land at its own offset, and the gap between + // them stays Rock (transparent, un-erased). + let building = Layout { + width: 4, + height: 8, + prefabs: Vec::new(), + placements: Vec::new(), + rooms: Vec::new(), + composed: vec![ + SubLayout { + layout: Box::new(toy_floor()), + x: 0, + y: 0, + }, + SubLayout { + layout: Box::new(toy_floor()), + x: 0, + y: 5, + }, + ], + cached: None, + }; + let tiles = building.stamp(); + let at = |x: i32, y: i32| tiles[(y * 4 + x) as usize]; + + // Each floor's interior Floor cell (1,1 within a floor) lands at its + // composed offset: floor 0 at y=1, floor 1 at y=6. + assert_eq!(at(1, 1), TileType::Floor, "ground floor interior"); + assert_eq!(at(1, 6), TileType::Floor, "upper floor interior, offset +5"); + // Walls compose too. + assert_eq!(at(0, 0), TileType::Wall); + assert_eq!(at(0, 5), TileType::Wall); + // The gap between the two floors is untouched background. + assert_eq!(at(1, 4), TileType::Rock, "the inter-floor gap stays Rock"); + } + #[test] fn basement_has_a_core_bay() { let layout = basement(); diff --git a/wiki/log/2026-07-12-zplanes-nested-layout.md b/wiki/log/2026-07-12-zplanes-nested-layout.md new file mode 100644 index 00000000..2b519521 --- /dev/null +++ b/wiki/log/2026-07-12-zplanes-nested-layout.md @@ -0,0 +1,39 @@ +# Z-planes criterion 2: nested Layout composition + +``` +Type: log +``` + +## Intent + +Land zplanes criterion 2 — `Layout` can be composed of child `Layout`s — on the +held zplanes branch. Chosen because it is independent of the two open design +decisions (active-plane-is-sim-or-frontend; sensors-per-plane), so it is safe to +build while those await review. + +## What landed + +- `prefab.rs`: `Layout` gained a `composed: Vec` field and a + `SubLayout { layout: Box, x, y }` type. `Layout::stamp` now, after + stamping its own prefab placements, overlays each composed child's tiles: the + child stamps itself (recursively) and its non-`Rock` tiles land at the child's + offset. `Rock` is transparent, so a child composes onto the parent without + erasing it — the same stamping recursion the plane stack uses one level up. +- Pinned by `composed_layouts_stamp_at_their_offsets`: two floor-Layouts + composed into one taller grid at different y-offsets stamp their interiors at + the right places, walls compose, and the inter-floor gap stays `Rock`. + +Scope is tile composition only. Composing child **room-rects** (offsetting a +child's named rooms into the parent) is deferred to criterion 3, because it runs +straight into the unresolved room-name-namespacing decision (are room names +world-unique or `(z, name)` keys?). Building it now would bake in an answer. + +## Defense + +Additive and non-breaking: the new field is empty for the authored basement and +the toy layout (the only two `Layout` builders), so `stamp` produces identical +tiles for every existing map and all 372 core tests pass (one new). The overlay +treats `Rock` as transparent, matching how the base stamp leaves `Rock` as +background, so composition never silently erases parent tiles. No plane or +sensor semantics are touched, so this commits nothing about the two open +design decisions. Still on the held branch — not merged. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 4c0ad948..29f64c22 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-07-12 - Z-planes criterion 2: nested Layout composition + +- Intent: Land zplanes criterion 2 — `Layout` can be composed of child `Layout`s — on the held zplanes branch. Chosen because it is independent of the two open design decisions (active-plane-is-sim-or-frontend; sensors-per-plane), so it is safe to build while those await review. +- Log: [wiki/log/2026-07-12-zplanes-nested-layout.md](2026-07-12-zplanes-nested-layout.md) + ## 2026-07-12 - Z-planes criterion 1: the world is a plane stack - Intent: Land zplanes criterion 1 — the non-breaking substrate step — and **hold the rest of the migration for review** (Cameron's call, large-migration discipline). diff --git a/wiki/world/places/zplanes.md b/wiki/world/places/zplanes.md index d4565172..e1525d21 100644 --- a/wiki/world/places/zplanes.md +++ b/wiki/world/places/zplanes.md @@ -11,12 +11,18 @@ Status note: 2026-07-12 (zplanes, save lane) — criterion 1 landed: the world i run is a one-plane world (`World::from_basement_map`); the single-plane save is byte-identical, so no save-version bump. Pinned by `basement_is_plane_zero_of_a_one_plane_world` and the full existing suite. - DEFERRED (criteria 2-6, held for review): nested `Layout` placement, paired - transitions, a second authored plane with its own persons/schedules, per-plane - observers, and the repeated-per-plane save format. `Plane` also omits the - spec sketch's `sensors: Vec` for now — sensing lives in the reach - graph (reach.rs), and moving it to per-plane lists is the criterion-5 observer - work. See wiki/log/2026-07-12-zplanes-criterion-one.md. + Criterion 2 also landed: `Layout` gained nested composition — a `SubLayout` + child stamps its non-`Rock` tiles into the parent grid at an offset + (`prefab.rs`; `stamp` recurses), pinned by + `composed_layouts_stamp_at_their_offsets`. Tile composition only; composing + child room-rects across planes rides the room-namespacing decision (criterion + 3). DEFERRED (criteria 3-6, held for review): a second authored plane with its + own persons/schedules, paired transitions, per-plane observers, and the + repeated-per-plane save format. `Plane` also omits the spec sketch's + `sensors: Vec` for now — sensing lives in the reach graph (reach.rs), + and moving it to per-plane lists is the criterion-5 observer work. See + wiki/log/2026-07-12-zplanes-criterion-one.md and + wiki/log/2026-07-12-zplanes-nested-layout.md. Status note (prior): B2's structural spine. Written now so the Space interface's recursive shape is decided before anyone builds against the flat one. Stage: B2 — The Tower @@ -76,7 +82,10 @@ pub struct World { pub planes: Vec } same `Person` template, schedules spanning both planes (Dana appears on both — continuity of cast is the point). - Save: world serializes as repeated per-plane blocks of the existing - format; v4 saves load as a one-plane world (migration). + format; pre-multi-plane (single-map) saves load as a one-plane world + (migration). The multi-plane block format bumps the save version when + criterion 6 lands; criterion 1's one-plane world serializes byte-identically, + so no bump was needed yet. ## Player surface @@ -100,4 +109,5 @@ line (legibility). 5. Observers notice only same-plane signatures (aggregates unchanged); test: a Physical signature on Floor 1 never moves a basement-only observer. -6. Save/load round-trips a two-plane world; a v4 save loads as plane 0. +6. Save/load round-trips a two-plane world; a pre-multi-plane (single-map) + save loads as plane 0.