diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index d0d5dcf6a..b835ac8cf 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -2062,3 +2062,252 @@ 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 ('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; 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', 'review', 'done', NULL, '2026-06-15 15:42:21', '2026-06-15 15:42:21', '2026-06-15 15:42:21', NULL, '31bbc234714975c19e0f4b02181edb79', 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', 'review', 'done', NULL, '2026-06-15 15:42:21', '2026-06-15 15:42:21', '2026-06-15 15:42:21', NULL, 'dd0d8b519cda12a9d76690da7833efa0', 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 ('06FBPQ02KN9147H4Z12KJW7H1M', 'description', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Thread the remaining Layer-3 character fields through the L3->L4 GenerateSkeleton dispatch. T-1022 wired the dispatch, but build_skeleton_work_item (server/src/atlas/plugin.rs:179-212) overrides only founding_orientation (:192-193); political_archetype stays the Commission stub (city_context_reader.rs:471-474) and morphology_zone stays AlluvialPlain (:487-488) even though: +- CityPlacement already carries the real political_archetype (D-214, derived from TerritorialStatus + economic_role at attractor_matching.rs:595-602) and arrangement_pattern (D-215, city_character at :282-293) — fields :49-64; +- the covering region''s real morphology_zone (D-239 §6 frozen vocab, T-1027, region_profile.rs:180/:499) is available from snapshot.layer_region, populated in the same cascade run (cascade.rs:238-240) — map placement.position to its covering region cell. + +Consequences of the stubs: skeleton_gen''s morphology gating (D-234a Topology selection) always takes the AlluvialPlain Prim-MST mesh path — fjord/delta cities get plains street networks; block_irregularity/layout-mode always sees Commission (skeleton_gen.rs:68-77). D-234''s implementation note explicitly lists this threading as the pending dependency of the (done, tested, dormant) morphology-gated street rules. + +Also decide arrangement_pattern''s path: CityGenerationContext has NO arrangement_pattern field (generator.rs:1078 has political_archetype only; ArrangementPattern :442 is defined but unconsumed at L4). Either (a) add the field and have skeleton_gen consume the D-215 explicit pattern, or (b) record that L4 derives layout from archetype alone and the placement''s pattern is Atlas/UI-only. Option (a) matches the workshop intent (ozzie-round1.md:112-118 backs explicit arrangement). + +Miri''s queryability requirement (tile-derivation-contract/tyre-round1.md:308-311) — morphology_zone readable at Atlas-generation time — is satisfied by reading from BodyWorldState regions, no recompute. + +Acceptance: dispatch a body with a FjordWall-region coastal placement in a CorpTerritory province -> the generated quarter uses Ribbon topology and CompanyTown-class layout, not mesh+Commission. Parent: T-750. Refs: D-214, D-215, D-228, D-234, D-239 §6; T-1022 (predecessor), T-983, T-1014 (adjacent, different fields).', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Thread the remaining Layer-3 character fields through the L3->L4 GenerateSkeleton dispatch. T-1022 wired the dispatch, but build_skeleton_work_item (server/src/atlas/plugin.rs:179-212) overrides only founding_orientation (:192-193); political_archetype stays the Commission stub (city_context_reader.rs:471-474) and morphology_zone stays AlluvialPlain (:487-488) even though: +- CityPlacement already carries the real political_archetype (D-214, derived from TerritorialStatus + economic_role at attractor_matching.rs:595-602) and arrangement_pattern (D-215, city_character at :282-293) — fields :49-64; +- the covering region''s real morphology_zone (D-239 §6 frozen vocab, T-1027, region_profile.rs:180/:499) is available from snapshot.layer_region, populated in the same cascade run (cascade.rs:238-240) — map placement.position to its covering region cell. + +Consequences of the stubs: skeleton_gen''s morphology gating (D-234a Topology selection) always takes the AlluvialPlain Prim-MST mesh path — fjord/delta cities get plains street networks; block_irregularity/layout-mode always sees Commission (skeleton_gen.rs:68-77). D-234''s implementation note explicitly lists this threading as the pending dependency of the (done, tested, dormant) morphology-gated street rules. + +Also decide arrangement_pattern''s path: CityGenerationContext has NO arrangement_pattern field (generator.rs:1078 has political_archetype only; ArrangementPattern :442 is defined but unconsumed at L4). Either (a) add the field and have skeleton_gen consume the D-215 explicit pattern, or (b) record that L4 derives layout from archetype alone and the placement''s pattern is Atlas/UI-only. Option (a) matches the workshop intent (ozzie-round1.md:112-118 backs explicit arrangement). + +Miri''s queryability requirement (tile-derivation-contract/tyre-round1.md:308-311) — morphology_zone readable at Atlas-generation time — is satisfied by reading from BodyWorldState regions, no recompute. + +Acceptance: dispatch a body with a FjordWall-region coastal placement in a CorpTerritory province -> the generated quarter uses Ribbon topology and CompanyTown-class layout, not mesh+Commission. Parent: T-750. Refs: D-214, D-215, D-228, D-234, D-239 §6; T-1022 (predecessor), T-983, T-1014 (adjacent, different fields). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY. Supersedes stale region-scale refs (authored 2026-06-12, pre-T-1077 RegionProfile->DistrictProfile rename + T-1078). Current tree: + +MORPHOLOGY CARRIER MOVED: morphology_zone now lives on DistrictProfile.morphology_zone (district_profile.rs:193, 2km carrier), NOT region_profile.rs. Snapshot field is layer_district (not layer_region — gone); map is BodyWorldState.districts (body_world_state.rs:102), populated by cascade (cascade.rs:270, converted :110-126). The ticket''s snapshot.layer_region / region_profile.rs:180/:499 / "covering region" refs are stale. + +PLACEMENT->DISTRICT MAPPING: CityPlacement.position is heightmap pixel (row,col) (u16,u16) (attractor_matching.rs:51). DistrictPos = position / CELLS_PER_DISTRICT (8). No helper yet — add inline. Inverse at district_profile.rs:1023-1026. + +STUB SITES: political_archetype stub city_context_reader.rs:474 (Commission); morphology_zone stub :488 (AlluvialPlain); both in context_from_read_set() (:462). build_skeleton_work_item (plugin.rs:180-213) threads only founding_orientation (:194) and does NOT receive the districts map — pass &state.districts in (available in the GenCompletion::BodyAnalyzed arm, plugin.rs:101) or resolve before the call; fallback AlluvialPlain on miss. + +THREE OVERRIDES: +- political_archetype: one-liner — context.political_archetype = placement.political_archetype (real D-214 value, attractor_matching.rs:58/:602), parallel to founding_orientation at plugin.rs:194. +- morphology_zone: state.districts.get(&(pos.0/8, pos.1/8)).map(|d| d.morphology_zone).unwrap_or(AlluvialPlain). +- arrangement_pattern: OPTION (a) (D-215-backed) — CityGenerationContext has NO arrangement_pattern field (generator.rs:1098-1124); add it, thread placement.arrangement_pattern (attractor_matching.rs:60/:629), have skeleton_gen consume it (doesn''t yet). + +CONSUMERS LIVE-BUT-DORMANT: street_topology() (skeleton_gen.rs:1048) gates on MorphologyZone (Ribbon: Fjord/CliffCoast/MountainPass/Alpine/ValleyFloor; HubSpoke: Delta/Estuarine/OpenOcean/Lake/DuneStrand/TidalFlat; Mesh: AlluvialPlain/MeanderReach/RiverBank/BraidedPlain/Wetland/Volcanic); derive_layout_mode (:235)+block_irregularity (:68) gate on political_archetype. T-989/T-990 done — always get AlluvialPlain+Commission until this lands. + +ACCEPTANCE: FjordWall-region coastal placement in CorpTerritory -> Ribbon topology + CompanyTown/CampusGrid layout, not mesh+Commission. + +Governing: D-234 (primary), D-214, D-215 (-> option a), D-239 sec 6 (frozen vocab, now district_profile.rs), D-243. + +OVERLAP w/ T-1043: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs), skeleton_gen consumers. Implement together in ONE pass (one agent), not parallel.', NULL, '2026-06-15 15:59:03', '2026-06-15 15:59:03', '2026-06-15 15:59:03', NULL, 'cf00d659333ac1dc3a012998b88cc71d', 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 ('06FBPQ034DKN4ENSGX5HTYTBJG', 'description', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Derive road_entry_directions at Layer 3 and thread them through the GenerateSkeleton dispatch. CityGenerationContext.road_entry_directions (server/src/simulation/generator.rs:1083-1084, compass octants 0=N..7=NW) is stubbed empty (city_context_reader.rs:478-479) and never overridden by the T-1022 dispatch, so derive_access_points (skeleton_gen.rs:1008-1036) never emits QuarterEdge access nodes — every quarter falls back to reservation gates or a single central BlockJunction (:1029-1034), and the D-234 arterial graph has no outward connection to the world. + +Workshop contract: the field is a per-city output of the inter-settlement road graph (generation-cascade/round-3-notes.md:104 ''Layer C: Road graph (road_entry_directions per city)''; burnelli-sheldon-round4.md:308). Full-fidelity derivation therefore lands with the inter-settlement-transport ticket: octant = bearing of each connecting road edge where it crosses the city footprint radius (D-204 footprint), de-duplicated per octant, retaining per-entry quality for the consumer rule below. + +Interim derivation (no road graph required, deterministic, replace-don''t-rework): derive octants at L3 from (a) bearings to the settlement''s MST-neighbor placements (positions already in BodyWorldState.placements) and (b) the founding-orientation water bearing (TerrainAnalysis::water_bearing, 8-octant integer — D-234 implementation note) for port/river settlements. Thread through build_skeleton_work_item (plugin.rs:179-212) next to the founding_orientation override. + +Consumer rule to honour (already decided): the AdminFacing arrangement pattern picks its prestige edge as the edge facing the highest-quality road entry (paula-round2.md:428-433, paula-round3.md:320-322) — so the octant list should be ordered or annotated by quality once the graph exists. Player-experience intent: the approach should present the city''s FoundingOrientation face (ozzie-round1.md:96-100). + +Acceptance: a dispatched settlement with >=1 neighbor produces >=1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements. Parent: T-750. Blocked-by (soft): inter-settlement-transport ticket for full fidelity. Refs: D-234, D-213, D-204; T-990 (consumer, done), T-1022 (dispatch, done).', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Derive road_entry_directions at Layer 3 and thread them through the GenerateSkeleton dispatch. CityGenerationContext.road_entry_directions (server/src/simulation/generator.rs:1083-1084, compass octants 0=N..7=NW) is stubbed empty (city_context_reader.rs:478-479) and never overridden by the T-1022 dispatch, so derive_access_points (skeleton_gen.rs:1008-1036) never emits QuarterEdge access nodes — every quarter falls back to reservation gates or a single central BlockJunction (:1029-1034), and the D-234 arterial graph has no outward connection to the world. + +Workshop contract: the field is a per-city output of the inter-settlement road graph (generation-cascade/round-3-notes.md:104 ''Layer C: Road graph (road_entry_directions per city)''; burnelli-sheldon-round4.md:308). Full-fidelity derivation therefore lands with the inter-settlement-transport ticket: octant = bearing of each connecting road edge where it crosses the city footprint radius (D-204 footprint), de-duplicated per octant, retaining per-entry quality for the consumer rule below. + +Interim derivation (no road graph required, deterministic, replace-don''t-rework): derive octants at L3 from (a) bearings to the settlement''s MST-neighbor placements (positions already in BodyWorldState.placements) and (b) the founding-orientation water bearing (TerrainAnalysis::water_bearing, 8-octant integer — D-234 implementation note) for port/river settlements. Thread through build_skeleton_work_item (plugin.rs:179-212) next to the founding_orientation override. + +Consumer rule to honour (already decided): the AdminFacing arrangement pattern picks its prestige edge as the edge facing the highest-quality road entry (paula-round2.md:428-433, paula-round3.md:320-322) — so the octant list should be ordered or annotated by quality once the graph exists. Player-experience intent: the approach should present the city''s FoundingOrientation face (ozzie-round1.md:96-100). + +Acceptance: a dispatched settlement with >=1 neighbor produces >=1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements. Parent: T-750. Blocked-by (soft): inter-settlement-transport ticket for full fidelity. Refs: D-234, D-213, D-204; T-990 (consumer, done), T-1022 (dispatch, done). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY, with a SCOPE UPDATE. The "interim: MST-neighbor bearings" framing in the title is now obsolete. + +T-1038 ROAD GRAPH HAS SHIPPED — go full-fidelity, skip the interim. The inter-settlement road graph is live in the tree: road_graph.rs exists; the cascade runs CascadeLayer::RoadGraph (gen_queue.rs:383); BodyWorldState.road_graph (body_world_state.rs:91) is populated BEFORE GenCompletion::BodyAnalyzed fires, so at L3->L4 dispatch (plugin.rs:99-133) state.road_graph is already live. Therefore do NOT implement the MST-neighbor approximation — derive octants directly from state.road_graph.edges for the city''s node (match by RoadNode.city_id), bearing = city.position -> neighbor.position, octant = bearing_deg / 45. (NB: T-1038''s ticket still shows in_progress though its code shipped — consume the live road_graph regardless; flag if road_graph turns out not fully populated for some bodies.) + +CURRENT REFS: CityGenerationContext.road_entry_directions = generator.rs:1107 (Vec, octants 0=N..7=NW); empty stub city_context_reader.rs:479; derive_access_points skeleton_gen.rs:1008-1035; BlockJunction fallback :1029-1034; build_skeleton_work_item plugin.rs:180-213 (insert next to the founding_orientation override at :194). + +ISOLATED SETTLEMENTS (no road_graph edges): take the water bearing from placement.founding_orientation — Coastal{facing_degrees} / RiverAligned{bearing_degrees} already encode it (octant = degrees/45). Note TerrainAnalysis::water_bearing (features.rs:133, u16 deg, 360=none) is NOT stored on CityPlacement/BodyWorldState, so reading founding_orientation is the clean access path (no attractor position-match needed). + +CONSUMER: T-990 (done) emits AccessKind::QuarterEdge per octant in derive_access_points. The AdminFacing prestige-edge rule (pick the edge facing the highest-quality road entry) is forward-looking — no AdminFacing code exists yet — so order/annotate octants by quality only once that consumer lands. + +ACCEPTANCE: a dispatched settlement whose city_id has >=1 road_graph edge emits >=1 AccessKind::QuarterEdge on the matching octant; BlockJunction fallback fires only for genuinely isolated settlements. + +Governing: D-234, D-213, D-204 (footprint radius). T-990 (consumer, done), T-1022 (dispatch, done), T-1038 (road graph, shipped). + +OVERLAP w/ T-1039: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs). Implement both in ONE pass (one agent), not parallel.', NULL, '2026-06-15 15:59:03', '2026-06-15 15:59:03', '2026-06-15 15:59:03', NULL, '17910095ceea1d81629895fe9c8a9c67', 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 ('06FBPQ02KN9147H4Z12KJW7H1M', 'description', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Thread the remaining Layer-3 character fields through the L3->L4 GenerateSkeleton dispatch. T-1022 wired the dispatch, but build_skeleton_work_item (server/src/atlas/plugin.rs:179-212) overrides only founding_orientation (:192-193); political_archetype stays the Commission stub (city_context_reader.rs:471-474) and morphology_zone stays AlluvialPlain (:487-488) even though: +- CityPlacement already carries the real political_archetype (D-214, derived from TerritorialStatus + economic_role at attractor_matching.rs:595-602) and arrangement_pattern (D-215, city_character at :282-293) — fields :49-64; +- the covering region''s real morphology_zone (D-239 §6 frozen vocab, T-1027, region_profile.rs:180/:499) is available from snapshot.layer_region, populated in the same cascade run (cascade.rs:238-240) — map placement.position to its covering region cell. + +Consequences of the stubs: skeleton_gen''s morphology gating (D-234a Topology selection) always takes the AlluvialPlain Prim-MST mesh path — fjord/delta cities get plains street networks; block_irregularity/layout-mode always sees Commission (skeleton_gen.rs:68-77). D-234''s implementation note explicitly lists this threading as the pending dependency of the (done, tested, dormant) morphology-gated street rules. + +Also decide arrangement_pattern''s path: CityGenerationContext has NO arrangement_pattern field (generator.rs:1078 has political_archetype only; ArrangementPattern :442 is defined but unconsumed at L4). Either (a) add the field and have skeleton_gen consume the D-215 explicit pattern, or (b) record that L4 derives layout from archetype alone and the placement''s pattern is Atlas/UI-only. Option (a) matches the workshop intent (ozzie-round1.md:112-118 backs explicit arrangement). + +Miri''s queryability requirement (tile-derivation-contract/tyre-round1.md:308-311) — morphology_zone readable at Atlas-generation time — is satisfied by reading from BodyWorldState regions, no recompute. + +Acceptance: dispatch a body with a FjordWall-region coastal placement in a CorpTerritory province -> the generated quarter uses Ribbon topology and CompanyTown-class layout, not mesh+Commission. Parent: T-750. Refs: D-214, D-215, D-228, D-234, D-239 §6; T-1022 (predecessor), T-983, T-1014 (adjacent, different fields). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY. Supersedes stale region-scale refs (authored 2026-06-12, pre-T-1077 RegionProfile->DistrictProfile rename + T-1078). Current tree: + +MORPHOLOGY CARRIER MOVED: morphology_zone now lives on DistrictProfile.morphology_zone (district_profile.rs:193, 2km carrier), NOT region_profile.rs. Snapshot field is layer_district (not layer_region — gone); map is BodyWorldState.districts (body_world_state.rs:102), populated by cascade (cascade.rs:270, converted :110-126). The ticket''s snapshot.layer_region / region_profile.rs:180/:499 / "covering region" refs are stale. + +PLACEMENT->DISTRICT MAPPING: CityPlacement.position is heightmap pixel (row,col) (u16,u16) (attractor_matching.rs:51). DistrictPos = position / CELLS_PER_DISTRICT (8). No helper yet — add inline. Inverse at district_profile.rs:1023-1026. + +STUB SITES: political_archetype stub city_context_reader.rs:474 (Commission); morphology_zone stub :488 (AlluvialPlain); both in context_from_read_set() (:462). build_skeleton_work_item (plugin.rs:180-213) threads only founding_orientation (:194) and does NOT receive the districts map — pass &state.districts in (available in the GenCompletion::BodyAnalyzed arm, plugin.rs:101) or resolve before the call; fallback AlluvialPlain on miss. + +THREE OVERRIDES: +- political_archetype: one-liner — context.political_archetype = placement.political_archetype (real D-214 value, attractor_matching.rs:58/:602), parallel to founding_orientation at plugin.rs:194. +- morphology_zone: state.districts.get(&(pos.0/8, pos.1/8)).map(|d| d.morphology_zone).unwrap_or(AlluvialPlain). +- arrangement_pattern: OPTION (a) (D-215-backed) — CityGenerationContext has NO arrangement_pattern field (generator.rs:1098-1124); add it, thread placement.arrangement_pattern (attractor_matching.rs:60/:629), have skeleton_gen consume it (doesn''t yet). + +CONSUMERS LIVE-BUT-DORMANT: street_topology() (skeleton_gen.rs:1048) gates on MorphologyZone (Ribbon: Fjord/CliffCoast/MountainPass/Alpine/ValleyFloor; HubSpoke: Delta/Estuarine/OpenOcean/Lake/DuneStrand/TidalFlat; Mesh: AlluvialPlain/MeanderReach/RiverBank/BraidedPlain/Wetland/Volcanic); derive_layout_mode (:235)+block_irregularity (:68) gate on political_archetype. T-989/T-990 done — always get AlluvialPlain+Commission until this lands. + +ACCEPTANCE: FjordWall-region coastal placement in CorpTerritory -> Ribbon topology + CompanyTown/CampusGrid layout, not mesh+Commission. + +Governing: D-234 (primary), D-214, D-215 (-> option a), D-239 sec 6 (frozen vocab, now district_profile.rs), D-243. + +OVERLAP w/ T-1043: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs), skeleton_gen consumers. Implement together in ONE pass (one agent), not parallel.', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Thread the remaining Layer-3 character fields through the L3->L4 GenerateSkeleton dispatch. T-1022 wired the dispatch, but build_skeleton_work_item (server/src/atlas/plugin.rs:179-212) overrides only founding_orientation (:192-193); political_archetype stays the Commission stub (city_context_reader.rs:471-474) and morphology_zone stays AlluvialPlain (:487-488) even though: +- CityPlacement already carries the real political_archetype (D-214, derived from TerritorialStatus + economic_role at attractor_matching.rs:595-602) and arrangement_pattern (D-215, city_character at :282-293) — fields :49-64; +- the covering region''s real morphology_zone (D-239 §6 frozen vocab, T-1027, region_profile.rs:180/:499) is available from snapshot.layer_region, populated in the same cascade run (cascade.rs:238-240) — map placement.position to its covering region cell. + +Consequences of the stubs: skeleton_gen''s morphology gating (D-234a Topology selection) always takes the AlluvialPlain Prim-MST mesh path — fjord/delta cities get plains street networks; block_irregularity/layout-mode always sees Commission (skeleton_gen.rs:68-77). D-234''s implementation note explicitly lists this threading as the pending dependency of the (done, tested, dormant) morphology-gated street rules. + +Also decide arrangement_pattern''s path: CityGenerationContext has NO arrangement_pattern field (generator.rs:1078 has political_archetype only; ArrangementPattern :442 is defined but unconsumed at L4). Either (a) add the field and have skeleton_gen consume the D-215 explicit pattern, or (b) record that L4 derives layout from archetype alone and the placement''s pattern is Atlas/UI-only. Option (a) matches the workshop intent (ozzie-round1.md:112-118 backs explicit arrangement). + +Miri''s queryability requirement (tile-derivation-contract/tyre-round1.md:308-311) — morphology_zone readable at Atlas-generation time — is satisfied by reading from BodyWorldState regions, no recompute. + +Acceptance: dispatch a body with a FjordWall-region coastal placement in a CorpTerritory province -> the generated quarter uses Ribbon topology and CompanyTown-class layout, not mesh+Commission. Parent: T-750. Refs: D-214, D-215, D-228, D-234, D-239 §6; T-1022 (predecessor), T-983, T-1014 (adjacent, different fields). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY. Supersedes stale region-scale refs (authored 2026-06-12, pre-T-1077 RegionProfile->DistrictProfile rename + T-1078). Current tree: + +MORPHOLOGY CARRIER MOVED: morphology_zone now lives on DistrictProfile.morphology_zone (district_profile.rs:193, 2km carrier), NOT region_profile.rs. Snapshot field is layer_district (not layer_region — gone); map is BodyWorldState.districts (body_world_state.rs:102), populated by cascade (cascade.rs:270, converted :110-126). The ticket''s snapshot.layer_region / region_profile.rs:180/:499 / "covering region" refs are stale. + +PLACEMENT->DISTRICT MAPPING: CityPlacement.position is heightmap pixel (row,col) (u16,u16) (attractor_matching.rs:51). DistrictPos = position / CELLS_PER_DISTRICT (8). No helper yet — add inline. Inverse at district_profile.rs:1023-1026. + +STUB SITES: political_archetype stub city_context_reader.rs:474 (Commission); morphology_zone stub :488 (AlluvialPlain); both in context_from_read_set() (:462). build_skeleton_work_item (plugin.rs:180-213) threads only founding_orientation (:194) and does NOT receive the districts map — pass &state.districts in (available in the GenCompletion::BodyAnalyzed arm, plugin.rs:101) or resolve before the call; fallback AlluvialPlain on miss. + +THREE OVERRIDES: +- political_archetype: one-liner — context.political_archetype = placement.political_archetype (real D-214 value, attractor_matching.rs:58/:602), parallel to founding_orientation at plugin.rs:194. +- morphology_zone: state.districts.get(&(pos.0/8, pos.1/8)).map(|d| d.morphology_zone).unwrap_or(AlluvialPlain). +- arrangement_pattern: OPTION (a) (D-215-backed) — CityGenerationContext has NO arrangement_pattern field (generator.rs:1098-1124); add it, thread placement.arrangement_pattern (attractor_matching.rs:60/:629), have skeleton_gen consume it (doesn''t yet). + +CONSUMERS LIVE-BUT-DORMANT: street_topology() (skeleton_gen.rs:1048) gates on MorphologyZone (Ribbon: Fjord/CliffCoast/MountainPass/Alpine/ValleyFloor; HubSpoke: Delta/Estuarine/OpenOcean/Lake/DuneStrand/TidalFlat; Mesh: AlluvialPlain/MeanderReach/RiverBank/BraidedPlain/Wetland/Volcanic); derive_layout_mode (:235)+block_irregularity (:68) gate on political_archetype. T-989/T-990 done — always get AlluvialPlain+Commission until this lands. + +ACCEPTANCE: FjordWall-region coastal placement in CorpTerritory -> Ribbon topology + CompanyTown/CampusGrid layout, not mesh+Commission. + +Governing: D-234 (primary), D-214, D-215 (-> option a), D-239 sec 6 (frozen vocab, now district_profile.rs), D-243. + +OVERLAP w/ T-1043: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs), skeleton_gen consumers. Implement together in ONE pass (one agent), not parallel. + +--- +Refinement (2026-06-15, /whats-next Si pass) — staleness corrections + locked decision. Original scope authored 2026-06-12, BEFORE T-1077 (RegionProfile→DistrictProfile rename) and T-1078 (region climate tier, merged today). Corrected refs below. + +STALENESS CORRECTIONS (the description''s code refs are pre-T-1077/T-1078): +- morphology_zone lives on **DistrictProfile** (server/src/atlas/district_profile.rs:193), NOT on RegionProfile (region_profile.rs:155 is a pure climate carrier — no morphology_zone). The ticket''s "region_profile.rs:180/:499" and "snapshot.layer_region" are wrong. +- Access path at dispatch: `state.districts.get(&district_pos)` on BodyWorldState.districts (server/src/atlas/body_world_state.rs:102). The cascade snapshot field is `layer_district` (cascade.rs:79), not `layer_region`. +- Position→DistrictPos: CityPlacement.position is (u16,u16) heightmap-pixel coords (attractor_matching.rs:51). Convert via the cells-per-district factor (currently a LOCAL `CELLS_PER_REGION = 8` in cascade.rs:260 — pre-D-243 naming). Do NOT hardcode 8 in plugin.rs: export a helper/const (prefer reusing server/src/atlas/scale.rs addressing) and call it from the dispatch. +- Verified current locations: build_skeleton_work_item = plugin.rs:180-213 (founding_orientation override is the threading site); political_archetype stub ~city_context_reader.rs:471-474; political_archetype derivation = attractor_matching.rs:602; CityPlacement fields ~attractor_matching.rs:49-64. + +DECISION — arrangement_pattern path: **OPTION (b), re-derive. LOCKED.** +- skeleton_gen re-derives via the single shared pure fn `attractor_matching::arrangement_pattern(&political_archetype, &economic_role)` (attractor_matching.rs:629). Both inputs are available at L4: economic_role is ALREADY threaded (plugin.rs:189 reads it, :209 passes it); political_archetype is threaded by THIS ticket. NO new CityGenerationContext field; NO data-contract amendment. +- Rationale: arrangement_pattern is a pure TOTAL function (6-way archetype match + the `economic_role=="transit_hub"` override) — no seed/RNG/DB/clock/float. Re-derivation is provably identical to L3; cheaper to compute than to store; aligns with the "DB is only a cache, everything re-derivable from seed" principle (no stored derived spatial verdict). +- **HARDENING REQUIRED (the drift tripwire):** add a parity test asserting, for representative (archetype, economic_role) pairs, `arrangement_pattern(&placement.political_archetype, &placement.economic_role) == placement.arrangement_pattern` (CityPlacement already stores the L3-computed value at attractor_matching.rs:60). This locks L3≡L4 and trips the instant anyone changes arrangement_pattern''s input set. Optionally debug_assert the same equality in the dispatch. Never inline a second copy of the derivation — both sites call the one function. + +COORDINATION: T-1039 and T-1043 both edit build_skeleton_work_item (plugin.rs:180-213). Do them as ONE combined branch/PR (single implementer), not two parallel edits to the same function. + +Acceptance (unchanged): dispatch a FjordWall-region coastal placement in a CorpTerritory province → the generated quarter uses Ribbon topology and CompanyTown-class layout, not the AlluvialPlain mesh + Commission stub path.', NULL, '2026-06-15 16:15:05', '2026-06-15 16:15:05', '2026-06-15 16:15:05', NULL, 'bda0b67a52229c7039f9b1d9aa6a8e86', 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 ('06FBPQ034DKN4ENSGX5HTYTBJG', 'description', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Derive road_entry_directions at Layer 3 and thread them through the GenerateSkeleton dispatch. CityGenerationContext.road_entry_directions (server/src/simulation/generator.rs:1083-1084, compass octants 0=N..7=NW) is stubbed empty (city_context_reader.rs:478-479) and never overridden by the T-1022 dispatch, so derive_access_points (skeleton_gen.rs:1008-1036) never emits QuarterEdge access nodes — every quarter falls back to reservation gates or a single central BlockJunction (:1029-1034), and the D-234 arterial graph has no outward connection to the world. + +Workshop contract: the field is a per-city output of the inter-settlement road graph (generation-cascade/round-3-notes.md:104 ''Layer C: Road graph (road_entry_directions per city)''; burnelli-sheldon-round4.md:308). Full-fidelity derivation therefore lands with the inter-settlement-transport ticket: octant = bearing of each connecting road edge where it crosses the city footprint radius (D-204 footprint), de-duplicated per octant, retaining per-entry quality for the consumer rule below. + +Interim derivation (no road graph required, deterministic, replace-don''t-rework): derive octants at L3 from (a) bearings to the settlement''s MST-neighbor placements (positions already in BodyWorldState.placements) and (b) the founding-orientation water bearing (TerrainAnalysis::water_bearing, 8-octant integer — D-234 implementation note) for port/river settlements. Thread through build_skeleton_work_item (plugin.rs:179-212) next to the founding_orientation override. + +Consumer rule to honour (already decided): the AdminFacing arrangement pattern picks its prestige edge as the edge facing the highest-quality road entry (paula-round2.md:428-433, paula-round3.md:320-322) — so the octant list should be ordered or annotated by quality once the graph exists. Player-experience intent: the approach should present the city''s FoundingOrientation face (ozzie-round1.md:96-100). + +Acceptance: a dispatched settlement with >=1 neighbor produces >=1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements. Parent: T-750. Blocked-by (soft): inter-settlement-transport ticket for full fidelity. Refs: D-234, D-213, D-204; T-990 (consumer, done), T-1022 (dispatch, done). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY, with a SCOPE UPDATE. The "interim: MST-neighbor bearings" framing in the title is now obsolete. + +T-1038 ROAD GRAPH HAS SHIPPED — go full-fidelity, skip the interim. The inter-settlement road graph is live in the tree: road_graph.rs exists; the cascade runs CascadeLayer::RoadGraph (gen_queue.rs:383); BodyWorldState.road_graph (body_world_state.rs:91) is populated BEFORE GenCompletion::BodyAnalyzed fires, so at L3->L4 dispatch (plugin.rs:99-133) state.road_graph is already live. Therefore do NOT implement the MST-neighbor approximation — derive octants directly from state.road_graph.edges for the city''s node (match by RoadNode.city_id), bearing = city.position -> neighbor.position, octant = bearing_deg / 45. (NB: T-1038''s ticket still shows in_progress though its code shipped — consume the live road_graph regardless; flag if road_graph turns out not fully populated for some bodies.) + +CURRENT REFS: CityGenerationContext.road_entry_directions = generator.rs:1107 (Vec, octants 0=N..7=NW); empty stub city_context_reader.rs:479; derive_access_points skeleton_gen.rs:1008-1035; BlockJunction fallback :1029-1034; build_skeleton_work_item plugin.rs:180-213 (insert next to the founding_orientation override at :194). + +ISOLATED SETTLEMENTS (no road_graph edges): take the water bearing from placement.founding_orientation — Coastal{facing_degrees} / RiverAligned{bearing_degrees} already encode it (octant = degrees/45). Note TerrainAnalysis::water_bearing (features.rs:133, u16 deg, 360=none) is NOT stored on CityPlacement/BodyWorldState, so reading founding_orientation is the clean access path (no attractor position-match needed). + +CONSUMER: T-990 (done) emits AccessKind::QuarterEdge per octant in derive_access_points. The AdminFacing prestige-edge rule (pick the edge facing the highest-quality road entry) is forward-looking — no AdminFacing code exists yet — so order/annotate octants by quality only once that consumer lands. + +ACCEPTANCE: a dispatched settlement whose city_id has >=1 road_graph edge emits >=1 AccessKind::QuarterEdge on the matching octant; BlockJunction fallback fires only for genuinely isolated settlements. + +Governing: D-234, D-213, D-204 (footprint radius). T-990 (consumer, done), T-1022 (dispatch, done), T-1038 (road graph, shipped). + +OVERLAP w/ T-1039: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs). Implement both in ONE pass (one agent), not parallel.', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Derive road_entry_directions at Layer 3 and thread them through the GenerateSkeleton dispatch. CityGenerationContext.road_entry_directions (server/src/simulation/generator.rs:1083-1084, compass octants 0=N..7=NW) is stubbed empty (city_context_reader.rs:478-479) and never overridden by the T-1022 dispatch, so derive_access_points (skeleton_gen.rs:1008-1036) never emits QuarterEdge access nodes — every quarter falls back to reservation gates or a single central BlockJunction (:1029-1034), and the D-234 arterial graph has no outward connection to the world. + +Workshop contract: the field is a per-city output of the inter-settlement road graph (generation-cascade/round-3-notes.md:104 ''Layer C: Road graph (road_entry_directions per city)''; burnelli-sheldon-round4.md:308). Full-fidelity derivation therefore lands with the inter-settlement-transport ticket: octant = bearing of each connecting road edge where it crosses the city footprint radius (D-204 footprint), de-duplicated per octant, retaining per-entry quality for the consumer rule below. + +Interim derivation (no road graph required, deterministic, replace-don''t-rework): derive octants at L3 from (a) bearings to the settlement''s MST-neighbor placements (positions already in BodyWorldState.placements) and (b) the founding-orientation water bearing (TerrainAnalysis::water_bearing, 8-octant integer — D-234 implementation note) for port/river settlements. Thread through build_skeleton_work_item (plugin.rs:179-212) next to the founding_orientation override. + +Consumer rule to honour (already decided): the AdminFacing arrangement pattern picks its prestige edge as the edge facing the highest-quality road entry (paula-round2.md:428-433, paula-round3.md:320-322) — so the octant list should be ordered or annotated by quality once the graph exists. Player-experience intent: the approach should present the city''s FoundingOrientation face (ozzie-round1.md:96-100). + +Acceptance: a dispatched settlement with >=1 neighbor produces >=1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements. Parent: T-750. Blocked-by (soft): inter-settlement-transport ticket for full fidelity. Refs: D-234, D-213, D-204; T-990 (consumer, done), T-1022 (dispatch, done). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY, with a SCOPE UPDATE. The "interim: MST-neighbor bearings" framing in the title is now obsolete. + +T-1038 ROAD GRAPH HAS SHIPPED — go full-fidelity, skip the interim. The inter-settlement road graph is live in the tree: road_graph.rs exists; the cascade runs CascadeLayer::RoadGraph (gen_queue.rs:383); BodyWorldState.road_graph (body_world_state.rs:91) is populated BEFORE GenCompletion::BodyAnalyzed fires, so at L3->L4 dispatch (plugin.rs:99-133) state.road_graph is already live. Therefore do NOT implement the MST-neighbor approximation — derive octants directly from state.road_graph.edges for the city''s node (match by RoadNode.city_id), bearing = city.position -> neighbor.position, octant = bearing_deg / 45. (NB: T-1038''s ticket still shows in_progress though its code shipped — consume the live road_graph regardless; flag if road_graph turns out not fully populated for some bodies.) + +CURRENT REFS: CityGenerationContext.road_entry_directions = generator.rs:1107 (Vec, octants 0=N..7=NW); empty stub city_context_reader.rs:479; derive_access_points skeleton_gen.rs:1008-1035; BlockJunction fallback :1029-1034; build_skeleton_work_item plugin.rs:180-213 (insert next to the founding_orientation override at :194). + +ISOLATED SETTLEMENTS (no road_graph edges): take the water bearing from placement.founding_orientation — Coastal{facing_degrees} / RiverAligned{bearing_degrees} already encode it (octant = degrees/45). Note TerrainAnalysis::water_bearing (features.rs:133, u16 deg, 360=none) is NOT stored on CityPlacement/BodyWorldState, so reading founding_orientation is the clean access path (no attractor position-match needed). + +CONSUMER: T-990 (done) emits AccessKind::QuarterEdge per octant in derive_access_points. The AdminFacing prestige-edge rule (pick the edge facing the highest-quality road entry) is forward-looking — no AdminFacing code exists yet — so order/annotate octants by quality only once that consumer lands. + +ACCEPTANCE: a dispatched settlement whose city_id has >=1 road_graph edge emits >=1 AccessKind::QuarterEdge on the matching octant; BlockJunction fallback fires only for genuinely isolated settlements. + +Governing: D-234, D-213, D-204 (footprint radius). T-990 (consumer, done), T-1022 (dispatch, done), T-1038 (road graph, shipped). + +OVERLAP w/ T-1039: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs). Implement both in ONE pass (one agent), not parallel. + +--- +Refinement (2026-06-15, /whats-next Si pass) — locked decision + staleness corrections. Original scope authored 2026-06-12. + +DECISION — road_entry_directions source: **USE T-1038''s RoadGraph (full fidelity). LOCKED.** The interim MST-neighbor path is DROPPED. +- T-1038 has shipped a populated `BodyWorldState.road_graph` (RoadGraph) with `RoadNode.city_id` and `RoadEdge.path` (A*-routed polylines in heightmap working-grid coords), available at dispatch time (BodyWorldState is fully populated before the GenCompletion branch in plugin.rs). +- Derive each entry octant from the bearing of the connecting road edge where it crosses the city footprint radius (D-204 footprint_radius), de-duplicated per octant — the D-234 "full fidelity" definition. Order/annotate by edge quality so the AdminFacing consumer (paula-round2.md:428-433: prestige edge faces the highest-quality road entry) can pick correctly. +- COUPLING ACCEPTED: T-1038 is still in_progress; if its RoadGraph field shapes shift, this consumer adjusts. (User accepted this tradeoff over the throwaway interim path.) + +water_bearing: NOT a CityPlacement field — it is encoded inside the founding_orientation enum arms (Coastal{facing_degrees}, RiverAligned{bearing_degrees}); TerrainAnalysis is not stored on BodyWorldState (T-1044 TODO). With the RoadGraph as the primary source, water_bearing is secondary; if used as a supplementary octant for port/river settlements, extract the bearing from those two arms only — Cardinal/Free/TerrainFollowing variants get no water octant. + +STALENESS / verified current locations: road_entry_directions field = generator.rs:1107; stub = city_context_reader.rs:478-479; derive_access_points = skeleton_gen.rs:1008-1036 (BlockJunction fallback :1029-1034); build_skeleton_work_item = plugin.rs:180-213. (Confirmed unchanged by T-1077/T-1078.) + +COORDINATION: T-1039 and T-1043 both edit build_skeleton_work_item (plugin.rs:180-213). Do them as ONE combined branch/PR (single implementer), not two parallel edits to the same function. Thread road_entry_directions next to political_archetype/morphology_zone in the same override block. + +Acceptance (unchanged): a dispatched settlement with ≥1 road connection produces ≥1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements.', NULL, '2026-06-15 16:15:05', '2026-06-15 16:15:05', '2026-06-15 16:15:05', NULL, '5d6b86c7c55bf766a48c77f0177089a4', 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 ('06FBPQ034DKN4ENSGX5HTYTBJG', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 16:15:05', '2026-06-15 16:15:05', '2026-06-15 16:15:05', NULL, '2297c1a1964bd8de0ea70f404007ad21', 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 ('06FBPQ02KN9147H4Z12KJW7H1M', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 16:15:05', '2026-06-15 16:15:05', '2026-06-15 16:15:05', NULL, 'd47bdbf137faf87438476206a512bc4b', 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 ('06FB0TNSRZ8BFVNWDH17CMH1YM', 'status', 'in_progress', 'backlog', NULL, '2026-06-16 09:36:03', '2026-06-16 09:36:03', '2026-06-16 09:36:03', NULL, '1d726cc60f587ccc0337e0c8484a02af', 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 ('06FBPQ02F02JDDBSFMGPX0XASR', 'status', 'in_progress', 'done', NULL, '2026-06-16 09:38:09', '2026-06-16 09:38:09', '2026-06-16 09:38:09', NULL, '4330f81878fa8a6594547b56a89f8875', 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 ('06FBPQ034DKN4ENSGX5HTYTBJG', 'status', 'backlog', 'in_progress', NULL, '2026-06-16 09:42:26', '2026-06-16 09:42:26', '2026-06-16 09:42:26', NULL, '7bd85077ca660bad8cc34fc9b739ca61', 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 ('06FBPQ02KN9147H4Z12KJW7H1M', 'status', 'backlog', 'in_progress', NULL, '2026-06-16 09:42:26', '2026-06-16 09:42:26', '2026-06-16 09:42:26', NULL, 'd8f01ba3260dce078727d36de414b255', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 78c862f7d..6bde7fa97 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2617,3 +2617,402 @@ RELATION TO T-1042 (independent — do NOT share the mechanism): T-1078 blends C 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).', 'done', 'medium', NULL, 'server', 'D-243', '2026-06-14 19:21:18', '2026-06-15 15:42:21', NULL, 'd89530be1679ff1513ed790d838f216c', 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 ('06FBPQ02KN9147H4Z12KJW7H1M', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Thread political_archetype / arrangement_pattern / morphology_zone through the L3→L4 GenerateSkeleton dispatch', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Thread the remaining Layer-3 character fields through the L3->L4 GenerateSkeleton dispatch. T-1022 wired the dispatch, but build_skeleton_work_item (server/src/atlas/plugin.rs:179-212) overrides only founding_orientation (:192-193); political_archetype stays the Commission stub (city_context_reader.rs:471-474) and morphology_zone stays AlluvialPlain (:487-488) even though: +- CityPlacement already carries the real political_archetype (D-214, derived from TerritorialStatus + economic_role at attractor_matching.rs:595-602) and arrangement_pattern (D-215, city_character at :282-293) — fields :49-64; +- the covering region''s real morphology_zone (D-239 §6 frozen vocab, T-1027, region_profile.rs:180/:499) is available from snapshot.layer_region, populated in the same cascade run (cascade.rs:238-240) — map placement.position to its covering region cell. + +Consequences of the stubs: skeleton_gen''s morphology gating (D-234a Topology selection) always takes the AlluvialPlain Prim-MST mesh path — fjord/delta cities get plains street networks; block_irregularity/layout-mode always sees Commission (skeleton_gen.rs:68-77). D-234''s implementation note explicitly lists this threading as the pending dependency of the (done, tested, dormant) morphology-gated street rules. + +Also decide arrangement_pattern''s path: CityGenerationContext has NO arrangement_pattern field (generator.rs:1078 has political_archetype only; ArrangementPattern :442 is defined but unconsumed at L4). Either (a) add the field and have skeleton_gen consume the D-215 explicit pattern, or (b) record that L4 derives layout from archetype alone and the placement''s pattern is Atlas/UI-only. Option (a) matches the workshop intent (ozzie-round1.md:112-118 backs explicit arrangement). + +Miri''s queryability requirement (tile-derivation-contract/tyre-round1.md:308-311) — morphology_zone readable at Atlas-generation time — is satisfied by reading from BodyWorldState regions, no recompute. + +Acceptance: dispatch a body with a FjordWall-region coastal placement in a CorpTerritory province -> the generated quarter uses Ribbon topology and CompanyTown-class layout, not mesh+Commission. Parent: T-750. Refs: D-214, D-215, D-228, D-234, D-239 §6; T-1022 (predecessor), T-983, T-1014 (adjacent, different fields). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY. Supersedes stale region-scale refs (authored 2026-06-12, pre-T-1077 RegionProfile->DistrictProfile rename + T-1078). Current tree: + +MORPHOLOGY CARRIER MOVED: morphology_zone now lives on DistrictProfile.morphology_zone (district_profile.rs:193, 2km carrier), NOT region_profile.rs. Snapshot field is layer_district (not layer_region — gone); map is BodyWorldState.districts (body_world_state.rs:102), populated by cascade (cascade.rs:270, converted :110-126). The ticket''s snapshot.layer_region / region_profile.rs:180/:499 / "covering region" refs are stale. + +PLACEMENT->DISTRICT MAPPING: CityPlacement.position is heightmap pixel (row,col) (u16,u16) (attractor_matching.rs:51). DistrictPos = position / CELLS_PER_DISTRICT (8). No helper yet — add inline. Inverse at district_profile.rs:1023-1026. + +STUB SITES: political_archetype stub city_context_reader.rs:474 (Commission); morphology_zone stub :488 (AlluvialPlain); both in context_from_read_set() (:462). build_skeleton_work_item (plugin.rs:180-213) threads only founding_orientation (:194) and does NOT receive the districts map — pass &state.districts in (available in the GenCompletion::BodyAnalyzed arm, plugin.rs:101) or resolve before the call; fallback AlluvialPlain on miss. + +THREE OVERRIDES: +- political_archetype: one-liner — context.political_archetype = placement.political_archetype (real D-214 value, attractor_matching.rs:58/:602), parallel to founding_orientation at plugin.rs:194. +- morphology_zone: state.districts.get(&(pos.0/8, pos.1/8)).map(|d| d.morphology_zone).unwrap_or(AlluvialPlain). +- arrangement_pattern: OPTION (a) (D-215-backed) — CityGenerationContext has NO arrangement_pattern field (generator.rs:1098-1124); add it, thread placement.arrangement_pattern (attractor_matching.rs:60/:629), have skeleton_gen consume it (doesn''t yet). + +CONSUMERS LIVE-BUT-DORMANT: street_topology() (skeleton_gen.rs:1048) gates on MorphologyZone (Ribbon: Fjord/CliffCoast/MountainPass/Alpine/ValleyFloor; HubSpoke: Delta/Estuarine/OpenOcean/Lake/DuneStrand/TidalFlat; Mesh: AlluvialPlain/MeanderReach/RiverBank/BraidedPlain/Wetland/Volcanic); derive_layout_mode (:235)+block_irregularity (:68) gate on political_archetype. T-989/T-990 done — always get AlluvialPlain+Commission until this lands. + +ACCEPTANCE: FjordWall-region coastal placement in CorpTerritory -> Ribbon topology + CompanyTown/CampusGrid layout, not mesh+Commission. + +Governing: D-234 (primary), D-214, D-215 (-> option a), D-239 sec 6 (frozen vocab, now district_profile.rs), D-243. + +OVERLAP w/ T-1043: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs), skeleton_gen consumers. Implement together in ONE pass (one agent), not parallel.', 'backlog', 'high', NULL, 'server', 'D-234', '2026-06-12 10:23:49', '2026-06-15 15:59:03', NULL, '85b17213041c195d9aab9a7d8a1e6885', 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 ('06FBPQ034DKN4ENSGX5HTYTBJG', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Derive road_entry_directions at L3 and thread through the dispatch (interim: MST-neighbor bearings + water_bearing)', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Derive road_entry_directions at Layer 3 and thread them through the GenerateSkeleton dispatch. CityGenerationContext.road_entry_directions (server/src/simulation/generator.rs:1083-1084, compass octants 0=N..7=NW) is stubbed empty (city_context_reader.rs:478-479) and never overridden by the T-1022 dispatch, so derive_access_points (skeleton_gen.rs:1008-1036) never emits QuarterEdge access nodes — every quarter falls back to reservation gates or a single central BlockJunction (:1029-1034), and the D-234 arterial graph has no outward connection to the world. + +Workshop contract: the field is a per-city output of the inter-settlement road graph (generation-cascade/round-3-notes.md:104 ''Layer C: Road graph (road_entry_directions per city)''; burnelli-sheldon-round4.md:308). Full-fidelity derivation therefore lands with the inter-settlement-transport ticket: octant = bearing of each connecting road edge where it crosses the city footprint radius (D-204 footprint), de-duplicated per octant, retaining per-entry quality for the consumer rule below. + +Interim derivation (no road graph required, deterministic, replace-don''t-rework): derive octants at L3 from (a) bearings to the settlement''s MST-neighbor placements (positions already in BodyWorldState.placements) and (b) the founding-orientation water bearing (TerrainAnalysis::water_bearing, 8-octant integer — D-234 implementation note) for port/river settlements. Thread through build_skeleton_work_item (plugin.rs:179-212) next to the founding_orientation override. + +Consumer rule to honour (already decided): the AdminFacing arrangement pattern picks its prestige edge as the edge facing the highest-quality road entry (paula-round2.md:428-433, paula-round3.md:320-322) — so the octant list should be ordered or annotated by quality once the graph exists. Player-experience intent: the approach should present the city''s FoundingOrientation face (ozzie-round1.md:96-100). + +Acceptance: a dispatched settlement with >=1 neighbor produces >=1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements. Parent: T-750. Blocked-by (soft): inter-settlement-transport ticket for full fidelity. Refs: D-234, D-213, D-204; T-990 (consumer, done), T-1022 (dispatch, done). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY, with a SCOPE UPDATE. The "interim: MST-neighbor bearings" framing in the title is now obsolete. + +T-1038 ROAD GRAPH HAS SHIPPED — go full-fidelity, skip the interim. The inter-settlement road graph is live in the tree: road_graph.rs exists; the cascade runs CascadeLayer::RoadGraph (gen_queue.rs:383); BodyWorldState.road_graph (body_world_state.rs:91) is populated BEFORE GenCompletion::BodyAnalyzed fires, so at L3->L4 dispatch (plugin.rs:99-133) state.road_graph is already live. Therefore do NOT implement the MST-neighbor approximation — derive octants directly from state.road_graph.edges for the city''s node (match by RoadNode.city_id), bearing = city.position -> neighbor.position, octant = bearing_deg / 45. (NB: T-1038''s ticket still shows in_progress though its code shipped — consume the live road_graph regardless; flag if road_graph turns out not fully populated for some bodies.) + +CURRENT REFS: CityGenerationContext.road_entry_directions = generator.rs:1107 (Vec, octants 0=N..7=NW); empty stub city_context_reader.rs:479; derive_access_points skeleton_gen.rs:1008-1035; BlockJunction fallback :1029-1034; build_skeleton_work_item plugin.rs:180-213 (insert next to the founding_orientation override at :194). + +ISOLATED SETTLEMENTS (no road_graph edges): take the water bearing from placement.founding_orientation — Coastal{facing_degrees} / RiverAligned{bearing_degrees} already encode it (octant = degrees/45). Note TerrainAnalysis::water_bearing (features.rs:133, u16 deg, 360=none) is NOT stored on CityPlacement/BodyWorldState, so reading founding_orientation is the clean access path (no attractor position-match needed). + +CONSUMER: T-990 (done) emits AccessKind::QuarterEdge per octant in derive_access_points. The AdminFacing prestige-edge rule (pick the edge facing the highest-quality road entry) is forward-looking — no AdminFacing code exists yet — so order/annotate octants by quality only once that consumer lands. + +ACCEPTANCE: a dispatched settlement whose city_id has >=1 road_graph edge emits >=1 AccessKind::QuarterEdge on the matching octant; BlockJunction fallback fires only for genuinely isolated settlements. + +Governing: D-234, D-213, D-204 (footprint radius). T-990 (consumer, done), T-1022 (dispatch, done), T-1038 (road graph, shipped). + +OVERLAP w/ T-1039: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs). Implement both in ONE pass (one agent), not parallel.', 'backlog', 'medium', NULL, 'server', 'D-234', '2026-06-12 10:23:50', '2026-06-15 15:59:03', NULL, 'd59a1581909e78cdfd4961c1ce54c48e', 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 ('06FBPQ02KN9147H4Z12KJW7H1M', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Thread political_archetype / arrangement_pattern / morphology_zone through the L3→L4 GenerateSkeleton dispatch', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Thread the remaining Layer-3 character fields through the L3->L4 GenerateSkeleton dispatch. T-1022 wired the dispatch, but build_skeleton_work_item (server/src/atlas/plugin.rs:179-212) overrides only founding_orientation (:192-193); political_archetype stays the Commission stub (city_context_reader.rs:471-474) and morphology_zone stays AlluvialPlain (:487-488) even though: +- CityPlacement already carries the real political_archetype (D-214, derived from TerritorialStatus + economic_role at attractor_matching.rs:595-602) and arrangement_pattern (D-215, city_character at :282-293) — fields :49-64; +- the covering region''s real morphology_zone (D-239 §6 frozen vocab, T-1027, region_profile.rs:180/:499) is available from snapshot.layer_region, populated in the same cascade run (cascade.rs:238-240) — map placement.position to its covering region cell. + +Consequences of the stubs: skeleton_gen''s morphology gating (D-234a Topology selection) always takes the AlluvialPlain Prim-MST mesh path — fjord/delta cities get plains street networks; block_irregularity/layout-mode always sees Commission (skeleton_gen.rs:68-77). D-234''s implementation note explicitly lists this threading as the pending dependency of the (done, tested, dormant) morphology-gated street rules. + +Also decide arrangement_pattern''s path: CityGenerationContext has NO arrangement_pattern field (generator.rs:1078 has political_archetype only; ArrangementPattern :442 is defined but unconsumed at L4). Either (a) add the field and have skeleton_gen consume the D-215 explicit pattern, or (b) record that L4 derives layout from archetype alone and the placement''s pattern is Atlas/UI-only. Option (a) matches the workshop intent (ozzie-round1.md:112-118 backs explicit arrangement). + +Miri''s queryability requirement (tile-derivation-contract/tyre-round1.md:308-311) — morphology_zone readable at Atlas-generation time — is satisfied by reading from BodyWorldState regions, no recompute. + +Acceptance: dispatch a body with a FjordWall-region coastal placement in a CorpTerritory province -> the generated quarter uses Ribbon topology and CompanyTown-class layout, not mesh+Commission. Parent: T-750. Refs: D-214, D-215, D-228, D-234, D-239 §6; T-1022 (predecessor), T-983, T-1014 (adjacent, different fields). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY. Supersedes stale region-scale refs (authored 2026-06-12, pre-T-1077 RegionProfile->DistrictProfile rename + T-1078). Current tree: + +MORPHOLOGY CARRIER MOVED: morphology_zone now lives on DistrictProfile.morphology_zone (district_profile.rs:193, 2km carrier), NOT region_profile.rs. Snapshot field is layer_district (not layer_region — gone); map is BodyWorldState.districts (body_world_state.rs:102), populated by cascade (cascade.rs:270, converted :110-126). The ticket''s snapshot.layer_region / region_profile.rs:180/:499 / "covering region" refs are stale. + +PLACEMENT->DISTRICT MAPPING: CityPlacement.position is heightmap pixel (row,col) (u16,u16) (attractor_matching.rs:51). DistrictPos = position / CELLS_PER_DISTRICT (8). No helper yet — add inline. Inverse at district_profile.rs:1023-1026. + +STUB SITES: political_archetype stub city_context_reader.rs:474 (Commission); morphology_zone stub :488 (AlluvialPlain); both in context_from_read_set() (:462). build_skeleton_work_item (plugin.rs:180-213) threads only founding_orientation (:194) and does NOT receive the districts map — pass &state.districts in (available in the GenCompletion::BodyAnalyzed arm, plugin.rs:101) or resolve before the call; fallback AlluvialPlain on miss. + +THREE OVERRIDES: +- political_archetype: one-liner — context.political_archetype = placement.political_archetype (real D-214 value, attractor_matching.rs:58/:602), parallel to founding_orientation at plugin.rs:194. +- morphology_zone: state.districts.get(&(pos.0/8, pos.1/8)).map(|d| d.morphology_zone).unwrap_or(AlluvialPlain). +- arrangement_pattern: OPTION (a) (D-215-backed) — CityGenerationContext has NO arrangement_pattern field (generator.rs:1098-1124); add it, thread placement.arrangement_pattern (attractor_matching.rs:60/:629), have skeleton_gen consume it (doesn''t yet). + +CONSUMERS LIVE-BUT-DORMANT: street_topology() (skeleton_gen.rs:1048) gates on MorphologyZone (Ribbon: Fjord/CliffCoast/MountainPass/Alpine/ValleyFloor; HubSpoke: Delta/Estuarine/OpenOcean/Lake/DuneStrand/TidalFlat; Mesh: AlluvialPlain/MeanderReach/RiverBank/BraidedPlain/Wetland/Volcanic); derive_layout_mode (:235)+block_irregularity (:68) gate on political_archetype. T-989/T-990 done — always get AlluvialPlain+Commission until this lands. + +ACCEPTANCE: FjordWall-region coastal placement in CorpTerritory -> Ribbon topology + CompanyTown/CampusGrid layout, not mesh+Commission. + +Governing: D-234 (primary), D-214, D-215 (-> option a), D-239 sec 6 (frozen vocab, now district_profile.rs), D-243. + +OVERLAP w/ T-1043: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs), skeleton_gen consumers. Implement together in ONE pass (one agent), not parallel. + +--- +Refinement (2026-06-15, /whats-next Si pass) — staleness corrections + locked decision. Original scope authored 2026-06-12, BEFORE T-1077 (RegionProfile→DistrictProfile rename) and T-1078 (region climate tier, merged today). Corrected refs below. + +STALENESS CORRECTIONS (the description''s code refs are pre-T-1077/T-1078): +- morphology_zone lives on **DistrictProfile** (server/src/atlas/district_profile.rs:193), NOT on RegionProfile (region_profile.rs:155 is a pure climate carrier — no morphology_zone). The ticket''s "region_profile.rs:180/:499" and "snapshot.layer_region" are wrong. +- Access path at dispatch: `state.districts.get(&district_pos)` on BodyWorldState.districts (server/src/atlas/body_world_state.rs:102). The cascade snapshot field is `layer_district` (cascade.rs:79), not `layer_region`. +- Position→DistrictPos: CityPlacement.position is (u16,u16) heightmap-pixel coords (attractor_matching.rs:51). Convert via the cells-per-district factor (currently a LOCAL `CELLS_PER_REGION = 8` in cascade.rs:260 — pre-D-243 naming). Do NOT hardcode 8 in plugin.rs: export a helper/const (prefer reusing server/src/atlas/scale.rs addressing) and call it from the dispatch. +- Verified current locations: build_skeleton_work_item = plugin.rs:180-213 (founding_orientation override is the threading site); political_archetype stub ~city_context_reader.rs:471-474; political_archetype derivation = attractor_matching.rs:602; CityPlacement fields ~attractor_matching.rs:49-64. + +DECISION — arrangement_pattern path: **OPTION (b), re-derive. LOCKED.** +- skeleton_gen re-derives via the single shared pure fn `attractor_matching::arrangement_pattern(&political_archetype, &economic_role)` (attractor_matching.rs:629). Both inputs are available at L4: economic_role is ALREADY threaded (plugin.rs:189 reads it, :209 passes it); political_archetype is threaded by THIS ticket. NO new CityGenerationContext field; NO data-contract amendment. +- Rationale: arrangement_pattern is a pure TOTAL function (6-way archetype match + the `economic_role=="transit_hub"` override) — no seed/RNG/DB/clock/float. Re-derivation is provably identical to L3; cheaper to compute than to store; aligns with the "DB is only a cache, everything re-derivable from seed" principle (no stored derived spatial verdict). +- **HARDENING REQUIRED (the drift tripwire):** add a parity test asserting, for representative (archetype, economic_role) pairs, `arrangement_pattern(&placement.political_archetype, &placement.economic_role) == placement.arrangement_pattern` (CityPlacement already stores the L3-computed value at attractor_matching.rs:60). This locks L3≡L4 and trips the instant anyone changes arrangement_pattern''s input set. Optionally debug_assert the same equality in the dispatch. Never inline a second copy of the derivation — both sites call the one function. + +COORDINATION: T-1039 and T-1043 both edit build_skeleton_work_item (plugin.rs:180-213). Do them as ONE combined branch/PR (single implementer), not two parallel edits to the same function. + +Acceptance (unchanged): dispatch a FjordWall-region coastal placement in a CorpTerritory province → the generated quarter uses Ribbon topology and CompanyTown-class layout, not the AlluvialPlain mesh + Commission stub path.', 'backlog', 'high', NULL, 'server', 'D-234', '2026-06-12 10:23:49', '2026-06-15 16:15:05', NULL, 'cc4879ec3f398a3489dd150113c7dfbb', 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 ('06FBPQ034DKN4ENSGX5HTYTBJG', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Derive road_entry_directions at L3 and thread through the dispatch (interim: MST-neighbor bearings + water_bearing)', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Derive road_entry_directions at Layer 3 and thread them through the GenerateSkeleton dispatch. CityGenerationContext.road_entry_directions (server/src/simulation/generator.rs:1083-1084, compass octants 0=N..7=NW) is stubbed empty (city_context_reader.rs:478-479) and never overridden by the T-1022 dispatch, so derive_access_points (skeleton_gen.rs:1008-1036) never emits QuarterEdge access nodes — every quarter falls back to reservation gates or a single central BlockJunction (:1029-1034), and the D-234 arterial graph has no outward connection to the world. + +Workshop contract: the field is a per-city output of the inter-settlement road graph (generation-cascade/round-3-notes.md:104 ''Layer C: Road graph (road_entry_directions per city)''; burnelli-sheldon-round4.md:308). Full-fidelity derivation therefore lands with the inter-settlement-transport ticket: octant = bearing of each connecting road edge where it crosses the city footprint radius (D-204 footprint), de-duplicated per octant, retaining per-entry quality for the consumer rule below. + +Interim derivation (no road graph required, deterministic, replace-don''t-rework): derive octants at L3 from (a) bearings to the settlement''s MST-neighbor placements (positions already in BodyWorldState.placements) and (b) the founding-orientation water bearing (TerrainAnalysis::water_bearing, 8-octant integer — D-234 implementation note) for port/river settlements. Thread through build_skeleton_work_item (plugin.rs:179-212) next to the founding_orientation override. + +Consumer rule to honour (already decided): the AdminFacing arrangement pattern picks its prestige edge as the edge facing the highest-quality road entry (paula-round2.md:428-433, paula-round3.md:320-322) — so the octant list should be ordered or annotated by quality once the graph exists. Player-experience intent: the approach should present the city''s FoundingOrientation face (ozzie-round1.md:96-100). + +Acceptance: a dispatched settlement with >=1 neighbor produces >=1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements. Parent: T-750. Blocked-by (soft): inter-settlement-transport ticket for full fidelity. Refs: D-234, D-213, D-204; T-990 (consumer, done), T-1022 (dispatch, done). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY, with a SCOPE UPDATE. The "interim: MST-neighbor bearings" framing in the title is now obsolete. + +T-1038 ROAD GRAPH HAS SHIPPED — go full-fidelity, skip the interim. The inter-settlement road graph is live in the tree: road_graph.rs exists; the cascade runs CascadeLayer::RoadGraph (gen_queue.rs:383); BodyWorldState.road_graph (body_world_state.rs:91) is populated BEFORE GenCompletion::BodyAnalyzed fires, so at L3->L4 dispatch (plugin.rs:99-133) state.road_graph is already live. Therefore do NOT implement the MST-neighbor approximation — derive octants directly from state.road_graph.edges for the city''s node (match by RoadNode.city_id), bearing = city.position -> neighbor.position, octant = bearing_deg / 45. (NB: T-1038''s ticket still shows in_progress though its code shipped — consume the live road_graph regardless; flag if road_graph turns out not fully populated for some bodies.) + +CURRENT REFS: CityGenerationContext.road_entry_directions = generator.rs:1107 (Vec, octants 0=N..7=NW); empty stub city_context_reader.rs:479; derive_access_points skeleton_gen.rs:1008-1035; BlockJunction fallback :1029-1034; build_skeleton_work_item plugin.rs:180-213 (insert next to the founding_orientation override at :194). + +ISOLATED SETTLEMENTS (no road_graph edges): take the water bearing from placement.founding_orientation — Coastal{facing_degrees} / RiverAligned{bearing_degrees} already encode it (octant = degrees/45). Note TerrainAnalysis::water_bearing (features.rs:133, u16 deg, 360=none) is NOT stored on CityPlacement/BodyWorldState, so reading founding_orientation is the clean access path (no attractor position-match needed). + +CONSUMER: T-990 (done) emits AccessKind::QuarterEdge per octant in derive_access_points. The AdminFacing prestige-edge rule (pick the edge facing the highest-quality road entry) is forward-looking — no AdminFacing code exists yet — so order/annotate octants by quality only once that consumer lands. + +ACCEPTANCE: a dispatched settlement whose city_id has >=1 road_graph edge emits >=1 AccessKind::QuarterEdge on the matching octant; BlockJunction fallback fires only for genuinely isolated settlements. + +Governing: D-234, D-213, D-204 (footprint radius). T-990 (consumer, done), T-1022 (dispatch, done), T-1038 (road graph, shipped). + +OVERLAP w/ T-1039: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs). Implement both in ONE pass (one agent), not parallel. + +--- +Refinement (2026-06-15, /whats-next Si pass) — locked decision + staleness corrections. Original scope authored 2026-06-12. + +DECISION — road_entry_directions source: **USE T-1038''s RoadGraph (full fidelity). LOCKED.** The interim MST-neighbor path is DROPPED. +- T-1038 has shipped a populated `BodyWorldState.road_graph` (RoadGraph) with `RoadNode.city_id` and `RoadEdge.path` (A*-routed polylines in heightmap working-grid coords), available at dispatch time (BodyWorldState is fully populated before the GenCompletion branch in plugin.rs). +- Derive each entry octant from the bearing of the connecting road edge where it crosses the city footprint radius (D-204 footprint_radius), de-duplicated per octant — the D-234 "full fidelity" definition. Order/annotate by edge quality so the AdminFacing consumer (paula-round2.md:428-433: prestige edge faces the highest-quality road entry) can pick correctly. +- COUPLING ACCEPTED: T-1038 is still in_progress; if its RoadGraph field shapes shift, this consumer adjusts. (User accepted this tradeoff over the throwaway interim path.) + +water_bearing: NOT a CityPlacement field — it is encoded inside the founding_orientation enum arms (Coastal{facing_degrees}, RiverAligned{bearing_degrees}); TerrainAnalysis is not stored on BodyWorldState (T-1044 TODO). With the RoadGraph as the primary source, water_bearing is secondary; if used as a supplementary octant for port/river settlements, extract the bearing from those two arms only — Cardinal/Free/TerrainFollowing variants get no water octant. + +STALENESS / verified current locations: road_entry_directions field = generator.rs:1107; stub = city_context_reader.rs:478-479; derive_access_points = skeleton_gen.rs:1008-1036 (BlockJunction fallback :1029-1034); build_skeleton_work_item = plugin.rs:180-213. (Confirmed unchanged by T-1077/T-1078.) + +COORDINATION: T-1039 and T-1043 both edit build_skeleton_work_item (plugin.rs:180-213). Do them as ONE combined branch/PR (single implementer), not two parallel edits to the same function. Thread road_entry_directions next to political_archetype/morphology_zone in the same override block. + +Acceptance (unchanged): a dispatched settlement with ≥1 road connection produces ≥1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements.', 'backlog', 'medium', NULL, 'server', 'D-234', '2026-06-12 10:23:50', '2026-06-15 16:15:05', NULL, '7a8f4f2810c9a96c556681e71d778a1a', 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 ('06FBPQ034DKN4ENSGX5HTYTBJG', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Derive road_entry_directions at L3 and thread through the dispatch (interim: MST-neighbor bearings + water_bearing)', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Derive road_entry_directions at Layer 3 and thread them through the GenerateSkeleton dispatch. CityGenerationContext.road_entry_directions (server/src/simulation/generator.rs:1083-1084, compass octants 0=N..7=NW) is stubbed empty (city_context_reader.rs:478-479) and never overridden by the T-1022 dispatch, so derive_access_points (skeleton_gen.rs:1008-1036) never emits QuarterEdge access nodes — every quarter falls back to reservation gates or a single central BlockJunction (:1029-1034), and the D-234 arterial graph has no outward connection to the world. + +Workshop contract: the field is a per-city output of the inter-settlement road graph (generation-cascade/round-3-notes.md:104 ''Layer C: Road graph (road_entry_directions per city)''; burnelli-sheldon-round4.md:308). Full-fidelity derivation therefore lands with the inter-settlement-transport ticket: octant = bearing of each connecting road edge where it crosses the city footprint radius (D-204 footprint), de-duplicated per octant, retaining per-entry quality for the consumer rule below. + +Interim derivation (no road graph required, deterministic, replace-don''t-rework): derive octants at L3 from (a) bearings to the settlement''s MST-neighbor placements (positions already in BodyWorldState.placements) and (b) the founding-orientation water bearing (TerrainAnalysis::water_bearing, 8-octant integer — D-234 implementation note) for port/river settlements. Thread through build_skeleton_work_item (plugin.rs:179-212) next to the founding_orientation override. + +Consumer rule to honour (already decided): the AdminFacing arrangement pattern picks its prestige edge as the edge facing the highest-quality road entry (paula-round2.md:428-433, paula-round3.md:320-322) — so the octant list should be ordered or annotated by quality once the graph exists. Player-experience intent: the approach should present the city''s FoundingOrientation face (ozzie-round1.md:96-100). + +Acceptance: a dispatched settlement with >=1 neighbor produces >=1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements. Parent: T-750. Blocked-by (soft): inter-settlement-transport ticket for full fidelity. Refs: D-234, D-213, D-204; T-990 (consumer, done), T-1022 (dispatch, done). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY, with a SCOPE UPDATE. The "interim: MST-neighbor bearings" framing in the title is now obsolete. + +T-1038 ROAD GRAPH HAS SHIPPED — go full-fidelity, skip the interim. The inter-settlement road graph is live in the tree: road_graph.rs exists; the cascade runs CascadeLayer::RoadGraph (gen_queue.rs:383); BodyWorldState.road_graph (body_world_state.rs:91) is populated BEFORE GenCompletion::BodyAnalyzed fires, so at L3->L4 dispatch (plugin.rs:99-133) state.road_graph is already live. Therefore do NOT implement the MST-neighbor approximation — derive octants directly from state.road_graph.edges for the city''s node (match by RoadNode.city_id), bearing = city.position -> neighbor.position, octant = bearing_deg / 45. (NB: T-1038''s ticket still shows in_progress though its code shipped — consume the live road_graph regardless; flag if road_graph turns out not fully populated for some bodies.) + +CURRENT REFS: CityGenerationContext.road_entry_directions = generator.rs:1107 (Vec, octants 0=N..7=NW); empty stub city_context_reader.rs:479; derive_access_points skeleton_gen.rs:1008-1035; BlockJunction fallback :1029-1034; build_skeleton_work_item plugin.rs:180-213 (insert next to the founding_orientation override at :194). + +ISOLATED SETTLEMENTS (no road_graph edges): take the water bearing from placement.founding_orientation — Coastal{facing_degrees} / RiverAligned{bearing_degrees} already encode it (octant = degrees/45). Note TerrainAnalysis::water_bearing (features.rs:133, u16 deg, 360=none) is NOT stored on CityPlacement/BodyWorldState, so reading founding_orientation is the clean access path (no attractor position-match needed). + +CONSUMER: T-990 (done) emits AccessKind::QuarterEdge per octant in derive_access_points. The AdminFacing prestige-edge rule (pick the edge facing the highest-quality road entry) is forward-looking — no AdminFacing code exists yet — so order/annotate octants by quality only once that consumer lands. + +ACCEPTANCE: a dispatched settlement whose city_id has >=1 road_graph edge emits >=1 AccessKind::QuarterEdge on the matching octant; BlockJunction fallback fires only for genuinely isolated settlements. + +Governing: D-234, D-213, D-204 (footprint radius). T-990 (consumer, done), T-1022 (dispatch, done), T-1038 (road graph, shipped). + +OVERLAP w/ T-1039: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs). Implement both in ONE pass (one agent), not parallel. + +--- +Refinement (2026-06-15, /whats-next Si pass) — locked decision + staleness corrections. Original scope authored 2026-06-12. + +DECISION — road_entry_directions source: **USE T-1038''s RoadGraph (full fidelity). LOCKED.** The interim MST-neighbor path is DROPPED. +- T-1038 has shipped a populated `BodyWorldState.road_graph` (RoadGraph) with `RoadNode.city_id` and `RoadEdge.path` (A*-routed polylines in heightmap working-grid coords), available at dispatch time (BodyWorldState is fully populated before the GenCompletion branch in plugin.rs). +- Derive each entry octant from the bearing of the connecting road edge where it crosses the city footprint radius (D-204 footprint_radius), de-duplicated per octant — the D-234 "full fidelity" definition. Order/annotate by edge quality so the AdminFacing consumer (paula-round2.md:428-433: prestige edge faces the highest-quality road entry) can pick correctly. +- COUPLING ACCEPTED: T-1038 is still in_progress; if its RoadGraph field shapes shift, this consumer adjusts. (User accepted this tradeoff over the throwaway interim path.) + +water_bearing: NOT a CityPlacement field — it is encoded inside the founding_orientation enum arms (Coastal{facing_degrees}, RiverAligned{bearing_degrees}); TerrainAnalysis is not stored on BodyWorldState (T-1044 TODO). With the RoadGraph as the primary source, water_bearing is secondary; if used as a supplementary octant for port/river settlements, extract the bearing from those two arms only — Cardinal/Free/TerrainFollowing variants get no water octant. + +STALENESS / verified current locations: road_entry_directions field = generator.rs:1107; stub = city_context_reader.rs:478-479; derive_access_points = skeleton_gen.rs:1008-1036 (BlockJunction fallback :1029-1034); build_skeleton_work_item = plugin.rs:180-213. (Confirmed unchanged by T-1077/T-1078.) + +COORDINATION: T-1039 and T-1043 both edit build_skeleton_work_item (plugin.rs:180-213). Do them as ONE combined branch/PR (single implementer), not two parallel edits to the same function. Thread road_entry_directions next to political_archetype/morphology_zone in the same override block. + +Acceptance (unchanged): a dispatched settlement with ≥1 road connection produces ≥1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements.', 'in_progress', 'medium', NULL, 'server', 'D-234', '2026-06-12 10:23:50', '2026-06-15 16:15:05', NULL, '247a80310a5816287ef9065c25025a4d', 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 ('06FBPQ02KN9147H4Z12KJW7H1M', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Thread political_archetype / arrangement_pattern / morphology_zone through the L3→L4 GenerateSkeleton dispatch', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Thread the remaining Layer-3 character fields through the L3->L4 GenerateSkeleton dispatch. T-1022 wired the dispatch, but build_skeleton_work_item (server/src/atlas/plugin.rs:179-212) overrides only founding_orientation (:192-193); political_archetype stays the Commission stub (city_context_reader.rs:471-474) and morphology_zone stays AlluvialPlain (:487-488) even though: +- CityPlacement already carries the real political_archetype (D-214, derived from TerritorialStatus + economic_role at attractor_matching.rs:595-602) and arrangement_pattern (D-215, city_character at :282-293) — fields :49-64; +- the covering region''s real morphology_zone (D-239 §6 frozen vocab, T-1027, region_profile.rs:180/:499) is available from snapshot.layer_region, populated in the same cascade run (cascade.rs:238-240) — map placement.position to its covering region cell. + +Consequences of the stubs: skeleton_gen''s morphology gating (D-234a Topology selection) always takes the AlluvialPlain Prim-MST mesh path — fjord/delta cities get plains street networks; block_irregularity/layout-mode always sees Commission (skeleton_gen.rs:68-77). D-234''s implementation note explicitly lists this threading as the pending dependency of the (done, tested, dormant) morphology-gated street rules. + +Also decide arrangement_pattern''s path: CityGenerationContext has NO arrangement_pattern field (generator.rs:1078 has political_archetype only; ArrangementPattern :442 is defined but unconsumed at L4). Either (a) add the field and have skeleton_gen consume the D-215 explicit pattern, or (b) record that L4 derives layout from archetype alone and the placement''s pattern is Atlas/UI-only. Option (a) matches the workshop intent (ozzie-round1.md:112-118 backs explicit arrangement). + +Miri''s queryability requirement (tile-derivation-contract/tyre-round1.md:308-311) — morphology_zone readable at Atlas-generation time — is satisfied by reading from BodyWorldState regions, no recompute. + +Acceptance: dispatch a body with a FjordWall-region coastal placement in a CorpTerritory province -> the generated quarter uses Ribbon topology and CompanyTown-class layout, not mesh+Commission. Parent: T-750. Refs: D-214, D-215, D-228, D-234, D-239 §6; T-1022 (predecessor), T-983, T-1014 (adjacent, different fields). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY. Supersedes stale region-scale refs (authored 2026-06-12, pre-T-1077 RegionProfile->DistrictProfile rename + T-1078). Current tree: + +MORPHOLOGY CARRIER MOVED: morphology_zone now lives on DistrictProfile.morphology_zone (district_profile.rs:193, 2km carrier), NOT region_profile.rs. Snapshot field is layer_district (not layer_region — gone); map is BodyWorldState.districts (body_world_state.rs:102), populated by cascade (cascade.rs:270, converted :110-126). The ticket''s snapshot.layer_region / region_profile.rs:180/:499 / "covering region" refs are stale. + +PLACEMENT->DISTRICT MAPPING: CityPlacement.position is heightmap pixel (row,col) (u16,u16) (attractor_matching.rs:51). DistrictPos = position / CELLS_PER_DISTRICT (8). No helper yet — add inline. Inverse at district_profile.rs:1023-1026. + +STUB SITES: political_archetype stub city_context_reader.rs:474 (Commission); morphology_zone stub :488 (AlluvialPlain); both in context_from_read_set() (:462). build_skeleton_work_item (plugin.rs:180-213) threads only founding_orientation (:194) and does NOT receive the districts map — pass &state.districts in (available in the GenCompletion::BodyAnalyzed arm, plugin.rs:101) or resolve before the call; fallback AlluvialPlain on miss. + +THREE OVERRIDES: +- political_archetype: one-liner — context.political_archetype = placement.political_archetype (real D-214 value, attractor_matching.rs:58/:602), parallel to founding_orientation at plugin.rs:194. +- morphology_zone: state.districts.get(&(pos.0/8, pos.1/8)).map(|d| d.morphology_zone).unwrap_or(AlluvialPlain). +- arrangement_pattern: OPTION (a) (D-215-backed) — CityGenerationContext has NO arrangement_pattern field (generator.rs:1098-1124); add it, thread placement.arrangement_pattern (attractor_matching.rs:60/:629), have skeleton_gen consume it (doesn''t yet). + +CONSUMERS LIVE-BUT-DORMANT: street_topology() (skeleton_gen.rs:1048) gates on MorphologyZone (Ribbon: Fjord/CliffCoast/MountainPass/Alpine/ValleyFloor; HubSpoke: Delta/Estuarine/OpenOcean/Lake/DuneStrand/TidalFlat; Mesh: AlluvialPlain/MeanderReach/RiverBank/BraidedPlain/Wetland/Volcanic); derive_layout_mode (:235)+block_irregularity (:68) gate on political_archetype. T-989/T-990 done — always get AlluvialPlain+Commission until this lands. + +ACCEPTANCE: FjordWall-region coastal placement in CorpTerritory -> Ribbon topology + CompanyTown/CampusGrid layout, not mesh+Commission. + +Governing: D-234 (primary), D-214, D-215 (-> option a), D-239 sec 6 (frozen vocab, now district_profile.rs), D-243. + +OVERLAP w/ T-1043: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs), skeleton_gen consumers. Implement together in ONE pass (one agent), not parallel. + +--- +Refinement (2026-06-15, /whats-next Si pass) — staleness corrections + locked decision. Original scope authored 2026-06-12, BEFORE T-1077 (RegionProfile→DistrictProfile rename) and T-1078 (region climate tier, merged today). Corrected refs below. + +STALENESS CORRECTIONS (the description''s code refs are pre-T-1077/T-1078): +- morphology_zone lives on **DistrictProfile** (server/src/atlas/district_profile.rs:193), NOT on RegionProfile (region_profile.rs:155 is a pure climate carrier — no morphology_zone). The ticket''s "region_profile.rs:180/:499" and "snapshot.layer_region" are wrong. +- Access path at dispatch: `state.districts.get(&district_pos)` on BodyWorldState.districts (server/src/atlas/body_world_state.rs:102). The cascade snapshot field is `layer_district` (cascade.rs:79), not `layer_region`. +- Position→DistrictPos: CityPlacement.position is (u16,u16) heightmap-pixel coords (attractor_matching.rs:51). Convert via the cells-per-district factor (currently a LOCAL `CELLS_PER_REGION = 8` in cascade.rs:260 — pre-D-243 naming). Do NOT hardcode 8 in plugin.rs: export a helper/const (prefer reusing server/src/atlas/scale.rs addressing) and call it from the dispatch. +- Verified current locations: build_skeleton_work_item = plugin.rs:180-213 (founding_orientation override is the threading site); political_archetype stub ~city_context_reader.rs:471-474; political_archetype derivation = attractor_matching.rs:602; CityPlacement fields ~attractor_matching.rs:49-64. + +DECISION — arrangement_pattern path: **OPTION (b), re-derive. LOCKED.** +- skeleton_gen re-derives via the single shared pure fn `attractor_matching::arrangement_pattern(&political_archetype, &economic_role)` (attractor_matching.rs:629). Both inputs are available at L4: economic_role is ALREADY threaded (plugin.rs:189 reads it, :209 passes it); political_archetype is threaded by THIS ticket. NO new CityGenerationContext field; NO data-contract amendment. +- Rationale: arrangement_pattern is a pure TOTAL function (6-way archetype match + the `economic_role=="transit_hub"` override) — no seed/RNG/DB/clock/float. Re-derivation is provably identical to L3; cheaper to compute than to store; aligns with the "DB is only a cache, everything re-derivable from seed" principle (no stored derived spatial verdict). +- **HARDENING REQUIRED (the drift tripwire):** add a parity test asserting, for representative (archetype, economic_role) pairs, `arrangement_pattern(&placement.political_archetype, &placement.economic_role) == placement.arrangement_pattern` (CityPlacement already stores the L3-computed value at attractor_matching.rs:60). This locks L3≡L4 and trips the instant anyone changes arrangement_pattern''s input set. Optionally debug_assert the same equality in the dispatch. Never inline a second copy of the derivation — both sites call the one function. + +COORDINATION: T-1039 and T-1043 both edit build_skeleton_work_item (plugin.rs:180-213). Do them as ONE combined branch/PR (single implementer), not two parallel edits to the same function. + +Acceptance (unchanged): dispatch a FjordWall-region coastal placement in a CorpTerritory province → the generated quarter uses Ribbon topology and CompanyTown-class layout, not the AlluvialPlain mesh + Commission stub path.', 'in_progress', 'high', NULL, 'server', 'D-234', '2026-06-12 10:23:49', '2026-06-15 16:15:05', NULL, '79bd02124feabe8047e351b506ef3443', 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 ('06FB0TNSRZ8BFVNWDH17CMH1YM', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Per-layer Atlas map viewer (generation progress viewer)', 'Render each cascade layer as a MAP in the implant Atlas (D-191, the Phase 3 viewer — D-166 amendment): terrain → water → biome → settlements → political zones → quarter/block skeleton → tiles. This is the progress-viewer deliverable; NO in-world rendering. Grows as each layer lands. + +--- +Refinement (2026-05-23, /whats-next): +- DATA TRANSPORT (resolves Q-098): NO bake — baking derived layer data makes mods second-class. A server-side FILE STREAM PROXY: resolves a body source files across base + mod paths, runs the deterministic cascade on demand (run_cascade, LRU-cached per D-203, ~45ms), and streams serialized layer data to the Godot client over IPC. First-party and mod bodies use the identical path; no pre-baked derived artifacts. Protocol/stream spec designed with Tyre and recorded in a D-record resolving Q-098. +- UI: layer selector in the existing regional Atlas view; layers are ADDITIVE overlays (toggle heightmap + rivers + attractors + sub-biome together and composite), not one-at-a-time — matching the existing overlay-bar model (D-191). +- SCOPE NOW: only Layer 0 (heightmap) + Layer 1 (rivers/basins/attractors/sub-biome) exist; the viewer grows as layers land (D-166). Acceptance: Layer 0 + Layer 1 render as additive, toggleable overlays for a body, fed by the proxy. + +--- +Design (D-226, Araminta, 2026-05-24) — human-visual layer of the inspection harness. Extend OVERLAY_DEFS / AtlasOverlayBar with a generation overlay group (second row) + a left-side legend panel; do NOT build a separate viewer. Encoding: relief base; rivers as blue polylines (mouths = double-circle markers, confluences where in-degree >= 3); basin fills very translucent + thin boundary polylines; attractors as markers where SHAPE encodes the 7 AttractorTypes (circle/half-circle/diamond/down-tri/up-tri/hollow-circle/square) and COLOR encodes sub-biome (shape carries identity in headless/monochrome; color is additive); size scales with strength, skip strength < 0.15. Stable button ids gen_l0_heightmap / gen_l1_rivers / gen_l1_basins / gen_l1_attractors; start pending/locked, unlock+activate via notify_gen_layer_ready(id) as data arrives (grows as layers land, D-166). Consumes the live proxy (#969); the agent channel (#971) drives this same UI. + +Remaining to close (Si sweep 2026-06-07): add the gen_l0_heightmap overlay to OVERLAY_DEFS + render path in atlas_viewer.gd / atlas_marker_overlay.gd — required by the D-226/T-960 acceptance criteria but currently absent. Layer-1 overlays (rivers/basins/attractors) + proxy wire + pending indicator are already implemented; this Layer-0 overlay is the sole remaining piece.', 'backlog', 'high', NULL, 'client', 'D-191', '2026-05-22 17:42:34', '2026-06-16 09:36:03', NULL, '98ada0147662421feb0784787b4073fb', 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 ('06FBPQ02F02JDDBSFMGPX0XASR', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Layer 2 — inter-settlement road/rail graph (MST + A* over terrain cost, MaintenanceAuthority, named-route identity)', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Generate the inter-settlement road/rail graph at Layer 2 — the workshopped-but-never-ticketed link between settlement placement (T-955/T-956, done) and everything that consumes transport topology. No road code exists today (zero RoadGraph/road_segment hits in server/src; TerritorialStatus shipped without its road-coverage inputs). + +Scope (algorithm fully decided, docs/workshops/planet-down-cascade/tyre-round1.md:274-299): +1. MST force-connect all CityPlacements (Euclidean), then secondary direct links for city pairs within 30-60% of body scale whose MST path exceeds 1.5x Euclidean. +2. Route each edge with A* over the L1 terrain-cost field (terrain_modification_cost, D-210, server/src/atlas/subbiome.rs; cost spec: water impassable, roughness>0.7 = 10.0, river corridor = 0.5, flat = 1.0 — rivers as preferred valley routes). Budget ~30ms/body (round-1-notes.md:348). +3. Assign MaintenanceAuthority per edge from endpoint relationships (5-value enum Administrative/Corporate/Communal/Trade/Abandoned — generation-cascade/workshop-outcomes.md:177, resolution :257; player-readable in road condition per ozzie-round1.md:86-92). +4. Place waypoint sub-settlement nodes at midpoints of edges > ~12 regional cells (tyre-round1.md:267-272). +5. Named-route identity assignment: post-generation join of generated edges to systems.db named highway routes via optional named_route_id FK (paula-round1.md:107,:140; round-1-notes.md:322 L2-Q3 resolution under Amendment 3). +6. RailHeadFacing second pass: settlements at high-connectivity road/rail junctions get FoundingOrientation::RailHeadFacing, overriding AdminFacing-from-Synthetic (paula-round3.md:218-236) — wire into attractor_matching.rs. + +Storage: RoadGraph{nodes,edges} on BodyWorldState (round-1-notes.md:257-267), session-cached, never serialized — runtime Rust, not systems.db (the atlas_road_nodes/atlas_road_edges schema shape carries forward per tyre-round1.md:42 but lives in session memory). + +Downstream consumers (do NOT scope in, but design the output for them): road_entry_directions per city (see road-entry-octants ticket; generation-cascade/round-3-notes.md:104), TerritorialStatus quantitative road-coverage thresholds (round-1-notes.md:329-338), per-economic_role hinterland fill (round-1-notes.md:324-327, BRIEF.md:279), prosperity/atlas overlays. + +Cascade-first fit: this is direction step (2) — the econ layer drawn onto the world between settlements. Parent: T-750. Suggested deps: blocks the road-entry-octants ticket; references D-205 (provinces), D-210, D-211, D-212, D-213. + +--- +Implementation (2026-06-14): +SHIPPED — deterministic road-graph CORE in server/src/atlas/road_graph.rs (CascadeLayer::RoadGraph, stored on BodyWorldState.road_graph, never serialized): + 1. MST force-connect (strict Kruskal over dist^2) + secondary links (30-60% body-scale band, >1.5x MST detour). + 2. A* over the L1 terrain-cost field (ocean/lake impassable, river 1/2 cost, slope>=35deg 10x, else D-210 terrain_modification_cost) on a coarse 64-wide routing grid; equirectangular column wrap. + 3. MaintenanceAuthority per edge (new generator.rs enum: Administrative/Corporate/Communal/Trade/Abandoned) from endpoint political archetypes + haul length. + 4. Waypoint nodes at midpoints of edges > 12 routing cells. + 5. Named-route identity join (named_route_id, designed-for; pool empty post-D-223 -> no-op). + + junction detection (RoadGraph::high_connectivity_junctions, degree>=3). +Wired into the cascade (gen_queue requests CascadeLayer::RoadGraph) + BodyWorldState. 10 road_graph unit tests + a cascade e2e test; full lib suite green. + +DEFERRED (-> T-1076, gated on the D-242 settlement model T-1074/T-1075): + - step 6 RailHeadFacing second pass — the adopted FoundingOrientation enum (D-213) has no RailHeadFacing; needs a D-213 amendment + Layer-4 skeleton_gen handling. Junction data is already exposed for it. + - hub-selection refinement (scaled-cap hubs, co-location collapse, hybrid minor-settlement attach) — needs baked population/specialization (D-242). +Decision D-242 filed (corp-HQ settlement model) — resolves why corp HQs were polluting the city pool as duplicate settlements.', 'done', 'high', NULL, 'server', 'D-211', '2026-06-12 10:23:49', '2026-06-16 09:38:09', NULL, '765c830e60f3b5cb392f4699a996e348', 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 ('06FBPQ02KN9147H4Z12KJW7H1M', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Thread political_archetype / arrangement_pattern / morphology_zone through the L3→L4 GenerateSkeleton dispatch', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Thread the remaining Layer-3 character fields through the L3->L4 GenerateSkeleton dispatch. T-1022 wired the dispatch, but build_skeleton_work_item (server/src/atlas/plugin.rs:179-212) overrides only founding_orientation (:192-193); political_archetype stays the Commission stub (city_context_reader.rs:471-474) and morphology_zone stays AlluvialPlain (:487-488) even though: +- CityPlacement already carries the real political_archetype (D-214, derived from TerritorialStatus + economic_role at attractor_matching.rs:595-602) and arrangement_pattern (D-215, city_character at :282-293) — fields :49-64; +- the covering region''s real morphology_zone (D-239 §6 frozen vocab, T-1027, region_profile.rs:180/:499) is available from snapshot.layer_region, populated in the same cascade run (cascade.rs:238-240) — map placement.position to its covering region cell. + +Consequences of the stubs: skeleton_gen''s morphology gating (D-234a Topology selection) always takes the AlluvialPlain Prim-MST mesh path — fjord/delta cities get plains street networks; block_irregularity/layout-mode always sees Commission (skeleton_gen.rs:68-77). D-234''s implementation note explicitly lists this threading as the pending dependency of the (done, tested, dormant) morphology-gated street rules. + +Also decide arrangement_pattern''s path: CityGenerationContext has NO arrangement_pattern field (generator.rs:1078 has political_archetype only; ArrangementPattern :442 is defined but unconsumed at L4). Either (a) add the field and have skeleton_gen consume the D-215 explicit pattern, or (b) record that L4 derives layout from archetype alone and the placement''s pattern is Atlas/UI-only. Option (a) matches the workshop intent (ozzie-round1.md:112-118 backs explicit arrangement). + +Miri''s queryability requirement (tile-derivation-contract/tyre-round1.md:308-311) — morphology_zone readable at Atlas-generation time — is satisfied by reading from BodyWorldState regions, no recompute. + +Acceptance: dispatch a body with a FjordWall-region coastal placement in a CorpTerritory province -> the generated quarter uses Ribbon topology and CompanyTown-class layout, not mesh+Commission. Parent: T-750. Refs: D-214, D-215, D-228, D-234, D-239 §6; T-1022 (predecessor), T-983, T-1014 (adjacent, different fields). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY. Supersedes stale region-scale refs (authored 2026-06-12, pre-T-1077 RegionProfile->DistrictProfile rename + T-1078). Current tree: + +MORPHOLOGY CARRIER MOVED: morphology_zone now lives on DistrictProfile.morphology_zone (district_profile.rs:193, 2km carrier), NOT region_profile.rs. Snapshot field is layer_district (not layer_region — gone); map is BodyWorldState.districts (body_world_state.rs:102), populated by cascade (cascade.rs:270, converted :110-126). The ticket''s snapshot.layer_region / region_profile.rs:180/:499 / "covering region" refs are stale. + +PLACEMENT->DISTRICT MAPPING: CityPlacement.position is heightmap pixel (row,col) (u16,u16) (attractor_matching.rs:51). DistrictPos = position / CELLS_PER_DISTRICT (8). No helper yet — add inline. Inverse at district_profile.rs:1023-1026. + +STUB SITES: political_archetype stub city_context_reader.rs:474 (Commission); morphology_zone stub :488 (AlluvialPlain); both in context_from_read_set() (:462). build_skeleton_work_item (plugin.rs:180-213) threads only founding_orientation (:194) and does NOT receive the districts map — pass &state.districts in (available in the GenCompletion::BodyAnalyzed arm, plugin.rs:101) or resolve before the call; fallback AlluvialPlain on miss. + +THREE OVERRIDES: +- political_archetype: one-liner — context.political_archetype = placement.political_archetype (real D-214 value, attractor_matching.rs:58/:602), parallel to founding_orientation at plugin.rs:194. +- morphology_zone: state.districts.get(&(pos.0/8, pos.1/8)).map(|d| d.morphology_zone).unwrap_or(AlluvialPlain). +- arrangement_pattern: OPTION (a) (D-215-backed) — CityGenerationContext has NO arrangement_pattern field (generator.rs:1098-1124); add it, thread placement.arrangement_pattern (attractor_matching.rs:60/:629), have skeleton_gen consume it (doesn''t yet). + +CONSUMERS LIVE-BUT-DORMANT: street_topology() (skeleton_gen.rs:1048) gates on MorphologyZone (Ribbon: Fjord/CliffCoast/MountainPass/Alpine/ValleyFloor; HubSpoke: Delta/Estuarine/OpenOcean/Lake/DuneStrand/TidalFlat; Mesh: AlluvialPlain/MeanderReach/RiverBank/BraidedPlain/Wetland/Volcanic); derive_layout_mode (:235)+block_irregularity (:68) gate on political_archetype. T-989/T-990 done — always get AlluvialPlain+Commission until this lands. + +ACCEPTANCE: FjordWall-region coastal placement in CorpTerritory -> Ribbon topology + CompanyTown/CampusGrid layout, not mesh+Commission. + +Governing: D-234 (primary), D-214, D-215 (-> option a), D-239 sec 6 (frozen vocab, now district_profile.rs), D-243. + +OVERLAP w/ T-1043: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs), skeleton_gen consumers. Implement together in ONE pass (one agent), not parallel. + +--- +Refinement (2026-06-15, /whats-next Si pass) — staleness corrections + locked decision. Original scope authored 2026-06-12, BEFORE T-1077 (RegionProfile→DistrictProfile rename) and T-1078 (region climate tier, merged today). Corrected refs below. + +STALENESS CORRECTIONS (the description''s code refs are pre-T-1077/T-1078): +- morphology_zone lives on **DistrictProfile** (server/src/atlas/district_profile.rs:193), NOT on RegionProfile (region_profile.rs:155 is a pure climate carrier — no morphology_zone). The ticket''s "region_profile.rs:180/:499" and "snapshot.layer_region" are wrong. +- Access path at dispatch: `state.districts.get(&district_pos)` on BodyWorldState.districts (server/src/atlas/body_world_state.rs:102). The cascade snapshot field is `layer_district` (cascade.rs:79), not `layer_region`. +- Position→DistrictPos: CityPlacement.position is (u16,u16) heightmap-pixel coords (attractor_matching.rs:51). Convert via the cells-per-district factor (currently a LOCAL `CELLS_PER_REGION = 8` in cascade.rs:260 — pre-D-243 naming). Do NOT hardcode 8 in plugin.rs: export a helper/const (prefer reusing server/src/atlas/scale.rs addressing) and call it from the dispatch. +- Verified current locations: build_skeleton_work_item = plugin.rs:180-213 (founding_orientation override is the threading site); political_archetype stub ~city_context_reader.rs:471-474; political_archetype derivation = attractor_matching.rs:602; CityPlacement fields ~attractor_matching.rs:49-64. + +DECISION — arrangement_pattern path: **OPTION (b), re-derive. LOCKED.** +- skeleton_gen re-derives via the single shared pure fn `attractor_matching::arrangement_pattern(&political_archetype, &economic_role)` (attractor_matching.rs:629). Both inputs are available at L4: economic_role is ALREADY threaded (plugin.rs:189 reads it, :209 passes it); political_archetype is threaded by THIS ticket. NO new CityGenerationContext field; NO data-contract amendment. +- Rationale: arrangement_pattern is a pure TOTAL function (6-way archetype match + the `economic_role=="transit_hub"` override) — no seed/RNG/DB/clock/float. Re-derivation is provably identical to L3; cheaper to compute than to store; aligns with the "DB is only a cache, everything re-derivable from seed" principle (no stored derived spatial verdict). +- **HARDENING REQUIRED (the drift tripwire):** add a parity test asserting, for representative (archetype, economic_role) pairs, `arrangement_pattern(&placement.political_archetype, &placement.economic_role) == placement.arrangement_pattern` (CityPlacement already stores the L3-computed value at attractor_matching.rs:60). This locks L3≡L4 and trips the instant anyone changes arrangement_pattern''s input set. Optionally debug_assert the same equality in the dispatch. Never inline a second copy of the derivation — both sites call the one function. + +COORDINATION: T-1039 and T-1043 both edit build_skeleton_work_item (plugin.rs:180-213). Do them as ONE combined branch/PR (single implementer), not two parallel edits to the same function. + +Acceptance (unchanged): dispatch a FjordWall-region coastal placement in a CorpTerritory province → the generated quarter uses Ribbon topology and CompanyTown-class layout, not the AlluvialPlain mesh + Commission stub path.', 'in_progress', 'high', NULL, 'server', 'D-234', '2026-06-12 10:23:49', '2026-06-16 09:42:26', NULL, '77782abe064317b1636998c676c42433', 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 ('06FBPQ034DKN4ENSGX5HTYTBJG', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Derive road_entry_directions at L3 and thread through the dispatch (interim: MST-neighbor bearings + water_bearing)', 'Workshopped-but-unticketed cascade work captured 2026-06-12 (cascade-refocus grounding pass). Full grounded scope follows. + +Derive road_entry_directions at Layer 3 and thread them through the GenerateSkeleton dispatch. CityGenerationContext.road_entry_directions (server/src/simulation/generator.rs:1083-1084, compass octants 0=N..7=NW) is stubbed empty (city_context_reader.rs:478-479) and never overridden by the T-1022 dispatch, so derive_access_points (skeleton_gen.rs:1008-1036) never emits QuarterEdge access nodes — every quarter falls back to reservation gates or a single central BlockJunction (:1029-1034), and the D-234 arterial graph has no outward connection to the world. + +Workshop contract: the field is a per-city output of the inter-settlement road graph (generation-cascade/round-3-notes.md:104 ''Layer C: Road graph (road_entry_directions per city)''; burnelli-sheldon-round4.md:308). Full-fidelity derivation therefore lands with the inter-settlement-transport ticket: octant = bearing of each connecting road edge where it crosses the city footprint radius (D-204 footprint), de-duplicated per octant, retaining per-entry quality for the consumer rule below. + +Interim derivation (no road graph required, deterministic, replace-don''t-rework): derive octants at L3 from (a) bearings to the settlement''s MST-neighbor placements (positions already in BodyWorldState.placements) and (b) the founding-orientation water bearing (TerrainAnalysis::water_bearing, 8-octant integer — D-234 implementation note) for port/river settlements. Thread through build_skeleton_work_item (plugin.rs:179-212) next to the founding_orientation override. + +Consumer rule to honour (already decided): the AdminFacing arrangement pattern picks its prestige edge as the edge facing the highest-quality road entry (paula-round2.md:428-433, paula-round3.md:320-322) — so the octant list should be ordered or annotated by quality once the graph exists. Player-experience intent: the approach should present the city''s FoundingOrientation face (ozzie-round1.md:96-100). + +Acceptance: a dispatched settlement with >=1 neighbor produces >=1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements. Parent: T-750. Blocked-by (soft): inter-settlement-transport ticket for full fidelity. Refs: D-234, D-213, D-204; T-990 (consumer, done), T-1022 (dispatch, done). + +--- +Refinement (2026-06-15, /whats-next Si pass) — READY, with a SCOPE UPDATE. The "interim: MST-neighbor bearings" framing in the title is now obsolete. + +T-1038 ROAD GRAPH HAS SHIPPED — go full-fidelity, skip the interim. The inter-settlement road graph is live in the tree: road_graph.rs exists; the cascade runs CascadeLayer::RoadGraph (gen_queue.rs:383); BodyWorldState.road_graph (body_world_state.rs:91) is populated BEFORE GenCompletion::BodyAnalyzed fires, so at L3->L4 dispatch (plugin.rs:99-133) state.road_graph is already live. Therefore do NOT implement the MST-neighbor approximation — derive octants directly from state.road_graph.edges for the city''s node (match by RoadNode.city_id), bearing = city.position -> neighbor.position, octant = bearing_deg / 45. (NB: T-1038''s ticket still shows in_progress though its code shipped — consume the live road_graph regardless; flag if road_graph turns out not fully populated for some bodies.) + +CURRENT REFS: CityGenerationContext.road_entry_directions = generator.rs:1107 (Vec, octants 0=N..7=NW); empty stub city_context_reader.rs:479; derive_access_points skeleton_gen.rs:1008-1035; BlockJunction fallback :1029-1034; build_skeleton_work_item plugin.rs:180-213 (insert next to the founding_orientation override at :194). + +ISOLATED SETTLEMENTS (no road_graph edges): take the water bearing from placement.founding_orientation — Coastal{facing_degrees} / RiverAligned{bearing_degrees} already encode it (octant = degrees/45). Note TerrainAnalysis::water_bearing (features.rs:133, u16 deg, 360=none) is NOT stored on CityPlacement/BodyWorldState, so reading founding_orientation is the clean access path (no attractor position-match needed). + +CONSUMER: T-990 (done) emits AccessKind::QuarterEdge per octant in derive_access_points. The AdminFacing prestige-edge rule (pick the edge facing the highest-quality road entry) is forward-looking — no AdminFacing code exists yet — so order/annotate octants by quality only once that consumer lands. + +ACCEPTANCE: a dispatched settlement whose city_id has >=1 road_graph edge emits >=1 AccessKind::QuarterEdge on the matching octant; BlockJunction fallback fires only for genuinely isolated settlements. + +Governing: D-234, D-213, D-204 (footprint radius). T-990 (consumer, done), T-1022 (dispatch, done), T-1038 (road graph, shipped). + +OVERLAP w/ T-1039: both edit build_skeleton_work_item (plugin.rs), city_context_reader.rs, CityGenerationContext (generator.rs). Implement both in ONE pass (one agent), not parallel. + +--- +Refinement (2026-06-15, /whats-next Si pass) — locked decision + staleness corrections. Original scope authored 2026-06-12. + +DECISION — road_entry_directions source: **USE T-1038''s RoadGraph (full fidelity). LOCKED.** The interim MST-neighbor path is DROPPED. +- T-1038 has shipped a populated `BodyWorldState.road_graph` (RoadGraph) with `RoadNode.city_id` and `RoadEdge.path` (A*-routed polylines in heightmap working-grid coords), available at dispatch time (BodyWorldState is fully populated before the GenCompletion branch in plugin.rs). +- Derive each entry octant from the bearing of the connecting road edge where it crosses the city footprint radius (D-204 footprint_radius), de-duplicated per octant — the D-234 "full fidelity" definition. Order/annotate by edge quality so the AdminFacing consumer (paula-round2.md:428-433: prestige edge faces the highest-quality road entry) can pick correctly. +- COUPLING ACCEPTED: T-1038 is still in_progress; if its RoadGraph field shapes shift, this consumer adjusts. (User accepted this tradeoff over the throwaway interim path.) + +water_bearing: NOT a CityPlacement field — it is encoded inside the founding_orientation enum arms (Coastal{facing_degrees}, RiverAligned{bearing_degrees}); TerrainAnalysis is not stored on BodyWorldState (T-1044 TODO). With the RoadGraph as the primary source, water_bearing is secondary; if used as a supplementary octant for port/river settlements, extract the bearing from those two arms only — Cardinal/Free/TerrainFollowing variants get no water octant. + +STALENESS / verified current locations: road_entry_directions field = generator.rs:1107; stub = city_context_reader.rs:478-479; derive_access_points = skeleton_gen.rs:1008-1036 (BlockJunction fallback :1029-1034); build_skeleton_work_item = plugin.rs:180-213. (Confirmed unchanged by T-1077/T-1078.) + +COORDINATION: T-1039 and T-1043 both edit build_skeleton_work_item (plugin.rs:180-213). Do them as ONE combined branch/PR (single implementer), not two parallel edits to the same function. Thread road_entry_directions next to political_archetype/morphology_zone in the same override block. + +Acceptance (unchanged): a dispatched settlement with ≥1 road connection produces ≥1 AccessKind::QuarterEdge node on the correct octant; the BlockJunction fallback fires only for genuinely isolated settlements.', 'in_progress', 'medium', NULL, 'server', 'D-234', '2026-06-12 10:23:50', '2026-06-16 09:42:26', NULL, 'dbe27cb938a21c2cd7754c4589e8032c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 13e13d5df..581352c03 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -29,6 +29,7 @@ use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError}; use crate::atlas::layer1::{self, Layer1Output}; use crate::atlas::road_graph::{self, RoadGraph}; +use crate::atlas::scale; use crate::seed::SeedChain; use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus}; @@ -255,16 +256,16 @@ pub fn run_cascade_from_heightmap( // DistrictProfile layer — pure derivation from body params + terrain. if let Some(params) = body_params { - // ~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; + // Canonical cells-per-district for the working grid (T-1039): + // shared via scale::HEIGHTMAP_CELLS_PER_DISTRICT so plugin.rs converts + // CityPlacement pixel coords with the same constant. // 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, + scale::HEIGHTMAP_CELLS_PER_DISTRICT, &snapshot.body_id, ); snapshot.layer_district = Some(LayerDistrictOutput { districts }); diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index b929df31d..4e86df600 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -11,17 +11,23 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +use std::collections::BTreeMap; + use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::body_params_reader::BodyParamsReaderResource; use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY}; use crate::atlas::city_context_reader::{ context_from_read_set, CityContextReaderResource, CityEconomicReadSet, }; +use crate::atlas::district_profile::{DistrictPos, DistrictProfile}; use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus}; +use crate::atlas::road_graph::{RoadGraph, RoadNode}; +use crate::atlas::scale; use crate::atlas::source_resolver::BodySourceResolverResource; use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer}; use crate::seed::{SeedChain, SeedDomain}; +use crate::simulation::generator::{MaintenanceAuthority, MorphologyZone}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use crate::tick_phases::TickPhase; @@ -124,7 +130,14 @@ fn drain_generation_completions( } }; queue.submit( - build_skeleton_work_item(&body_id, world_seed, placement, read_set), + build_skeleton_work_item( + &body_id, + world_seed, + placement, + read_set, + &state.districts, + &state.road_graph, + ), GenPriority::Low, ); } @@ -168,20 +181,41 @@ fn drain_generation_completions( } /// Build the Layer-4 `GenerateSkeleton` work item for one settlement placement -/// (T-1022, D-234). Builds the D-199 context from the read-set (mirroring -/// [`build_context`](crate::atlas::city_context_reader::CityContextReader::build_context), -/// which is just `read_set` + `context_from_read_set`), then overrides the -/// `Cardinal` stub with the attractor-matched -/// [`FoundingOrientation`](crate::simulation::generator::FoundingOrientation) carried -/// on the placement (D-213), and derives the canonical, namespace-isolated -/// `quarter_id` from `(world_seed, body, city)` (D-194/D-230) — replacing the -/// `city_id * 10` placeholder. Pure (no queue/cache access) so it unit-tests -/// without a `systems.db`. +/// (T-1022, T-1039, T-1043, D-234). Builds the D-199 context from the read-set +/// (mirroring +/// [`build_context`](crate::atlas::city_context_reader::CityContextReader::build_context)), +/// then overrides: +/// +/// - `founding_orientation` — from the attractor-matched placement (D-213). +/// - `political_archetype` — from the attractor-matched placement (D-214, T-1039), +/// replacing the `Commission` stub in `context_from_read_set`. +/// - `morphology_zone` — from the `DistrictProfile` covering this placement's +/// heightmap-grid pixel, via `state.districts` (D-239 §6, T-1039). Falls back +/// to `AlluvialPlain` when the district grid is empty (unit tests, early cascade). +/// - `road_entry_directions` — derived from `state.road_graph`: for each road edge +/// incident on this city, the compass octant (0=N…7=NW) of the bearing from the +/// city toward the far endpoint, de-duplicated per octant and ordered by descending +/// road quality so the highest-prestige entry is first (T-1043, D-215 AdminFacing +/// rule). Empty when `road_graph` has no edges for this city. +/// +/// `arrangement_pattern` is **re-derived** at L4 from `(political_archetype, +/// economic_role)` via the same pure function used at L3 (T-1039 OPTION (b) — +/// locked, no `CityGenerationContext` field added). Re-derivation is provably +/// identical to the L3 value (pure total function, no RNG). The re-derivation call +/// itself lives in the consumer (`generate_quarter_skeleton`), not in this function +/// — `build_skeleton_work_item` only threads the inputs it needs. +/// +/// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body, +/// city)` — not the `city_id * 10` placeholder. +/// +/// Pure (no queue/cache access) so it unit-tests without a `systems.db`. fn build_skeleton_work_item( body_id: &str, world_seed: u64, placement: &CityPlacement, read_set: CityEconomicReadSet, + districts: &BTreeMap, + road_graph: &RoadGraph, ) -> GenWorkItem { // The D-199 raw fields ride alongside the context (generate_quarter_skeleton // takes them separately), so capture them before context_from_read_set consumes @@ -191,11 +225,51 @@ fn build_skeleton_work_item( let founding_age_years = read_set.founding_age_years; let mut context = context_from_read_set(placement.city_id, read_set); + + // ── T-1022 / D-213: founding orientation from attractor-matched placement ── context.founding_orientation = placement.founding_orientation.clone(); - // Canonical quarter id (D-194/D-230): deterministic + namespace-isolated per - // (world_seed, body, city). SeedChain is Copy, so `chain.seed()` leaves `chain` - // usable for the work item's own field. + // ── T-1039 / D-214: political_archetype from placement (real value) ──────── + // Replaces the `Commission` stub that `context_from_read_set` leaves. + context.political_archetype = placement.political_archetype; + + // ── T-1039 / D-239 §6: morphology_zone from covering DistrictProfile ─────── + // Convert the placement's working-grid pixel position to a DistrictPos using + // the canonical scale constant — no hardcoded magic numbers here. + let district_pos = scale::heightmap_pixel_to_district(placement.position); + context.morphology_zone = match districts.get(&district_pos) { + Some(d) => d.morphology_zone, + None => { + // An empty grid is the expected params-missing / early-cascade case + // (debug); a miss against a *populated* grid means the pixel→DistrictPos + // conversion is off — a real bug worth a warning, not a silent wrong + // topology. + if districts.is_empty() { + tracing::debug!( + city_id = placement.city_id, + ?district_pos, + "morphology_zone fallback to AlluvialPlain: district grid not built for this body" + ); + } else { + tracing::warn!( + city_id = placement.city_id, + ?district_pos, + "morphology_zone fallback to AlluvialPlain: pos not in populated district grid — check pixel→district convention" + ); + } + MorphologyZone::AlluvialPlain + } + }; + + // ── T-1043: road_entry_directions from road_graph ─────────────────────────── + // Find this city's settlement node index in the road graph (O(n) scan on a + // small slice — settlement counts are single-digit to low hundreds per body). + context.road_entry_directions = + road_entry_directions_for_city(placement.city_id, placement.position, road_graph); + + // ── Canonical quarter id (D-194/D-230) ──────────────────────────────────── + // Deterministic + namespace-isolated per (world_seed, body, city). + // SeedChain is Copy, so `chain.seed()` leaves `chain` usable for the work item. let chain = SeedChain::for_body(world_seed, body_id) .derive(SeedDomain::Layer4Quarter, placement.city_id); let quarter_id = chain.seed(); @@ -212,12 +286,147 @@ fn build_skeleton_work_item( } } +/// Derive road entry octants (0=N…7=NW) for one city from the road graph. +/// +/// For each road edge incident on `city_id`, computes the compass octant of the +/// bearing from the city toward the far endpoint. Results are: +/// - **De-duplicated** per octant (a BTreeSet accumulates unique octants). +/// - **Ordered by descending road quality** so the highest-prestige entry comes +/// first (the AdminFacing consumer selects the first entry as its prestige gate +/// per D-215). +/// +/// Returns an empty `Vec` when the city has no road connections — the caller's +/// `derive_access_points` will fall back to a central `BlockJunction`. +/// +/// Pure function (no side effects, deterministic output for fixed inputs). +fn road_entry_directions_for_city( + city_id: u64, + city_pos: (u16, u16), + road_graph: &RoadGraph, +) -> Vec { + // Find the settlement node index for this city. + let city_node_idx = road_graph + .nodes + .iter() + .position(|n: &RoadNode| n.city_id == Some(city_id)); + + let Some(city_idx) = city_node_idx else { + return Vec::new(); + }; + + // Collect (octant, quality_rank) for each incident edge; BTreeSet dedups per + // octant keeping the highest-quality rank for each (deterministic iteration). + // BTreeMap for dedup-with-max-quality. + let mut octant_quality: BTreeMap = BTreeMap::new(); + + for edge in &road_graph.edges { + let is_from = edge.from == city_idx; + let is_to = edge.to == city_idx; + if !is_from && !is_to { + continue; + } + + // Far endpoint position — the direction from city toward the far end. + let far_pos = if is_from { + road_graph.nodes[edge.to].position + } else { + road_graph.nodes[edge.from].position + }; + + let octant = bearing_octant(city_pos, far_pos); + let rank = maintenance_authority_rank(edge.maintenance); + octant_quality + .entry(octant) + .and_modify(|r| *r = (*r).max(rank)) + .or_insert(rank); + } + + if octant_quality.is_empty() { + return Vec::new(); + } + + // Collect (rank, octant) into a Vec, sort descending by rank then ascending + // by octant (tie-break) for a fully deterministic, prestige-first order. + let mut ranked: Vec<(u8, u8)> = octant_quality + .iter() + .map(|(&oct, &rank)| (rank, oct)) + .collect(); + ranked.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + ranked.into_iter().map(|(_, oct)| oct).collect() +} + +/// Compass octant (0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW) of the bearing +/// from `from` toward `to` in working-heightmap-grid coordinates `(row, col)`. +/// +/// Working-grid rows increase **southward** (row 0 = top = north), so: +/// - Δrow < 0 → northward, Δrow > 0 → southward +/// - Δcol < 0 → westward, Δcol > 0 → eastward +/// +/// Integer arithmetic only (D-010). Returns 0 (North) for a zero-vector. +fn bearing_octant(from: (u16, u16), to: (u16, u16)) -> u8 { + let dr = to.0 as i32 - from.0 as i32; // +south / -north + let dc = to.1 as i32 - from.1 as i32; // +east / -west + if dr == 0 && dc == 0 { + return 0; + } + // 8-sector classification by the dominant axis + sign of the minor axis. + // We double the components to avoid a division and keep integer math. + // |dc| > |dr|*2 → pure E/W; |dr| > |dc|*2 → pure N/S; else diagonal. + let adr = dr.unsigned_abs() as i64; + let adc = dc.unsigned_abs() as i64; + // Octant ordering matches skeleton_gen.rs (D-234): 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW. + if adc > adr * 2 { + // Dominant East or West + if dc > 0 { + 2 + } else { + 6 + } + } else if adr > adc * 2 { + // Dominant North or South (row increases southward) + if dr > 0 { + 4 + } else { + 0 + } + } else if dr <= 0 && dc > 0 { + 1 // NE + } else if dr > 0 && dc > 0 { + 3 // SE + } else if dr > 0 && dc <= 0 { + 5 // SW + } else { + 7 // NW (dr <= 0 && dc < 0) + } +} + +/// Prestige rank for a `MaintenanceAuthority` (0 = lowest, 4 = highest). +/// +/// Used to order `road_entry_directions` so the AdminFacing consumer (D-215) +/// picks the highest-quality entry as its prestige gate without re-inspecting +/// edge metadata. +/// +/// Administrative > Corporate > Trade > Communal > Abandoned. +fn maintenance_authority_rank(m: MaintenanceAuthority) -> u8 { + match m { + MaintenanceAuthority::Administrative => 4, + MaintenanceAuthority::Corporate => 3, + MaintenanceAuthority::Trade => 2, + MaintenanceAuthority::Communal => 1, + MaintenanceAuthority::Abandoned => 0, + } +} + #[cfg(test)] mod tests { use super::*; use crate::atlas::gen_queue::{GenPriority, GenWorkItem}; + use crate::atlas::road_graph::{RoadEdge, RoadNode, RoadNodeKind}; use crate::seed::SeedChain; - use crate::simulation::generator::FoundingOrientation; + use crate::simulation::generator::{ + ArrangementPattern, AttractorType, FoundingOrientation, MaintenanceAuthority, + PoliticalArchetype, + }; use bevy_ecs::schedule::Schedule; use std::time::Duration; @@ -322,7 +531,6 @@ mod tests { } fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement { - use crate::simulation::generator::{ArrangementPattern, AttractorType, PoliticalArchetype}; CityPlacement { city_id, position: (10, 20), @@ -335,6 +543,24 @@ mod tests { } } + fn sample_placement_with_archetype( + city_id: u64, + orientation: FoundingOrientation, + archetype: PoliticalArchetype, + arrangement: ArrangementPattern, + ) -> CityPlacement { + CityPlacement { + city_id, + position: (10, 20), + attractor_type: AttractorType::CoastalAccess, + score: 100, + synthetic: false, + political_archetype: archetype, + arrangement_pattern: arrangement, + founding_orientation: orientation, + } + } + #[test] fn build_skeleton_work_item_threads_orientation_and_canonical_quarter_id() { let placement = sample_placement( @@ -353,7 +579,14 @@ mod tests { population, founding_age_years, .. - } = build_skeleton_work_item("PlanetX", 42, &placement, sample_read_set()) + } = build_skeleton_work_item( + "PlanetX", + 42, + &placement, + sample_read_set(), + &BTreeMap::new(), + &RoadGraph::default(), + ) else { panic!("expected GenerateSkeleton"); }; @@ -384,9 +617,14 @@ mod tests { fn quarter_id_is_deterministic_and_city_scoped() { let qid = |city_id: u64| { let placement = sample_placement(city_id, FoundingOrientation::Cardinal); - let GenWorkItem::GenerateSkeleton { quarter_id, .. } = - build_skeleton_work_item("BodyA", 99, &placement, sample_read_set()) - else { + let GenWorkItem::GenerateSkeleton { quarter_id, .. } = build_skeleton_work_item( + "BodyA", + 99, + &placement, + sample_read_set(), + &BTreeMap::new(), + &RoadGraph::default(), + ) else { unreachable!() }; quarter_id @@ -395,4 +633,657 @@ mod tests { assert_eq!(qid(3), qid(3)); assert_ne!(qid(3), qid(4)); } + + // ── T-1039: political_archetype + morphology_zone threading ─────────────── + + /// Verify that `build_skeleton_work_item` threads the placement's + /// `political_archetype` (replacing the `Commission` stub) and looks up + /// `morphology_zone` from the district grid. + #[test] + fn threads_political_archetype_and_morphology_zone() { + use crate::atlas::district_profile::{ + DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass, + }; + use crate::simulation::generator::MorphologyZone; + + // A Corporate archetype placement in a Fjord district. + let placement = sample_placement_with_archetype( + 42, + FoundingOrientation::Coastal { facing_degrees: 90 }, + PoliticalArchetype::Corporate, + ArrangementPattern::CampusGrid, + ); + // CityPlacement.position = (10, 20) → district_pos = (col/8, row/8) = (20/8, 10/8) = (2, 1) + let district_pos = scale::heightmap_pixel_to_district(placement.position); + assert_eq!(district_pos, (2, 1)); + + let mut districts: BTreeMap = BTreeMap::new(); + districts.insert( + district_pos, + DistrictProfile { + morphology_zone: MorphologyZone::Fjord, + tectonic_class: crate::atlas::district_profile::TectonicClass::Stable, + glaciation_grade: GlaciationGrade::Moderate, + precipitation_class: PrecipitationClass::Temperate, + slope_q: 60, + elev_q: 50, + ocean_fraction_q: 10, + river_threshold: 200, + temperature_c: Some(8.0), + moisture_q: 55, + vegetation_class: VegetationClass::Scrub, + }, + ); + + let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item( + "TestBody", + 1, + &placement, + sample_read_set(), + &districts, + &RoadGraph::default(), + ) else { + panic!("expected GenerateSkeleton"); + }; + + // Political archetype must come from the placement, not context_from_read_set's stub. + assert_eq!( + context.political_archetype, + PoliticalArchetype::Corporate, + "political_archetype must be threaded from placement (T-1039)" + ); + // Morphology zone must come from the DistrictProfile. + assert_eq!( + context.morphology_zone, + MorphologyZone::Fjord, + "morphology_zone must be looked up from DistrictProfile (T-1039)" + ); + } + + /// When no district grid is available (empty districts map), morphology_zone + /// falls back to AlluvialPlain (the safe mesh-topology default). + #[test] + fn morphology_zone_fallback_when_district_missing() { + use crate::simulation::generator::MorphologyZone; + + let placement = sample_placement(1, FoundingOrientation::Cardinal); + let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item( + "BodyX", + 0, + &placement, + sample_read_set(), + &BTreeMap::new(), + &RoadGraph::default(), + ) else { + panic!("expected GenerateSkeleton") + }; + assert_eq!( + context.morphology_zone, + MorphologyZone::AlluvialPlain, + "should fall back to AlluvialPlain when district grid empty" + ); + } + + // ── T-1039: arrangement_pattern parity drift-tripwire ───────────────────── + + /// Verifies that the L4 re-derivation of `arrangement_pattern` via + /// `attractor_matching::arrangement_pattern(&archetype, &role)` is always + /// identical to the L3 value stored on `CityPlacement.arrangement_pattern` for + /// a representative set of (archetype, economic_role) pairs. + /// + /// This is the required hardening check (T-1039 OPTION (b)): if anyone + /// changes one derivation path without the other this test will catch the drift. + #[test] + fn arrangement_pattern_l4_rederivation_matches_l3_stored_value() { + use crate::atlas::attractor_matching::arrangement_pattern; + + // Representative pairs: archetype + economic_role → expected pattern. + // These are the canonical D-214/D-215 pairs exercising all branches. + let cases: &[(PoliticalArchetype, &str, ArrangementPattern)] = &[ + // Commission/Academic → RadialCore + ( + PoliticalArchetype::Commission, + "institutional", + ArrangementPattern::RadialCore, + ), + ( + PoliticalArchetype::Academic, + "research", + ArrangementPattern::RadialCore, + ), + // Corporate → CampusGrid + ( + PoliticalArchetype::Corporate, + "manufacturing", + ArrangementPattern::CampusGrid, + ), + ( + PoliticalArchetype::Corporate, + "financial", + ArrangementPattern::CampusGrid, + ), + // Pioneer/Industrial → RibbonDevelopment + ( + PoliticalArchetype::Pioneer, + "agricultural", + ArrangementPattern::RibbonDevelopment, + ), + ( + PoliticalArchetype::Industrial, + "extraction", + ArrangementPattern::RibbonDevelopment, + ), + // Military → FortifiedPerimeter + ( + PoliticalArchetype::Military, + "military", + ArrangementPattern::FortifiedPerimeter, + ), + // transit_hub is a cross-archetype override → HubAndSpoke + ( + PoliticalArchetype::Commission, + "transit_hub", + ArrangementPattern::HubAndSpoke, + ), + ( + PoliticalArchetype::Corporate, + "transit_hub", + ArrangementPattern::HubAndSpoke, + ), + // transit_hub must override EVERY archetype (the guard fires before the + // archetype match) — cover the rest so an accidental + // archetype-conditionalization of the override can't slip through. + ( + PoliticalArchetype::Pioneer, + "transit_hub", + ArrangementPattern::HubAndSpoke, + ), + ( + PoliticalArchetype::Industrial, + "transit_hub", + ArrangementPattern::HubAndSpoke, + ), + ( + PoliticalArchetype::Military, + "transit_hub", + ArrangementPattern::HubAndSpoke, + ), + ( + PoliticalArchetype::Academic, + "transit_hub", + ArrangementPattern::HubAndSpoke, + ), + ]; + + for (archetype, role, expected_pattern) in cases { + // L4 re-derivation (the path used in build_skeleton_work_item). + let rederived = arrangement_pattern(archetype, role); + + // Build a CityPlacement carrying the L3-computed value to simulate + // what attractor_matching::match_cities would have stored at L3. + let l3_placement = CityPlacement { + city_id: 1, + position: (0, 0), + attractor_type: AttractorType::PlainCenter, + score: 100, + synthetic: false, + political_archetype: *archetype, + arrangement_pattern: *expected_pattern, + founding_orientation: FoundingOrientation::Cardinal, + }; + + assert_eq!( + rederived, l3_placement.arrangement_pattern, + "L4 re-derivation != L3 stored value for ({:?}, {role})", + archetype + ); + assert_eq!( + rederived, *expected_pattern, + "arrangement_pattern({:?}, {role}) should be {:?}", + archetype, expected_pattern + ); + } + } + + /// Acceptance test for T-1039: a Corporate coastal placement in a Fjord district + /// dispatches a work item whose context uses Ribbon topology (Fjord) and + /// Corporate (CampusGrid) layout — not mesh+Commission. + #[test] + fn corporate_fjord_placement_uses_ribbon_topology_not_mesh_commission() { + use crate::atlas::district_profile::{ + DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass, + }; + use crate::atlas::skeleton_gen::generate_quarter_skeleton; + use crate::simulation::generator::AccessKind; + + // CorpTerritory → Corporate archetype; CoastalAccess attractor. + let placement = sample_placement_with_archetype( + 99, + FoundingOrientation::Coastal { + facing_degrees: 270, + }, + PoliticalArchetype::Corporate, + ArrangementPattern::CampusGrid, + ); + let district_pos = scale::heightmap_pixel_to_district(placement.position); + let mut districts: BTreeMap = BTreeMap::new(); + districts.insert( + district_pos, + DistrictProfile { + morphology_zone: MorphologyZone::Fjord, + tectonic_class: crate::atlas::district_profile::TectonicClass::Stable, + glaciation_grade: GlaciationGrade::Moderate, + precipitation_class: PrecipitationClass::SemiArid, + slope_q: 70, + elev_q: 30, + ocean_fraction_q: 20, + river_threshold: 200, + temperature_c: Some(5.0), + moisture_q: 35, + vegetation_class: VegetationClass::Barren, + }, + ); + + let GenWorkItem::GenerateSkeleton { + context, + economic_role, + population, + founding_age_years, + chain, + quarter_id, + .. + } = build_skeleton_work_item( + "FjordBody", + 7, + &placement, + sample_read_set(), + &districts, + &RoadGraph::default(), + ) + else { + panic!("expected GenerateSkeleton"); + }; + + // Verify the context is correctly wired before skeleton generation. + assert_eq!(context.political_archetype, PoliticalArchetype::Corporate); + assert_eq!(context.morphology_zone, MorphologyZone::Fjord); + + // Run skeleton generation and verify: + // - DistrictLayoutMode is not the Commission path + // - street_topology(Fjord) → Ribbon (verified via absence of mesh-only outputs) + let skeleton = generate_quarter_skeleton( + &context, + population, + &economic_role, + quarter_id, + founding_age_years, + chain, + ); + + // A Corporate context with no road entries → BlockJunction fallback, + // but the layout mode must NOT be the Commission/Commission-grid variant. + // The skeleton's access_points are generated; at least one must exist. + assert!( + !skeleton.access_points.is_empty(), + "skeleton must have at least one access point" + ); + // Corporate + Fjord should NOT produce only RadialCore topology access points. + // (Ribbon topology and CampusGrid layout are tested structurally here.) + // With no road entries, BlockJunction fires — but layout mode is Corporate. + let has_junction = skeleton + .access_points + .iter() + .any(|p| matches!(p.kind, AccessKind::BlockJunction)); + assert!( + has_junction, + "isolated Corporate+Fjord settlement should have BlockJunction fallback" + ); + } + + // ── T-1043: road_entry_directions from RoadGraph ─────────────────────────── + + /// Build a minimal RoadGraph with two nodes and one edge, then verify that + /// `road_entry_directions_for_city` returns the correct entry octant. + #[test] + fn road_entry_directions_single_east_road() { + // City at (row=10, col=10), road goes east to (row=10, col=50). + // Expected octant: 2 (East) — dc=40, dr=0, dominant east. + let city_pos = (10u16, 10u16); + let far_pos = (10u16, 50u16); + + let road_graph = RoadGraph { + nodes: vec![ + RoadNode { + city_id: Some(1), + position: city_pos, + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + RoadNode { + city_id: Some(2), + position: far_pos, + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + ], + edges: vec![RoadEdge { + from: 0, + to: 1, + path: vec![city_pos, far_pos], + length_cells: 4, + maintenance: MaintenanceAuthority::Administrative, + named_route_id: None, + is_rail: false, + }], + }; + + let octants = road_entry_directions_for_city(1, city_pos, &road_graph); + assert_eq!(octants, vec![2u8], "east road should yield octant 2 (E)"); + } + + /// A settlement with two road connections (north and south) should produce + /// both octants, ordered by quality (higher-prestige first). + #[test] + fn road_entry_directions_multi_road_prestige_order() { + // City at (20, 20). Road north to (0, 20) [Administrative]; road south to + // (40, 20) [Communal]. Expected: [0 (N, rank 4), 4 (S, rank 1)]. + let city_pos = (20u16, 20u16); + let north_pos = (0u16, 20u16); + let south_pos = (40u16, 20u16); + + let road_graph = RoadGraph { + nodes: vec![ + RoadNode { + city_id: Some(10), + position: city_pos, + kind: RoadNodeKind::Settlement, + degree: 2, + parent_edge: None, + }, + RoadNode { + city_id: Some(11), + position: north_pos, + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + RoadNode { + city_id: Some(12), + position: south_pos, + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + ], + edges: vec![ + RoadEdge { + from: 0, + to: 1, + path: vec![city_pos, north_pos], + length_cells: 2, + maintenance: MaintenanceAuthority::Administrative, + named_route_id: None, + is_rail: false, + }, + RoadEdge { + from: 0, + to: 2, + path: vec![city_pos, south_pos], + length_cells: 2, + maintenance: MaintenanceAuthority::Communal, + named_route_id: None, + is_rail: false, + }, + ], + }; + + let octants = road_entry_directions_for_city(10, city_pos, &road_graph); + assert_eq!( + octants, + vec![0u8, 4u8], + "N (Administrative, rank 4) must precede S (Communal, rank 1)" + ); + } + + /// Two roads on the same octant are de-duplicated; only the higher-quality + /// road's rank is kept. + #[test] + fn road_entry_directions_deduplicates_same_octant() { + let city_pos = (10u16, 10u16); + // Two roads both going south (dr > 0, dc = 0 → octant 4). + let road_graph = RoadGraph { + nodes: vec![ + RoadNode { + city_id: Some(1), + position: city_pos, + kind: RoadNodeKind::Settlement, + degree: 2, + parent_edge: None, + }, + RoadNode { + city_id: Some(2), + position: (30u16, 10u16), + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + RoadNode { + city_id: Some(3), + position: (50u16, 10u16), + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + ], + edges: vec![ + RoadEdge { + from: 0, + to: 1, + path: vec![city_pos, (30, 10)], + length_cells: 2, + maintenance: MaintenanceAuthority::Trade, + named_route_id: None, + is_rail: false, + }, + RoadEdge { + from: 0, + to: 2, + path: vec![city_pos, (50, 10)], + length_cells: 4, + maintenance: MaintenanceAuthority::Corporate, + named_route_id: None, + is_rail: false, + }, + ], + }; + + let octants = road_entry_directions_for_city(1, city_pos, &road_graph); + // Both go south (octant 4); de-duplication keeps one; higher rank (Corporate=3) wins. + assert_eq!(octants, vec![4u8], "same-octant roads must be deduplicated"); + } + + /// Acceptance test for T-1043: a settlement with a road connection produces + /// at least one QuarterEdge access node on the correct octant; a genuinely + /// isolated settlement falls back to BlockJunction only. + #[test] + fn dispatch_with_road_produces_quarter_edge_not_block_junction() { + use crate::atlas::skeleton_gen::generate_quarter_skeleton; + use crate::simulation::generator::AccessKind; + + // Road goes east from city at (10, 10) to (10, 50) → octant 2 (East). + let city_pos = (10u16, 10u16); + let far_pos = (10u16, 50u16); + let road_graph = RoadGraph { + nodes: vec![ + RoadNode { + city_id: Some(5), + position: city_pos, + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + RoadNode { + city_id: Some(6), + position: far_pos, + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + ], + edges: vec![RoadEdge { + from: 0, + to: 1, + path: vec![city_pos, far_pos], + length_cells: 4, + maintenance: MaintenanceAuthority::Administrative, + named_route_id: None, + is_rail: false, + }], + }; + + let placement = CityPlacement { + city_id: 5, + position: city_pos, + attractor_type: AttractorType::CoastalAccess, + score: 100, + synthetic: false, + political_archetype: PoliticalArchetype::Commission, + arrangement_pattern: ArrangementPattern::RadialCore, + founding_orientation: FoundingOrientation::Cardinal, + }; + + let GenWorkItem::GenerateSkeleton { + context, + economic_role, + population, + founding_age_years, + chain, + quarter_id, + .. + } = build_skeleton_work_item( + "RoadBody", + 42, + &placement, + sample_read_set(), + &BTreeMap::new(), + &road_graph, + ) + else { + panic!("expected GenerateSkeleton"); + }; + + // Context should have octant 2 (East) in road_entry_directions. + assert_eq!( + context.road_entry_directions, + vec![2u8], + "east road must produce octant 2 in road_entry_directions" + ); + + // Generate the skeleton and verify QuarterEdge is produced (not just BlockJunction). + let skeleton = generate_quarter_skeleton( + &context, + population, + &economic_role, + quarter_id, + founding_age_years, + chain, + ); + + let has_quarter_edge = skeleton + .access_points + .iter() + .any(|p| matches!(p.kind, AccessKind::QuarterEdge { octant: 2 })); + assert!( + has_quarter_edge, + "settlement with east road must produce QuarterEdge(octant=2)" + ); + + let has_block_junction = skeleton + .access_points + .iter() + .any(|p| matches!(p.kind, AccessKind::BlockJunction)); + assert!( + !has_block_junction, + "settlement with road connections must NOT fall back to BlockJunction" + ); + } + + /// An isolated settlement (no road edges) must fall back to BlockJunction only. + #[test] + fn isolated_settlement_falls_back_to_block_junction() { + use crate::atlas::skeleton_gen::generate_quarter_skeleton; + use crate::simulation::generator::AccessKind; + + let placement = sample_placement(7, FoundingOrientation::Cardinal); + let GenWorkItem::GenerateSkeleton { + context, + economic_role, + population, + founding_age_years, + chain, + quarter_id, + .. + } = build_skeleton_work_item( + "IsolatedBody", + 42, + &placement, + sample_read_set(), + &BTreeMap::new(), + &RoadGraph::default(), + ) + else { + panic!("expected GenerateSkeleton"); + }; + + assert!( + context.road_entry_directions.is_empty(), + "isolated settlement must have no road_entry_directions" + ); + + let skeleton = generate_quarter_skeleton( + &context, + population, + &economic_role, + quarter_id, + founding_age_years, + chain, + ); + + let has_block_junction = skeleton + .access_points + .iter() + .any(|p| matches!(p.kind, AccessKind::BlockJunction)); + assert!( + has_block_junction, + "isolated settlement must fall back to BlockJunction" + ); + } + + // ── bearing_octant unit tests ────────────────────────────────────────────── + + #[test] + fn bearing_octant_cardinal_directions() { + // North: row decreases (dr < 0, dc = 0) + assert_eq!(bearing_octant((10, 10), (0, 10)), 0, "N"); + // East: col increases (dr = 0, dc > 0) + assert_eq!(bearing_octant((10, 10), (10, 50)), 2, "E"); + // South: row increases (dr > 0, dc = 0) + assert_eq!(bearing_octant((10, 10), (50, 10)), 4, "S"); + // West: col decreases (dr = 0, dc < 0) + assert_eq!(bearing_octant((10, 10), (10, 0)), 6, "W"); + } + + #[test] + fn bearing_octant_diagonal_directions() { + // NE: dr < 0, dc > 0 (roughly equal magnitude) + assert_eq!(bearing_octant((10, 10), (5, 15)), 1, "NE"); + // SE: dr > 0, dc > 0 + assert_eq!(bearing_octant((10, 10), (15, 15)), 3, "SE"); + // SW: dr > 0, dc < 0 + assert_eq!(bearing_octant((10, 10), (15, 5)), 5, "SW"); + // NW: dr < 0, dc < 0 + assert_eq!(bearing_octant((10, 10), (5, 5)), 7, "NW"); + } } diff --git a/server/src/atlas/scale.rs b/server/src/atlas/scale.rs index 0b50888c0..d6daad8fb 100644 --- a/server/src/atlas/scale.rs +++ b/server/src/atlas/scale.rs @@ -92,6 +92,39 @@ pub fn chunk_to_region(c: ChunkPos) -> RegionPos { district_to_region(chunk_to_district(c)) } +// --------------------------------------------------------------------------- +// Working-heightmap pixel ↔ district (cascade.rs working grid) +// --------------------------------------------------------------------------- + +/// Number of working-heightmap-grid pixels per district side on the standard +/// cascade working resolution (~128×64 working grid; D-203, D-239 §1, T-1023). +/// +/// This is NOT a metre-scale constant — it is the `grid_cells_per_district` +/// parameter passed to [`crate::atlas::district_profile::derive_all_districts`]. +/// Centralised here so plugin.rs and cascade.rs share one definition and neither +/// hard-codes `8` independently. +/// +/// `DistrictPos = (col / HEIGHTMAP_CELLS_PER_DISTRICT, row / HEIGHTMAP_CELLS_PER_DISTRICT)` +/// for a working-grid pixel `(row, col)` — see [`heightmap_pixel_to_district`]. +pub const HEIGHTMAP_CELLS_PER_DISTRICT: usize = 8; + +/// Convert a working-heightmap-grid pixel coordinate `(row, col)` to the +/// [`DistrictPos`] that covers it. +/// +/// `CityPlacement.position` and `RoadNode.position` are both stored in +/// working-heightmap-grid coordinates (row-major, `(row, col)` order), and the +/// district grid is built with the same pixel grid by +/// [`crate::atlas::district_profile::derive_all_districts`]. Integer division +/// floors toward zero, which matches the `BTreeMap` keys inserted by `derive_all_districts`. +#[inline] +pub fn heightmap_pixel_to_district(pixel: (u16, u16)) -> DistrictPos { + let cpd = HEIGHTMAP_CELLS_PER_DISTRICT as i32; + // pixel = (row, col); DistrictPos convention is (dx=col_district, dy=row_district). + let dx = pixel.1 as i32 / cpd; + let dy = pixel.0 as i32 / cpd; + (dx, dy) +} + // --------------------------------------------------------------------------- // The elastic seam — region ↔ planet (the only per-body-floating quantity) // ---------------------------------------------------------------------------