Merge PR #167: D-243 region-tier finish — region climate stack + cross-district blending (T-1078, T-1042)

Reviewed by Hoshe + Tyre; all findings addressed, T1 wiring re-reviewed (APPROVE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:40:56 +02:00
co-authored by Claude Opus 4.8
10 changed files with 2323 additions and 147 deletions
+103
View File
@@ -1957,3 +1957,106 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
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;
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', 'description', '(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.', '(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.
---
Implementation (2026-06-15) the derived tier is now VISIBLE in the Atlas.
(1) ChunkPos->RegionPos: done in scale.rs (T-1077) fixed integer addressing.
(2) Proxy serves the tier (d03b4d1e0): AtlasLayerResponse gains DistrictGridLayer (cols/rows + row-major MorphologyZone discriminants + elev_q) via build_district_grid(); populated on cache hit. rmp_serde msgpack flows it to the client automatically.
(3) Atlas surfacing (cfd5e18d0): protocol.gd decodes district_grid; atlas_viewer.gd adds the gen_district (''MRPH'') toggle + state; atlas_marker_overlay.gd paints the coarse grid coloured by morphology zone (17-zone palette, D-239 §6). gdUnit4 test green.
SCOPE NOTE: this surfaces the PLANETARY-SCALE coarse morphology grid the Atlas map view of the derived tier (the audit headline: "the Atlas cannot show the tiers that exist" is resolved). The on-demand 2km derive_district + per-voxel geometry (T-1077) are for IN-WORLD Phase 5 rendering, NOT the planetary map; "voxel layers in the proxy" from the original audit scope belongs to Phase 5, not here.
Status -> review. Visual palette tuning can follow once eyeballed in the running Atlas (the map now renders the morphology overlay over real bodies).', NULL, '2026-06-15 10:58:18', '2026-06-15 10:58:18', '2026-06-15 10:58:18', NULL, '66b9925155099ae230970900cf69ffb3', 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', 'review', NULL, '2026-06-15 10:58:18', '2026-06-15 10:58:18', '2026-06-15 10:58:18', NULL, '8f83e461b1fc38031447ac31b089780c', 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 ('06FBPQ0308CFF9WKNGBBYQ763W', 'description', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows.
Implement cross-region parameter blending at the chunk/voxel scale, replacing the walking-skeleton single-profile restriction (server/src/atlas/chunk_context.rs:119-123: ''in the future a blend of adjacent profiles will handle cross-region chunk seams''). Today every voxel generator reads the one covering RegionProfile''s values directly (region.elev_q at voxel.rs:614, :731, :1067-1068; channel/meander params likewise), so continuous parameters step discontinuously at every region-grid border elevation cliffs at the region pitch, visible as a grid at Atlas/voxel scale.
Design is decided stay inside the D-239 §7 seam policy:
- NEVER blend morphology family selection. Family seams are prevented at source (gate ordering + build-time compatibility matrix) or kept sharp (cliff-fjord, lithology faults are real geology). Tyre''s cross-family elevation-blend was explicitly resolved against (D-239 dissent line).
- DO blend continuous positional parameters: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Mechanism from tile-derivation-contract/tyre-round1.md: ChunkContext gains secondary: Option<RegionProfile> + blend_weight: u8 (255 = fully primary; :123-125), blend zone one chunk (64 m) each side of the region border (:101-102); derive_voxel resolves parameters from both profiles and integer-blends BEFORE material selection (:217-220), reusing the same warp offset so the blend seam cannot align with the chunk edge; elevation sampled bilinearly from the region grid to chunk resolution (:130, the elevation_grid sketch).
- D-010 discipline: integer blend arithmetic; the f64 path only for position math per D-239 §4.
Note: region size in metres is currently ambiguous (see the scale-anchoring Q-record) the blend can be implemented against the region grid index regardless, but the Q should be resolved before tuning blend-zone width in metres.
Acceptance: T-1031 harness cross-region chunk pairs show no elevation step exceeding the lithology-permitted slope (D-239 §8) at region borders; family seams remain sharp; determinism golden-seed unchanged for chunks far from borders. Parent: T-750. Refs: D-239 §4/§7/§8, D-010; Q-102; T-1026/T-1028/T-1029 (predecessors).', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows.
Implement cross-region parameter blending at the chunk/voxel scale, replacing the walking-skeleton single-profile restriction (server/src/atlas/chunk_context.rs:119-123: ''in the future a blend of adjacent profiles will handle cross-region chunk seams''). Today every voxel generator reads the one covering RegionProfile''s values directly (region.elev_q at voxel.rs:614, :731, :1067-1068; channel/meander params likewise), so continuous parameters step discontinuously at every region-grid border elevation cliffs at the region pitch, visible as a grid at Atlas/voxel scale.
Design is decided stay inside the D-239 §7 seam policy:
- NEVER blend morphology family selection. Family seams are prevented at source (gate ordering + build-time compatibility matrix) or kept sharp (cliff-fjord, lithology faults are real geology). Tyre''s cross-family elevation-blend was explicitly resolved against (D-239 dissent line).
- DO blend continuous positional parameters: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Mechanism from tile-derivation-contract/tyre-round1.md: ChunkContext gains secondary: Option<RegionProfile> + blend_weight: u8 (255 = fully primary; :123-125), blend zone one chunk (64 m) each side of the region border (:101-102); derive_voxel resolves parameters from both profiles and integer-blends BEFORE material selection (:217-220), reusing the same warp offset so the blend seam cannot align with the chunk edge; elevation sampled bilinearly from the region grid to chunk resolution (:130, the elevation_grid sketch).
- D-010 discipline: integer blend arithmetic; the f64 path only for position math per D-239 §4.
Note: region size in metres is currently ambiguous (see the scale-anchoring Q-record) the blend can be implemented against the region grid index regardless, but the Q should be resolved before tuning blend-zone width in metres.
Acceptance: T-1031 harness cross-region chunk pairs show no elevation step exceeding the lithology-permitted slope (D-239 §8) at region borders; family seams remain sharp; determinism golden-seed unchanged for chunks far from borders. Parent: T-750. Refs: D-239 §4/§7/§8, D-010; Q-102; T-1026/T-1028/T-1029 (predecessors).
---
Refinement (2026-06-15, /whats-next Si pass) supersedes the stale code/scale refs above. The original scope was authored 2026-06-12, BEFORE T-1077 landed (RegionProfileDistrictProfile rename + D-243 re-scale). Design intent unchanged; the references below are the current tree.
REFRAME this is cross-DISTRICT blending, not cross-region. After T-1077 the per-voxel carrier is the DistrictProfile (2,048 m district), so the single-profile "walking skeleton" restriction is a single-DISTRICT-profile restriction and the visible step is at the 2 km district pitch (finer / more visible than the old "region grid" framing). There is no RegionProfile / region.elev_q in the tree anymore.
CURRENT REFS (use these):
- Carrier: server/src/atlas/district_profile.rs (DistrictProfile).
- The "future blend" TODO: server/src/atlas/chunk_context.rs:166-168 ("in the future a blend of adjacent profiles will handle cross-district chunk seams, but for the walking skeleton one profile is sufficient").
- Per-voxel elevation reads: server/src/atlas/voxel.rs:443/515/605/723/808/984/1062-1063/1157 (all district.elev_q).
- channel width / meander wavelength+phase already live ON ChunkContext (precomputed from DistrictProfile) blend them on the context, do NOT re-read them from the profile in the blend path.
- derive_chunk_context entry: chunk_context.rs:176. derive_voxel_column entry: voxel.rs:330.
SCALE-Q RESOLVED: D-243 fixed district=2,048 m, region=~205 km the "region size in metres ambiguous" note is obsolete. Q-102 (noise-basis algorithm) stays open but does NOT block blend-zone arithmetic. Blend zone = one chunk (64 m) each side of the district border, against the district grid index.
D-243 §4 / D-239 §7 RECONCILIATION (no conflict): D-243 §4''s continuous edge-fuzz mandate governs CLIMATE scalars that is T-1078''s job. T-1042 blends continuous TERRAIN positional params only: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Morphology FAMILY selection is NEVER blended it stays sharp per D-239 §7 (gate ordering + compatibility matrix; cliff-fjord/lithology faults are real geology). The two decisions partition cleanly: family sharp, continuous params feathered.
API SHAPE (follow the ticket''s own mechanism): ChunkContext gains `secondary: Option<DistrictProfile>` + `blend_weight: u8` (255 = fully primary). The caller of derive_chunk_context detects a chunk lying within one chunk of a district boundary and supplies the adjacent DistrictProfile; derive_voxel_column reads secondary+blend_weight off ChunkContext and integer-blends (D-010: integer arithmetic; f64 only for position per D-239 §4) BEFORE material selection, reusing the same warp offset so the blend seam cannot align with the chunk edge. No new parameter on derive_voxel_column it reads the context.
ACCEPTANCE (concrete): add a cross-district chunk-pair test to server/tests/derivation_harness.rs (alongside channel_continuous_across_chunk_boundary_far_from_origin:1054). Pass condition the elevation step across a district seam is no larger than the typical step between adjacent INTERIOR chunks within a district (i.e. the seam is invisible operationalizes D-243 "the grid must be invisible"); morphology family seams remain sharp; golden-seed determinism unchanged for chunks far from any district border. No fixed metre threshold (D-239 §8 is qualitative per lithology) assert continuity relative to interior variation, not an absolute bound.', NULL, '2026-06-15 11:30:30', '2026-06-15 11:30:30', '2026-06-15 11:30:30', NULL, 'fb85fc6c7d3743b4a741ba78ef372b31', 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 ('06FCF56ED5ZJZRXFMF3PNSNZF0', 'description', 'Per D-243 §3/§4 (refines D-239 §2; contributes the region-clock toward Q-105).
Three-level climate stack:
- REGION (~205km): the climate context latitude-driven temperature baseline, weather state, seasonal clock. Resolved ONCE per region (the Q-105 cheap-dynamism source); inherited by every district/tile inside. ''Region temperature'' = context, not a uniform slab.
- DISTRICT (2km): local temperature = region baseline + elevation lapse + slope aspect (D-239 §2''s district temperature, now a MODULATION of the region rather than derived from scratch).
- CHUNK/VOXEL: D-239 §3 freeze/snow scatter on the local temperature.
EDGE FUZZ (D-243 §4): climate does not change on a line. A tile''s climate is a continuous warp-perturbed blend of surrounding regions'' baselines (bilinear across region centres + noise displacement so the boundary is ragged); same at district edges. The region/district grid must be invisible in the output. This is the climate counterpart to D-239 §4''s domain warp climate is feathered/blended (continuous field), unlike morphology which stays sharp-but-organic (D-239 §7).', 'Per D-243 §3/§4 (refines D-239 §2; contributes the region-clock toward Q-105).
Three-level climate stack:
- REGION (~205km): the climate context latitude-driven temperature baseline, weather state, seasonal clock. Resolved ONCE per region (the Q-105 cheap-dynamism source); inherited by every district/tile inside. ''Region temperature'' = context, not a uniform slab.
- DISTRICT (2km): local temperature = region baseline + elevation lapse + slope aspect (D-239 §2''s district temperature, now a MODULATION of the region rather than derived from scratch).
- CHUNK/VOXEL: D-239 §3 freeze/snow scatter on the local temperature.
EDGE FUZZ (D-243 §4): climate does not change on a line. A tile''s climate is a continuous warp-perturbed blend of surrounding regions'' baselines (bilinear across region centres + noise displacement so the boundary is ragged); same at district edges. The region/district grid must be invisible in the output. This is the climate counterpart to D-239 §4''s domain warp climate is feathered/blended (continuous field), unlike morphology which stays sharp-but-organic (D-239 §7).
---
Refinement (2026-06-15, /whats-next Si pass) READY. Implementer brief against the current tree (post-T-1077).
NET-NEW vs REFACTOR:
- NET-NEW: the region (~205 km) climate tier is new structure. RegionPos + district_to_region/chunk_to_region already exist (server/src/atlas/scale.rs:70/82/91, from T-1077) addressing skeleton is laid. There is NO RegionProfile/RegionClimate struct, no weather state, and no seasonal clock in the tree today; this ticket introduces them.
- REFACTOR: derive_temperature_c (server/src/atlas/district_profile.rs:792) currently derives district temperature FROM SCRATCH (D-240 class-envelope latitude lerp + greenhouse nudge + lapse + seed nudge, per district). Split it into (a) a region-baseline fn (latitude + greenhouse + nudge, keeping the D-240 planet_class envelope anchoring) and (b) a district-modulation fn (elevation lapse + slope aspect on top of the baseline) per D-239 §2 district temperature becomes a MODULATION of the region baseline, not an independent derivation.
DOWNSTREAM (no signature change): derive_cover freeze/snow scatter (voxel.rs:1408, reads district.temperature_c at :1417, from T-1030) consumes the post-refactor per-district value as-is.
EDGE FUZZ (D-243 §4): climate is a continuous warp-perturbed bilinear blend of surrounding region baselines (+ noise displacement so the boundary is ragged) the climate counterpart to D-239 §4''s domain warp. Resolved UPSTREAM of build_district_profile, so ChunkContext is unaffected.
RELATION TO T-1042 (independent do NOT share the mechanism): T-1078 blends CLIMATE scalars at the regiondistrict seam (205 km); T-1042 blends TERRAIN scalars at the districtchunk seam (2 km). Separate mechanisms do NOT reuse ChunkContext.secondary/blend_weight for climate. Both proceed in parallel.
Q-105 DEFERRAL: build the static/mean-state region baseline + edge-fuzz blend + the region-clock STRUCTURE. The transient clock-phase callbacks (seasonal frost form/melt, crop-cycle cadence, recompute schedule) are deferred to Q-105 per the two-phase convention already established in the derive_cover docblock (voxel.rs:185-189). This ticket CONTRIBUTES the region-clock toward Q-105; it does not resolve it.
FILES: district_profile.rs (split derive_temperature_c; refactor build_district_profile to take a region baseline); new region_profile.rs (RegionProfile/RegionClimate + baseline derivation + edge-fuzz blend); scale.rs (no change RegionPos/addressing already present); cascade.rs (wire the region tier into the derivation chain). Governing: D-243 §3/§4 (primary), D-239 §2/§3, D-240 (class-envelope anchoring), Q-105 (deferred transient clock).', NULL, '2026-06-15 11:30:30', '2026-06-15 11:30:30', '2026-06-15 11:30:30', NULL, '0e59dde20ae8a6537770a9f6b94a83c5', 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 ('06FBPQ0308CFF9WKNGBBYQ763W', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 11:32:21', '2026-06-15 11:32:21', '2026-06-15 11:32:21', NULL, '029d556f0a7f0f2f8c853a3133e4085f', 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 ('06FCF56ED5ZJZRXFMF3PNSNZF0', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 11:32:21', '2026-06-15 11:32:21', '2026-06-15 11:32:21', NULL, '783c2652441bc33e4f25cb843df2e488', 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', 'review', 'done', NULL, '2026-06-15 11:32:22', '2026-06-15 11:32:22', '2026-06-15 11:32:22', NULL, 'b8b2ec70d7657c3ccfb70560912251a1', 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 ('06FBPQ0308CFF9WKNGBBYQ763W', 'status', 'in_progress', 'review', NULL, '2026-06-15 12:05:57', '2026-06-15 12:05:57', '2026-06-15 12:05:57', NULL, '6562430e4adf95d5d6e780916e1f1263', 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 ('06FCF56ED5ZJZRXFMF3PNSNZF0', 'status', 'in_progress', 'review', NULL, '2026-06-15 12:05:57', '2026-06-15 12:05:57', '2026-06-15 12:05:57', NULL, 'd71059f27a38312075006f184d16385b', 2) ON CONFLICT(hash) DO NOTHING;
+222
View File
@@ -2338,3 +2338,225 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
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);
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.
---
Implementation (2026-06-15) the derived tier is now VISIBLE in the Atlas.
(1) ChunkPos->RegionPos: done in scale.rs (T-1077) fixed integer addressing.
(2) Proxy serves the tier (d03b4d1e0): AtlasLayerResponse gains DistrictGridLayer (cols/rows + row-major MorphologyZone discriminants + elev_q) via build_district_grid(); populated on cache hit. rmp_serde msgpack flows it to the client automatically.
(3) Atlas surfacing (cfd5e18d0): protocol.gd decodes district_grid; atlas_viewer.gd adds the gen_district (''MRPH'') toggle + state; atlas_marker_overlay.gd paints the coarse grid coloured by morphology zone (17-zone palette, D-239 §6). gdUnit4 test green.
SCOPE NOTE: this surfaces the PLANETARY-SCALE coarse morphology grid the Atlas map view of the derived tier (the audit headline: "the Atlas cannot show the tiers that exist" is resolved). The on-demand 2km derive_district + per-voxel geometry (T-1077) are for IN-WORLD Phase 5 rendering, NOT the planetary map; "voxel layers in the proxy" from the original audit scope belongs to Phase 5, not here.
Status -> review. Visual palette tuning can follow once eyeballed in the running Atlas (the map now renders the morphology overlay over real bodies).', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-12 10:40:58', '2026-06-15 10:58:18', NULL, 'bd6c24cce8317898b29f27d68958aea1', 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.
---
Implementation (2026-06-15) the derived tier is now VISIBLE in the Atlas.
(1) ChunkPos->RegionPos: done in scale.rs (T-1077) fixed integer addressing.
(2) Proxy serves the tier (d03b4d1e0): AtlasLayerResponse gains DistrictGridLayer (cols/rows + row-major MorphologyZone discriminants + elev_q) via build_district_grid(); populated on cache hit. rmp_serde msgpack flows it to the client automatically.
(3) Atlas surfacing (cfd5e18d0): protocol.gd decodes district_grid; atlas_viewer.gd adds the gen_district (''MRPH'') toggle + state; atlas_marker_overlay.gd paints the coarse grid coloured by morphology zone (17-zone palette, D-239 §6). gdUnit4 test green.
SCOPE NOTE: this surfaces the PLANETARY-SCALE coarse morphology grid the Atlas map view of the derived tier (the audit headline: "the Atlas cannot show the tiers that exist" is resolved). The on-demand 2km derive_district + per-voxel geometry (T-1077) are for IN-WORLD Phase 5 rendering, NOT the planetary map; "voxel layers in the proxy" from the original audit scope belongs to Phase 5, not here.
Status -> review. Visual palette tuning can follow once eyeballed in the running Atlas (the map now renders the morphology overlay over real bodies).', 'review', 'high', NULL, 'server', 'D-239', '2026-06-12 10:40:58', '2026-06-15 10:58:18', NULL, 'fdedb874bc8bd5e6c47e551508dca6b5', 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 ('06FBPQ0308CFF9WKNGBBYQ763W', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Cross-region parameter blending at chunk/voxel scale (replace single-profile walking skeleton)', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows.
Implement cross-region parameter blending at the chunk/voxel scale, replacing the walking-skeleton single-profile restriction (server/src/atlas/chunk_context.rs:119-123: ''in the future a blend of adjacent profiles will handle cross-region chunk seams''). Today every voxel generator reads the one covering RegionProfile''s values directly (region.elev_q at voxel.rs:614, :731, :1067-1068; channel/meander params likewise), so continuous parameters step discontinuously at every region-grid border elevation cliffs at the region pitch, visible as a grid at Atlas/voxel scale.
Design is decided stay inside the D-239 §7 seam policy:
- NEVER blend morphology family selection. Family seams are prevented at source (gate ordering + build-time compatibility matrix) or kept sharp (cliff-fjord, lithology faults are real geology). Tyre''s cross-family elevation-blend was explicitly resolved against (D-239 dissent line).
- DO blend continuous positional parameters: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Mechanism from tile-derivation-contract/tyre-round1.md: ChunkContext gains secondary: Option<RegionProfile> + blend_weight: u8 (255 = fully primary; :123-125), blend zone one chunk (64 m) each side of the region border (:101-102); derive_voxel resolves parameters from both profiles and integer-blends BEFORE material selection (:217-220), reusing the same warp offset so the blend seam cannot align with the chunk edge; elevation sampled bilinearly from the region grid to chunk resolution (:130, the elevation_grid sketch).
- D-010 discipline: integer blend arithmetic; the f64 path only for position math per D-239 §4.
Note: region size in metres is currently ambiguous (see the scale-anchoring Q-record) the blend can be implemented against the region grid index regardless, but the Q should be resolved before tuning blend-zone width in metres.
Acceptance: T-1031 harness cross-region chunk pairs show no elevation step exceeding the lithology-permitted slope (D-239 §8) at region borders; family seams remain sharp; determinism golden-seed unchanged for chunks far from borders. Parent: T-750. Refs: D-239 §4/§7/§8, D-010; Q-102; T-1026/T-1028/T-1029 (predecessors).
---
Refinement (2026-06-15, /whats-next Si pass) supersedes the stale code/scale refs above. The original scope was authored 2026-06-12, BEFORE T-1077 landed (RegionProfileDistrictProfile rename + D-243 re-scale). Design intent unchanged; the references below are the current tree.
REFRAME this is cross-DISTRICT blending, not cross-region. After T-1077 the per-voxel carrier is the DistrictProfile (2,048 m district), so the single-profile "walking skeleton" restriction is a single-DISTRICT-profile restriction and the visible step is at the 2 km district pitch (finer / more visible than the old "region grid" framing). There is no RegionProfile / region.elev_q in the tree anymore.
CURRENT REFS (use these):
- Carrier: server/src/atlas/district_profile.rs (DistrictProfile).
- The "future blend" TODO: server/src/atlas/chunk_context.rs:166-168 ("in the future a blend of adjacent profiles will handle cross-district chunk seams, but for the walking skeleton one profile is sufficient").
- Per-voxel elevation reads: server/src/atlas/voxel.rs:443/515/605/723/808/984/1062-1063/1157 (all district.elev_q).
- channel width / meander wavelength+phase already live ON ChunkContext (precomputed from DistrictProfile) blend them on the context, do NOT re-read them from the profile in the blend path.
- derive_chunk_context entry: chunk_context.rs:176. derive_voxel_column entry: voxel.rs:330.
SCALE-Q RESOLVED: D-243 fixed district=2,048 m, region=~205 km the "region size in metres ambiguous" note is obsolete. Q-102 (noise-basis algorithm) stays open but does NOT block blend-zone arithmetic. Blend zone = one chunk (64 m) each side of the district border, against the district grid index.
D-243 §4 / D-239 §7 RECONCILIATION (no conflict): D-243 §4''s continuous edge-fuzz mandate governs CLIMATE scalars that is T-1078''s job. T-1042 blends continuous TERRAIN positional params only: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Morphology FAMILY selection is NEVER blended it stays sharp per D-239 §7 (gate ordering + compatibility matrix; cliff-fjord/lithology faults are real geology). The two decisions partition cleanly: family sharp, continuous params feathered.
API SHAPE (follow the ticket''s own mechanism): ChunkContext gains `secondary: Option<DistrictProfile>` + `blend_weight: u8` (255 = fully primary). The caller of derive_chunk_context detects a chunk lying within one chunk of a district boundary and supplies the adjacent DistrictProfile; derive_voxel_column reads secondary+blend_weight off ChunkContext and integer-blends (D-010: integer arithmetic; f64 only for position per D-239 §4) BEFORE material selection, reusing the same warp offset so the blend seam cannot align with the chunk edge. No new parameter on derive_voxel_column it reads the context.
ACCEPTANCE (concrete): add a cross-district chunk-pair test to server/tests/derivation_harness.rs (alongside channel_continuous_across_chunk_boundary_far_from_origin:1054). Pass condition the elevation step across a district seam is no larger than the typical step between adjacent INTERIOR chunks within a district (i.e. the seam is invisible operationalizes D-243 "the grid must be invisible"); morphology family seams remain sharp; golden-seed determinism unchanged for chunks far from any district border. No fixed metre threshold (D-239 §8 is qualitative per lithology) assert continuity relative to interior variation, not an absolute bound.', 'backlog', 'medium', NULL, 'server', 'D-239', '2026-06-12 10:23:50', '2026-06-15 11:30:30', NULL, '9ab66310f1cf116a762378e72b44dfb4', 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 ('06FCF56ED5ZJZRXFMF3PNSNZF0', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Region climate stack — weather/season/temperature baseline + district modulation + edge fuzz', 'Per D-243 §3/§4 (refines D-239 §2; contributes the region-clock toward Q-105).
Three-level climate stack:
- REGION (~205km): the climate context latitude-driven temperature baseline, weather state, seasonal clock. Resolved ONCE per region (the Q-105 cheap-dynamism source); inherited by every district/tile inside. ''Region temperature'' = context, not a uniform slab.
- DISTRICT (2km): local temperature = region baseline + elevation lapse + slope aspect (D-239 §2''s district temperature, now a MODULATION of the region rather than derived from scratch).
- CHUNK/VOXEL: D-239 §3 freeze/snow scatter on the local temperature.
EDGE FUZZ (D-243 §4): climate does not change on a line. A tile''s climate is a continuous warp-perturbed blend of surrounding regions'' baselines (bilinear across region centres + noise displacement so the boundary is ragged); same at district edges. The region/district grid must be invisible in the output. This is the climate counterpart to D-239 §4''s domain warp climate is feathered/blended (continuous field), unlike morphology which stays sharp-but-organic (D-239 §7).
---
Refinement (2026-06-15, /whats-next Si pass) READY. Implementer brief against the current tree (post-T-1077).
NET-NEW vs REFACTOR:
- NET-NEW: the region (~205 km) climate tier is new structure. RegionPos + district_to_region/chunk_to_region already exist (server/src/atlas/scale.rs:70/82/91, from T-1077) addressing skeleton is laid. There is NO RegionProfile/RegionClimate struct, no weather state, and no seasonal clock in the tree today; this ticket introduces them.
- REFACTOR: derive_temperature_c (server/src/atlas/district_profile.rs:792) currently derives district temperature FROM SCRATCH (D-240 class-envelope latitude lerp + greenhouse nudge + lapse + seed nudge, per district). Split it into (a) a region-baseline fn (latitude + greenhouse + nudge, keeping the D-240 planet_class envelope anchoring) and (b) a district-modulation fn (elevation lapse + slope aspect on top of the baseline) per D-239 §2 district temperature becomes a MODULATION of the region baseline, not an independent derivation.
DOWNSTREAM (no signature change): derive_cover freeze/snow scatter (voxel.rs:1408, reads district.temperature_c at :1417, from T-1030) consumes the post-refactor per-district value as-is.
EDGE FUZZ (D-243 §4): climate is a continuous warp-perturbed bilinear blend of surrounding region baselines (+ noise displacement so the boundary is ragged) the climate counterpart to D-239 §4''s domain warp. Resolved UPSTREAM of build_district_profile, so ChunkContext is unaffected.
RELATION TO T-1042 (independent do NOT share the mechanism): T-1078 blends CLIMATE scalars at the regiondistrict seam (205 km); T-1042 blends TERRAIN scalars at the districtchunk seam (2 km). Separate mechanisms do NOT reuse ChunkContext.secondary/blend_weight for climate. Both proceed in parallel.
Q-105 DEFERRAL: build the static/mean-state region baseline + edge-fuzz blend + the region-clock STRUCTURE. The transient clock-phase callbacks (seasonal frost form/melt, crop-cycle cadence, recompute schedule) are deferred to Q-105 per the two-phase convention already established in the derive_cover docblock (voxel.rs:185-189). This ticket CONTRIBUTES the region-clock toward Q-105; it does not resolve it.
FILES: district_profile.rs (split derive_temperature_c; refactor build_district_profile to take a region baseline); new region_profile.rs (RegionProfile/RegionClimate + baseline derivation + edge-fuzz blend); scale.rs (no change RegionPos/addressing already present); cascade.rs (wire the region tier into the derivation chain). Governing: D-243 §3/§4 (primary), D-239 §2/§3, D-240 (class-envelope anchoring), Q-105 (deferred transient clock).', 'backlog', 'medium', NULL, 'server', 'D-243', '2026-06-14 19:21:18', '2026-06-15 11:30:30', NULL, '2a55bf30a8cc21711e3c5cd250d75958', 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 ('06FCF56ED5ZJZRXFMF3PNSNZF0', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Region climate stack — weather/season/temperature baseline + district modulation + edge fuzz', 'Per D-243 §3/§4 (refines D-239 §2; contributes the region-clock toward Q-105).
Three-level climate stack:
- REGION (~205km): the climate context latitude-driven temperature baseline, weather state, seasonal clock. Resolved ONCE per region (the Q-105 cheap-dynamism source); inherited by every district/tile inside. ''Region temperature'' = context, not a uniform slab.
- DISTRICT (2km): local temperature = region baseline + elevation lapse + slope aspect (D-239 §2''s district temperature, now a MODULATION of the region rather than derived from scratch).
- CHUNK/VOXEL: D-239 §3 freeze/snow scatter on the local temperature.
EDGE FUZZ (D-243 §4): climate does not change on a line. A tile''s climate is a continuous warp-perturbed blend of surrounding regions'' baselines (bilinear across region centres + noise displacement so the boundary is ragged); same at district edges. The region/district grid must be invisible in the output. This is the climate counterpart to D-239 §4''s domain warp climate is feathered/blended (continuous field), unlike morphology which stays sharp-but-organic (D-239 §7).
---
Refinement (2026-06-15, /whats-next Si pass) READY. Implementer brief against the current tree (post-T-1077).
NET-NEW vs REFACTOR:
- NET-NEW: the region (~205 km) climate tier is new structure. RegionPos + district_to_region/chunk_to_region already exist (server/src/atlas/scale.rs:70/82/91, from T-1077) addressing skeleton is laid. There is NO RegionProfile/RegionClimate struct, no weather state, and no seasonal clock in the tree today; this ticket introduces them.
- REFACTOR: derive_temperature_c (server/src/atlas/district_profile.rs:792) currently derives district temperature FROM SCRATCH (D-240 class-envelope latitude lerp + greenhouse nudge + lapse + seed nudge, per district). Split it into (a) a region-baseline fn (latitude + greenhouse + nudge, keeping the D-240 planet_class envelope anchoring) and (b) a district-modulation fn (elevation lapse + slope aspect on top of the baseline) per D-239 §2 district temperature becomes a MODULATION of the region baseline, not an independent derivation.
DOWNSTREAM (no signature change): derive_cover freeze/snow scatter (voxel.rs:1408, reads district.temperature_c at :1417, from T-1030) consumes the post-refactor per-district value as-is.
EDGE FUZZ (D-243 §4): climate is a continuous warp-perturbed bilinear blend of surrounding region baselines (+ noise displacement so the boundary is ragged) the climate counterpart to D-239 §4''s domain warp. Resolved UPSTREAM of build_district_profile, so ChunkContext is unaffected.
RELATION TO T-1042 (independent do NOT share the mechanism): T-1078 blends CLIMATE scalars at the regiondistrict seam (205 km); T-1042 blends TERRAIN scalars at the districtchunk seam (2 km). Separate mechanisms do NOT reuse ChunkContext.secondary/blend_weight for climate. Both proceed in parallel.
Q-105 DEFERRAL: build the static/mean-state region baseline + edge-fuzz blend + the region-clock STRUCTURE. The transient clock-phase callbacks (seasonal frost form/melt, crop-cycle cadence, recompute schedule) are deferred to Q-105 per the two-phase convention already established in the derive_cover docblock (voxel.rs:185-189). This ticket CONTRIBUTES the region-clock toward Q-105; it does not resolve it.
FILES: district_profile.rs (split derive_temperature_c; refactor build_district_profile to take a region baseline); new region_profile.rs (RegionProfile/RegionClimate + baseline derivation + edge-fuzz blend); scale.rs (no change RegionPos/addressing already present); cascade.rs (wire the region tier into the derivation chain). Governing: D-243 §3/§4 (primary), D-239 §2/§3, D-240 (class-envelope anchoring), Q-105 (deferred transient clock).', 'in_progress', 'medium', NULL, 'server', 'D-243', '2026-06-14 19:21:18', '2026-06-15 11:32:21', NULL, '5ee59a431e592e3a700cfbba56f29c15', 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 ('06FBPQ0308CFF9WKNGBBYQ763W', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Cross-region parameter blending at chunk/voxel scale (replace single-profile walking skeleton)', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows.
Implement cross-region parameter blending at the chunk/voxel scale, replacing the walking-skeleton single-profile restriction (server/src/atlas/chunk_context.rs:119-123: ''in the future a blend of adjacent profiles will handle cross-region chunk seams''). Today every voxel generator reads the one covering RegionProfile''s values directly (region.elev_q at voxel.rs:614, :731, :1067-1068; channel/meander params likewise), so continuous parameters step discontinuously at every region-grid border elevation cliffs at the region pitch, visible as a grid at Atlas/voxel scale.
Design is decided stay inside the D-239 §7 seam policy:
- NEVER blend morphology family selection. Family seams are prevented at source (gate ordering + build-time compatibility matrix) or kept sharp (cliff-fjord, lithology faults are real geology). Tyre''s cross-family elevation-blend was explicitly resolved against (D-239 dissent line).
- DO blend continuous positional parameters: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Mechanism from tile-derivation-contract/tyre-round1.md: ChunkContext gains secondary: Option<RegionProfile> + blend_weight: u8 (255 = fully primary; :123-125), blend zone one chunk (64 m) each side of the region border (:101-102); derive_voxel resolves parameters from both profiles and integer-blends BEFORE material selection (:217-220), reusing the same warp offset so the blend seam cannot align with the chunk edge; elevation sampled bilinearly from the region grid to chunk resolution (:130, the elevation_grid sketch).
- D-010 discipline: integer blend arithmetic; the f64 path only for position math per D-239 §4.
Note: region size in metres is currently ambiguous (see the scale-anchoring Q-record) the blend can be implemented against the region grid index regardless, but the Q should be resolved before tuning blend-zone width in metres.
Acceptance: T-1031 harness cross-region chunk pairs show no elevation step exceeding the lithology-permitted slope (D-239 §8) at region borders; family seams remain sharp; determinism golden-seed unchanged for chunks far from borders. Parent: T-750. Refs: D-239 §4/§7/§8, D-010; Q-102; T-1026/T-1028/T-1029 (predecessors).
---
Refinement (2026-06-15, /whats-next Si pass) supersedes the stale code/scale refs above. The original scope was authored 2026-06-12, BEFORE T-1077 landed (RegionProfileDistrictProfile rename + D-243 re-scale). Design intent unchanged; the references below are the current tree.
REFRAME this is cross-DISTRICT blending, not cross-region. After T-1077 the per-voxel carrier is the DistrictProfile (2,048 m district), so the single-profile "walking skeleton" restriction is a single-DISTRICT-profile restriction and the visible step is at the 2 km district pitch (finer / more visible than the old "region grid" framing). There is no RegionProfile / region.elev_q in the tree anymore.
CURRENT REFS (use these):
- Carrier: server/src/atlas/district_profile.rs (DistrictProfile).
- The "future blend" TODO: server/src/atlas/chunk_context.rs:166-168 ("in the future a blend of adjacent profiles will handle cross-district chunk seams, but for the walking skeleton one profile is sufficient").
- Per-voxel elevation reads: server/src/atlas/voxel.rs:443/515/605/723/808/984/1062-1063/1157 (all district.elev_q).
- channel width / meander wavelength+phase already live ON ChunkContext (precomputed from DistrictProfile) blend them on the context, do NOT re-read them from the profile in the blend path.
- derive_chunk_context entry: chunk_context.rs:176. derive_voxel_column entry: voxel.rs:330.
SCALE-Q RESOLVED: D-243 fixed district=2,048 m, region=~205 km the "region size in metres ambiguous" note is obsolete. Q-102 (noise-basis algorithm) stays open but does NOT block blend-zone arithmetic. Blend zone = one chunk (64 m) each side of the district border, against the district grid index.
D-243 §4 / D-239 §7 RECONCILIATION (no conflict): D-243 §4''s continuous edge-fuzz mandate governs CLIMATE scalars that is T-1078''s job. T-1042 blends continuous TERRAIN positional params only: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Morphology FAMILY selection is NEVER blended it stays sharp per D-239 §7 (gate ordering + compatibility matrix; cliff-fjord/lithology faults are real geology). The two decisions partition cleanly: family sharp, continuous params feathered.
API SHAPE (follow the ticket''s own mechanism): ChunkContext gains `secondary: Option<DistrictProfile>` + `blend_weight: u8` (255 = fully primary). The caller of derive_chunk_context detects a chunk lying within one chunk of a district boundary and supplies the adjacent DistrictProfile; derive_voxel_column reads secondary+blend_weight off ChunkContext and integer-blends (D-010: integer arithmetic; f64 only for position per D-239 §4) BEFORE material selection, reusing the same warp offset so the blend seam cannot align with the chunk edge. No new parameter on derive_voxel_column it reads the context.
ACCEPTANCE (concrete): add a cross-district chunk-pair test to server/tests/derivation_harness.rs (alongside channel_continuous_across_chunk_boundary_far_from_origin:1054). Pass condition the elevation step across a district seam is no larger than the typical step between adjacent INTERIOR chunks within a district (i.e. the seam is invisible operationalizes D-243 "the grid must be invisible"); morphology family seams remain sharp; golden-seed determinism unchanged for chunks far from any district border. No fixed metre threshold (D-239 §8 is qualitative per lithology) assert continuity relative to interior variation, not an absolute bound.', 'in_progress', 'medium', NULL, 'server', 'D-239', '2026-06-12 10:23:50', '2026-06-15 11:32:21', NULL, 'a6d1c442e8f9b428633f40440a4a7695', 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.
---
Implementation (2026-06-15) the derived tier is now VISIBLE in the Atlas.
(1) ChunkPos->RegionPos: done in scale.rs (T-1077) fixed integer addressing.
(2) Proxy serves the tier (d03b4d1e0): AtlasLayerResponse gains DistrictGridLayer (cols/rows + row-major MorphologyZone discriminants + elev_q) via build_district_grid(); populated on cache hit. rmp_serde msgpack flows it to the client automatically.
(3) Atlas surfacing (cfd5e18d0): protocol.gd decodes district_grid; atlas_viewer.gd adds the gen_district (''MRPH'') toggle + state; atlas_marker_overlay.gd paints the coarse grid coloured by morphology zone (17-zone palette, D-239 §6). gdUnit4 test green.
SCOPE NOTE: this surfaces the PLANETARY-SCALE coarse morphology grid the Atlas map view of the derived tier (the audit headline: "the Atlas cannot show the tiers that exist" is resolved). The on-demand 2km derive_district + per-voxel geometry (T-1077) are for IN-WORLD Phase 5 rendering, NOT the planetary map; "voxel layers in the proxy" from the original audit scope belongs to Phase 5, not here.
Status -> review. Visual palette tuning can follow once eyeballed in the running Atlas (the map now renders the morphology overlay over real bodies).', 'done', 'high', NULL, 'server', 'D-239', '2026-06-12 10:40:58', '2026-06-15 11:32:22', NULL, '4e3bf92ba623a3aa5717429ef13543bd', 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 ('06FCF56ED5ZJZRXFMF3PNSNZF0', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Region climate stack — weather/season/temperature baseline + district modulation + edge fuzz', 'Per D-243 §3/§4 (refines D-239 §2; contributes the region-clock toward Q-105).
Three-level climate stack:
- REGION (~205km): the climate context latitude-driven temperature baseline, weather state, seasonal clock. Resolved ONCE per region (the Q-105 cheap-dynamism source); inherited by every district/tile inside. ''Region temperature'' = context, not a uniform slab.
- DISTRICT (2km): local temperature = region baseline + elevation lapse + slope aspect (D-239 §2''s district temperature, now a MODULATION of the region rather than derived from scratch).
- CHUNK/VOXEL: D-239 §3 freeze/snow scatter on the local temperature.
EDGE FUZZ (D-243 §4): climate does not change on a line. A tile''s climate is a continuous warp-perturbed blend of surrounding regions'' baselines (bilinear across region centres + noise displacement so the boundary is ragged); same at district edges. The region/district grid must be invisible in the output. This is the climate counterpart to D-239 §4''s domain warp climate is feathered/blended (continuous field), unlike morphology which stays sharp-but-organic (D-239 §7).
---
Refinement (2026-06-15, /whats-next Si pass) READY. Implementer brief against the current tree (post-T-1077).
NET-NEW vs REFACTOR:
- NET-NEW: the region (~205 km) climate tier is new structure. RegionPos + district_to_region/chunk_to_region already exist (server/src/atlas/scale.rs:70/82/91, from T-1077) addressing skeleton is laid. There is NO RegionProfile/RegionClimate struct, no weather state, and no seasonal clock in the tree today; this ticket introduces them.
- REFACTOR: derive_temperature_c (server/src/atlas/district_profile.rs:792) currently derives district temperature FROM SCRATCH (D-240 class-envelope latitude lerp + greenhouse nudge + lapse + seed nudge, per district). Split it into (a) a region-baseline fn (latitude + greenhouse + nudge, keeping the D-240 planet_class envelope anchoring) and (b) a district-modulation fn (elevation lapse + slope aspect on top of the baseline) per D-239 §2 district temperature becomes a MODULATION of the region baseline, not an independent derivation.
DOWNSTREAM (no signature change): derive_cover freeze/snow scatter (voxel.rs:1408, reads district.temperature_c at :1417, from T-1030) consumes the post-refactor per-district value as-is.
EDGE FUZZ (D-243 §4): climate is a continuous warp-perturbed bilinear blend of surrounding region baselines (+ noise displacement so the boundary is ragged) the climate counterpart to D-239 §4''s domain warp. Resolved UPSTREAM of build_district_profile, so ChunkContext is unaffected.
RELATION TO T-1042 (independent do NOT share the mechanism): T-1078 blends CLIMATE scalars at the regiondistrict seam (205 km); T-1042 blends TERRAIN scalars at the districtchunk seam (2 km). Separate mechanisms do NOT reuse ChunkContext.secondary/blend_weight for climate. Both proceed in parallel.
Q-105 DEFERRAL: build the static/mean-state region baseline + edge-fuzz blend + the region-clock STRUCTURE. The transient clock-phase callbacks (seasonal frost form/melt, crop-cycle cadence, recompute schedule) are deferred to Q-105 per the two-phase convention already established in the derive_cover docblock (voxel.rs:185-189). This ticket CONTRIBUTES the region-clock toward Q-105; it does not resolve it.
FILES: district_profile.rs (split derive_temperature_c; refactor build_district_profile to take a region baseline); new region_profile.rs (RegionProfile/RegionClimate + baseline derivation + edge-fuzz blend); scale.rs (no change RegionPos/addressing already present); cascade.rs (wire the region tier into the derivation chain). Governing: D-243 §3/§4 (primary), D-239 §2/§3, D-240 (class-envelope anchoring), Q-105 (deferred transient clock).', 'review', 'medium', NULL, 'server', 'D-243', '2026-06-14 19:21:18', '2026-06-15 12:05:57', NULL, '2694bf9b555dbdd497774a82bb0b48bc', 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 ('06FBPQ0308CFF9WKNGBBYQ763W', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Cross-region parameter blending at chunk/voxel scale (replace single-profile walking skeleton)', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows.
Implement cross-region parameter blending at the chunk/voxel scale, replacing the walking-skeleton single-profile restriction (server/src/atlas/chunk_context.rs:119-123: ''in the future a blend of adjacent profiles will handle cross-region chunk seams''). Today every voxel generator reads the one covering RegionProfile''s values directly (region.elev_q at voxel.rs:614, :731, :1067-1068; channel/meander params likewise), so continuous parameters step discontinuously at every region-grid border elevation cliffs at the region pitch, visible as a grid at Atlas/voxel scale.
Design is decided stay inside the D-239 §7 seam policy:
- NEVER blend morphology family selection. Family seams are prevented at source (gate ordering + build-time compatibility matrix) or kept sharp (cliff-fjord, lithology faults are real geology). Tyre''s cross-family elevation-blend was explicitly resolved against (D-239 dissent line).
- DO blend continuous positional parameters: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Mechanism from tile-derivation-contract/tyre-round1.md: ChunkContext gains secondary: Option<RegionProfile> + blend_weight: u8 (255 = fully primary; :123-125), blend zone one chunk (64 m) each side of the region border (:101-102); derive_voxel resolves parameters from both profiles and integer-blends BEFORE material selection (:217-220), reusing the same warp offset so the blend seam cannot align with the chunk edge; elevation sampled bilinearly from the region grid to chunk resolution (:130, the elevation_grid sketch).
- D-010 discipline: integer blend arithmetic; the f64 path only for position math per D-239 §4.
Note: region size in metres is currently ambiguous (see the scale-anchoring Q-record) the blend can be implemented against the region grid index regardless, but the Q should be resolved before tuning blend-zone width in metres.
Acceptance: T-1031 harness cross-region chunk pairs show no elevation step exceeding the lithology-permitted slope (D-239 §8) at region borders; family seams remain sharp; determinism golden-seed unchanged for chunks far from borders. Parent: T-750. Refs: D-239 §4/§7/§8, D-010; Q-102; T-1026/T-1028/T-1029 (predecessors).
---
Refinement (2026-06-15, /whats-next Si pass) supersedes the stale code/scale refs above. The original scope was authored 2026-06-12, BEFORE T-1077 landed (RegionProfileDistrictProfile rename + D-243 re-scale). Design intent unchanged; the references below are the current tree.
REFRAME this is cross-DISTRICT blending, not cross-region. After T-1077 the per-voxel carrier is the DistrictProfile (2,048 m district), so the single-profile "walking skeleton" restriction is a single-DISTRICT-profile restriction and the visible step is at the 2 km district pitch (finer / more visible than the old "region grid" framing). There is no RegionProfile / region.elev_q in the tree anymore.
CURRENT REFS (use these):
- Carrier: server/src/atlas/district_profile.rs (DistrictProfile).
- The "future blend" TODO: server/src/atlas/chunk_context.rs:166-168 ("in the future a blend of adjacent profiles will handle cross-district chunk seams, but for the walking skeleton one profile is sufficient").
- Per-voxel elevation reads: server/src/atlas/voxel.rs:443/515/605/723/808/984/1062-1063/1157 (all district.elev_q).
- channel width / meander wavelength+phase already live ON ChunkContext (precomputed from DistrictProfile) blend them on the context, do NOT re-read them from the profile in the blend path.
- derive_chunk_context entry: chunk_context.rs:176. derive_voxel_column entry: voxel.rs:330.
SCALE-Q RESOLVED: D-243 fixed district=2,048 m, region=~205 km the "region size in metres ambiguous" note is obsolete. Q-102 (noise-basis algorithm) stays open but does NOT block blend-zone arithmetic. Blend zone = one chunk (64 m) each side of the district border, against the district grid index.
D-243 §4 / D-239 §7 RECONCILIATION (no conflict): D-243 §4''s continuous edge-fuzz mandate governs CLIMATE scalars that is T-1078''s job. T-1042 blends continuous TERRAIN positional params only: elevation, channel width, meander wavelength/phase, freeze/moisture scalars. Morphology FAMILY selection is NEVER blended it stays sharp per D-239 §7 (gate ordering + compatibility matrix; cliff-fjord/lithology faults are real geology). The two decisions partition cleanly: family sharp, continuous params feathered.
API SHAPE (follow the ticket''s own mechanism): ChunkContext gains `secondary: Option<DistrictProfile>` + `blend_weight: u8` (255 = fully primary). The caller of derive_chunk_context detects a chunk lying within one chunk of a district boundary and supplies the adjacent DistrictProfile; derive_voxel_column reads secondary+blend_weight off ChunkContext and integer-blends (D-010: integer arithmetic; f64 only for position per D-239 §4) BEFORE material selection, reusing the same warp offset so the blend seam cannot align with the chunk edge. No new parameter on derive_voxel_column it reads the context.
ACCEPTANCE (concrete): add a cross-district chunk-pair test to server/tests/derivation_harness.rs (alongside channel_continuous_across_chunk_boundary_far_from_origin:1054). Pass condition the elevation step across a district seam is no larger than the typical step between adjacent INTERIOR chunks within a district (i.e. the seam is invisible operationalizes D-243 "the grid must be invisible"); morphology family seams remain sharp; golden-seed determinism unchanged for chunks far from any district border. No fixed metre threshold (D-239 §8 is qualitative per lithology) assert continuity relative to interior variation, not an absolute bound.', 'review', 'medium', NULL, 'server', 'D-239', '2026-06-12 10:23:50', '2026-06-15 12:05:57', NULL, 'd61b0595ecc6d66929b7ecabf32a6d63', 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);
+3 -3
View File
@@ -23,7 +23,7 @@
//!
//! `tectonic_activity` is **not** in the current schema; `BodyParams.tectonic_activity`
//! is left `None` so the derivation falls back to `planet_class` as documented
//! on the struct. The per-district fields `district_latitude_deg` and `elevation_km`
//! on the struct. The per-district fields `latitude_deg` and `elevation_km`
//! are set by `derive_all_districts` / `derive_district_profile`, not here; they
//! remain at their struct defaults (0.0) from this reader.
//!
@@ -121,7 +121,7 @@ impl BodyParamsReader {
tectonic_activity: None,
// Per-district fields are set by derive_all_districts / derive_district_profile,
// not at the body level. Leave at struct defaults (0.0).
district_latitude_deg: 0.0,
latitude_deg: 0.0,
elevation_km: 0.0,
body_radius_km,
}),
@@ -216,7 +216,7 @@ mod tests {
assert_eq!(params.planet_class.as_deref(), Some("temperate"));
assert_eq!(params.body_radius_km, Some(6371.0));
// Per-district fields always start at 0.0 from the reader.
assert_eq!(params.district_latitude_deg, 0.0);
assert_eq!(params.latitude_deg, 0.0);
assert_eq!(params.elevation_km, 0.0);
// tectonic_activity not in schema → None.
assert!(params.tectonic_activity.is_none());
+9 -2
View File
@@ -258,8 +258,15 @@ pub fn run_cascade_from_heightmap(
// ~8 cells per district on a 128×64 working grid → ~80×32 = ~2 560 districts;
// at full working resolution the budget is ~6 000/body (D-203).
const CELLS_PER_REGION: usize = 8;
let districts =
district_profile::derive_all_districts(body_seed, params, &ta, CELLS_PER_REGION);
// body_id is required for the D-243 §4 climate edge-fuzz warp domain
// separation — derive_all_districts builds the region cache internally.
let districts = district_profile::derive_all_districts(
body_seed,
params,
&ta,
CELLS_PER_REGION,
&snapshot.body_id,
);
snapshot.layer_district = Some(LayerDistrictOutput { districts });
}
+188 -28
View File
@@ -25,6 +25,18 @@
//! anchors in continuous world coordinates — never from the world origin and
//! never folded into the 64 m chunk frame.
//!
//! ## Cross-district blending (T-1042, D-239 §4/§7)
//!
//! Chunks within one chunk (64 m) of a district boundary carry a `secondary`
//! `DistrictProfile` and a `blend_weight` (255 = 100% primary; 128 = 50-50 blend).
//! The blend applies to **continuous positional params only**: `meander_wavelength_m`,
//! `channel_width_m`, `meander_phase` (all pre-blended here on the context) and
//! `elev_q` / `moisture_q` (blended in `derive_voxel_column` before family dispatch).
//!
//! **Morphology family seams stay sharp** (D-239 §7): `secondary` is carried for
//! elevation/moisture blending only; the primary district's `morphology_zone` is
//! never overridden by the secondary.
//!
//! ## D-010 compliance
//!
//! All derivation is integer arithmetic. The only f64 in this module is
@@ -107,17 +119,26 @@ const WARP_BOUND_M: i32 = 8;
/// - `basin_direction` — dominant drainage direction (cardinal) for this chunk.
/// - `meander_phase` — integer phase offset (0255) for the meander curve.
/// Used by MeanderReach and AlluvialPlain voxel generators to place the channel.
/// When `blend_weight < 255`, this is already blended between the primary and
/// secondary district values (T-1042).
/// - `meander_wavelength_m` — meander wavelength in metres. Derived from
/// district-level morphology (slope, moisture), seeded at district scale (> 64 m).
/// f64 for positional physics (D-239 §4); structural decisions consume it
/// only via deterministic i32 truncation (the `has_active_channel` band).
/// When `blend_weight < 255`, this is already blended (T-1042).
/// - `has_active_channel` — whether a water channel is present in this chunk:
/// the district has water presence AND the channel's swept band around
/// `channel_anchor_m` crosses this chunk (T-1040).
/// - `channel_width_m` — channel width in metres (integer; D-010). 0 if no
/// active channel.
/// active channel. When `blend_weight < 255`, this is already blended (T-1042).
/// - `channel_anchor_m` / `coast_anchor_m` — district-anchored feature axes in
/// world metres (T-1040/T-1041, D-239 §10).
/// - `secondary` — adjacent district profile for cross-district blending (T-1042,
/// D-239 §4). `None` when the chunk is interior (≥ 1 chunk from any district edge).
/// Only continuous terrain params (`elev_q`, `moisture_q`) are blended from this
/// in `derive_voxel_column`; morphology family stays primary (D-239 §7).
/// - `blend_weight` — blend weight toward the primary district. 255 = fully primary
/// (no blend), 128 = 50-50 blend. Meaningful only when `secondary` is `Some`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkContext {
/// Dominant basin-flow direction for this chunk's drainage catchment.
@@ -125,10 +146,12 @@ pub struct ChunkContext {
/// Integer phase offset for the meander curve (0255).
/// Derived at district scale (wavelength > 64 m), NOT from the chunk seed.
/// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042).
pub meander_phase: u8,
/// Meander wavelength in metres. Positional physics value (f64 — D-239 §4).
/// Derived at district scale; typically 80500 m for AlluvialPlain.
/// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042).
pub meander_wavelength_m: f64,
/// Whether this chunk contains an active water channel.
@@ -140,6 +163,7 @@ pub struct ChunkContext {
/// Active channel width in metres (integer; D-010). 0 if no active channel.
/// Derived from district morphology and slope.
/// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042).
pub channel_width_m: i32,
/// Cross-axis world-metre coordinate of the district's feature centreline:
@@ -154,6 +178,26 @@ pub struct ChunkContext {
/// line. District-scale (T-1041): one continuous coast per district, not a
/// 64 m sawtooth. Along axis = y for N/S basins, x for E/W basins.
pub coast_anchor_m: i32,
/// Adjacent-district profile for cross-district terrain blending (T-1042,
/// D-239 §4/§7). `None` for interior chunks (≥ 1 chunk from any district edge).
///
/// Only continuous terrain params (`elev_q`, `moisture_q`) are blended from
/// this in `derive_voxel_column`. Morphology family selection always uses the
/// **primary** district's zone — seams stay sharp (D-239 §7).
/// `meander_wavelength_m`, `channel_width_m`, `meander_phase` are blended
/// here at context derivation time.
pub secondary: Option<DistrictProfile>,
/// Blend weight toward the primary district (D-010 integer arithmetic).
///
/// - `255` — fully primary; `secondary` is ignored (no blend).
/// - `128` — 50-50 blend (chunk at the district boundary).
///
/// Meaningful only when `secondary` is `Some`. Formula used throughout:
/// `blended = (primary * blend_weight as i32 + secondary * (255 - blend_weight) as i32 + 127) / 255`
/// (rounded integer lerp, symmetric for 128).
pub blend_weight: u8,
}
// ---------------------------------------------------------------------------
@@ -162,10 +206,20 @@ pub struct ChunkContext {
/// Derive a `ChunkContext` for the chunk at `chunk_pos` on the 64 m grid.
///
/// Pure function of `(seed, body_id, district, chunk_pos)`. Takes the covering
/// district's `DistrictProfile`; in the future a blend of adjacent profiles will
/// handle cross-district chunk seams, but for the walking skeleton one profile
/// is sufficient.
/// Pure function of `(seed, body_id, district, chunk_pos, secondary)`.
///
/// `secondary` supplies the adjacent `DistrictProfile` and a `blend_weight`
/// (255 = fully primary, 128 = 50-50) for cross-district terrain blending
/// (T-1042, D-239 §4/§7). Pass `None` for interior chunks. The caller is
/// responsible for detecting whether the chunk lies within one chunk (64 m)
/// of a district boundary and supplying the adjacent profile.
///
/// When `secondary` is `Some`, the context-level continuous params
/// (`meander_wavelength_m`, `channel_width_m`, `meander_phase`) are blended
/// here using integer arithmetic (D-010). The voxel-level params (`elev_q`,
/// `moisture_q`) are blended in `derive_voxel_column` before family dispatch.
/// Morphology family selection always uses the **primary** district's zone —
/// seams stay sharp (D-239 §7).
///
/// ## Seed usage
///
@@ -178,6 +232,7 @@ pub fn derive_chunk_context(
body_id: &str,
district: &DistrictProfile,
chunk_pos: ChunkPos,
secondary: Option<(&DistrictProfile, u8)>,
) -> ChunkContext {
// District-scale seed — features with wavelength > 64 m derive from here.
// Keyed on the chunk position mapped to district-scale units: `>>
@@ -199,12 +254,15 @@ pub fn derive_chunk_context(
// Meander phase — district-scale integer offset so the channel is consistent
// across all chunks in the same district. 0255.
let meander_phase = (district_seed.seed() >> 8) as u8;
let primary_meander_phase = (district_seed.seed() >> 8) as u8;
// Meander wavelength — derived from slope and morphology, district-scale.
// Lower slope → longer wavelength (wider meanders); integer inputs, f64 result
// is positional physics (D-239 §4, not a gate comparison).
let meander_wavelength_m = derive_meander_wavelength(district);
let primary_wavelength_m = derive_meander_wavelength(district);
// Channel width from primary district — integer metres (D-010).
let primary_channel_width = derive_channel_width(district);
// District-anchored feature axes (T-1040/T-1041, D-239 §10): channel and
// landform centrelines have wavelength > 64 m, so their position derives
@@ -218,6 +276,73 @@ pub fn derive_chunk_context(
derive_district_anchor(cross_chunk, (district_seed.seed() >> 16) & 0xFFFF);
let coast_anchor_m = derive_district_anchor(along_chunk, (district_seed.seed() >> 32) & 0xFFFF);
// ── Cross-district blending of context-level continuous params (T-1042) ──
//
// When a secondary district is supplied, blend `meander_wavelength_m`,
// `channel_width_m`, and `meander_phase` between the primary and secondary
// district values. These are the context-level continuous positional params
// (D-239 §4; spec: "blend them on the context, do NOT re-read from the
// profile in the blend path"). All integer arithmetic (D-010).
//
// Morphology family, basin direction, feature anchors, and has_active_channel
// are NOT blended — they are structural decisions driven by the primary
// district only (D-239 §7: morphology seams stay sharp).
let (blend_weight, secondary_stored) = match secondary {
Some((sec, weight)) => {
let sec_wavelength = derive_meander_wavelength(sec);
let sec_channel_width = derive_channel_width(sec);
let sec_phase = {
// The secondary district's true meander phase would require its
// district-scale seed, which in turn requires knowing the adjacent
// district's chunk coordinates — information not available at this
// call site. We use a bounded structural approximation: a hash of
// the secondary's morphology scalars (slope_q, moisture_q), which
// are the same inputs that drive `derive_meander_wavelength` and
// therefore capture the secondary district's channel character.
//
// This approximation is intentionally asymmetric in an acknowledged
// way: the blended phase is a cosmetic continuity aid at the 2 km
// seam (meander phase), not a structural gate decision. The seam
// is already invisible at the elevation level from elev_q blending;
// the phase contribution is second-order. If the real secondary seed
// is ever threaded through here, replace this with the proper
// district-scale derivation. Integer arithmetic (D-010).
sec.slope_q.wrapping_add(sec.moisture_q) as u8
};
let w = weight as i32;
let w_sec = 255 - w;
// Integer lerp: `(a * w + b * w_sec + 127) / 255`.
// The +127 biases the division round to nearest (symmetric at w=128).
let blended_phase =
((primary_meander_phase as i32 * w + sec_phase as i32 * w_sec + 127) / 255) as u8;
let blended_wavelength =
(primary_wavelength_m * w as f64 + sec_wavelength * w_sec as f64) / 255.0;
let blended_channel_width =
(primary_channel_width * w + sec_channel_width * w_sec + 127) / 255;
(
weight,
Some((
blended_phase,
blended_wavelength,
blended_channel_width,
sec.clone(),
)),
)
}
None => (255u8, None),
};
let (meander_phase, meander_wavelength_m, primary_channel_width_final, secondary_profile) =
match secondary_stored {
Some((ph, wl, cw, prof)) => (ph, wl, cw, Some(prof)),
None => (
primary_meander_phase,
primary_wavelength_m,
primary_channel_width,
None,
),
};
// Active channel — water presence (ocean_fraction_q >= 10 indicates a
// perennial waterway or water body covers at least 10% of the district) AND
// the channel's swept band around the district anchor crosses this chunk
@@ -228,15 +353,14 @@ pub fn derive_chunk_context(
// The band is generous (it must cover every chunk that can contain channel,
// levee, or warped-channel voxels — a gate-off chunk renders dry), using
// the larger MeanderReach amplitude (wavelength/4) for both channel families.
let channel_width = derive_channel_width(district);
let has_active_channel = district.ocean_fraction_q >= 10 && {
let wavelength_i = (meander_wavelength_m as i32).max(10);
let amplitude_max = (wavelength_i / 4).max(3);
let edge_max = (channel_width / 2).max(2) + 3; // half-width + max edge jitter
// Floor: the BraidedDelta belt reaches anchor ±(32 thread-centre + 4
// thread-half) before warp regardless of wavelength — the band must
// cover it even at the short-wavelength extreme (costs ≤4 m of extra
// gate generosity for the other families).
let edge_max = (primary_channel_width_final / 2).max(2) + 3; // half-width + max edge jitter
// Floor: the BraidedDelta belt reaches anchor ±(32 thread-centre + 4
// thread-half) before warp regardless of wavelength — the band must
// cover it even at the short-wavelength extreme (costs ≤4 m of extra
// gate generosity for the other families).
let reach = (amplitude_max + edge_max + LEVEE_BAND_MAX_M).max(32 + 4) + WARP_BOUND_M;
let cross_lo = cross_chunk * CHUNK_M;
let cross_hi = cross_lo + CHUNK_M - 1;
@@ -244,7 +368,11 @@ pub fn derive_chunk_context(
};
// Channel width — integer metres; 0 when no active channel in this chunk.
let channel_width_m = if has_active_channel { channel_width } else { 0 };
let channel_width_m = if has_active_channel {
primary_channel_width_final
} else {
0
};
ChunkContext {
basin_direction,
@@ -254,6 +382,38 @@ pub fn derive_chunk_context(
channel_width_m,
channel_anchor_m,
coast_anchor_m,
secondary: secondary_profile,
blend_weight,
}
}
/// Compute the cross-district blend weight for a chunk position (T-1042).
///
/// Returns `(is_near_boundary, blend_weight)` for the given chunk's district
/// proximity on either axis. `blend_weight` is 255 when interior (no blend),
/// or 128 when the chunk is the outermost within its district on either axis
/// (one chunk from the district boundary). The `is_near_boundary` flag is `true`
/// only when `blend_weight < 255`.
///
/// The caller uses `is_near_boundary` to decide whether to look up the adjacent
/// `DistrictProfile` and supply it to `derive_chunk_context`. Only the last chunk
/// of a district (chunk index `CHUNKS_PER_DISTRICT - 1` = 31 within the district)
/// triggers a blend; the first chunk of the next district does not — this way the
/// blend seam is always on the outgoing side, and the incoming district's first
/// chunk reads clean from its own primary profile.
///
/// Integer arithmetic (D-010).
pub fn district_boundary_blend_weight(chunk_pos: ChunkPos) -> (bool, u8) {
// Chunk index within its district on each axis (0..32).
let cx = chunk_pos.0.rem_euclid(scale::CHUNKS_PER_DISTRICT);
let cy = chunk_pos.1.rem_euclid(scale::CHUNKS_PER_DISTRICT);
// The last chunk (index 31) is within 64 m of the district boundary.
let near_x = cx == scale::CHUNKS_PER_DISTRICT - 1;
let near_y = cy == scale::CHUNKS_PER_DISTRICT - 1;
if near_x || near_y {
(true, 128u8)
} else {
(false, 255u8)
}
}
@@ -389,7 +549,7 @@ mod tests {
/// The chunk of district (0, 0) whose cross-range contains the district's
/// channel anchor — guaranteed inside the T-1040 channel band.
fn anchor_chunk_pos(world_seed: u64, body_id: &str, district: &DistrictProfile) -> ChunkPos {
let probe = derive_chunk_context(world_seed, body_id, district, (0, 0));
let probe = derive_chunk_context(world_seed, body_id, district, (0, 0), None);
let idx = probe.channel_anchor_m.div_euclid(CHUNK_M);
match probe.basin_direction {
BasinDirection::North | BasinDirection::South => (idx, 0),
@@ -400,8 +560,8 @@ mod tests {
#[test]
fn derive_chunk_context_is_deterministic() {
let district = alluvial_district();
let a = derive_chunk_context(42, "GJ1c", &district, (10, 20));
let b = derive_chunk_context(42, "GJ1c", &district, (10, 20));
let a = derive_chunk_context(42, "GJ1c", &district, (10, 20), None);
let b = derive_chunk_context(42, "GJ1c", &district, (10, 20), None);
assert_eq!(a.basin_direction, b.basin_direction);
assert_eq!(a.meander_phase, b.meander_phase);
assert_eq!(a.meander_wavelength_m, b.meander_wavelength_m);
@@ -412,8 +572,8 @@ mod tests {
#[test]
fn different_positions_yield_different_phases() {
let district = alluvial_district();
let a = derive_chunk_context(42, "GJ1c", &district, (0, 0));
let b = derive_chunk_context(42, "GJ1c", &district, (200, 100));
let a = derive_chunk_context(42, "GJ1c", &district, (0, 0), None);
let b = derive_chunk_context(42, "GJ1c", &district, (200, 100), None);
// Different district-scale ids → different phases (high probability).
assert_ne!(
a.meander_phase, b.meander_phase,
@@ -427,7 +587,7 @@ mod tests {
// T-1040: the channel is district-anchored — the chunk under the anchor
// must claim it (ocean_fraction_q=15 → water present).
let pos = anchor_chunk_pos(42, "GJ1c", &district);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None);
assert!(
ctx.has_active_channel,
"anchor-covering chunk of a watered district must have active channel"
@@ -443,7 +603,7 @@ mod tests {
let district = alluvial_district();
let (anchor_pos, probe) = {
let pos = anchor_chunk_pos(42, "GJ1c", &district);
(pos, derive_chunk_context(42, "GJ1c", &district, pos))
(pos, derive_chunk_context(42, "GJ1c", &district, pos, None))
};
// 8 cross-chunks away (512 m) is past any band reach but still inside
// district (0, 0) — the anchor margin keeps the anchor chunk in [3, 12].
@@ -457,7 +617,7 @@ mod tests {
BasinDirection::North | BasinDirection::South => (far_idx, 0),
BasinDirection::East | BasinDirection::West => (0, far_idx),
};
let far_ctx = derive_chunk_context(42, "GJ1c", &district, far_pos);
let far_ctx = derive_chunk_context(42, "GJ1c", &district, far_pos, None);
assert!(
!far_ctx.has_active_channel,
"chunk {far_pos:?} outside the channel band must not claim a channel"
@@ -476,9 +636,9 @@ mod tests {
// T-1040/T-1041: feature anchors are a district property — identical for
// every chunk of the district, and positioned inside the district's extent.
let district = alluvial_district();
let base = derive_chunk_context(42, "GJ1c", &district, (0, 0));
let base = derive_chunk_context(42, "GJ1c", &district, (0, 0), None);
for pos in [(1, 0), (0, 1), (15, 15), (7, 12)] {
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None);
assert_eq!(
ctx.channel_anchor_m, base.channel_anchor_m,
"channel anchor must be district-constant (chunk {pos:?})"
@@ -495,7 +655,7 @@ mod tests {
// (1000, -750) → district (1000 >> 5, -750 >> 5) = (31, -24); the cross
// axis (and thus which district index the channel anchor sits in) depends
// on the basin direction.
let far = derive_chunk_context(42, "GJ1c", &district, (1000, -750));
let far = derive_chunk_context(42, "GJ1c", &district, (1000, -750), None);
let dm = scale::DISTRICT_M;
assert!(
(31 * dm..32 * dm).contains(&far.channel_anchor_m)
@@ -520,7 +680,7 @@ mod tests {
moisture_q: 5,
vegetation_class: VegetationClass::Barren,
};
let ctx = derive_chunk_context(42, "dry_body", &district, (5, 5));
let ctx = derive_chunk_context(42, "dry_body", &district, (5, 5), None);
assert!(
!ctx.has_active_channel,
"arid district with ocean_fraction_q=0 must not have active channel"
@@ -533,7 +693,7 @@ mod tests {
// the channel (T-1040 gating zeroes the width elsewhere).
let district = alluvial_district();
let pos = anchor_chunk_pos(42, "GJ1c", &district);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos);
let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None);
assert!(
(3..=15).contains(&ctx.channel_width_m),
"channel_width_m {} out of game-feel range [3, 15]",
@@ -544,7 +704,7 @@ mod tests {
#[test]
fn meander_wavelength_within_physics_range() {
let district = alluvial_district();
let ctx = derive_chunk_context(42, "GJ1c", &district, (5, 5));
let ctx = derive_chunk_context(42, "GJ1c", &district, (5, 5), None);
// AlluvialPlain flat (slope_q=5): should be near max wavelength.
assert!(
ctx.meander_wavelength_m > 400.0 && ctx.meander_wavelength_m < 700.0,
+426 -41
View File
@@ -23,7 +23,8 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::scale;
use crate::atlas::region_profile::{self, RegionProfile};
use crate::atlas::scale::{self, RegionPos};
use crate::seed::SeedChain;
use crate::simulation::generator::MorphologyZone;
@@ -148,9 +149,17 @@ pub struct BodyParams {
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
/// from `planet_class`.
pub tectonic_activity: Option<String>,
/// Latitude of the district's centre in the body's reference frame, in degrees.
/// Latitude of this cell's centre in the body's reference frame, in degrees.
/// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient.
pub district_latitude_deg: f64,
///
/// This field serves at **both** the district (2 km) and region (~205 km) scales:
/// when building a `DistrictProfile` it holds the district centre latitude; when
/// passed to [`crate::atlas::region_profile::derive_region_baseline_c`] it is
/// expected to carry the **region centre latitude** (callers override it via
/// struct-update syntax before passing the params down). The name was changed
/// from `latitude_deg` to `latitude_deg` (T-1078) to remove the
/// misleading scale implication.
pub latitude_deg: f64,
/// Mean elevation of this district relative to sea level, in km. Used for lapse rate.
pub elevation_km: f64,
/// `bodies.body_radius_km` (D-204) — the body's radius in km. The single
@@ -813,7 +822,7 @@ pub fn derive_temperature_c(
let maritime = constants.maritime_factor(hydrosphere);
let mid = (cold + warm) * 0.5;
let half = band_width * 0.5 * maritime;
let lat_frac = (params.district_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0);
let lat_frac = (params.latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0);
// equator (frac 0) → mid + half; pole (frac 1) → mid half.
let t_lat = (mid + half) - (2.0 * half) * lat_frac;
@@ -847,6 +856,80 @@ pub fn derive_temperature_c(
Some(t_nudged.clamp(cold, warm))
}
/// Derive the district-level temperature as a **modulation** of a region baseline
/// (D-243 §3 / D-239 §2 split, T-1078).
///
/// This is **step (b)** of the two-phase temperature derivation:
/// - **Step (a)** is the region baseline (`region_profile::derive_region_baseline_c`):
/// latitude + greenhouse nudge + seed nudge, clamped to the class band.
/// - **Step (b)** is here: apply elevation lapse + slope aspect ON TOP of the
/// baseline, re-clamp to the class band.
///
/// When a region baseline is available (produced by the edge-fuzz blend in
/// `region_profile::region_baseline_at_district`), callers should prefer this
/// function over [`derive_temperature_c`]. The two-phase split ensures the
/// temperature gradient is a continuous, warp-perturbed scalar field (D-243 §4)
/// rather than independent per-district derivations.
///
/// ## Inputs
///
/// - `region_baseline_c` — the edge-fuzz-blended region mean temperature (from
/// `region_profile::region_baseline_at_district`). `None` means airless.
/// - `params` — `BodyParams` with the district's own `elevation_km` and
/// `atmosphere` (for lapse rate selection). The latitude/hydrosphere fields
/// are **not re-used here** — they were consumed by the region baseline.
/// - `constants` — climate constants (for the class-band clamp).
///
/// ## Returns
///
/// `None` if `region_baseline_c` is `None` (airless body). Otherwise the
/// district temperature in °C, clamped to the planet class band.
///
/// ## Slope aspect modulation (D-243 §3)
///
/// Slope aspect is a future input that will modulate temperature based on
/// sun-facing vs. shaded slopes. It is not yet available at the district tier
/// (no per-district aspect data). The parameter is reserved; pass `0.0`.
///
/// ## D-010 compliance
///
/// All structural gating downstream uses `temperature_c as i32`. The f32
/// arithmetic here is positional physics.
pub fn derive_district_temperature_c(
region_baseline_c: Option<f32>,
params: &BodyParams,
constants: &ClimateConstants,
_slope_aspect_deg: f32, // reserved for Q-105 / future per-district aspect
) -> Option<f32> {
// Airless: region baseline is None → no district temperature.
let baseline = region_baseline_c?;
let atmosphere = params.atmosphere.as_deref().unwrap_or("none");
// Double-check: if atmosphere is "none" the region baseline should already
// be None, but guard defensively.
if atmosphere == "none" {
return None;
}
// Elevation lapse rate (°C/km). Same as in derive_temperature_c.
let lapse = if atmosphere == "thin" {
3.5_f32
} else {
6.5_f32
};
let elev_km = (params.elevation_km as f32).max(0.0);
let t_lapse = baseline - lapse * elev_km;
// Slope aspect modulation: reserved for future Q-105 / per-district aspect data.
// _slope_aspect_deg is currently unused; the `let _ = …` suppresses the lint.
let _ = _slope_aspect_deg;
// Clamp to the class band (D-240 hard invariant).
let planet_class = params.planet_class.as_deref().unwrap_or("temperate");
let (cold, warm) = constants.envelope(planet_class);
Some(t_lapse.clamp(cold, warm))
}
/// Derive moisture primitive (0100 integer).
///
/// D-239 §2: moisture is derived from hydrosphere + atmosphere; 0 = arid, 100 = saturated.
@@ -901,9 +984,20 @@ pub fn derive_moisture_q(params: &BodyParams) -> i32 {
/// cell; default is 8 (at 128×64 working grid, that yields ~80×64 districts ≈
/// ~5 000 districts/body, within the D-203 ~6 000/body budget).
///
/// Temperature and moisture are derived inline via D-239 §2 / D-240 climate
/// functions (T-1024). Pass a `&ClimateConstants` to control the tuning constants.
/// The seed chain provides the body-scoped seed for the D-240 temperature nudge.
/// Temperature and moisture are derived via the D-243 §3/§4 two-phase stack:
/// the edge-fuzz-blended region baseline (from `region_cache` / on-the-fly
/// derivation via [`region_profile::region_baseline_at_district`]) feeds the
/// district modulation ([`derive_district_temperature_c`]). Pass a
/// `&ClimateConstants` to control the tuning constants. The seed chain provides
/// the body-scoped seed.
///
/// ## Parameters
///
/// - `body_id` — the body's string identifier; required for the D-243 §4 climate
/// edge-fuzz warp domain separation (distinct bodies get distinct warps).
/// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`];
/// if a neighbour region is missing it is derived on the fly. Build with
/// [`region_profile::derive_regions_for_body`] before calling this in a loop.
pub fn derive_district_profile(
seed: SeedChain,
body_params: &BodyParams,
@@ -911,6 +1005,8 @@ pub fn derive_district_profile(
pos: DistrictPos,
grid_cells_per_district: usize,
climate: &ClimateConstants,
body_id: &str,
region_cache: &BTreeMap<RegionPos, RegionProfile>,
) -> DistrictProfile {
let (rx, ry) = pos;
let w = ta.w;
@@ -951,6 +1047,19 @@ pub fn derive_district_profile(
(0, 0, 0)
};
// D-243 §3/§4: compute the edge-fuzz-blended region baseline for this district,
// then pass it through build_district_profile so the two-phase derivation path runs.
// The warp uses `seed.seed()` (the body-scoped seed) for domain separation.
let region_baseline_c = region_profile::region_baseline_at_district(
seed.seed(),
body_id,
pos,
body_params,
climate,
seed,
Some(region_cache),
);
build_district_profile(
seed,
body_params,
@@ -958,6 +1067,7 @@ pub fn derive_district_profile(
slope_q,
elev_q,
ocean_fraction_q,
region_baseline_c,
)
}
@@ -965,6 +1075,23 @@ pub fn derive_district_profile(
/// terrain primitives (`slope_q`, `elev_q`, `ocean_fraction_q`) — the shared tail
/// of every derivation path (cell-aggregate [`derive_district_profile`] and the
/// interpolation+scatter [`derive_district`]). Pure (T-1024, D-239 §2 / D-240).
///
/// ## Region baseline parameter (D-243 §3, T-1078)
///
/// `region_baseline_c` is the edge-fuzz-blended region mean temperature from
/// [`crate::atlas::region_profile::region_baseline_at_district`].
///
/// - When `Some(baseline)`: district temperature is derived as a **modulation**
/// of the baseline via [`derive_district_temperature_c`] — elevation lapse
/// only (latitude/greenhouse/nudge already in the baseline). This is the
/// D-243 §3 correct two-phase path.
/// - When `None`: falls back to the legacy single-phase [`derive_temperature_c`]
/// for backward compatibility (used by unit tests and paths where no region
/// layer has run yet).
///
/// The distinction is important for edge fuzz: only the two-phase path produces
/// a continuous, warp-perturbed temperature gradient. The single-phase path
/// still satisfies D-240 but without edge fuzz.
fn build_district_profile(
seed: SeedChain,
body_params: &BodyParams,
@@ -972,23 +1099,34 @@ fn build_district_profile(
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
region_baseline_c: Option<f32>,
) -> DistrictProfile {
let tectonic_class = derive_tectonic_class(body_params);
// Climate derivation (T-1024, D-239 §2). Temperature lapse must vary by THIS
// district's elevation — otherwise every district on a body shares one body-level
// elevation and gets an identical lapse, defeating the per-district temperature
// primitive. Build a district-local BodyParams whose elevation_km comes from the
// district's own normalized elevation (elev_q, 0100) scaled to the body's
// elevation span. district_latitude_deg is already per-district (set by the caller
// / derive_all_districts). Per-cell refinement happens later at ChunkContext.
// District-local BodyParams: elevation_km comes from the district's own
// elev_q (0100 scaled to the body's elevation span). latitude_deg
// is already set per-district by the caller. Per-cell refinement at ChunkContext.
let district_climate_params = BodyParams {
elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
..body_params.clone()
};
// D-240: body-scoped seed for the deterministic per-body temperature nudge.
let body_seed = seed.seed();
let temperature_c = derive_temperature_c(&district_climate_params, climate, body_seed);
// Temperature derivation: two-phase (D-243 §3) when a region baseline is
// available; single-phase legacy fallback otherwise.
let temperature_c = match region_baseline_c {
Some(baseline) => {
// Two-phase path: apply only elevation lapse on top of the
// edge-fuzz-blended region baseline (D-243 §3 / D-239 §2 split).
// slope_aspect_deg = 0.0: reserved, not yet available (Q-105).
derive_district_temperature_c(Some(baseline), &district_climate_params, climate, 0.0)
}
None => {
// Legacy single-phase path: derive temperature from scratch.
// D-240: body-scoped seed for the deterministic per-body nudge.
let body_seed = seed.seed();
derive_temperature_c(&district_climate_params, climate, body_seed)
}
};
let moisture_q = derive_moisture_q(body_params);
// Climate-derived fields: computed from temperature + moisture primitives
@@ -1037,10 +1175,22 @@ fn build_district_profile(
/// to direct heightmap indexing.
///
/// Pure and deterministic (D-227/D-010): a function of
/// `(seed, body_params, terrain, district_pos)`; the f64 scatter is quantised to
/// integer `slope_q`/`elev_q` at the decision boundary.
/// `(seed, body_id, body_params, terrain, district_pos)`; the f64 scatter is
/// quantised to integer `slope_q`/`elev_q` at the decision boundary.
///
/// ## Region baseline (D-243 §3/§4, T-1078)
///
/// The district temperature is derived as a **modulation** of the edge-fuzz-blended
/// region baseline ([`region_profile::region_baseline_at_district`]). No pre-built
/// region cache is required here — the on-demand path derives the four surrounding
/// region baselines on-the-fly (pure, deterministic, cheap: four `derive_region_baseline_c`
/// calls). For batch derivation of many districts use [`derive_all_districts`], which
/// builds a region cache once per body.
///
/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation.
pub fn derive_district(
seed: SeedChain,
body_id: &str,
body_params: &BodyParams,
ta: &TerrainAnalysis,
district_pos: DistrictPos,
@@ -1103,10 +1253,33 @@ pub fn derive_district(
let ocean_fraction_q = ((ocean_frac * 100.0).round() as i32).clamp(0, 100);
let params = BodyParams {
district_latitude_deg: lat_deg,
latitude_deg: lat_deg,
..body_params.clone()
};
build_district_profile(seed, &params, climate, slope_q, elev_q, ocean_fraction_q)
// D-243 §3/§4: compute the edge-fuzz-blended region baseline on-the-fly for
// this district. No pre-built cache here — the on-demand path derives the four
// surrounding region baselines directly. Pure, deterministic, cheap.
// `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation.
let region_baseline_c = region_profile::region_baseline_at_district(
seed.seed(),
body_id,
district_pos,
&params,
climate,
seed,
None, // no pre-built cache; derive on-the-fly
);
build_district_profile(
seed,
&params,
climate,
slope_q,
elev_q,
ocean_fraction_q,
region_baseline_c,
)
}
/// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`.
@@ -1170,17 +1343,53 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
/// District latitude is derived from the row index: ry=0 maps to the north pole
/// (+90°), ry=district_rows-1 maps to the south pole (-90°). This is a linear
/// mapping across the equirectangular heightmap.
///
/// ## Region cache (D-243 §3/§4, T-1078)
///
/// A [`RegionProfile`] cache is built once per body from all region positions
/// that cover the district grid, then passed to each [`derive_district_profile`]
/// call so the D-243 §4 edge-fuzz blend reads a consistent set of region
/// baselines — all four corners of every blend come from the same derived set.
///
/// `body_id` is the body's string identifier, required for the climate edge-fuzz
/// warp domain separation.
pub fn derive_all_districts(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
grid_cells_per_district: usize,
body_id: &str,
) -> BTreeMap<DistrictPos, DistrictProfile> {
let climate = ClimateConstants::default();
let gcpr = grid_cells_per_district.max(1);
let district_cols = ta.w.div_ceil(gcpr) as i32;
let district_rows = ta.h.div_ceil(gcpr) as i32;
// Build the region cache once for the whole body before district derivation.
// Collect all unique region positions that cover this district grid, plus
// their immediate neighbours (the edge-fuzz blend samples up to one region
// beyond the district's own region). Using a BTreeSet for determinism (D-010).
let region_positions: std::collections::BTreeSet<RegionPos> = {
let mut set = std::collections::BTreeSet::new();
for ry in 0..district_rows {
for rx in 0..district_cols {
let district_pos = (rx, ry);
let rpos = scale::district_to_region(district_pos);
// The edge-fuzz blend samples the base region and one neighbour
// in each axis direction (±1). Pre-populate all 9 candidates so
// cache hits dominate and on-the-fly derivations are rare.
for dy in -1i32..=1 {
for dx in -1i32..=1 {
set.insert((rpos.0 + dx, rpos.1 + dy));
}
}
}
}
set
};
let region_cache =
region_profile::derive_regions_for_body(seed, body_params, &climate, region_positions);
let mut out = BTreeMap::new();
for ry in 0..district_rows {
// Map ry to latitude: row 0 → +90°, row (rows-1) → -90°.
@@ -1197,10 +1406,19 @@ pub fn derive_all_districts(
// deriving elevation_km from the district's own elev_q (not the caller's
// body_params.elevation_km). Per-cell refinement happens at ChunkContext (D-239).
let district_params = BodyParams {
district_latitude_deg: lat_deg,
latitude_deg: lat_deg,
..body_params.clone()
};
let profile = derive_district_profile(seed, &district_params, ta, pos, gcpr, &climate);
let profile = derive_district_profile(
seed,
&district_params,
ta,
pos,
gcpr,
&climate,
body_id,
&region_cache,
);
out.insert(pos, profile);
}
}
@@ -1251,7 +1469,7 @@ mod tests {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let districts = derive_all_districts(test_seed(), &params, &ta, 8);
let districts = derive_all_districts(test_seed(), &params, &ta, 8, "test_body");
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
assert_eq!(districts.len(), 32, "district count mismatch");
@@ -1275,8 +1493,8 @@ mod tests {
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let a = derive_district(test_seed(), &p, &ta, (1234, -567), &climate);
let b = derive_district(test_seed(), &p, &ta, (1234, -567), &climate);
let a = derive_district(test_seed(), "test_body", &p, &ta, (1234, -567), &climate);
let b = derive_district(test_seed(), "test_body", &p, &ta, (1234, -567), &climate);
assert_eq!(a.elev_q, b.elev_q);
assert_eq!(a.slope_q, b.slope_q);
assert_eq!(a.morphology_zone, b.morphology_zone);
@@ -1294,9 +1512,15 @@ mod tests {
// meridian ≈ π·6371·1000 m; a district near the pole is ~quarter-meridian away.
let merid_districts =
(std::f64::consts::PI * 6371.0 * 1000.0 / scale::DISTRICT_M as f64) as i32;
let equator = derive_district(test_seed(), &p, &ta, (0, 0), &climate);
let high_lat =
derive_district(test_seed(), &p, &ta, (0, merid_districts / 2 - 2), &climate);
let equator = derive_district(test_seed(), "test_body", &p, &ta, (0, 0), &climate);
let high_lat = derive_district(
test_seed(),
"test_body",
&p,
&ta,
(0, merid_districts / 2 - 2),
&climate,
);
match (equator.temperature_c, high_lat.temperature_c) {
(Some(eq), Some(hi)) => {
assert!(hi < eq, "near-pole district must be colder ({hi} !< {eq})")
@@ -1320,7 +1544,7 @@ mod tests {
let ta = test_ta(&flat);
let climate = ClimateConstants::default();
let p = earth_params();
let d = derive_district(test_seed(), &p, &ta, (500, 100), &climate);
let d = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate);
// Flat land everywhere → slope 0 → no invented relief, no ocean.
assert_eq!(
d.slope_q, 0,
@@ -1341,8 +1565,8 @@ mod tests {
atmosphere: Some("breathable".into()),
..Default::default() // body_radius_km: None
};
let a = derive_district(test_seed(), &p, &ta, (20, 10), &climate);
let b = derive_district(test_seed(), &p, &ta, (20, 10), &climate);
let a = derive_district(test_seed(), "test_body", &p, &ta, (20, 10), &climate);
let b = derive_district(test_seed(), "test_body", &p, &ta, (20, 10), &climate);
assert_eq!(a.elev_q, b.elev_q);
assert!((0..=100).contains(&a.elev_q) && (0..=100).contains(&a.slope_q));
}
@@ -1360,8 +1584,26 @@ mod tests {
};
let pos = (2, 1);
let climate = ClimateConstants::default();
let p1 = derive_district_profile(test_seed(), &params, &ta, pos, 8, &climate);
let p2 = derive_district_profile(test_seed(), &params, &ta, pos, 8, &climate);
let p1 = derive_district_profile(
test_seed(),
&params,
&ta,
pos,
8,
&climate,
"test_body",
&BTreeMap::new(),
);
let p2 = derive_district_profile(
test_seed(),
&params,
&ta,
pos,
8,
&climate,
"test_body",
&BTreeMap::new(),
);
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
assert_eq!(
format!("{:?}", p1.morphology_zone),
@@ -1455,7 +1697,7 @@ mod tests {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let districts = derive_all_districts(test_seed(), &params, &ta, 8);
let districts = derive_all_districts(test_seed(), &params, &ta, 8, "test_body");
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
let first = districts.keys().next().expect("at least one district");
assert_eq!(*first, (0, 0), "first district must be at origin");
@@ -1486,7 +1728,7 @@ mod tests {
let params = BodyParams {
planet_class: Some(planet_class.into()),
atmosphere: Some(atmosphere.into()),
district_latitude_deg: lat,
latitude_deg: lat,
elevation_km: elev_km,
..Default::default()
};
@@ -1562,7 +1804,7 @@ mod tests {
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("arid".into()),
district_latitude_deg: 45.0,
latitude_deg: 45.0,
elevation_km: 1.5,
..Default::default()
};
@@ -1579,7 +1821,7 @@ mod tests {
let params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
district_latitude_deg: 30.0,
latitude_deg: 30.0,
elevation_km: 0.0,
..Default::default()
};
@@ -1634,7 +1876,7 @@ mod tests {
planet_class: Some((*class).into()),
atmosphere: Some((*atmo).into()),
hydrosphere: Some(hydro.into()),
district_latitude_deg: lat,
latitude_deg: lat,
elevation_km: elev,
..Default::default()
};
@@ -1663,7 +1905,7 @@ mod tests {
planet_class: Some("temperate".into()),
atmosphere: Some("standard".into()),
hydrosphere: Some(hydro.into()),
district_latitude_deg: lat,
latitude_deg: lat,
elevation_km: 0.0,
..Default::default()
};
@@ -1689,7 +1931,7 @@ mod tests {
let params = BodyParams {
planet_class: Some("unknown_alien_class".into()),
atmosphere: Some("breathable".into()),
district_latitude_deg: 0.0,
latitude_deg: 0.0,
elevation_km: 0.0,
..Default::default()
};
@@ -2435,4 +2677,147 @@ mod tests {
"Volcanic tectonic must prevent Fjord production (gate order)"
);
}
// -----------------------------------------------------------------------
// T-1078 / D-243 §3: derive_district_temperature_c — two-phase split
// -----------------------------------------------------------------------
#[test]
fn district_modulation_applies_lapse_on_baseline() {
// High elevation must produce colder district temperature than sea level,
// given the same region baseline.
let climate = ClimateConstants::default();
let baseline = Some(15.0f32); // hypothetical region baseline at sea level
let sea_level_params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
elevation_km: 0.0,
..Default::default()
};
let high_params = BodyParams {
elevation_km: 4.0,
..sea_level_params.clone()
};
let t_sea = derive_district_temperature_c(baseline, &sea_level_params, &climate, 0.0)
.expect("breathable body must have temperature");
let t_high = derive_district_temperature_c(baseline, &high_params, &climate, 0.0)
.expect("breathable body must have temperature");
assert!(
t_high < t_sea,
"district at 4 km ({t_high}°C) must be colder than sea level ({t_sea}°C)"
);
// 4 km × 6.5 °C/km = 26 °C lapse; clamping may reduce it, but at
// least a few degrees should register.
let delta = t_sea - t_high;
assert!(delta >= 5.0, "4 km elevation delta {delta}°C too small");
}
#[test]
fn district_modulation_airless_baseline_returns_none() {
// None baseline (airless body) → None district temperature.
let climate = ClimateConstants::default();
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
elevation_km: 0.0,
..Default::default()
};
let t = derive_district_temperature_c(None, &params, &climate, 0.0);
assert_eq!(
t, None,
"None baseline must propagate as None district temperature"
);
}
#[test]
fn district_modulation_within_class_band() {
// Even with high lapse, the clamped output must stay within the class band.
let climate = ClimateConstants::default();
let (cold, warm) = climate.envelope("frozen");
// Baseline at the warm end of the frozen band.
let baseline = Some(warm);
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
elevation_km: 8.0, // max elevation → would push far below cold end
..Default::default()
};
let t = derive_district_temperature_c(baseline, &params, &climate, 0.0)
.expect("non-airless body must have temperature");
assert!(
t >= cold && t <= warm,
"district temperature {t}°C outside frozen band [{cold}, {warm}]"
);
}
#[test]
fn district_modulation_does_not_re_apply_latitude_or_greenhouse() {
// The district modulation function must NOT include latitude or greenhouse
// effects — those are already in the region baseline. Pass different baselines
// (simulating the latitude gradient) and verify the delta is exactly what the
// lapse adds, with no additional latitude-induced shift.
let climate = ClimateConstants::default();
let params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
elevation_km: 2.0,
latitude_deg: 0.0, // this should be irrelevant for the modulation
..Default::default()
};
// Two different baselines (simulating equatorial vs mid-latitude regions).
let t_warm_region = derive_district_temperature_c(Some(20.0), &params, &climate, 0.0);
let t_cool_region = derive_district_temperature_c(Some(5.0), &params, &climate, 0.0);
// Both get the same lapse (same params), so the delta between them must
// equal the delta between the baselines: 15°C.
let delta = t_warm_region.unwrap() - t_cool_region.unwrap();
assert!(
(delta - 15.0).abs() < 1.0,
"district modulation should preserve the baseline delta (got {delta}°C, expected ~15°C)"
);
}
#[test]
fn district_modulation_thin_atmosphere_uses_lower_lapse() {
// Thin atmosphere → lapse = 3.5 °C/km (vs 6.5 for standard/breathable).
// At 2 km elevation, thin should be ~6 °C warmer than breathable.
let climate = ClimateConstants::default();
let baseline = Some(0.0f32);
let thin_params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
elevation_km: 2.0,
..Default::default()
};
let breathable_params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
elevation_km: 2.0,
..Default::default()
};
let t_thin = derive_district_temperature_c(baseline, &thin_params, &climate, 0.0).unwrap();
let t_breathable =
derive_district_temperature_c(baseline, &breathable_params, &climate, 0.0).unwrap();
// thin lapse: 3.5 × 2 = 7°C; breathable lapse: 6.5 × 2 = 13°C.
// t_thin should be ~6°C warmer than t_breathable (both start from 0°C).
// Note: clamping to class bands may reduce the difference at band edges.
// Just verify the ordering holds.
assert!(
t_thin > t_breathable || {
// If clamping squishes both to the cold end, verify at least thin
// didn't produce MORE lapse than breathable.
let (frozen_cold, _) = climate.envelope("frozen");
let (_, temperate_warm) = climate.envelope("temperate");
t_thin >= frozen_cold && t_breathable <= temperate_warm
},
"thin atmosphere lapse ({t_thin}°C) should be milder than breathable ({t_breathable}°C) at same elevation"
);
}
}
+1
View File
@@ -21,6 +21,7 @@ pub mod heightmap;
pub mod layer1;
pub mod layer_proxy;
pub mod plugin;
pub mod region_profile;
pub mod road_graph;
pub mod scale;
pub mod skeleton_gen;
+882
View File
@@ -0,0 +1,882 @@
//! Region climate stack — the ~205 km top hard block (D-243 §3 / §4, T-1078).
//!
//! A [`RegionProfile`] holds the **region-level climate context**: latitude-driven
//! temperature baseline, weather state, seasonal clock structure. Every district and
//! tile inside the region inherits these values, then applies its own modulation
//! (elevation lapse + slope aspect at the district tier; freeze/snow scatter at
//! the chunk/voxel tier).
//!
//! ## The climate three-level stack (D-243 §3)
//!
//! ```text
//! Region (~205 km) — climate context: baseline temperature, weather state, season
//! ↓ modulation: elevation lapse + slope aspect
//! District (2 km) — local temperature = baseline + lapse + aspect
//! ↓ scatter: freeze/snow (D-239 §3)
//! Chunk/Voxel (64 m / 1 m) — cover scatter
//! ```
//!
//! ## Edge fuzz (D-243 §4)
//!
//! Climate does not change on a line. A tile's climate value is a **continuous,
//! warp-perturbed bilinear blend of surrounding region baselines** — the same
//! meta-rule as [D-239 §4]'s domain warp for terrain, but for the climate scalar
//! field. [`region_baseline_at_district`] implements this: it looks up the four
//! surrounding region baselines and blends them with a noise-displaced bilinear
//! interpolation.
//!
//! ## Q-105 deferral
//!
//! The `RegionClock` structure is scaffolded here (season + weather state) to
//! unblock Q-105's transient clock callbacks. The mean-state derivation and the
//! edge-fuzz blend are fully implemented; the **transient clock-phase tick
//! callbacks** (seasonal frost form/melt, crop-cycle cadence, recompute schedule)
//! are deferred to Q-105. `RegionClock` carries field-level Q-105 annotations.
//!
//! ## D-010 compliance
//!
//! All structural gating decisions use integer arithmetic. `f64` is used only for
//! positional computations (bilinear weights, warp displacement) that are then
//! quantised before any structural decision is made.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::atlas::district_profile::{BodyParams, ClimateConstants};
use crate::atlas::scale::{self, DistrictPos, RegionPos};
use crate::seed::{splitmix64, SeedChain};
// ---------------------------------------------------------------------------
// SeasonPhase — the region's current seasonal position
// ---------------------------------------------------------------------------
/// Broad seasonal phase for a region's clock (Q-105 forward contract).
///
/// The discriminants are **pinned and append-only** (D-010). The tick callbacks
/// that advance through these phases are deferred to Q-105 — today only the
/// struct is scaffolded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum SeasonPhase {
/// Warmest quarter of the year — long days, peak vegetation.
#[default]
Summer = 0,
/// Cooling quarter — harvest, leaf-fall, falling precipitation.
Autumn = 1,
/// Coldest quarter of the year — short days, peak freeze extent.
Winter = 2,
/// Warming quarter — melt, sowing, rising temperatures.
Spring = 3,
}
// ---------------------------------------------------------------------------
// WeatherState — the region's weather snapshot
// ---------------------------------------------------------------------------
/// Coarse weather state for a region (Q-105 forward contract).
///
/// The **mean-state** used today is `Clear` (no active weather event). Q-105
/// will provide the tick that advances through these states based on the
/// region's climate class and seasonal phase.
///
/// Integer-discriminant, append-only (D-010).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum WeatherState {
/// Clear sky — no active weather event.
#[default]
Clear = 0,
/// Overcast — cloud cover, diffuse light, reduced heat gain.
Overcast = 1,
/// Rain — precipitation in liquid form (temperature ≥ 0 °C).
Rain = 2,
/// Snow — precipitation in solid form (temperature < 0 °C).
Snow = 3,
/// Blizzard — heavy snow + high wind; severe passability impact.
Blizzard = 4,
/// Dust storm — driven by arid conditions + wind.
DustStorm = 5,
}
// ---------------------------------------------------------------------------
// RegionClock — scaffolded seasonal/weather clock (Q-105 forward contract)
// ---------------------------------------------------------------------------
/// Region-level time state: the seasonal clock and active weather.
///
/// Today only the **mean-state struct is scaffolded** (season = Summer,
/// weather = Clear, all clock fields at their neutral values). The tick
/// callbacks that drive seasonal transitions and weather events are
/// **deferred to Q-105**.
///
/// Consumers needing the mean-state (static seasonal derivation) read this
/// struct directly. Q-105 will extend `RegionClock` with a tick-phase callback
/// and the `recompute_schedule` logic — the base fields here are load-bearing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionClock {
/// Current broad seasonal phase (Q-105: ticked by the region clock).
pub season: SeasonPhase,
/// Active weather event for the region (Q-105: ticked by the weather cycle).
pub weather: WeatherState,
/// Mean-annual temperature baseline at the region centre (°C), `None` for
/// airless bodies. This is the **pre-edge-fuzz** region-level baseline
/// derived from `planet_class` + latitude + greenhouse nudge (D-243 §3).
///
/// District temperatures are derived by modulating this value with elevation
/// lapse and slope aspect — see [`district_profile::derive_district_temperature_c`].
pub mean_temp_c: Option<f32>,
}
impl Default for RegionClock {
fn default() -> Self {
Self {
season: SeasonPhase::Summer,
weather: WeatherState::Clear,
mean_temp_c: None,
}
}
}
// ---------------------------------------------------------------------------
// RegionProfile — the region climate carrier
// ---------------------------------------------------------------------------
/// Per-region (~205 km) climate context (D-243 §3, T-1078).
///
/// Derived once per region; every district inside inherits it and applies
/// its own modulation (elevation lapse, slope aspect). The edge-fuzz blend
/// ([`region_baseline_at_district`]) ensures the ~205 km grid is invisible in
/// the output — temperatures grade smoothly and raggedly across region boundaries.
///
/// **Derivation inputs:** `(seed, body_params, region_pos)`. Pure and
/// deterministic — no I/O, no side effects (D-010 / D-227).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionProfile {
/// Region position (region-units) — the key in `BodyWorldState.regions`.
pub pos: RegionPos,
/// Climate context for this region: baseline temperature, season, weather.
pub clock: RegionClock,
/// Latitude of the region centre in the body's reference frame (degrees).
/// 0.0 = equator, ±90.0 = poles. Derived from `region_pos`.
pub latitude_deg: f32,
/// Region-level moisture primitive (0100), inherited from body params.
/// Moisture modulation is body-scale for now; per-district refinement lives
/// in `DistrictProfile.moisture_q`.
pub moisture_q: i32,
}
// ---------------------------------------------------------------------------
// Region baseline derivation
// ---------------------------------------------------------------------------
/// Derive the region-level mean-annual temperature baseline in °C.
///
/// This is **step (a)** of the D-239 §2 / D-243 §3 split:
/// - Latitude lerp across the maritime-moderated band
/// - Atmosphere greenhouse nudge
/// - Seed nudge (deterministic ±~3 °C per-body variety)
/// - Clamp to [cold, warm] class band (hard invariant, D-240)
///
/// **Not included here:** elevation lapse and slope aspect. Those are
/// district-level modulations applied in
/// [`crate::atlas::district_profile::derive_district_temperature_c`].
///
/// ## Inputs
///
/// - `body_params` — `planet_class`, `atmosphere`, `hydrosphere`, and the
/// **region centre latitude** in `latitude_deg` (repurposed for
/// the region's central latitude — the field name is historical).
/// - `constants` — tunable climate constants (D-240 table).
/// - `body_seed` — the body-scoped seed for the deterministic nudge (D-010).
///
/// ## Returns
///
/// `None` if the body is airless (`atmosphere == "none"`). Otherwise the
/// region baseline temperature in °C, clamped to the class band.
///
/// ## D-010 compliance
///
/// All structural gating uses integer arithmetic. f32 is used only for the
/// positional physics (latitude lerp, greenhouse fraction) — downstream
/// gating casts to `i32` before comparison.
pub fn derive_region_baseline_c(
body_params: &BodyParams,
constants: &ClimateConstants,
body_seed: u64,
) -> Option<f32> {
let atmosphere = body_params.atmosphere.as_deref().unwrap_or("none");
// No atmosphere → airless body; baseline is None.
if atmosphere == "none" {
return None;
}
// Step 1: planet_class → (cold, warm) envelope (D-240).
let planet_class = body_params.planet_class.as_deref().unwrap_or("temperate");
let (cold, warm) = constants.envelope(planet_class);
let band_width = warm - cold;
// Step 2: latitude lerp across the maritime-moderated band (D-240).
// Water-rich worlds compress the equator→pole gradient toward the band midpoint.
let hydrosphere = body_params.hydrosphere.as_deref().unwrap_or("none");
let maritime = constants.maritime_factor(hydrosphere);
let mid = (cold + warm) * 0.5;
let half = band_width * 0.5 * maritime;
let lat_frac = (body_params.latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0);
// equator (frac 0) → mid + half; pole (frac 1) → mid half.
let t_lat = (mid + half) - (2.0 * half) * lat_frac;
// Step 3: atmosphere greenhouse nudge — fraction of band_width toward warm end.
let gh_frac = constants.greenhouse(atmosphere);
let t_atmo = t_lat + gh_frac * band_width;
// Step 4: seed nudge — deterministic ±~3 °C per-body variety (D-010, D-240).
// Uses the same splitmix64 mixing as the original derive_temperature_c so the
// body-level nudge character is preserved across the refactor.
let nudge = {
let h = body_seed
.wrapping_add(0x9e37_79b9_7f4a_7c15)
.wrapping_mul(0x6c62_272e_07bb_0142);
let unit = (h as i64 as f64 / i64::MAX as f64) as f32;
unit * 3.0_f32
};
let t_nudged = t_atmo + nudge;
// Step 5: clamp to [cold, warm] — class band is a hard invariant (D-240).
// Elevation lapse and slope aspect are NOT applied here — those are district
// modulations (D-243 §3; applied in derive_district_temperature_c).
Some(t_nudged.clamp(cold, warm))
}
// ---------------------------------------------------------------------------
// RegionProfile builder
// ---------------------------------------------------------------------------
/// Compute the latitude of a region centre from its grid position.
///
/// The region grid is equirectangular. Row 0 sits at the north pole; the
/// equator is in the middle. Returns degrees: +90.0 = north pole, 90.0 =
/// south pole.
///
/// If no body radius is available (tiny test bodies), the region lat is 0.0.
pub fn region_centre_latitude_deg(region_pos: RegionPos, body_radius_km: Option<f64>) -> f64 {
let Some(r_km) = body_radius_km else {
return 0.0;
};
if r_km <= 0.0 {
return 0.0;
}
// The meridian spans πR km. Each region is REGION_M metres tall.
// region_y = 0 maps to the north pole (lat +90), rising y → south.
let meridian_m = std::f64::consts::PI * r_km * 1_000.0;
let region_centre_y_m = (region_pos.1 as f64 + 0.5) * scale::REGION_M as f64;
// Clamp: lat_frac in [0, 1]; 0 = N pole (+90°), 1 = S pole (90°).
let lat_frac = (region_centre_y_m / meridian_m).clamp(0.0, 1.0);
90.0 - lat_frac * 180.0
}
/// Build a [`RegionProfile`] for the region at `region_pos`.
///
/// Pure and deterministic: `(seed, body_params, region_pos)` → `RegionProfile`.
///
/// `body_params.latitude_deg` is **overridden** to the region centre's
/// latitude internally — callers do not need to pre-set it.
pub fn build_region_profile(
seed: SeedChain,
body_params: &BodyParams,
constants: &ClimateConstants,
region_pos: RegionPos,
) -> RegionProfile {
let lat_deg = region_centre_latitude_deg(region_pos, body_params.body_radius_km);
// Build region-local params: override latitude to the region centre.
let region_params = BodyParams {
latitude_deg: lat_deg,
// Elevation at region level is sea level (baseline only; no lapse here).
elevation_km: 0.0,
..body_params.clone()
};
let body_seed = seed.seed();
let baseline_temp_c = derive_region_baseline_c(&region_params, constants, body_seed);
// Moisture at the region tier is body-scale (same as body_params).
let moisture_q = crate::atlas::district_profile::derive_moisture_q(body_params);
RegionProfile {
pos: region_pos,
clock: RegionClock {
season: SeasonPhase::Summer,
weather: WeatherState::Clear,
mean_temp_c: baseline_temp_c,
},
latitude_deg: lat_deg as f32,
moisture_q,
}
}
// ---------------------------------------------------------------------------
// Edge-fuzz blend — D-243 §4
// ---------------------------------------------------------------------------
/// Region-baseline temperature at a **district position**, using the D-243 §4
/// edge-fuzz blend.
///
/// Climate does not change on a line. This function blends the four surrounding
/// region baselines (bilinear across region centres), displaced by a
/// noise-warp field so the blend boundary is ragged rather than a straight
/// gradient. The ~205 km region grid is therefore **invisible** in the output —
/// temperatures grade smoothly and organically across boundaries.
///
/// ## Algorithm
///
/// 1. Compute the district's fractional position within the region grid,
/// displaced by a per-district warp (keyed on `(world_seed, body_id, district_pos)`).
/// 2. Identify the four surrounding region positions.
/// 3. Derive (or look up from the optional `region_cache`) the baseline
/// temperature for each region.
/// 4. Bilinear-blend the four baselines by the displaced fractional weights.
///
/// ## D-010 compliance
///
/// All warp and blend arithmetic is f64 positional math. The blended baseline
/// returned here is f32 and is used only as input to the district modulation
/// (elevation lapse, slope aspect) in
/// [`crate::atlas::district_profile::derive_district_temperature_c`].
/// No structural gate is applied to this value — gates happen downstream on
/// integer casts of the final district temperature.
///
/// ## Parameters
///
/// - `world_seed` — master world seed (the edge-fuzz warp is keyed to it).
/// - `body_id` — body identifier for warp domain separation.
/// - `district_pos` — the district being derived.
/// - `body_params` — body physical parameters (planet_class, atmosphere, etc.).
/// - `constants` — climate constants.
/// - `seed` — body SeedChain (for per-region baseline derivation).
/// - `region_cache` — optional pre-computed region baselines; if a region is
/// missing it is derived on the fly.
pub fn region_baseline_at_district(
world_seed: u64,
body_id: &str,
district_pos: DistrictPos,
body_params: &BodyParams,
constants: &ClimateConstants,
seed: SeedChain,
region_cache: Option<&BTreeMap<RegionPos, RegionProfile>>,
) -> Option<f32> {
// Atmosphere gate: airless bodies have no temperature baseline.
let atmosphere = body_params.atmosphere.as_deref().unwrap_or("none");
if atmosphere == "none" {
return None;
}
// ── Step 1: Fractional district position in the region grid ─────────────
// A district at `(dx, dy)` lies at fractional position
// fx = (dx mod DISTRICTS_PER_REGION + 0.5) / DISTRICTS_PER_REGION
// fy = (dy mod DISTRICTS_PER_REGION + 0.5) / DISTRICTS_PER_REGION
// in region space, where (0,0) is the region corner.
let dpir = scale::DISTRICTS_PER_REGION as f64;
// The district's offset within its region (0.0..1.0 each axis).
let local_fx = {
let rem = district_pos.0.rem_euclid(scale::DISTRICTS_PER_REGION) as f64;
(rem + 0.5) / dpir
};
let local_fy = {
let rem = district_pos.1.rem_euclid(scale::DISTRICTS_PER_REGION) as f64;
(rem + 0.5) / dpir
};
// ── Step 2: Edge-fuzz warp displacement ─────────────────────────────────
// A per-district noise warp displaces the sampling point so the blend
// boundary is ragged. The warp is keyed on (world_seed, body_id,
// district_pos) and is bounded to ±CLIMATE_WARP_FRAC of the region extent
// (not ±8 m — climate warp is fractional region units, not metres).
//
// Implementation: derive two hash values from the district position and
// map them to [-WARP_FRAC, +WARP_FRAC].
//
// D-010: the warp arithmetic is f64 positional math; the downstream blend
// weights are not used in any structural comparison.
const CLIMATE_WARP_FRAC: f64 = 0.25; // ±25% of a region's width/height.
let (warp_dx, warp_dy) = climate_edge_warp(world_seed, body_id, district_pos);
let warped_fx = (local_fx + warp_dx * CLIMATE_WARP_FRAC).clamp(0.0, 1.0);
let warped_fy = (local_fy + warp_dy * CLIMATE_WARP_FRAC).clamp(0.0, 1.0);
// ── Step 3: Identify four surrounding region positions ───────────────────
// The district's parent region.
let base_region = scale::district_to_region(district_pos);
// Determine which quadrant of the region the district falls in (after warp):
// if warped_fx > 0.5 the district is in the eastern half → blend with +X
// neighbour; else blend with X neighbour. Same for Y.
let (neighbour_dx, blend_tx) = if warped_fx >= 0.5 {
(1i32, (warped_fx - 0.5) * 2.0) // 0.0 at centre → 1.0 at +X edge
} else {
(-1i32, (0.5 - warped_fx) * 2.0) // 0.0 at centre → 1.0 at X edge
};
let (neighbour_dy, blend_ty) = if warped_fy >= 0.5 {
(1i32, (warped_fy - 0.5) * 2.0)
} else {
(-1i32, (0.5 - warped_fy) * 2.0)
};
// Four corner regions: (base, x-neighbour, y-neighbour, xy-neighbour).
let r00 = base_region;
let r10 = (base_region.0 + neighbour_dx, base_region.1);
let r01 = (base_region.0, base_region.1 + neighbour_dy);
let r11 = (base_region.0 + neighbour_dx, base_region.1 + neighbour_dy);
// ── Step 4: Fetch or derive the four region baselines ───────────────────
let baseline_for = |rpos: RegionPos| -> Option<f32> {
// Try the cache first.
if let Some(cache) = region_cache {
if let Some(rp) = cache.get(&rpos) {
return rp.clock.mean_temp_c;
}
}
// Derive on the fly (test path / cache miss).
let lat = region_centre_latitude_deg(rpos, body_params.body_radius_km);
let r_params = BodyParams {
latitude_deg: lat,
elevation_km: 0.0,
..body_params.clone()
};
derive_region_baseline_c(&r_params, constants, seed.seed())
};
let b00 = baseline_for(r00)?;
let b10 = baseline_for(r10)?;
let b01 = baseline_for(r01)?;
let b11 = baseline_for(r11)?;
// ── Step 5: Bilinear blend ───────────────────────────────────────────────
// Standard bilinear: tx blends X pairs, ty blends the Y result.
let tx = blend_tx as f32;
let ty = blend_ty as f32;
let top = b00 + (b10 - b00) * tx;
let bot = b01 + (b11 - b01) * tx;
Some(top + (bot - top) * ty)
}
/// Compute the climate edge-fuzz warp displacement for a district.
///
/// Returns `(warp_dx, warp_dy)` each in `[-1.0, +1.0]`, intended to be scaled
/// by `CLIMATE_WARP_FRAC` by the caller. Keyed on
/// `(world_seed, body_id, district_pos)` — same domain-separation conventions
/// as [`crate::atlas::domain_warp`], but using a distinct hash path so the
/// climate warp is never correlated with the terrain warp.
///
/// ## D-010 compliance
///
/// Pure integer hash, f64 mapping. No structural comparison.
fn climate_edge_warp(world_seed: u64, body_id: &str, district_pos: DistrictPos) -> (f64, f64) {
// Hash body_id into a u64 using FNV-1a (canonical per D-224).
let body_hash = crate::seed::fnv1a_64(body_id);
let base = world_seed
.wrapping_add(body_hash)
.wrapping_add(0x1234_5678_9abc_def0);
// Per-district position hash (zigzag + Cantor pairing — same as domain_warp).
let zz = |v: i32| -> u64 {
let v = v as i64;
((v << 1) ^ (v >> 63)) as u64
};
let x = zz(district_pos.0);
let y = zz(district_pos.1);
let s = x.wrapping_add(y);
let pos_id = s
.wrapping_mul(s.wrapping_add(1))
.wrapping_div(2)
.wrapping_add(y);
// Two independent streams: one for dx, one for dy.
let seed_x = splitmix64(base.wrapping_add(pos_id));
let seed_y = splitmix64(base.wrapping_add(pos_id).wrapping_add(0xdeadbeef_cafebabe));
let u64_to_unit = |h: u64| -> f64 {
let unit = (h >> 11) as f64 * (1.0 / (1u64 << 53) as f64);
(unit - 0.5) * 2.0 // [-1.0, +1.0)
};
(u64_to_unit(seed_x), u64_to_unit(seed_y))
}
// ---------------------------------------------------------------------------
// Region cache builder (for batch derivation)
// ---------------------------------------------------------------------------
/// Derive [`RegionProfile`]s for all regions that cover the set of districts
/// supplied, returning a `BTreeMap<RegionPos, RegionProfile>`.
///
/// Used when processing a batch of districts: call this first to populate the
/// region cache, then pass the cache into [`region_baseline_at_district`].
///
/// Pure and deterministic. Does not de-duplicate within this call — the caller
/// provides the unique set of region positions.
pub fn derive_regions_for_body(
seed: SeedChain,
body_params: &BodyParams,
constants: &ClimateConstants,
region_positions: impl IntoIterator<Item = RegionPos>,
) -> BTreeMap<RegionPos, RegionProfile> {
let mut out = BTreeMap::new();
for rpos in region_positions {
let profile = build_region_profile(seed, body_params, constants, rpos);
out.insert(rpos, profile);
}
out
}
// ---------------------------------------------------------------------------
// SeedDomain extension: RegionClimate = 11
// ---------------------------------------------------------------------------
//
// A new SeedDomain variant `RegionClimate = 11` is registered in
// `crate::seed::SeedDomain` to enable region-scoped seed derivation without
// colliding with other domains (D-224 domain separation). Because `SeedDomain`
// is in seed.rs (not this file), the variant is added there.
//
// This module uses the `seed.seed()` output for region baseline derivation
// (the same approach as `derive_temperature_c` uses `body_seed`), which is
// equivalent to calling `.derive(SeedDomain::Body, body_id_hash)` at the
// parent level and then reading the raw seed. No additional derivation level
// is needed unless per-region RNG streams are required (deferred to Q-105).
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::district_profile::{BodyParams, ClimateConstants};
use crate::seed::{SeedChain, SeedDomain};
fn body_seed() -> SeedChain {
SeedChain::root(42).derive(SeedDomain::Body, 1)
}
fn earth_params() -> BodyParams {
BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
}
}
// ── RegionProfile builder ────────────────────────────────────────────────
#[test]
fn build_region_profile_is_deterministic() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = build_region_profile(body_seed(), &params, &constants, (5, 10));
let b = build_region_profile(body_seed(), &params, &constants, (5, 10));
assert_eq!(
a.clock.mean_temp_c, b.clock.mean_temp_c,
"region baseline must be deterministic"
);
assert_eq!(a.latitude_deg, b.latitude_deg);
assert_eq!(a.moisture_q, b.moisture_q);
}
#[test]
fn airless_body_has_no_region_baseline() {
let params = BodyParams {
atmosphere: Some("none".into()),
planet_class: Some("frozen".into()),
body_radius_km: Some(1500.0),
..Default::default()
};
let constants = ClimateConstants::default();
let rp = build_region_profile(body_seed(), &params, &constants, (0, 0));
assert_eq!(
rp.clock.mean_temp_c, None,
"airless body must have None region baseline"
);
}
#[test]
fn equatorial_region_is_warmer_than_polar() {
// With a body radius, north-pole region vs equatorial region.
let params = earth_params();
let constants = ClimateConstants::default();
// Region (0, 0) is near the north pole; (97, 15) is roughly equatorial.
let polar = build_region_profile(body_seed(), &params, &constants, (0, 0));
let equatorial = build_region_profile(body_seed(), &params, &constants, (97, 15));
match (polar.clock.mean_temp_c, equatorial.clock.mean_temp_c) {
(Some(p), Some(e)) => assert!(
p < e,
"polar baseline {p}°C must be colder than equatorial {e}°C"
),
_ => panic!("breathable body must have a temperature"),
}
}
#[test]
fn frozen_body_region_within_class_band() {
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
body_radius_km: Some(2000.0),
..Default::default()
};
let constants = ClimateConstants::default();
let (cold, warm) = constants.envelope("frozen");
for ry in [0i32, 5, 10] {
let rp = build_region_profile(body_seed(), &params, &constants, (0, ry));
let t = rp
.clock
.mean_temp_c
.expect("non-airless body must have temperature");
assert!(
t >= cold && t <= warm,
"frozen body region ({}, {ry}) baseline {t}°C outside band [{cold}, {warm}]",
0
);
}
}
#[test]
fn region_clock_default_state() {
let params = earth_params();
let constants = ClimateConstants::default();
let rp = build_region_profile(body_seed(), &params, &constants, (10, 10));
// Q-105 is deferred: mean state is Summer + Clear.
assert_eq!(rp.clock.season, SeasonPhase::Summer);
assert_eq!(rp.clock.weather, WeatherState::Clear);
}
// ── Baseline derivation ──────────────────────────────────────────────────
#[test]
fn region_baseline_no_elevation_lapse() {
// The region baseline must NOT include elevation lapse — that is a
// district-level modulation. Two regions at the same latitude but
// different positions should produce the same baseline if seed is the
// same (latitude is the only varying input here).
let constants = ClimateConstants::default();
let params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
latitude_deg: 30.0,
elevation_km: 5.0, // This must be ignored by the region baseline
..Default::default()
};
// Explicitly pass elevation_km = 5.0; the baseline function overrides
// it to 0.0 internally.
let t_high_elev = derive_region_baseline_c(&params, &constants, 42);
let params_low = BodyParams {
elevation_km: 0.0,
..params.clone()
};
let t_low_elev = derive_region_baseline_c(&params_low, &constants, 42);
// Both must be equal — elevation is NOT a region-level input.
assert_eq!(
t_high_elev, t_low_elev,
"region baseline must be independent of elevation_km (lapse is district-level)"
);
}
// ── Edge-fuzz blend ──────────────────────────────────────────────────────
#[test]
fn edge_fuzz_is_deterministic() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = region_baseline_at_district(
42,
"TestBody",
(50, 75),
&params,
&constants,
body_seed(),
None,
);
let b = region_baseline_at_district(
42,
"TestBody",
(50, 75),
&params,
&constants,
body_seed(),
None,
);
assert_eq!(a, b, "edge-fuzz blend must be deterministic");
}
#[test]
fn edge_fuzz_within_class_band() {
let params = earth_params();
let constants = ClimateConstants::default();
let (cold, warm) = constants.envelope("temperate");
// Check several district positions.
for dx in [0i32, 50, 99, 100, 150] {
for dy in [0i32, 25, 50, 75] {
let t = region_baseline_at_district(
42,
"TestBody",
(dx, dy),
&params,
&constants,
body_seed(),
None,
)
.expect("breathable body must have a baseline");
assert!(
t >= cold && t <= warm,
"edge-fuzz baseline at ({dx},{dy}): {t}°C outside [{cold}, {warm}]"
);
}
}
}
#[test]
fn edge_fuzz_varies_with_body_id() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = region_baseline_at_district(
42,
"BodyA",
(50, 50),
&params,
&constants,
body_seed(),
None,
);
let b = region_baseline_at_district(
42,
"BodyB",
(50, 50),
&params,
&constants,
body_seed(),
None,
);
// Same region pos but different body_id → different warp → different blend.
// Not guaranteed to differ (could accidentally hit same blend), but should
// for these inputs.
assert_ne!(
a, b,
"edge-fuzz blend should vary with body_id (different warp)"
);
}
#[test]
fn edge_fuzz_airless_returns_none() {
let params = BodyParams {
atmosphere: Some("none".into()),
planet_class: Some("frozen".into()),
..Default::default()
};
let constants = ClimateConstants::default();
let t = region_baseline_at_district(
42,
"AirlessBody",
(10, 10),
&params,
&constants,
body_seed(),
None,
);
assert_eq!(t, None, "airless body must return None from edge-fuzz");
}
// ── SeasonPhase / WeatherState discriminant pin ──────────────────────────
#[test]
fn season_phase_discriminants_pinned() {
// Append-only (D-010): renaming breaks serialised state.
assert_eq!(SeasonPhase::Summer as u8, 0);
assert_eq!(SeasonPhase::Autumn as u8, 1);
assert_eq!(SeasonPhase::Winter as u8, 2);
assert_eq!(SeasonPhase::Spring as u8, 3);
}
#[test]
fn weather_state_discriminants_pinned() {
assert_eq!(WeatherState::Clear as u8, 0);
assert_eq!(WeatherState::Overcast as u8, 1);
assert_eq!(WeatherState::Rain as u8, 2);
assert_eq!(WeatherState::Snow as u8, 3);
assert_eq!(WeatherState::Blizzard as u8, 4);
assert_eq!(WeatherState::DustStorm as u8, 5);
}
// ── Region cache batch builder ───────────────────────────────────────────
#[test]
fn derive_regions_for_body_covers_all_positions() {
let params = earth_params();
let constants = ClimateConstants::default();
let positions: Vec<RegionPos> = vec![(0, 0), (1, 0), (0, 1), (5, 5)];
let cache = derive_regions_for_body(body_seed(), &params, &constants, positions.clone());
assert_eq!(
cache.len(),
positions.len(),
"all positions must be present"
);
for pos in &positions {
assert!(
cache.contains_key(pos),
"region {pos:?} must be in the cache"
);
}
}
#[test]
fn edge_fuzz_cache_hit_matches_derived() {
// region_baseline_at_district with a cache should produce the same
// result as without (within the bilinear blend — the same regions are
// sampled in both paths).
let params = earth_params();
let constants = ClimateConstants::default();
let district_pos = (55i32, 20i32);
let base_region = scale::district_to_region(district_pos);
let positions: Vec<RegionPos> = vec![
base_region,
(base_region.0 + 1, base_region.1),
(base_region.0 - 1, base_region.1),
(base_region.0, base_region.1 + 1),
(base_region.0, base_region.1 - 1),
(base_region.0 + 1, base_region.1 + 1),
(base_region.0 - 1, base_region.1 + 1),
(base_region.0 + 1, base_region.1 - 1),
(base_region.0 - 1, base_region.1 - 1),
];
let cache = derive_regions_for_body(body_seed(), &params, &constants, positions);
let t_cached = region_baseline_at_district(
42,
"TestBody",
district_pos,
&params,
&constants,
body_seed(),
Some(&cache),
);
let t_derived = region_baseline_at_district(
42,
"TestBody",
district_pos,
&params,
&constants,
body_seed(),
None,
);
// Both paths should produce the same result (both use the same seed
// for on-demand derivation when the cache derives with the same seed).
assert_eq!(
t_cached, t_derived,
"cache hit and on-demand derivation must agree"
);
}
}
+217 -50
View File
@@ -338,6 +338,9 @@ pub fn derive_voxel_column(
// ── 1. Domain warp (D-239 §4) ─────────────────────────────────────────
// Apply warp and truncate to integer voxel address. This is the ONLY f64
// in the structural path; all decisions below use integer voxel coords.
// The warp is applied BEFORE the blend boundary, so the seam cannot align
// with a chunk edge (T-1042: "reusing the same warp offset so the blend
// seam cannot align with the chunk edge").
let (dx, dy) = domain_warp(world_seed, body_id, (tile_x, tile_y));
let voxel_x = (tile_x as f64 + dx) as i32;
let voxel_y = (tile_y as f64 + dy) as i32;
@@ -351,31 +354,67 @@ pub fn derive_voxel_column(
.derive(SeedDomain::Voxel, voxel_pos_to_id(voxel_pos))
.seed();
// ── 2b. Cross-district terrain blending (T-1042, D-239 §4/§7) ─────────
// When a secondary district is present AND blend_weight < 255, blend the
// continuous terrain scalars (`elev_q`, `moisture_q`) between the primary
// and secondary district. This is done BEFORE family dispatch so the
// blended values propagate through ALL family generators.
//
// Morphology family is NOT changed — family dispatch always uses the
// primary district's `morphology_zone` (D-239 §7: morphology seams stay
// sharp). Only `elev_q` and `moisture_q` are blended; all structural
// fields (glaciation_grade, tectonic_class, precipitation_class, etc.)
// stay primary.
//
// Integer blend formula: `(a * w + b * (255 - w) + 127) / 255` (D-010).
// This is a zero-cost branch when blend_weight == 255 (no secondary active).
let effective_district: std::borrow::Cow<DistrictProfile> = if chunk.blend_weight < 255 {
if let Some(ref sec) = chunk.secondary {
let w = chunk.blend_weight as i32;
let w_sec = 255 - w;
let blended_elev_q = (district.elev_q * w + sec.elev_q * w_sec + 127) / 255;
let blended_moisture_q = (district.moisture_q * w + sec.moisture_q * w_sec + 127) / 255;
let mut blended = district.clone();
blended.elev_q = blended_elev_q;
blended.moisture_q = blended_moisture_q;
std::borrow::Cow::Owned(blended)
} else {
std::borrow::Cow::Borrowed(district)
}
} else {
std::borrow::Cow::Borrowed(district)
};
let district_eff: &DistrictProfile = &effective_district;
// ── 3. Family dispatch (D-239 §5) ─────────────────────────────────────
// Family selection uses the PRIMARY district's morphology_zone (D-239 §7:
// seams stay sharp). Blended `district_eff` is passed to the generators
// so their `elev_q`/`moisture_q` reads get the blended values.
let family = zone_to_family(&district.morphology_zone);
let mut column = match family {
MorphologyFamily::AlluvialPlain => {
generate_alluvial_plain(district, chunk, voxel_pos, sub_chunk_seed)
generate_alluvial_plain(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
MorphologyFamily::LavaField => generate_lava_field(district, voxel_pos, sub_chunk_seed),
MorphologyFamily::LavaField => generate_lava_field(district_eff, voxel_pos, sub_chunk_seed),
MorphologyFamily::FjordWall => {
generate_fjord_wall(district, chunk, voxel_pos, sub_chunk_seed)
generate_fjord_wall(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
MorphologyFamily::CliffCoast => {
generate_cliff_coast(district, chunk, voxel_pos, sub_chunk_seed)
generate_cliff_coast(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
MorphologyFamily::BraidedDelta => {
generate_braided_delta(district, chunk, voxel_pos, sub_chunk_seed)
generate_braided_delta(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
MorphologyFamily::DuneStrand => {
generate_dune_strand(district, chunk, voxel_pos, sub_chunk_seed)
generate_dune_strand(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
MorphologyFamily::IncisedGorge => {
generate_incised_gorge(district, chunk, voxel_pos, sub_chunk_seed)
generate_incised_gorge(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
MorphologyFamily::MeanderReach => {
generate_meander_reach(district, chunk, voxel_pos, sub_chunk_seed)
generate_meander_reach(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
};
@@ -384,6 +423,8 @@ pub fn derive_voxel_column(
// axes (terrain/water/vegetation/elevation); cover is a separate orthogonal
// axis derived from the district's mean temperature + water/terrain + coherent
// cluster scatter. One site, set here — no family generator needs changing.
// Cover derives from the primary district's temperature (not blended) since
// temperature blending is the sibling ticket's domain (T-1078, D-243 §4).
column.cover = derive_cover(world_seed, body_id, district, &column, voxel_pos);
column
@@ -1796,9 +1837,31 @@ struct CacheEntry {
///
/// - At most `capacity` entries are stored at any time.
/// - Entries are evicted in LRU order (lowest `access_gen`).
/// - Same `(seed, body_id, tile_pos)` → same column, always.
/// - Nothing is persisted to disk; eviction forces re-derivation.
///
/// ## Caller precondition — chunk context must cover the tile (T-1042)
///
/// After T-1042, `derive_voxel_column` reads `chunk.blend_weight` and
/// `chunk.secondary` to apply cross-district terrain blending. The cache
/// key is `(seed, body_id_hash, post-warp voxel position)` — it does NOT
/// capture the blend parameters. The cache invariant therefore holds only
/// under the following precondition:
///
/// **The `chunk` argument passed to `get_or_derive` must be the
/// `ChunkContext` derived for the chunk that actually contains the tile.**
/// Concretely: `chunk = derive_chunk_context(seed, body_id, district,
/// tile_pos.div_euclid(CHUNK_M), secondary)`. A boundary tile derived with
/// a blended context (blend_weight < 255) and later re-requested with a
/// pure-primary context (blend_weight = 255) would produce a different
/// column — the cache would return the first (blended) value, silently
/// giving the wrong result. The caller must ensure the same context
/// construction is used on every call for the same tile.
///
/// **In practice this is satisfied by construction**: callers derive one
/// `ChunkContext` per chunk and call `get_or_derive` only for tiles within
/// that chunk. The precondition is made explicit here so Phase-5 render-loop
/// callers do not inadvertently mix contexts across calls.
///
/// ## D-010 compliance
///
/// Uses `BTreeMap` (ordered) for the cache store. Iteration order is
@@ -1830,6 +1893,15 @@ impl VoxelCache {
///
/// The body-id hash is computed internally via the canonical
/// [`crate::seed::fnv1a_64`] — callers pass the raw `body_id`.
///
/// ## Precondition — context must cover the tile (T-1042)
///
/// `chunk` must be the `ChunkContext` derived for the chunk that
/// contains `(tile_x, tile_y)`. The cache key does not capture
/// `chunk.blend_weight` or `chunk.secondary`; if the same tile is
/// requested with a different context (e.g. once blended, once
/// unblended) the cache will return the first-derived column for both
/// calls. See the struct-level doc for the full constraint.
pub fn get_or_derive(
&mut self,
world_seed: u64,
@@ -1951,13 +2023,13 @@ mod tests {
/// Feature placement is district-anchored, so tests sample around
/// `chunk.channel_anchor_m` instead of assuming chunk-frame positions.
fn anchor_chunk(seed: u64, body: &str, district: &DistrictProfile) -> ChunkContext {
let probe = derive_chunk_context(seed, body, district, (0, 0));
let probe = derive_chunk_context(seed, body, district, (0, 0), None);
let idx = probe.channel_anchor_m.div_euclid(64);
let pos = match probe.basin_direction {
BasinDirection::North | BasinDirection::South => (idx, 0),
BasinDirection::East | BasinDirection::West => (0, idx),
};
derive_chunk_context(seed, body, district, pos)
derive_chunk_context(seed, body, district, pos, None)
}
/// Map a (cross, along) coordinate pair to (tile_x, tile_y) for the
@@ -1986,7 +2058,7 @@ mod tests {
}
fn alluvial_chunk(district: &DistrictProfile) -> ChunkContext {
derive_chunk_context(42, "GJ1c", district, (10, 20))
derive_chunk_context(42, "GJ1c", district, (10, 20), None)
}
// -----------------------------------------------------------------------
@@ -2109,8 +2181,8 @@ mod tests {
#[test]
fn different_seeds_produce_different_outputs() {
let district = alluvial_district();
let chunk1 = derive_chunk_context(1, "GJ1c", &district, (10, 20));
let chunk2 = derive_chunk_context(2, "GJ1c", &district, (10, 20));
let chunk1 = derive_chunk_context(1, "GJ1c", &district, (10, 20), None);
let chunk2 = derive_chunk_context(2, "GJ1c", &district, (10, 20), None);
let a = derive_voxel_column(1, "GJ1c", &district, &chunk1, 100, 100);
let b = derive_voxel_column(2, "GJ1c", &district, &chunk2, 100, 100);
assert!(
@@ -2222,6 +2294,101 @@ mod tests {
);
}
#[test]
fn cache_boundary_tile_blend_is_stable() {
// T-1042 / H3: VoxelCache.get_or_derive must return identical blended
// output on two successive calls for the same boundary tile. This test
// exercises the `Cow::Owned` path (blend_weight=128, secondary=Some(...))
// through the cache and guards against future cache-key drift where a
// second call with a different context returns a stale unblended entry.
//
// Precondition (VoxelCache struct doc, T-1042): both calls use the SAME
// ChunkContext — the one derived for the chunk containing the tile. This
// is the correct call pattern; the test intentionally validates it.
use crate::atlas::chunk_context::district_boundary_blend_weight;
let district_primary = DistrictProfile {
morphology_zone: MorphologyZone::AlluvialPlain,
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
glaciation_grade: crate::atlas::district_profile::GlaciationGrade::None,
precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate,
slope_q: 5,
elev_q: 20, // low elevation
ocean_fraction_q: 0, // no channel — simpler tile layout for elevation check
river_threshold: 200,
temperature_c: Some(18.0),
moisture_q: 55,
vegetation_class: VegetationClass::Forest,
};
let district_secondary = DistrictProfile {
morphology_zone: MorphologyZone::AlluvialPlain,
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
glaciation_grade: crate::atlas::district_profile::GlaciationGrade::None,
precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate,
slope_q: 5,
elev_q: 70, // high elevation — makes the blend measurable
ocean_fraction_q: 0,
river_threshold: 200,
temperature_c: Some(18.0),
moisture_q: 55,
vegetation_class: VegetationClass::Forest,
};
let (seed, body_id) = (42u64, "blend_cache_test");
// Chunk (31, 0): the last chunk in district (0, 0) — at the district boundary.
let boundary_chunk_pos = (31i32, 0i32);
let (near, blend_w) = district_boundary_blend_weight(boundary_chunk_pos);
assert!(near, "chunk (31, 0) must be detected as a boundary chunk");
assert_eq!(blend_w, 128);
let chunk = derive_chunk_context(
seed,
body_id,
&district_primary,
boundary_chunk_pos,
Some((&district_secondary, blend_w)),
);
// A tile inside the boundary chunk — world position = chunk_x * 64 + offset.
let tile_x = boundary_chunk_pos.0 * 64 + 16;
let tile_y = boundary_chunk_pos.1 * 64 + 16;
let mut cache = VoxelCache::new(64);
// First call: cache miss → derives the blended column and inserts it.
let col1 = cache.get_or_derive(seed, body_id, &district_primary, &chunk, tile_x, tile_y);
assert_eq!(cache.len(), 1, "first call must produce one cache entry");
// Second call: cache hit → must return the same blended column.
let col2 = cache.get_or_derive(seed, body_id, &district_primary, &chunk, tile_x, tile_y);
assert_eq!(
cache.len(),
1,
"second call must be a cache hit (len unchanged)"
);
assert_eq!(
col1.elevation_m, col2.elevation_m,
"cache hit must return identical blended elevation for boundary tile"
);
assert_eq!(
col1.terrain, col2.terrain,
"cache hit must return identical terrain for boundary tile"
);
// Sanity: the blended elevation must sit between the two district values.
// primary elev_q=20 → base_elev_m=10; secondary elev_q=70 → base_elev_m=35.
// At 50-50 blend the blended elev_q = (20*128 + 70*127 + 127)/255 = 44 (rounded).
// base_elev_m = 44/2 = 22. With micro-relief ±4 m, range is [18, 25] m.
// This asserts the blend actually ran (not the pure-primary 10 m result).
assert!(
col1.elevation_m > 15,
"blended boundary tile elevation ({}) must be above pure-primary level (~10 m) \
— blend did not apply",
col1.elevation_m
);
}
// -----------------------------------------------------------------------
// Domain warp sanity
// -----------------------------------------------------------------------
@@ -2269,7 +2436,7 @@ mod tests {
#[test]
fn lava_field_terrain_is_lava() {
let district = lava_district();
let chunk = derive_chunk_context(42, "Io", &district, (0, 0));
let chunk = derive_chunk_context(42, "Io", &district, (0, 0), None);
for pos in [(10, 10), (0, 0), (50, 25), (-5, 5)] {
let col = derive_voxel_column(42, "Io", &district, &chunk, pos.0, pos.1);
assert_eq!(
@@ -2284,7 +2451,7 @@ mod tests {
#[test]
fn lava_field_vegetation_is_barren() {
let district = lava_district();
let chunk = derive_chunk_context(42, "Io", &district, (0, 0));
let chunk = derive_chunk_context(42, "Io", &district, (0, 0), None);
for tx in 0..20i32 {
let col = derive_voxel_column(42, "Io", &district, &chunk, tx, 10);
assert_eq!(
@@ -2299,7 +2466,7 @@ mod tests {
fn lava_field_no_deep_water() {
// Immature drainage law (D-239 §8): no deep organised channels on lava.
let district = lava_district();
let chunk = derive_chunk_context(42, "Io", &district, (0, 0));
let chunk = derive_chunk_context(42, "Io", &district, (0, 0), None);
for (tx, ty) in (0..50).map(|i| (i * 7, i * 3)) {
let col = derive_voxel_column(42, "Io", &district, &chunk, tx, ty);
assert_ne!(
@@ -2313,7 +2480,7 @@ mod tests {
#[test]
fn lava_field_is_deterministic() {
let district = lava_district();
let chunk = derive_chunk_context(7, "Io", &district, (5, 3));
let chunk = derive_chunk_context(7, "Io", &district, (5, 3), None);
for (tx, ty) in [(0, 0), (10, 20), (-5, 7)] {
let a = derive_voxel_column(7, "Io", &district, &chunk, tx, ty);
let b = derive_voxel_column(7, "Io", &district, &chunk, tx, ty);
@@ -2324,7 +2491,7 @@ mod tests {
#[test]
fn lava_field_elevation_non_negative() {
let district = lava_district();
let chunk = derive_chunk_context(42, "Io", &district, (0, 0));
let chunk = derive_chunk_context(42, "Io", &district, (0, 0), None);
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
let col = derive_voxel_column(42, "Io", &district, &chunk, tx, ty);
assert!(col.elevation_m >= 0, "LavaField elevation must be >= 0");
@@ -2352,7 +2519,7 @@ mod tests {
#[test]
fn fjord_wall_terrain_is_rock() {
let district = fjord_district();
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0));
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
let col = derive_voxel_column(42, "Fjordheim", &district, &chunk, tx, ty);
assert_eq!(
@@ -2369,7 +2536,7 @@ mod tests {
// water. Sample a small window around the anchor — the domain warp can
// displace any single tile off the 48 m floor.
let district = fjord_district();
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0));
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
let anchor = chunk.channel_anchor_m;
let any_deep = (anchor - 4..=anchor + 4).any(|c| {
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
@@ -2385,7 +2552,7 @@ mod tests {
fn fjord_wall_walls_are_high_relative_to_floor() {
// Wall elevation must be substantially higher than fjord floor.
let district = fjord_district();
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0));
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
let anchor = chunk.channel_anchor_m;
// Floor: the lowest tile in the trough window around the anchor.
let floor_elev = (anchor - 4..=anchor + 4)
@@ -2409,7 +2576,7 @@ mod tests {
#[test]
fn fjord_wall_is_deterministic() {
let district = fjord_district();
let chunk = derive_chunk_context(11, "Fjordheim", &district, (2, 3));
let chunk = derive_chunk_context(11, "Fjordheim", &district, (2, 3), None);
for (tx, ty) in [(32, 50), (0, 50), (63, 20)] {
let a = derive_voxel_column(11, "Fjordheim", &district, &chunk, tx, ty);
let b = derive_voxel_column(11, "Fjordheim", &district, &chunk, tx, ty);
@@ -2420,7 +2587,7 @@ mod tests {
#[test]
fn fjord_wall_elevation_non_negative() {
let district = fjord_district();
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0));
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
let col = derive_voxel_column(42, "Fjordheim", &district, &chunk, tx, ty);
assert!(col.elevation_m >= 0, "FjordWall elevation must be >= 0");
@@ -2434,7 +2601,7 @@ mod tests {
// total Deep-water width = 2×floor_half + noise ∈ [4, 8] m.
// Cross-section spans the district-anchored trough (T-1041).
let district = fjord_district(); // glaciation_grade = Moderate (= 2)
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0));
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
let anchor = chunk.channel_anchor_m;
// Count Deep-water tiles across a 64-tile cross-section around the anchor.
let deep_tiles = (anchor - 32..anchor + 32)
@@ -2469,7 +2636,7 @@ mod tests {
#[test]
fn cliff_coast_terrain_is_rock() {
let district = cliff_district();
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0));
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0), None);
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
let col = derive_voxel_column(42, "Velen", &district, &chunk, tx, ty);
assert_eq!(
@@ -2487,7 +2654,7 @@ mod tests {
// The coast face sits on the district-anchored line `coast_anchor_m` along
// the seaward (basin) axis (T-1041) — sample a transect across it.
let district = cliff_district();
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0));
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0), None);
let coast = chunk.coast_anchor_m;
// Sample 64 positions along the seaward axis, crossing the coast line.
let cols: Vec<VoxelColumn> = (coast - 32..coast + 32)
@@ -2517,7 +2684,7 @@ mod tests {
#[test]
fn cliff_coast_is_deterministic() {
let district = cliff_district();
let chunk = derive_chunk_context(5, "Velen", &district, (1, 2));
let chunk = derive_chunk_context(5, "Velen", &district, (1, 2), None);
for (tx, ty) in [(5, 50), (30, 20), (62, 50)] {
let a = derive_voxel_column(5, "Velen", &district, &chunk, tx, ty);
let b = derive_voxel_column(5, "Velen", &district, &chunk, tx, ty);
@@ -2528,7 +2695,7 @@ mod tests {
#[test]
fn cliff_coast_elevation_non_negative() {
let district = cliff_district();
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0));
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0), None);
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
let col = derive_voxel_column(42, "Velen", &district, &chunk, tx, ty);
assert!(col.elevation_m >= 0, "CliffCoast elevation must be >= 0");
@@ -2554,7 +2721,7 @@ mod tests {
#[test]
fn braided_delta_terrain_is_gravel() {
let district = delta_district();
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0), None);
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
let col = derive_voxel_column(42, "delta_body", &district, &chunk, tx, ty);
assert_eq!(
@@ -2569,7 +2736,7 @@ mod tests {
fn braided_delta_low_elevation() {
// Delta is near sea level — D-239 §8 drainage monotonicity: mouths at sea level.
let district = delta_district();
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0), None);
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5), (63, 0)] {
let col = derive_voxel_column(42, "delta_body", &district, &chunk, tx, ty);
assert!(
@@ -2634,7 +2801,7 @@ mod tests {
#[test]
fn braided_delta_is_deterministic() {
let district = delta_district();
let chunk = derive_chunk_context(99, "delta_body", &district, (3, 1));
let chunk = derive_chunk_context(99, "delta_body", &district, (3, 1), None);
for (tx, ty) in [(10, 10), (0, 100), (-5, 7)] {
let a = derive_voxel_column(99, "delta_body", &district, &chunk, tx, ty);
let b = derive_voxel_column(99, "delta_body", &district, &chunk, tx, ty);
@@ -2645,7 +2812,7 @@ mod tests {
#[test]
fn braided_delta_elevation_non_negative() {
let district = delta_district();
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0), None);
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
let col = derive_voxel_column(42, "delta_body", &district, &chunk, tx, ty);
assert!(col.elevation_m >= 0, "BraidedDelta elevation must be >= 0");
@@ -2671,7 +2838,7 @@ mod tests {
#[test]
fn dune_strand_terrain_is_sand() {
let district = dune_district();
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0), None);
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
let col = derive_voxel_column(42, "dune_body", &district, &chunk, tx, ty);
assert_eq!(
@@ -2689,7 +2856,7 @@ mod tests {
// We verify by checking that elevation differences between adjacent tiles
// don't exceed the physics cap.
let district = dune_district();
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0), None);
// Sample a row of tiles along the dune wave direction.
let elevations: Vec<i32> = (0..40i32)
.map(|y| derive_voxel_column(42, "dune_body", &district, &chunk, 10, y).elevation_m)
@@ -2711,7 +2878,7 @@ mod tests {
fn dune_strand_mostly_dry() {
// Dunes are mostly dry; only the strand toe may be Shallow.
let district = dune_district();
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0), None);
let dry_or_shallow_count = (0..100i32)
.filter(|&x| {
let col = derive_voxel_column(42, "dune_body", &district, &chunk, x, 50);
@@ -2727,7 +2894,7 @@ mod tests {
#[test]
fn dune_strand_is_deterministic() {
let district = dune_district();
let chunk = derive_chunk_context(3, "dune_body", &district, (1, 0));
let chunk = derive_chunk_context(3, "dune_body", &district, (1, 0), None);
for (tx, ty) in [(10, 10), (0, 30), (-5, 7)] {
let a = derive_voxel_column(3, "dune_body", &district, &chunk, tx, ty);
let b = derive_voxel_column(3, "dune_body", &district, &chunk, tx, ty);
@@ -2738,7 +2905,7 @@ mod tests {
#[test]
fn dune_strand_elevation_non_negative() {
let district = dune_district();
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0), None);
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
let col = derive_voxel_column(42, "dune_body", &district, &chunk, tx, ty);
assert!(col.elevation_m >= 0, "DuneStrand elevation must be >= 0");
@@ -2765,7 +2932,7 @@ mod tests {
#[test]
fn incised_gorge_terrain_is_rock() {
let district = gorge_district();
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0), None);
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
let col = derive_voxel_column(42, "gorge_body", &district, &chunk, tx, ty);
assert_eq!(
@@ -2783,7 +2950,7 @@ mod tests {
// across a cross-section is within [2, 8] m. Cross-section spans the
// district-anchored gorge centreline (T-1041).
let district = gorge_district();
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0), None);
let anchor = chunk.channel_anchor_m;
// The floor's intrinsic width is the D-239 §9 quantity. A single warped
// cross-section is noisy (the ±8 m domain warp can split the band at some
@@ -2812,7 +2979,7 @@ mod tests {
fn incised_gorge_walls_much_higher_than_floor() {
// Walls must be substantially higher than the gorge floor.
let district = gorge_district();
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0), None);
let anchor = chunk.channel_anchor_m;
// Floor: the lowest tile in the trough window around the anchor
// (the warp can displace any single tile off the 28 m floor).
@@ -2837,7 +3004,7 @@ mod tests {
#[test]
fn incised_gorge_is_deterministic() {
let district = gorge_district();
let chunk = derive_chunk_context(17, "gorge_body", &district, (4, 2));
let chunk = derive_chunk_context(17, "gorge_body", &district, (4, 2), None);
for (tx, ty) in [(32, 100), (62, 100), (10, 50)] {
let a = derive_voxel_column(17, "gorge_body", &district, &chunk, tx, ty);
let b = derive_voxel_column(17, "gorge_body", &district, &chunk, tx, ty);
@@ -2848,7 +3015,7 @@ mod tests {
#[test]
fn incised_gorge_elevation_non_negative() {
let district = gorge_district();
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0), None);
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
let col = derive_voxel_column(42, "gorge_body", &district, &chunk, tx, ty);
assert!(col.elevation_m >= 0, "IncisedGorge elevation must be >= 0");
@@ -2878,7 +3045,7 @@ mod tests {
// for saturated floodplains (slope_q ≤ 3, moisture_q ≥ 70 in the meander_district).
// This test checks that NO other material appears (no Rock, Sand, Gravel, Lava).
let district = meander_district();
let chunk = derive_chunk_context(42, "meander_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "meander_body", &district, (0, 0), None);
for (tx, ty) in [
(0, 50),
(5, 50),
@@ -2913,7 +3080,7 @@ mod tests {
vegetation_class: VegetationClass::Forest,
..alluvial_district()
};
let chunk = derive_chunk_context(42, "meander_body2", &district, (0, 0));
let chunk = derive_chunk_context(42, "meander_body2", &district, (0, 0), None);
let mut soil_count = 0;
for tx in [0, 5, 10, 15, 100, 200] {
let col = derive_voxel_column(42, "meander_body2", &district, &chunk, tx, 50);
@@ -3074,7 +3241,7 @@ mod tests {
#[test]
fn meander_reach_is_deterministic() {
let district = meander_district();
let chunk = derive_chunk_context(23, "meander_body", &district, (2, 5));
let chunk = derive_chunk_context(23, "meander_body", &district, (2, 5), None);
for (tx, ty) in [(0, 50), (50, 200), (-20, 100)] {
let a = derive_voxel_column(23, "meander_body", &district, &chunk, tx, ty);
let b = derive_voxel_column(23, "meander_body", &district, &chunk, tx, ty);
@@ -3085,7 +3252,7 @@ mod tests {
#[test]
fn meander_reach_elevation_non_negative() {
let district = meander_district();
let chunk = derive_chunk_context(42, "meander_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "meander_body", &district, (0, 0), None);
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
let col = derive_voxel_column(42, "meander_body", &district, &chunk, tx, ty);
assert!(col.elevation_m >= 0, "MeanderReach elevation must be >= 0");
@@ -3127,7 +3294,7 @@ mod tests {
moisture_q: 50,
..alluvial_district()
};
let chunk = derive_chunk_context(42, "stress_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "stress_body", &district, (0, 0), None);
for (tx, ty) in &positions {
// Must not panic.
let col = derive_voxel_column(42, "stress_body", &district, &chunk, *tx, *ty);
@@ -3163,7 +3330,7 @@ mod tests {
vegetation_class: VegetationClass::Barren,
..alluvial_district()
};
let chunk = derive_chunk_context(42, "mat_check", &district, (0, 0));
let chunk = derive_chunk_context(42, "mat_check", &district, (0, 0), None);
let col = derive_voxel_column(42, "mat_check", &district, &chunk, 10, 10);
assert_eq!(
col.terrain, *expected_terrain,
@@ -3563,7 +3730,7 @@ mod tests {
fn cover_end_to_end_via_derive_voxel_column_is_deterministic() {
// Cover must be deterministic through the full derive_voxel_column path.
let district = cover_district(MorphologyZone::AlluvialPlain, -12.0, 60);
let chunk = derive_chunk_context(42, "cold_body", &district, (0, 0));
let chunk = derive_chunk_context(42, "cold_body", &district, (0, 0), None);
for (tx, ty) in [(0, 0), (50, 100), (-20, 30), (200, -10)] {
let a = derive_voxel_column(42, "cold_body", &district, &chunk, tx, ty);
let b = derive_voxel_column(42, "cold_body", &district, &chunk, tx, ty);
+272 -23
View File
@@ -36,10 +36,13 @@
//! Update golden: `UPDATE_GOLDEN=1 cargo test --test derivation_harness`
//! Budget gate: `BUDGET_ASSERT=1 cargo test --test derivation_harness -- budget`
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Instant;
use settled_reach_server::atlas::chunk_context::{derive_chunk_context, BasinDirection, ChunkPos};
use settled_reach_server::atlas::chunk_context::{
derive_chunk_context, district_boundary_blend_weight, BasinDirection, ChunkPos,
};
use settled_reach_server::atlas::district_profile::{
derive_district_profile, derive_morphology_zone, derive_precipitation_class_from_climate,
derive_river_threshold, derive_vegetation, BodyParams, ClimateConstants, DistrictProfile,
@@ -85,7 +88,7 @@ fn derive_golden(
tile_x: i32,
tile_y: i32,
) -> GoldenEntry {
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
let col = derive_voxel_column(seed, body_id, district, &chunk, tile_x, tile_y);
GoldenEntry {
label: label.to_string(),
@@ -119,7 +122,7 @@ fn anchor_golden_pos(
(0..scale::CHUNKS_PER_DISTRICT).contains(&along_chunk),
"along_chunk must stay within district (0, 0)"
);
let probe = derive_chunk_context(seed, body_id, district, (0, 0));
let probe = derive_chunk_context(seed, body_id, district, (0, 0), None);
let anchor = probe.channel_anchor_m;
let along_tile = along_chunk * 64 + 32;
match probe.basin_direction {
@@ -300,7 +303,7 @@ fn assert_drainage_monotonicity(
district: &DistrictProfile,
chunk_pos: (i32, i32),
) -> bool {
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
if !chunk.has_active_channel {
return false; // No channel → monotonicity trivially satisfied.
}
@@ -420,13 +423,13 @@ fn law_drainage_monotonicity_fjord_floor_at_sea_level() {
// The fjord trough is district-anchored (T-1041): scan the chunk whose
// cross-range contains the channel anchor — only that chunk column carries
// the deep-water trough.
let probe = derive_chunk_context(42, "fjord_body", &district, (0, 0));
let probe = derive_chunk_context(42, "fjord_body", &district, (0, 0), None);
let anchor_idx = probe.channel_anchor_m.div_euclid(64);
let chunk_pos = match probe.basin_direction {
BasinDirection::North | BasinDirection::South => (anchor_idx, 0),
BasinDirection::East | BasinDirection::West => (0, anchor_idx),
};
let chunk = derive_chunk_context(42, "fjord_body", &district, chunk_pos);
let chunk = derive_chunk_context(42, "fjord_body", &district, chunk_pos, None);
let (base_x, base_y) = (chunk_pos.0 * 64, chunk_pos.1 * 64);
let mut deep_elevs: Vec<i32> = vec![];
let mut dry_elevs: Vec<i32> = vec![];
@@ -522,7 +525,7 @@ fn assert_all_terrain_is(
expected: TerrainMaterial,
label: &str,
) {
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
let base_x = chunk_pos.0 * 64;
let base_y = chunk_pos.1 * 64;
for dy in (0..64i32).step_by(8) {
@@ -1012,7 +1015,7 @@ fn channel_present_in_active_chunks_far_from_origin() {
} else {
(1000, cross_base + i)
};
let chunk = derive_chunk_context(seed, body, &district, pos);
let chunk = derive_chunk_context(seed, body, &district, pos, None);
let (bx, by) = (pos.0 * 64, pos.1 * 64);
let mut wet = 0usize;
for dy in 0..64i32 {
@@ -1068,7 +1071,7 @@ fn channel_continuous_across_chunk_boundary_far_from_origin() {
VegetationClass::Forest,
);
let (seed, body) = (42u64, "GJ144d");
let probe = derive_chunk_context(seed, body, &district, (1000, -750));
let probe = derive_chunk_context(seed, body, &district, (1000, -750), None);
let ns = matches!(
probe.basin_direction,
BasinDirection::North | BasinDirection::South
@@ -1108,6 +1111,243 @@ fn channel_continuous_across_chunk_boundary_far_from_origin() {
}
}
// ---------------------------------------------------------------------------
// T-1042 — Cross-district parameter blending at chunk/voxel scale
// ---------------------------------------------------------------------------
//
// Acceptance criteria (from the ticket brief):
// 1. Elevation step across a district seam ≤ typical step between adjacent
// interior chunks of the same district (seam is invisible in practice).
// 2. Morphology family seams remain sharp (no blending of family selection).
// 3. Golden-seed determinism unchanged for chunks far from any district border.
//
// The test constructs two adjacent AlluvialPlain districts with a significant
// `elev_q` contrast (20 vs 70) and measures:
// - Average elevation of the last chunk of district A (using blend toward B).
// - Average elevation of the last+1 chunk, which is the first chunk of district
// B (no blend — it reads cleanly from district B).
// - Average elevation of a pure interior chunk in district A (far from any seam).
// - Average elevation of a pure interior chunk in district B (far from any seam).
//
// Pass condition: the seam step (last-of-A vs first-of-B) ≤ typical interior
// step (interior-A vs interior-B), because the blend reduces the apparent jump.
// We also confirm that chunks far from any boundary match exact unblended output
// (golden-seed determinism preserved, T-1042 acceptance criterion 3).
#[test]
fn cross_district_elevation_blend_reduces_seam_step() {
// Two AlluvialPlain districts with a large elev_q contrast to make the
// seam measurable. Chose distinct seeds so the morphology family stays
// AlluvialPlain for both (gates trivially satisfied at grade=0, stable).
let district_a = make_region(
MorphologyZone::AlluvialPlain,
TectonicClass::Stable,
GlaciationGrade::None,
5, // slope_q
20, // elev_q — low
15, // ocean_fraction_q (water present for a channel)
55, // moisture_q
Some(15.0),
VegetationClass::Forest,
);
let district_b = make_region(
MorphologyZone::AlluvialPlain,
TectonicClass::Stable,
GlaciationGrade::None,
5, // slope_q
70, // elev_q — high (50-unit contrast with A)
15, // ocean_fraction_q
55, // moisture_q
Some(15.0),
VegetationClass::Forest,
);
let (seed, body) = (42u64, "blend_test_body");
// District A occupies chunk columns [0, 31]; district B = [32, 63].
// CHUNKS_PER_DISTRICT = 32.
//
// The LAST chunk of district A: chunk x=31 (within-district index 31 =
// CHUNKS_PER_DISTRICT-1). `district_boundary_blend_weight` returns
// (true, 128) for this position — a 50-50 blend with district B.
//
// The FIRST chunk of district B: chunk x=32 (within-district index 0).
// `district_boundary_blend_weight` returns (false, 255) — no blend.
let last_a_chunk: ChunkPos = (31, 0);
let first_b_chunk: ChunkPos = (32, 0);
// Interior: well inside district A and B, far from any district boundary.
let interior_a_chunk: ChunkPos = (15, 0);
let interior_b_chunk: ChunkPos = (48, 0);
// Derive boundary detection for the last-A chunk.
let (near_boundary, blend_w) = district_boundary_blend_weight(last_a_chunk);
assert!(
near_boundary,
"T-1042: chunk {:?} must be detected as near a district boundary",
last_a_chunk
);
assert_eq!(
blend_w, 128,
"T-1042: boundary blend weight must be 128 (50-50)"
);
// Derive ChunkContext for last-A with the secondary district B at 50-50 blend.
let ctx_last_a = derive_chunk_context(
seed,
body,
&district_a,
last_a_chunk,
Some((&district_b, blend_w)),
);
// First-B: no blend (interior to B).
let ctx_first_b = derive_chunk_context(seed, body, &district_b, first_b_chunk, None);
// Interior chunks: no blend.
let ctx_interior_a = derive_chunk_context(seed, body, &district_a, interior_a_chunk, None);
let ctx_interior_b = derive_chunk_context(seed, body, &district_b, interior_b_chunk, None);
// Average elevation across a full 64-voxel row through each chunk.
// We scan y=0 (along the x cross-axis for this AlluvialPlain basin).
let avg_elev = |chunk_pos: ChunkPos,
ctx: &settled_reach_server::atlas::chunk_context::ChunkContext,
dist: &DistrictProfile|
-> i64 {
let base_x = chunk_pos.0 * scale::CHUNK_M;
let base_y = chunk_pos.1 * scale::CHUNK_M;
let mut sum = 0i64;
for dx in 0..scale::VOXELS_PER_CHUNK {
let col = derive_voxel_column(seed, body, dist, ctx, base_x + dx, base_y);
sum += col.elevation_m as i64;
}
sum / scale::VOXELS_PER_CHUNK as i64
};
let elev_last_a = avg_elev(last_a_chunk, &ctx_last_a, &district_a);
let elev_first_b = avg_elev(first_b_chunk, &ctx_first_b, &district_b);
let elev_interior_a = avg_elev(interior_a_chunk, &ctx_interior_a, &district_a);
let elev_interior_b = avg_elev(interior_b_chunk, &ctx_interior_b, &district_b);
// Seam step = elevation gap between the blended last-A chunk and the clean first-B chunk.
let seam_step = (elev_last_a - elev_first_b).unsigned_abs() as i64;
// Unblended step = elevation gap between pure interior chunks.
let interior_step = (elev_interior_a - elev_interior_b).unsigned_abs() as i64;
// Criterion 1: the seam step must be strictly less than the interior step.
// The blend reduces the apparent jump — if blending were absent the seam
// step would equal the interior step (both districts differ by 50 elev_q units).
assert!(
seam_step < interior_step,
"T-1042: cross-district seam step ({seam_step} m) must be < unblended \
interior step ({interior_step} m) — blend is not reducing the seam"
);
// Criterion 3: interior chunks produce IDENTICAL output to an unblended context.
// `ctx_interior_a` has blend_weight=255, secondary=None — same as the
// pre-T-1042 path. Derive twice; must match.
let ctx_interior_a2 = derive_chunk_context(seed, body, &district_a, interior_a_chunk, None);
for dx in 0..scale::VOXELS_PER_CHUNK {
let base_x = interior_a_chunk.0 * scale::CHUNK_M;
let base_y = interior_a_chunk.1 * scale::CHUNK_M;
let col1 = derive_voxel_column(
seed,
body,
&district_a,
&ctx_interior_a,
base_x + dx,
base_y,
);
let col2 = derive_voxel_column(
seed,
body,
&district_a,
&ctx_interior_a2,
base_x + dx,
base_y,
);
assert_eq!(
col1.elevation_m, col2.elevation_m,
"T-1042: interior chunk elevation must be deterministic across two derivations \
at voxel offset {dx}"
);
assert_eq!(
col1.terrain, col2.terrain,
"T-1042: interior chunk terrain must be deterministic at voxel offset {dx}"
);
}
}
#[test]
fn cross_district_morphology_family_seams_stay_sharp() {
// T-1042 Criterion 2: morphology family is NEVER blended across a district
// boundary (D-239 §7). An AlluvialPlain district adjacent to a FjordWall
// district must produce strictly AlluvialPlain (Soil terrain) in the
// last chunk of the alluvial district, even at 50-50 blend weight.
//
// The secondary district (FjordWall) has Rock terrain; the primary (Alluvial)
// has Soil. After blending, if family selection were inadvertently reading
// the secondary's zone, some tiles would switch to Rock — catch that here.
let district_alluvial = make_region(
MorphologyZone::AlluvialPlain,
TectonicClass::Stable,
GlaciationGrade::None,
5,
20,
0, // no channel — simpler tile layout for a clean terrain check
40,
Some(15.0),
VegetationClass::Forest,
);
let district_fjord = make_region(
MorphologyZone::Fjord,
TectonicClass::Active,
GlaciationGrade::Moderate,
55,
60,
28,
55,
Some(-8.0),
VegetationClass::Barren,
);
let (seed, body) = (99u64, "seam_sharp_test");
// Last chunk of the alluvial district — blend with the fjord at 50-50.
let boundary_chunk: ChunkPos = (31, 0);
let (_, blend_w) = district_boundary_blend_weight(boundary_chunk);
let ctx = derive_chunk_context(
seed,
body,
&district_alluvial,
boundary_chunk,
Some((&district_fjord, blend_w)),
);
// Every voxel in this chunk must have Soil terrain (AlluvialPlain primary family).
// If family dispatch accidentally picked up the secondary (FjordWall → Rock),
// this assertion fails.
let base_x = boundary_chunk.0 * scale::CHUNK_M;
let base_y = boundary_chunk.1 * scale::CHUNK_M;
for dy in (0..scale::VOXELS_PER_CHUNK).step_by(8) {
for dx in (0..scale::VOXELS_PER_CHUNK).step_by(8) {
let col = derive_voxel_column(
seed,
body,
&district_alluvial,
&ctx,
base_x + dx,
base_y + dy,
);
assert_eq!(
col.terrain,
TerrainMaterial::Soil,
"T-1042 §7 VIOLATED: cross-district boundary chunk must keep primary \
morphology (AlluvialPlain→Soil) at voxel ({},{}) — got {:?}",
base_x + dx,
base_y + dy,
col.terrain
);
}
}
}
#[test]
fn fjord_district_has_one_valley_spanning_chunks() {
// T-1041 (b): a FjordWall district contains ONE deep-water trough spanning
@@ -1126,7 +1366,7 @@ fn fjord_district_has_one_valley_spanning_chunks() {
);
let (seed, body) = (42u64, "fjord_body");
for district_chunk in [(640, -480), (-336, 992)] {
let probe = derive_chunk_context(seed, body, &district, district_chunk);
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
let ns = matches!(
probe.basin_direction,
BasinDirection::North | BasinDirection::South
@@ -1176,7 +1416,7 @@ fn gorge_district_has_one_valley_spanning_chunks() {
);
let (seed, body) = (42u64, "gorge_body");
for district_chunk in [(640, -480), (-336, 992)] {
let probe = derive_chunk_context(seed, body, &district, district_chunk);
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
let ns = matches!(
probe.basin_direction,
BasinDirection::North | BasinDirection::South
@@ -1231,7 +1471,7 @@ fn cliff_coast_one_continuous_coastline_per_region() {
);
let (seed, body) = (42u64, "cliff_body");
for district_chunk in [(656, -464), (-256, 768)] {
let probe = derive_chunk_context(seed, body, &district, district_chunk);
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
let coast = probe.coast_anchor_m;
let ns = matches!(
probe.basin_direction,
@@ -1305,7 +1545,7 @@ fn braided_threads_confined_to_region_belt() {
);
let (seed, body) = (17u64, "delta_body");
for district_chunk in [(800, -592)] {
let probe = derive_chunk_context(seed, body, &district, district_chunk);
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
let ns = matches!(
probe.basin_direction,
BasinDirection::North | BasinDirection::South
@@ -1359,7 +1599,7 @@ fn derive_chunk_timed(
district: &DistrictProfile,
chunk_pos: (i32, i32),
) -> (usize, u128) {
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
let base_x = chunk_pos.0 * 64;
let base_y = chunk_pos.1 * 64;
let t0 = Instant::now();
@@ -1573,7 +1813,7 @@ fn budget_voxel_cache_lru_overhead() {
VegetationClass::Forest,
);
let chunk_pos = (2, 2);
let chunk = derive_chunk_context(seed, body_id, &district, chunk_pos);
let chunk = derive_chunk_context(seed, body_id, &district, chunk_pos, None);
let base_x = chunk_pos.0 * 64;
let base_y = chunk_pos.1 * 64;
@@ -1633,7 +1873,7 @@ fn kallast_body_params() -> BodyParams {
atmosphere: Some("standard".into()),
planet_class: Some("temperate".into()),
tectonic_activity: Some("active".into()),
district_latitude_deg: 0.0,
latitude_deg: 0.0,
elevation_km: 0.0,
body_radius_km: None,
}
@@ -1648,7 +1888,7 @@ fn gloedberg_body_params() -> BodyParams {
atmosphere: Some("standard".into()),
planet_class: Some("volcanic".into()),
tectonic_activity: Some("volcanic".into()),
district_latitude_deg: 0.0,
latitude_deg: 0.0,
elevation_km: 0.5,
body_radius_km: None,
}
@@ -1664,7 +1904,7 @@ fn marevna_body_params() -> BodyParams {
atmosphere: Some("standard".into()),
planet_class: Some("oceanic".into()),
tectonic_activity: Some("active".into()),
district_latitude_deg: 0.0,
latitude_deg: 0.0,
elevation_km: 0.0,
body_radius_km: None,
}
@@ -1681,7 +1921,16 @@ fn derive_profile_for_body(params: &BodyParams) -> DistrictProfile {
let climate = ClimateConstants::default();
let seed = SeedChain::root(42).derive(SeedDomain::Body, 1);
let ta = make_dry_terrain_analysis();
derive_district_profile(seed, params, &ta, (0, 0), 8, &climate)
derive_district_profile(
seed,
params,
&ta,
(0, 0),
8,
&climate,
"test_body",
&BTreeMap::new(),
)
}
#[test]
@@ -1777,7 +2026,7 @@ fn validation_gloedberg_volcanic_immature_drainage() {
);
// Derive a voxel and check TerrainMaterial::Lava (§8 lithology law).
let chunk = derive_chunk_context(42, "GJ581c", &profile, (0, 0));
let chunk = derive_chunk_context(42, "GJ581c", &profile, (0, 0), None);
let col = derive_voxel_column(42, "GJ581c", &profile, &chunk, 100, 100);
assert_eq!(
col.terrain,
@@ -1907,7 +2156,7 @@ fn basin_is_ns(
district: &DistrictProfile,
district_chunk: ChunkPos,
) -> bool {
let probe = derive_chunk_context(seed, body_id, district, district_chunk);
let probe = derive_chunk_context(seed, body_id, district, district_chunk, None);
matches!(
probe.basin_direction,
BasinDirection::North | BasinDirection::South
@@ -1928,7 +2177,7 @@ fn anchor_band_chunks(
district: &DistrictProfile,
district_chunk: ChunkPos,
) -> Vec<ChunkPos> {
let probe = derive_chunk_context(seed, body_id, district, district_chunk);
let probe = derive_chunk_context(seed, body_id, district, district_chunk, None);
let anchor_idx = probe.channel_anchor_m.div_euclid(64);
let ns = matches!(
probe.basin_direction,
@@ -1963,7 +2212,7 @@ fn derive_at_cross_along(
) -> settled_reach_server::atlas::voxel::VoxelColumn {
let (tx, ty) = if ns { (cross, along) } else { (along, cross) };
let chunk_pos = (tx.div_euclid(64), ty.div_euclid(64));
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
derive_voxel_column(seed, body_id, district, &chunk, tx, ty)
}