diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index a8d908463..01bc2cfee 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -1954,3 +1954,6 @@ HAND-OFF to T-1046: production still reads the coarse eager derive_all_districts Status -> review pending that visual validation.', NULL, '2026-06-15 08:49:17', '2026-06-15 08:49:17', '2026-06-15 08:49:17', NULL, '217867e4adb296823bec94ba44cc7e46', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCF56EAJ55PHQ977SV866CFM', 'status', 'in_progress', 'review', NULL, '2026-06-15 08:49:17', '2026-06-15 08:49:17', '2026-06-15 08:49:17', NULL, '8e3480f83cd5658e8b94811b2abcefc9', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXP19FFZEVED140S1708W', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 08:57:03', '2026-06-15 08:57:03', '2026-06-15 08:57:03', NULL, 'cee3d272291aef338d753022f34bad8e', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCF56EAJ55PHQ977SV866CFM', 'status', 'review', 'done', NULL, '2026-06-15 08:57:30', '2026-06-15 08:57:30', '2026-06-15 08:57:30', NULL, '6293419a72c853f5752c067049bb807e', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXP19FFZEVED140S1708W', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 08:57:31', '2026-06-15 08:57:31', '2026-06-15 08:57:31', NULL, 'c22e6f145573148a4cd05d8027cc09c5', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 691c4f022..9f1f10f80 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2303,3 +2303,38 @@ Ticket parts: (1) carrier re-scaled to 2km district = done (derive_district + re HAND-OFF to T-1046: production still reads the coarse eager derive_all_districts grid (the Atlas overlay); swapping it to on-demand district + eager 205km region surfacing (decision A) is T-1046''s chartered scope. The detail-scatter ships with sensible defaults; its VISUAL quality (''reads as real, doesn''t invite scrutiny'') can only be validated once T-1046 surfaces districts in the Atlas — tune there if needed. Status -> review pending that visual validation.', 'review', 'high', NULL, 'server', 'D-243', '2026-06-14 19:21:18', '2026-06-15 08:49:17', NULL, 'b764b4944775746d584223afc535292f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXP19FFZEVED140S1708W', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Wire the Region→Chunk→Voxel tier into production — ChunkPos→RegionPos mapping, proxy layers, Atlas surfacing', '(description follows in first append) + +--- +Q-110 resolved (2026-06-14) -> D-243. Scope item (1) ChunkPos->RegionPos is now defined: the D-243 absolute ladder is voxel(1m)->chunk(64m)->block(128m)->quarter(512m)->district(2km)->region(~205km), with a single elastic seam region<->planet (round(2*pi*R/204.8km) regions per body from body_radius_km). ChunkPos->district->region is fixed integer math; only region->heightmap-sample is body-specific. + +Prerequisite: T-1077 re-scales the code to this ladder (the current ~1km RegionProfile moves onto the 2km district; the region becomes the new 205km top hard block) — T-1046 now blocked by T-1077. Climate/weather lockdown + edge fuzz on the region is T-1078. Once T-1077 lands, T-1046 is the production wiring (proxy layers + Atlas surfacing) over a ladder whose scale is finally coherent.', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-12 10:40:58', '2026-06-15 08:57:03', NULL, 'c7008f3bcd45d91ecc82fb0e0dedf82e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCF56EAJ55PHQ977SV866CFM', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Re-scale the cascade to the D-243 ladder + elastic-seam resampling (region 205km / district 2km)', 'Per D-243 (resolves Q-110). Make the code match the canonical ladder. + +1. Re-scale the terrain/climate carrier: the current ~1km RegionProfile (region_profile.rs, derive_all_regions 8-cells/region) moves to the DISTRICT (2km), aligning with D-239 §2''s climate district. Rename RegionProfile -> DistrictProfile (or equivalent); fix the contradictory scale assumptions Q-110 flagged (region_profile.rs:3-4 ~1km doc, cascade.rs:235-237 ''8 cells/region on 128x64'', chunk_context.rs:137-141 ''region=16 chunks''). +2. Add the REGION tier = 100x100 districts = 204.8km (~205km) = the top hard block. +3. Elastic seam region<->planet: round(2*pi*R/204.8km) regions per body from body_radius_km (D-204). ChunkPos -> district -> region is fixed integer math; region->heightmap-sample is the only body-specific function. +4. Detail-scatter synthesis (D-243 §2): interpolate the coarse heightmap (~40-78km/px) + compose a simple multi-octave seed+position-keyed detail field (shaped by morphology/slope, continuous) atop the D-239 §4 domain warp — invented, not stored (D-227), plausible-at-a-glance. + +Blocks T-1046 (the tier can''t wire to production until its scale matches the ladder). + +--- +Implementation (2026-06-15) — all four parts delivered, committed to main, full suite + clippy -D warnings green: + +Phase 1 (38054d4b1): server/src/atlas/scale.rs — the single canonical ladder (voxel/chunk/block/quarter/district/region edge metres + ChunkPos->DistrictPos->RegionPos integer addressing + regions_per_equator elastic seam), with compile-time const asserts so the rungs cannot drift again (the structural fix for the Q-110 failure mode). body_radius_km wired through BodyParams + the reader (D-204, the seam input). + +Phase 2 (b3b5bfc2e): RegionProfile->DistrictProfile, RegionPos->DistrictPos, region_profile.rs->district_profile.rs, derive_*_regions->derive_*_districts across all 10 consumers + the harness. chunk_context re-scaled to scale:: (district 1024m->2048m, shift 4->5). Caught + fixed a real latent bug: the seed-district used a literal ''>> 4'' while the anchor used the constant, so same-district chunks could derive different anchors. Harness scale literals converted to scale:: constants; voxel golden re-pinned (anchors moved, deterministic). + +Phase 3 (e96ed0e9e): detail_scatter.rs (the mid-scale 2-40km ''stretch magic'' — adaptive ruggedness, envelope+modulation, deterministic value-noise) + derive_district() (on-demand 2km carrier: heightmap interpolation via the body_radius seam + scatter), factored build_district_profile() tail. 10 new tests. + +Ticket parts: (1) carrier re-scaled to 2km district = done (derive_district + rename). (2) REGION tier 205km = done (scale.rs). (3) elastic seam = done. (4) detail-scatter = done. + +HAND-OFF to T-1046: production still reads the coarse eager derive_all_districts grid (the Atlas overlay); swapping it to on-demand district + eager 205km region surfacing (decision A) is T-1046''s chartered scope. The detail-scatter ships with sensible defaults; its VISUAL quality (''reads as real, doesn''t invite scrutiny'') can only be validated once T-1046 surfaces districts in the Atlas — tune there if needed. + +Status -> review pending that visual validation.', 'done', 'high', NULL, 'server', 'D-243', '2026-06-14 19:21:18', '2026-06-15 08:57:30', NULL, '0fd215db7a33e6569fbe7d76c846df48', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXP19FFZEVED140S1708W', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Wire the Region→Chunk→Voxel tier into production — ChunkPos→RegionPos mapping, proxy layers, Atlas surfacing', '(description follows in first append) + +--- +Q-110 resolved (2026-06-14) -> D-243. Scope item (1) ChunkPos->RegionPos is now defined: the D-243 absolute ladder is voxel(1m)->chunk(64m)->block(128m)->quarter(512m)->district(2km)->region(~205km), with a single elastic seam region<->planet (round(2*pi*R/204.8km) regions per body from body_radius_km). ChunkPos->district->region is fixed integer math; only region->heightmap-sample is body-specific. + +Prerequisite: T-1077 re-scales the code to this ladder (the current ~1km RegionProfile moves onto the 2km district; the region becomes the new 205km top hard block) — T-1046 now blocked by T-1077. Climate/weather lockdown + edge fuzz on the region is T-1078. Once T-1077 lands, T-1046 is the production wiring (proxy layers + Atlas surfacing) over a ladder whose scale is finally coherent.', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-12 10:40:58', '2026-06-15 08:57:31', NULL, 'fa35e0e45bb6ad8c5659d5b8327ea4e8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 0e3d60306..0f55c0e63 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -51,12 +51,64 @@ pub enum AtlasLayerStatus { Error(String), } -/// A layer response: the computed `Layer1Output`, or a non-ready status. +/// The cascade's coarse district grid, surfaced for the Atlas generation overlay +/// (T-1046, D-226). This is the **planetary-scale** view — one cell per coarse +/// grid square (`grid_w/cols` heightmap pixels) — not the on-demand 2 km districts +/// (those derive only when a player enters a settlement, Phase 5). `morphology` +/// and `elev_q` are row-major (`rows × cols`); `morphology[i]` is a `MorphologyZone` +/// discriminant (D-239 §6, `repr(u8)`), `elev_q[i]` is 0–100 elevation for relief +/// shading. The client maps `cols × rows` onto the displayed heightmap. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DistrictGridLayer { + pub cols: u32, + pub rows: u32, + pub morphology: Vec, + pub elev_q: Vec, +} + +/// A layer response: the computed `Layer1Output` + the coarse district grid +/// (D-225, T-1046), or a non-ready status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtlasLayerResponse { pub body_id: String, pub status: AtlasLayerStatus, pub layer1: Option, + /// The coarse district/morphology grid for the Atlas overlay (T-1046). + /// `Some` on a cache hit once the DistrictProfile layer has run; `None` otherwise. + pub district_grid: Option, +} + +/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046). +/// Returns `None` when the DistrictProfile layer has not run (empty `districts`). +/// The grid is dense `[0, cols) × [0, rows)` (the cascade tiles the full +/// heightmap), so the extent comes from the maximum `DistrictPos`. +pub fn build_district_grid( + state: &crate::atlas::body_world_state::BodyWorldState, +) -> Option { + if state.districts.is_empty() { + return None; + } + let cols = state.districts.keys().map(|(x, _)| *x).max().unwrap_or(0) as u32 + 1; + let rows = state.districts.keys().map(|(_, y)| *y).max().unwrap_or(0) as u32 + 1; + let n = (cols * rows) as usize; + let mut morphology = vec![0u8; n]; + let mut elev_q = vec![0u8; n]; + for (&(x, y), profile) in &state.districts { + if x < 0 || y < 0 { + continue; + } + let i = (y as u32 * cols + x as u32) as usize; + if i < n { + morphology[i] = profile.morphology_zone as u8; + elev_q[i] = profile.elev_q.clamp(0, 100) as u8; + } + } + Some(DistrictGridLayer { + cols, + rows, + morphology, + elev_q, + }) } /// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit; @@ -94,10 +146,12 @@ pub fn handle_atlas_request( grid_w: state.heightmap_width, grid_h: state.heightmap_height, }; + let district_grid = build_district_grid(state); return AtlasLayerResponse { body_id: req.body_id.clone(), status: AtlasLayerStatus::Ready, layer1: Some(layer1), + district_grid, }; } @@ -167,6 +221,7 @@ pub fn handle_atlas_request( body_id: req.body_id.clone(), status: AtlasLayerStatus::Pending, layer1: None, + district_grid: None, } } // Unknown / no terrain → re-requesting won't help. @@ -175,11 +230,13 @@ pub fn handle_atlas_request( body_id: req.body_id.clone(), status: AtlasLayerStatus::NotFound, layer1: None, + district_grid: None, }, Err(e) => AtlasLayerResponse { body_id: req.body_id.clone(), status: AtlasLayerStatus::Error(e.to_string()), layer1: None, + district_grid: None, }, } } @@ -196,6 +253,62 @@ mod tests { static SEQ: AtomicU32 = AtomicU32::new(0); + #[test] + fn district_grid_built_from_cached_districts() { + use crate::atlas::district_profile::{ + DistrictProfile, GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass, + }; + use crate::simulation::generator::MorphologyZone; + let dp = |zone: MorphologyZone, elev: i32| DistrictProfile { + morphology_zone: zone, + tectonic_class: TectonicClass::Stable, + glaciation_grade: GlaciationGrade::None, + precipitation_class: PrecipitationClass::Arid, + slope_q: 0, + elev_q: elev, + ocean_fraction_q: 0, + river_threshold: 200, + temperature_c: Some(10.0), + moisture_q: 50, + vegetation_class: VegetationClass::Barren, + }; + let mut state = BodyWorldState { + body_id: "GJ1c".into(), + heightmap: vec![], + heightmap_width: 16, + heightmap_height: 8, + river_network: RiverNetwork::default(), + drainage_basins: vec![], + attractors: vec![], + placements: vec![], + road_graph: crate::atlas::road_graph::RoadGraph::default(), + quarters: std::collections::BTreeMap::new(), + districts: std::collections::BTreeMap::new(), + last_accessed: 0, + }; + // 3×2 grid with two distinct zones at the corners. + state + .districts + .insert((0, 0), dp(MorphologyZone::AlluvialPlain, 10)); + state + .districts + .insert((2, 1), dp(MorphologyZone::Alpine, 90)); + + let grid = build_district_grid(&state).expect("districts present → Some grid"); + assert_eq!((grid.cols, grid.rows), (3, 2)); + assert_eq!(grid.morphology.len(), 6); + assert_eq!(grid.morphology[0], MorphologyZone::AlluvialPlain as u8); + assert_eq!( + grid.morphology[grid.cols as usize + 2], + MorphologyZone::Alpine as u8 + ); + assert_eq!(grid.elev_q[grid.cols as usize + 2], 90); + + // Empty districts → None (DistrictProfile layer hasn't run). + state.districts.clear(); + assert!(build_district_grid(&state).is_none()); + } + fn req(body_id: &str) -> AtlasLayerRequest { AtlasLayerRequest { body_id: body_id.to_string(), diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index b6c4648e1..b929df31d 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -79,6 +79,7 @@ fn serve_atlas_requests( body_id: req.body_id.clone(), status: AtlasLayerStatus::Error("no body source resolver".to_string()), layer1: None, + district_grid: None, }, }; responses.0.push(resp); diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index 94d982b67..14ca4e68b 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -3118,7 +3118,7 @@ mod tests { ]; for zone in &zones { let district = DistrictProfile { - morphology_zone: zone.clone(), + morphology_zone: *zone, tectonic_class: TectonicClass::Volcanic, glaciation_grade: GlaciationGrade::Moderate, slope_q: 50, @@ -3153,7 +3153,7 @@ mod tests { ]; for (zone, expected_terrain) in cases { let district = DistrictProfile { - morphology_zone: zone.clone(), + morphology_zone: *zone, tectonic_class: TectonicClass::Volcanic, glaciation_grade: GlaciationGrade::Moderate, slope_q: 50, diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 3778ff419..f23ce179f 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -986,7 +986,7 @@ pub struct DoorSpec { /// /// Integer-discriminant, D-010 compliant. `repr(u8)` pins values for /// serialisation stability; append-only. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)] #[repr(u8)] pub enum MorphologyZone { /// Deep-water ocean — hub-and-spoke; perimeter access priority. diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 13ca08a57..2643c8666 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -571,6 +571,7 @@ fn generate_atlas_layer_response_fixtures() { body_id: "GJ1c".into(), status: AtlasLayerStatus::Ready, layer1: Some(layer1), + district_grid: None, }; write_fixture( "atlas_response_ready", @@ -581,6 +582,7 @@ fn generate_atlas_layer_response_fixtures() { body_id: "GJ1c".into(), status: AtlasLayerStatus::Pending, layer1: None, + district_grid: None, }; write_fixture( "atlas_response_pending", @@ -591,6 +593,7 @@ fn generate_atlas_layer_response_fixtures() { body_id: "ghost".into(), status: AtlasLayerStatus::NotFound, layer1: None, + district_grid: None, }; write_fixture( "atlas_response_not_found",