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:
@@ -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 (RegionProfile→DistrictProfile 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 region→district seam (205 km); T-1042 blends TERRAIN scalars at the district→chunk 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;
|
||||
|
||||
@@ -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 (RegionProfile→DistrictProfile 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 region→district seam (205 km); T-1042 blends TERRAIN scalars at the district→chunk 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 region→district seam (205 km); T-1042 blends TERRAIN scalars at the district→chunk 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 (RegionProfile→DistrictProfile 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 region→district seam (205 km); T-1042 blends TERRAIN scalars at the district→chunk 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 (RegionProfile→DistrictProfile 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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (0–255) 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 (0–255).
|
||||
/// 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 80–500 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. 0–255.
|
||||
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,
|
||||
|
||||
@@ -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 (0–100 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, 0–100) 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 (0–100 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, ¶ms, 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,
|
||||
¶ms,
|
||||
climate,
|
||||
seed,
|
||||
None, // no pre-built cache; derive on-the-fly
|
||||
);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
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,
|
||||
®ion_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(), ¶ms, &ta, 8);
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &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(), ¶ms, &ta, pos, 8, &climate);
|
||||
let p2 = derive_district_profile(test_seed(), ¶ms, &ta, pos, 8, &climate);
|
||||
let p1 = derive_district_profile(
|
||||
test_seed(),
|
||||
¶ms,
|
||||
&ta,
|
||||
pos,
|
||||
8,
|
||||
&climate,
|
||||
"test_body",
|
||||
&BTreeMap::new(),
|
||||
);
|
||||
let p2 = derive_district_profile(
|
||||
test_seed(),
|
||||
¶ms,
|
||||
&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(), ¶ms, &ta, 8);
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &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, ¶ms, &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, ¶ms, &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), ¶ms, &climate, 0.0);
|
||||
let t_cool_region = derive_district_temperature_c(Some(5.0), ¶ms, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (0–100), 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(®ion_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(), ¶ms, &constants, (5, 10));
|
||||
let b = build_region_profile(body_seed(), ¶ms, &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(), ¶ms, &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(), ¶ms, &constants, (0, 0));
|
||||
let equatorial = build_region_profile(body_seed(), ¶ms, &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(), ¶ms, &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(), ¶ms, &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(¶ms, &constants, 42);
|
||||
let params_low = BodyParams {
|
||||
elevation_km: 0.0,
|
||||
..params.clone()
|
||||
};
|
||||
let t_low_elev = derive_region_baseline_c(¶ms_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),
|
||||
¶ms,
|
||||
&constants,
|
||||
body_seed(),
|
||||
None,
|
||||
);
|
||||
let b = region_baseline_at_district(
|
||||
42,
|
||||
"TestBody",
|
||||
(50, 75),
|
||||
¶ms,
|
||||
&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),
|
||||
¶ms,
|
||||
&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),
|
||||
¶ms,
|
||||
&constants,
|
||||
body_seed(),
|
||||
None,
|
||||
);
|
||||
let b = region_baseline_at_district(
|
||||
42,
|
||||
"BodyB",
|
||||
(50, 50),
|
||||
¶ms,
|
||||
&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),
|
||||
¶ms,
|
||||
&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(), ¶ms, &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(), ¶ms, &constants, positions);
|
||||
let t_cached = region_baseline_at_district(
|
||||
42,
|
||||
"TestBody",
|
||||
district_pos,
|
||||
¶ms,
|
||||
&constants,
|
||||
body_seed(),
|
||||
Some(&cache),
|
||||
);
|
||||
let t_derived = region_baseline_at_district(
|
||||
42,
|
||||
"TestBody",
|
||||
district_pos,
|
||||
¶ms,
|
||||
&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
@@ -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 4–8 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 2–8 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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user