diff --git a/governance/README.md b/governance/README.md index 3a1b86c0c..ab9603c2a 100644 --- a/governance/README.md +++ b/governance/README.md @@ -318,6 +318,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it. - [D-250: Wall cutaway is client-side camera presentation, decoupled from fog-of-perception](decisions/perception.md#d-250-wall-cutaway-is-client-side-camera-presentation-decoupled-from-fog-of-perception) — _perception_ - [D-251: Character asset route, 2026 reconfirmation — Quaternius rig, in-house wardrobe, purchased animation tiers](decisions/content.md#d-251-character-asset-route-2026-reconfirmation--quaternius-rig-in-house-wardrobe-purchased-animation-tiers) — _content_ - [D-252: Facing is view-only — movement no longer writes Facing; NPC gaze is intent](decisions/architecture.md#d-252-facing-is-view-only--movement-no-longer-writes-facing-npc-gaze-is-intent) — _architecture_ +- [D-253: Region transient state model — seasonal/tidal/weather/snow phase functions (resolves Q-105)](decisions/architecture.md#d-253-region-transient-state-model--seasonaltidalweathersnow-phase-functions-resolves-q-105) — _architecture_ ## Open questions @@ -385,9 +386,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it. - [Q-093: Tile-based exploration map in player insert (Google Maps for the implant)](questions/architecture.md#q-093-tile-based-exploration-map-in-player-insert-google-maps-for-the-implant) — _architecture_ - [Q-097: Strip "What They Don't Talk About" from corporation pages](questions/content.md#q-097-strip-what-they-dont-talk-about-from-corporation-pages) — _content_ - [Q-099: Mod content catalog — body rows / terrain_reference overlay for systems.db](questions/architecture.md#q-099-mod-content-catalog--body-rows--terrain-reference-overlay-for-systemsdb) — _architecture_ -- [Q-102: Cohesion-matrix algorithm — seam-free continuous variation](questions/architecture.md#q-102-cohesion-matrix-algorithm--seam-free-continuous-variation) — _architecture_ - [Q-103: Tile-mutator op schema](questions/architecture.md#q-103-tile-mutator-op-schema) — _architecture_ -- [Q-105: Region seasonal/clock state — the shared cheap-dynamism source](questions/architecture.md#q-105-region-seasonalclock-state--the-shared-cheap-dynamism-source) — _architecture_ - [Q-107: Wiki → Atlas content-set consolidation](questions/architecture.md#q-107-wiki--atlas-content-set-consolidation) — _architecture_ - [Q-108: Subterranean / domed / sealed-habitat settlement morphology — does the built-world fill model need a surface-vs-enclosed branch](questions/architecture.md#q-108-subterranean--domed--sealed-habitat-settlement-morphology--does-the-built-world-fill-model-need-a-surface-vs-enclosed-branch) — _architecture_ - [Q-109: Cascade generation-source dispatch — planetary / station / mod-DLC-forked / save-only](questions/architecture.md#q-109-cascade-generation-source-dispatch--planetary--station--mod-dlc-forked--save-only) — _architecture_ @@ -443,7 +442,9 @@ line in place — keep the Q-record for the audit trail rather than deleting it. - [Q-098: Persistence of generated river/city mapping outputs](questions/architecture.md#q-098-persistence-of-generated-rivercity-mapping-outputs) — _architecture_ - [Q-100: Biome authority — Python sim vs Rust cascade](questions/architecture.md#q-100-biome-authority--python-sim-vs-rust-cascade) — _architecture_ - [Q-101: Refinement / derivation contract — coarse hint → fine geometry + morphology algorithm family](questions/architecture.md#q-101-refinement--derivation-contract--coarse-hint--fine-geometry--morphology-algorithm-family) — _architecture_ +- [Q-102: Cohesion-matrix algorithm — seam-free continuous variation](questions/architecture.md#q-102-cohesion-matrix-algorithm--seam-free-continuous-variation) — _architecture_ - [Q-104: Floor-index ↔ absolute voxel-z coordinate mapping](questions/architecture.md#q-104-floor-index--absolute-voxel-z-coordinate-mapping) — _architecture_ +- [Q-105: Region seasonal/clock state — the shared cheap-dynamism source](questions/architecture.md#q-105-region-seasonalclock-state--the-shared-cheap-dynamism-source) — _architecture_ - [Q-106: Era-band stacking depth for layered architecture-flavor](questions/architecture.md#q-106-era-band-stacking-depth-for-layered-architecture-flavor) — _architecture_ - [Q-110: Region/chunk physical-scale anchoring — what is a region in metres, and what anchors a heightmap pixel?](questions/architecture.md#q-110-regionchunk-physical-scale-anchoring--what-is-a-region-in-metres-and-what-anchors-a-heightmap-pixel) — _architecture_ diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 85c9ae8e4..9b8649454 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1966,4 +1966,34 @@ Technical foundation decisions that constrain implementation: engine, client-ser --- -*106 decisions (D-001 through D-252, excluding gaps). Last updated: 2026-07-06 (D-252 — Facing is view-only: movement no longer writes Facing, NPC gaze becomes AI intent, bump-to-turn retired; resolves the Q-084 walk-vs-aim split with T-1088 play evidence).* +### D-253: Region transient state model — seasonal/tidal/weather/snow phase functions (resolves Q-105) + +- **Date:** 2026-07-08 +- **Resolves:** [Q-105](../questions/architecture.md#q-105). +- **Decision:** The region ([D-243](#d-243)) is the single scale at which **transient (clock-bound) surface state** is resolved — "computed once per region per phase, inherited by every district and tile inside" ([D-243](#d-243) §3, the cheap-dynamism source). This record pins that model: **what** transient state a region carries, **how** its clock advances and recomputes, **how** tiles inherit it, and its **determinism** — but explicitly **not** the gameplay/rendering that later consumes it. + + **(a) The transient bundle — four phase terms + their derived surface scalars.** A region carries a memoized `RegionPhase` (proposed carrier name) built from **four clock terms, one per natural clock rate** (fastest → slowest), each a pure function of the in-game clock ([D-031](#d-031)) and the region's static params: + - **Diurnal** — day/night, from the [D-031](#d-031) day-phase clock; amplitude = the atmosphere heat-retention already defined in [D-239](#d-239) §2 (thick air → small swing). *This term is the one [D-239](#d-239) §2 already owns for temperature; D-253 folds it into the same region phase model as the other three, rather than leaving it a separate mechanism.* + - **Tidal** — from the lunar/day clock; amplitude from the body's satellite config; **absent (zero) when the body has no moon**. Drives the tidal component of water-height. + - **Weather** — a coarse, seeded, deterministic precipitation/condition term (a bounded pseudo-sequence keyed on `(region, weather-bucket)` — **not** a simulated advecting weather system; deliberately minimal to stay "nearly free"). Drives puddles + general conditions. + - **Seasonal** — from the year clock, **phased continuously by latitude** (amplitude → 0 at the equator and sign inverting across it — a continuous function of latitude, not a binary hemisphere flag, so the equatorial seam never steps). The slow temperature term + the seasonal component of water-height + the snow/crop drivers. + - From these terms the region derives its **transient surface scalars** (region-level, not per-tile): **temperature(time)** = the T-1078 / [D-240](#d-240) static baseline **+** seasonal offset **+** diurnal offset (this makes [D-239](#d-239) §2's "temperature(time)" concrete — the static baseline T-1078 ships is the mean-phase value); **water-height(time)** = mean water level **+** seasonal term **+** tidal term; **snow/ice depth** = `f(temperature(time), moisture)` feeding the **existing T-1030 scattered band** ([D-239](#d-239) §3) — D-253 supplies only the *depth over time*, T-1030 owns *where* the band scatters; **weather intensity/category**; and the **crop-cycle phase** (sown → growing → ripe → harvested → fallow) = `f(seasonal)`, the cadence clock for [D-228](#d-228)'s managed-`Vegetation` farmland override (D-253 owns the *cadence*; the settlement/economic layer owns crop *type* and farmland *placement*). + + **(b) Phase-stepping — recompute on bucket rollover, never per tick, never integrated.** Each term declares a **clock-bucket size** (diurnal ≈ day-phase; tidal ≈ day-phase; weather ≈ a multi-phase block; seasonal ≈ a season step). `RegionPhase` is **memoized with the clock-buckets it was computed for**; a query recomputes a term **only when its bucket rolls over** — the "nearly free" requirement, and [D-226](#d-226)'s dynamic-state path. Crucially, every scalar is evaluated from the **absolute clock value** (`state = f(clock)`), **never integrated step-by-step** (`state += Δ`): absolute evaluation is drift-free, reproducible, and hands [D-226](#d-226) its **frozen-phase inspection** for free (freeze the clock → the whole world's transient state is stable and re-derivable). Default bucket sizes are source-canonical + tunable (the `climate_constants.toml` precedent), provisional pending calibration. + + **(c) Inheritance — region computes the scalar, the tile realizes it locally, edge-fuzzed.** Districts and tiles **never compute their own phase** — they read the region scalars, blended across region neighbours by the **same [D-243](#d-243) §4 edge-fuzz** the static climate baseline uses (warp-perturbed bilinear across region centres), so a weather front / seasonal gradient / thaw line never steps on the ~205 km grid (and the equatorial seasonal seam is continuous by construction, per (a)). The **per-tile realization is a local comparison**, not an inherited value: a tile is **flooded** iff `region-water-height(time) > tile.elevation`; **snowed** to a depth capped by the region snow-depth, scattered by the T-1030 band and gated on its own material/moisture; **puddled** iff the weather term is wet and the tile sits in a micro-relief low (the T-1081 `voxel_relief` hollows) that diurnal evaporation has not burned off; its farmland shows the crop stage the region phase dictates. So floodplain / tidal-flat / seasonal-river **emerge** ([D-228](#d-228)) — the static world stays static; only the region scalars carry the clock. This directly discharges T-1082's deferred TidalFlat wet/dry alternation (`Shallow`/`Dry` = tidal water-height vs local elevation). + + **(d) Determinism ([D-010](#d-010)).** Every term and scalar is **integer fixed-point** (phase positions as basis-points of their cycle; depths/heights in integer mm/cm; temperature offsets on the existing integer scale) and a **seeded pure function** of `(absolute clock, region params, body seed)`. **No per-tick float accumulation exists anywhere in the model** — see (b). Weather's pseudo-sequence is a seeded hash of `(region, weather-bucket)`, so it replays identically. Frozen clock → byte-identical state on every platform and every reload; this is what lets the transient overlay live in the [D-227](#d-227) derive-don't-store tier (the memo is **not persisted** — a save records only the clock and the state re-derives). + + **(e) Scope boundary — this is the STATE MODEL, not its consumers.** D-253 defines *derivable transient state and the functions that produce it* — Phase-4-appropriate, viewable as an Atlas layer like every other cascade output, and completing the forward-contracts left open by T-1030 / T-1078 / T-1082. It does **NOT** build: weather/flood/snow **gameplay** (movement, perception, passability effects — [D-239](#d-239) §9 names the passability hook but its wiring is later), the **farming/crop simulation**, snow/water **rendering** (Phase 5), or any **verb** that reads this state. Those are later phases and inherit a fixed contract here. Adding a fifth term or a consumer is a future amendment, not a reinterpretation of this record. + +- **Rationale:** The model is cheap *because* the four transient terms line up with the four natural clock rates and reuse the existing temperature primitive — temperature(time) and water-height(time) are just a static baseline plus the same seasonal/diurnal/tidal offsets, so **one clock model drives temperature, flooding, tides, snow, puddles, weather, and crops** with no per-system simulation. The single load-bearing choice — **absolute-clock evaluation rather than step integration** — satisfies three requirements at once: determinism ([D-010](#d-010), no drift), cheapness (recompute only on bucket rollover, and per-region not per-tile → O(regions)), and inspectability ([D-226](#d-226) frozen phase). Computing at the region and realizing per-tile by comparison is what keeps the dynamism O(regions) while still producing per-tile flood/snow/puddle/crop extent — the "computed once, inherited" contract [D-243](#d-243) §3 promised. +- **Raised by:** Jeroen (Q-105 model constraints — clock + hemisphere bound, moon-gated tides, computed-once-per-region, "nearly free", 2026-05-25) + Tyre (T-1057 triage — the four-term phase-function model, the absolute-clock / memoized-by-bucket / edge-fuzzed-inheritance pins, 2026-07-08). **Authored as a proposed design for PR review** — the flagged judgment calls (below) are open for the lead/user to adjust. +- **Design choices flagged for review:** (1) **diurnal promoted to a first-class term** — Q-105 enumerated three terms (seasonal/tidal/weather); D-253 adds diurnal as the fourth, since [D-239](#d-239) §2 already owns it for temperature and T-1030's dawn-frost already depends on it (a coherence unification, but it extends Q-105's framing). (2) **weather kept deliberately minimal** (a seeded coarse precipitation/condition term, no advecting fronts) — the depth is a taste call. (3) **recompute-bucket granularities** (diurnal/tidal ≈ day-phase, weather ≈ multi-phase, seasonal ≈ season-step) are provisional/tunable, not hard-pinned. (4) **memoized-not-persisted** reconciles D-228's "region property computed once per phase" with [D-227](#d-227) derive-don't-store (the region *caches* the bundle keyed by bucket; it is never durable state). (5) **"phased by hemisphere" read as continuous-in-latitude** (amplitude→0 at equator) rather than a north/south sign flag, so the seam edge-fuzzes cleanly. +- **Implementation:** Phase 4+ (epic [T-750](../../.pql)), the forward contract from T-1030 (transient freeze/snow depth), T-1078 (region climate stack — shipped the static baseline + region-clock *structure*; D-253 adds the transient terms), and T-1082 (tidal-flat wet/dry). Proposed home: a `RegionPhase` carrier alongside `RegionProfile` in `BodyWorldState` ([D-203](#d-203) / [D-239](#d-239) §10 region storage), memoized by clock-bucket; proposed derivations `derive_region_phase(clock, region)` + per-tile realization helpers (flood / snow / puddle / crop) — names non-binding, the impl ticket fixes them. Constants in a source-canonical TOML (`climate_constants.toml` precedent). A new implementation ticket lands under T-750. +- **Cross-reference:** [D-243](#d-243) (region = climate/weather/season lockdown scale; §3 computed-once-inherited; §4 edge-fuzz — the inheritance contract), [D-228](#d-228) (the seasonal water-height / snow-cover overlay / crop cycle this model drives — its open water-height sub-question resolved here), [D-239](#d-239) §2 (temperature(time) primitive — made concrete) / §3 (T-1030 scattered freeze-snow — given its clock) / §9 (seasonal passability — a later consumer), [D-240](#d-240) (`planet_class` temperature envelope — the baseline the seasonal/diurnal terms offset), [D-226](#d-226) (dynamic-state frozen-phase inspection), [D-031](#d-031) (game clock / day phases — the clock source), [D-010](#d-010) (determinism), [D-203](#d-203) (BodyWorldState region cache — the memo home), [D-227](#d-227) (derive-don't-store — the memo is not persisted). Tickets: [T-1057](../../.pql) (this triage), T-1030 / T-1078 / T-1082 (the discharged forward-contracts), T-1081 (`voxel_relief` micro-lows — puddle/flood sites). +- **Dissent:** None + +--- + +*107 decisions (D-001 through D-253, excluding gaps). Last updated: 2026-07-08 (D-253 — region transient state model: four clock-terms (diurnal/tidal/weather/seasonal), memoized-by-bucket absolute-clock evaluation, edge-fuzzed per-tile realization; resolves Q-105).* diff --git a/governance/questions/architecture.md b/governance/questions/architecture.md index aa989d146..6a7c6e238 100644 --- a/governance/questions/architecture.md +++ b/governance/questions/architecture.md @@ -370,15 +370,17 @@ Technical foundation questions: engine, protocols, data structures, performance, --- ### Q-102: Cohesion-matrix algorithm — seam-free continuous variation -- **Status:** Open — deferred from D-228 (2026-05-25) +- **Status:** Resolved 2026-07-08 (T-1057) — resolved-in-practice by [D-246](../decisions/architecture.md#d-246) + [D-228](../decisions/architecture.md#d-228) (with T-1080 / T-1077); no dedicated cohesion-matrix D-record is needed. +- **Resolution:** All five sub-questions are answered by shipped work, so the cohesion matrix needs no separate decision. **(1) noise basis + (2) position-keying + (3) threshold mapping + (4) determinism** are exactly [D-246](../decisions/architecture.md#d-246)'s `voxel_mosaic()`: an enveloped-fBm **value-noise** band (the T-1081 `detail_scatter` machinery, `[64, 32, 16, 8]` m octaves) keyed on **absolute world position** under a body-global `SeedDomain::VoxelMosaic` (never the chunk index — so it is continuous across every chunk/region boundary), whose field value indexes the class palette's **cumulative weights** to select a micro-habitat (the threshold map), all integer-only per [D-010](../decisions/architecture.md#d-010). The precondition that there is *something* for the field to vary is the [D-243](../decisions/architecture.md#d-243) ladder's per-district climate/moisture gradient — T-1080 (meso-scale moisture-gradient fix: a flat body constant became a latitude/elevation/continentality gradient) over T-1077 (the D-243 ladder wiring) — so the noise modulates a real gradient, not a uniform slab. **(5) authored linear features composite without bleeding** is [D-228](../decisions/architecture.md#d-228)'s categorical `FloorMaterial`/`Vegetation` **override stack** (wild/natural → economic/managed → user): the topmost *present* layer wins and layers are **never blended**, so an authored road/plaza/field-edge (economic layer) sits on the continuous wild scatter with a hard categorical edge — D-228's "a straight line must always have a placed cause." Seam-free variation (blended noise) and crisp authored lines (categorical, unblended override) are therefore **two distinct mechanisms**, not one algorithm — which is precisely why a standalone cohesion-matrix decision is unnecessary. - **Question:** D-228 mandates that intra-region material / sub-biome scatter come from a **global, position-keyed continuous noise field** so chunk/region boundaries never read as grid seams, while authored straight lines (roads, plazas) stay crisp. Concretely: the noise basis (value / Perlin / simplex / worley); keying on world position (not chunk index) to stay continuous across boundaries; how material / sub-biome thresholds map onto it; integer-only / determinism (D-010); and how authored linear features composite over the continuous field without bleeding. - **Context:** The anti-squaring principle pushed down to the material layer. -- **Cross-reference:** [D-228](../decisions/architecture.md#d-228), [D-227](../decisions/architecture.md#d-227), [D-010](../decisions/architecture.md#d-010) +- **Cross-reference:** [D-228](../decisions/architecture.md#d-228), [D-246](../decisions/architecture.md#d-246), [D-227](../decisions/architecture.md#d-227), [D-010](../decisions/architecture.md#d-010), [D-243](../decisions/architecture.md#d-243); tickets T-1080, T-1077, T-1081, T-1084 --- ### Q-103: Tile-mutator op schema -- **Status:** Open — deferred from D-227 (2026-05-25) +- **Status:** Open — deferred from D-227 (2026-05-25); re-triaged 2026-07-08 (T-1057) → Phase-5 save-system kickoff. +- **Triage (2026-07-08, T-1057):** DEFERRED, scope unchanged. The question's own text scopes it to Phase 5+ — it is the save-format op vocabulary, and mutators are [D-227](../decisions/architecture.md#d-227)'s sole persisted state, so it cannot be settled without a concrete save serializer to answer against. Confirmed an **empirical non-blocker** for the Phase-4 tile-fill spine: T-987 (child of T-959) shipped the Layer-5 shell derivation (`server/src/atlas/shell.rs` — `Void`/`Wall`/`FloorSlab`/`Roof`) with **no mutator layer at all** — the derive phase is a pure function of `(seed, tags, z)`, and `ChunkMutations`/`TileOverride` already exist as the frozen-base overlay a mutator log will later stack on. Revisit at Phase-5 save-system kickoff, where the typed-op-vs-full-override fork, replay ordering / conflict semantics, and cross-re-derivation validity are decided together. - **Question:** D-227 makes tile mutators the sole persisted state (save = `seed + mutator log`). What does one mutator record — a full per-voxel override, or typed ops (`Dig`, `Build(FloorMaterial)`, `Place(object)`, `Destroy(object)`, `SetMaterial`)? Typed ops are compact, semantic, replayable, but need a fixed op vocabulary; full overrides are simple but heavier and lose intent. Also: replay ordering / conflict semantics, and confirmation that a position-keyed mutator stays valid across re-derivation (determinism guarantees an identical base — to be pinned). - **Context:** Needed when the save system is built (Phase 5+). - **Cross-reference:** [D-227](../decisions/architecture.md#d-227), [D-010](../decisions/architecture.md#d-010) @@ -395,10 +397,11 @@ Technical foundation questions: engine, protocols, data structures, performance, --- ### Q-105: Region seasonal/clock state — the shared cheap-dynamism source -- **Status:** Open — committed in principle (D-228); model details open (2026-05-25) +- **Status:** Resolved 2026-07-08 (T-1057) by [D-253](../decisions/architecture.md#d-253) +- **Resolution:** Resolved by [D-253](../decisions/architecture.md#d-253) — a **four-term region transient-state model** (diurnal · tidal · weather · seasonal, one term per natural clock rate) whose derived surface scalars are **temperature(time)** (= the T-1078/D-240 static baseline + seasonal + diurnal offsets), **water-height(time)** (= mean + seasonal + tidal), **snow/ice depth**, **weather**, and the **crop-cycle phase**. The bundle is **memoized on the region keyed by clock-bucket**, recomputed **on bucket rollover, never per tick and never integrated** (state = `f(absolute clock, region)`, so it is drift-free, reproducible per [D-010](../decisions/architecture.md#d-010), and gives D-226 frozen-phase inspection for free). Districts/tiles **never compute their own phase** — they read the region scalars, **edge-fuzzed** by the D-243 §4 blend, and **realize per-tile state by local comparison** (flooded iff water-height > tile elevation; snowed via the existing T-1030 scatter band; puddled in T-1081 micro-lows; crop stage from the region phase). D-253 supplies the clock the three shipped punts were forward-contracts to (T-1030 transient freeze/snow depth, T-1082 tidal-flat wet/dry, T-1078 region-clock structure), and is a **state model only** — the gameplay/rendering consumers are Phase 5+. - **Question:** D-228 commits to a deliberately cheap, deterministic, **region-level seasonal/clock state** — computed *once* per region per phase, inherited by its tiles — from which a family of **transient surface conditions** derive — all computed from the region's seasonal + tidal + weather phase, none of it stored: **flooded tiles** (floodplain / tidal-flat / seasonal-river, where the water-height crosses local elevation), **snow & ice cover**, **puddles** (weather / rain), **weather** generally, and the **farmland crop cycle** (sown → growing → ripe → harvested → fallow). The model must stay "nearly free" — simple deterministic functions of the in-game clock + body/region parameters, no per-tile or per-frame simulation. Open: the exact phase functions — a **seasonal** term phased by hemisphere (latitude sign), driven by the year clock; a **tidal** term only when the body has a moon (no satellite → no tide), driven by the lunar/day clock with amplitude from the satellite config; a **weather** term (shorter-term precipitation → puddles + general conditions); how snow/ice depth, puddles, flood extent, weather, and the crop cycle each read the same state; the **snow/ice cover model** — D-228 removed `Snow`/`Ice` from `TerrainMaterial`, so snow/ice are *entirely* this seasonal cover overlay (permanent only where climate never melts them — poles, glaciers), depth rising/falling with the seasonal phase; determinism (state = `f(clock, region)` → reproducible) and its interaction with the cache + the D-226 pause/inspection (a frozen phase for stable inspection); and recompute cadence (per phase-change, not per frame). - **Context:** Grew from the floodplain dependency — "floodplain is only feasible if water heights move" — into a general cheap-dynamism source. Jeroen's constraints: clock + hemisphere bound, moon-gated tides, computed once per region, simple rules; snow / weather / crop cycle ride the same calculation. -- **Cross-reference:** [D-228](../decisions/architecture.md#d-228), [D-227](../decisions/architecture.md#d-227), [D-226](../decisions/architecture.md#d-226) (dynamic-state inspection), [D-010](../decisions/architecture.md#d-010) +- **Cross-reference:** [D-253](../decisions/architecture.md#d-253) (the resolving model), [D-228](../decisions/architecture.md#d-228), [D-243](../decisions/architecture.md#d-243) (region = lockdown scale), [D-239](../decisions/architecture.md#d-239) §2/§3 (temperature primitive; T-1030 scatter band), [D-227](../decisions/architecture.md#d-227), [D-226](../decisions/architecture.md#d-226) (dynamic-state inspection), [D-010](../decisions/architecture.md#d-010) --- @@ -427,7 +430,8 @@ Technical foundation questions: engine, protocols, data structures, performance, --- ### Q-109: Cascade generation-source dispatch — planetary / station / mod-DLC-forked / save-only -- **Status:** Open — raised by Jeroen (2026-06-05, during T-957 zone-selection authoring) +- **Status:** Open — raised by Jeroen (2026-06-05, during T-957 zone-selection authoring); re-triaged 2026-07-08 (T-1057) → revisit before stations / mods / save-only bodies are prioritized (Phase 5+). +- **Triage (2026-07-08, T-1057):** DEFERRED. The **planetary path is the only live generation source** today, so the dispatch discriminator has exactly one branch to route to and nothing yet exercises the fork. The station-only interim is already carved by the [D-229](../decisions/architecture.md#d-229) amendment (2026-06-05): T-957's `(ZoningType × economic_role × setting)` zone-selection table deliberately excludes the station-only ids (`residential_station` / `extraction_space` / `port_space` / `rural_orbital`), reserving them for a separate station cascade — so the seam is *documented* without the dispatcher being built. Revisit before stations, mod/DLC-forked bodies, or save-only `player_base` bodies are prioritized (Phase 5+), when the discriminator's home (`SettingType` vs a dedicated `GenerationSource`), the mod/DLC external-code resolution, and the save-only cache-bypass (a `player_base` body must never enqueue an `AnalyzeBody` — [D-225](../decisions/architecture.md#d-225)/[D-203](../decisions/architecture.md#d-203)) are decided together with the savegame model. - **Question:** The generation cascade currently has a single implicit path: every body runs the planetary generator (`run_cascade` → Layers 0–5). But a body's `SettingType` (or a sibling `GenerationSource` discriminator) should **dispatch at the cascade entry** to one of several generation sources: (a) **planetary** — the default terrain→quarter→tile cascade (T-955/T-956/T-957…); (b) **station / orbital** — a *separate* cascade that owns the station-only zone types (`residential_station`, `extraction_space`, `port_space`, `rural_orbital`) and its own layout model, rather than branching the planetary code (this is why T-957's zone-selection table deliberately excludes those ids — see [D-229](../decisions/architecture.md#d-229) amendment 2026-06-05); (c) **mod / DLC-forked** — a value that routes generation to externally-provided code/templates (a mod or DLC name), so third-party content can supply a body's built form without patching core; (d) **save-only / `player_base`** — disables auto-generation entirely and loads the body's built world from a save file (player-constructed bases must persist, not regenerate). Where does this discriminator live — on `SettingType` itself (it already carries `Station`/`Orbital` variants) or a dedicated `GenerationSource` enum at the `run_cascade` entry? How does the mod/DLC fork resolve to external code (registry? trait object? content-pack manifest?)? And how does save-only compose with the D-203 hot cache and the D-225 compute-on-demand proxy (a `player_base` body must never enqueue an `AnalyzeBody`)? - **Context:** Surfaced while authoring T-957's `(ZoningType × economic_role × setting)` zone-selection table: `setting` is the right *tweaker* within the planetary path, but it is also the natural *dispatch* point above it — the two roles are distinct and only the tweaker belongs in T-957. The dispatch is a cross-cutting routing seam above any single layer; capturing it here so T-957 stays scoped to the planetary path. Not blocking — the planetary path is the only live source today. Wants Tyre (cascade architecture) + a look at modding/DLC strategy and the savegame model (Phase 5+) before it's decided. - **Cross-reference:** [D-200](../decisions/architecture.md#d-200) (runtime cascade), [D-225](../decisions/architecture.md#d-225) (compute-on-demand proxy — save-only must bypass), [D-203](../decisions/architecture.md#d-203) (hot cache), [D-229](../decisions/architecture.md#d-229) (zone-selection — excludes station ids for this reason), D-222 (Quarter/spatial tiers), and the development cascade (Phase 5 player control + savegame) diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index 029034f32..8fb0c4466 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -588,6 +588,36 @@ CREATE TABLE IF NOT EXISTS atlas_body_trait_bias ( ); CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_body ON atlas_body_trait_bias(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_tag ON atlas_body_trait_bias(template_tag); + +-- architecture_zone_bias: D-235 step-2 sparse bias table (T-988). Per +-- (template, zone_type) axis-token weight overrides within that template's +-- own visual_bundle; unlisted (template, zone_type) pairs — and unlisted +-- tokens within a listed entry — fall back to a uniform draw. Baked from +-- wiki/economics/architecture_zone_bias.toml. Importer-validated (V-TT-06): +-- every referenced token must already be in the template's visual_bundle. +CREATE TABLE IF NOT EXISTS architecture_zone_bias ( + template_tag TEXT NOT NULL REFERENCES trait_templates(tag) ON DELETE CASCADE, + zone_type_id TEXT NOT NULL, -- D-142 zone-type id (BuildingPropertyTag.zone_type_id) + bias TEXT NOT NULL, -- JSON {axis: {token: weight_bps}}, D-010 integer bps + PRIMARY KEY (template_tag, zone_type_id) +); +CREATE INDEX IF NOT EXISTS idx_architecture_zone_bias_template ON architecture_zone_bias(template_tag); + +-- color_register_bands: D-235 numeric HSV sampling band per trait-template +-- `color_register` label (T-988). One band per register; the fill seed +-- samples (hue, sat, val) uniformly within it per building. Baked from +-- wiki/economics/color_register_bands.toml. Importer-validated (V-TT-07): +-- every color_register referenced by trait_templates.visual_bundle must have +-- a band here, and every band's ranges must be in-bounds with min < max. +CREATE TABLE IF NOT EXISTS color_register_bands ( + color_register TEXT PRIMARY KEY, + hue_min INTEGER NOT NULL, -- centidegrees (degrees x 100), 0..36000 + hue_max INTEGER NOT NULL, + sat_min INTEGER NOT NULL, -- basis points, 0..10000 + sat_max INTEGER NOT NULL, + val_min INTEGER NOT NULL, -- basis points, 0..10000 + val_max INTEGER NOT NULL +); -- END TRAIT TEMPLATES (D-232, #993) -- Indexes diff --git a/server/data/systems.db b/server/data/systems.db index aaf68bf8c..93ffd0844 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 78479da6d..15fda3c69 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -34,6 +34,7 @@ use crate::atlas::district_profile::BodyParams; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; use crate::atlas::shell::{fill_chunk, FilledChunk}; use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton}; +use crate::atlas::trait_catalog_reader::ExteriorCatalog; use crate::seed::SeedChain; use crate::simulation::generator::{BuildingPropertyTag, CityGenerationContext, QuarterWorldState}; @@ -113,6 +114,15 @@ pub enum GenWorkItem { economic_role: String, population: i64, founding_age_years: u32, + /// D-235 exterior-grammar content (T-988): the trait-template catalog's + /// `visual_bundle`s plus the two sibling content tables + /// (`architecture_zone_bias`, `color_register_bands`), pre-resolved at + /// dispatch time — mirrors `context`'s own pre-resolution rationale, so + /// `assign_block_tags` never touches `systems.db` (T-987/D-230 purity). + /// A `Vec`/`BTreeMap`-backed struct is a handful of words on the stack + /// regardless of its contents' size (same reasoning already documented + /// for `FillChunk.block_tags` below), so this needs no `Box`. + exterior_catalog: ExteriorCatalog, }, /// Derive the building shell for one 64 m chunk of an existing quarter /// (D-230 derive phase, T-987). @@ -431,6 +441,7 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { economic_role, population, founding_age_years, + exterior_catalog, } => { // Build the Phase 1 skeleton from the pre-resolved D-199 context. // `economic_role`, `population`, and `founding_age_years` are the @@ -445,13 +456,16 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { *chain, ); // Step-3 building-property tags per footprint (D-229, #957): subdivide - // each block into building plots and tag them. + // each block into building plots and tag them. `exterior_catalog` + // (D-235, T-988) resolves each tag's BuildingExteriorTag in the + // same pass. let block_tags = assign_all_block_tags( &skeleton, context, economic_role, *founding_age_years, *chain, + exterior_catalog, ); GenCompletion::SkeletonGenerated { city_id: *city_id, @@ -591,6 +605,7 @@ mod tests { economic_role: "service_mixed".to_string(), population: 500_000, founding_age_years: 200, + exterior_catalog: ExteriorCatalog::default(), } } @@ -700,8 +715,10 @@ mod tests { fn quarter_with_one_building() -> QuarterWorldState { use crate::atlas::tile_condition::TileCondition; use crate::simulation::generator::{ - ArchitectureFlavorRef, BuildingEntryClass, BuildingPropertyTag, ConstructionEra, - EraCause, FloorExtent, FloorHeightProfile, QuarterSkeleton, TileRect, ZoneTypeId, + ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, BuildingPropertyTag, + ConstructionEra, EraCause, FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, + QuarterSkeleton, RoofForm, SetbackTier, StreetSurface, TileRect, WallMaterial, + ZoneTypeId, }; use std::collections::BTreeMap; @@ -721,6 +738,18 @@ mod tests { era: ConstructionEra::Founding, era_cause: EraCause::Original, initial_condition: TileCondition::Intact, + exterior: BuildingExteriorTag { + wall_material: WallMaterial::Generic, + roof_form: RoofForm::Generic, + facade_rhythm: FacadeRhythm::Generic, + setback_tier: SetbackTier::Standard, + color: HsvColor { + hue: 0, + sat: 0, + val: 5_000, + }, + street_surface: StreetSurface::Generic, + }, doors: Vec::new(), }], ); diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 220c05628..5b3572223 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -33,6 +33,7 @@ pub mod subbiome; pub mod tile_condition; pub mod trait_catalog_reader; pub mod trait_draw; +pub mod trait_exterior; pub mod trait_swerve; pub mod voxel; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index f307ffcfa..04acd2a8f 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -27,7 +27,9 @@ use crate::atlas::road_graph::{RoadGraph, RoadNode}; use crate::atlas::scale; use crate::atlas::skeleton_gen::derive_complexity; use crate::atlas::source_resolver::BodySourceResolverResource; -use crate::atlas::trait_catalog_reader::{TraitBias, TraitCatalogReaderResource, TraitTemplate}; +use crate::atlas::trait_catalog_reader::{ + ExteriorCatalog, TraitBias, TraitCatalogReaderResource, TraitTemplate, +}; use crate::atlas::trait_draw::{ complexity_k, draw_body_vocabulary, hard_gate_eligible, pick_district_dominant_by_type, VocabularyDrawInputs, @@ -177,6 +179,25 @@ fn drain_generation_completions( ), None => (Vec::new(), Vec::new()), }; + // D-235 exterior-grammar content (T-988), read alongside the + // D-232 catalog above — same L3→L4 dispatch-time rationale + // (`assign_block_tags` stays DB-free downstream, T-987/D-230). + // `templates` reuses the already-fetched `catalog` (itself + // OnceLock-cached inside the reader) rather than re-querying. + let exterior_catalog: ExteriorCatalog = match trait_catalog.as_ref() { + Some(tc) => ExteriorCatalog { + templates: catalog.clone(), + zone_bias: tc.0.read_zone_bias().unwrap_or_else(|e| { + tracing::warn!(body_id = %body_id, error = %e, "zone bias read failed — uniform draw everywhere"); + BTreeMap::new() + }), + color_bands: tc.0.read_color_register_bands().unwrap_or_else(|e| { + tracing::warn!(body_id = %body_id, error = %e, "color register bands read failed — neutral color everywhere"); + BTreeMap::new() + }), + }, + None => ExteriorCatalog::default(), + }; let body_sector: Option<&str> = resolved .first() .and_then(|(_, rs)| rs.geographic_sector.as_deref()); @@ -208,6 +229,7 @@ fn drain_generation_completions( catalog: &catalog, eligible: &eligible, swerve_pools: &swerve_pools, + exterior_catalog: &exterior_catalog, }; for (placement, read_set) in resolved { @@ -364,6 +386,12 @@ struct BodyVocabularyContext<'a> { /// heritage-callback), cloned onto each settlement's context — the /// per-building wildcard draws from these at `assign_block_tags` time. swerve_pools: &'a SwervePools, + /// D-235 exterior-grammar content (T-988): the catalog's `visual_bundle`s + /// plus the two sibling content tables, read once per body and cloned + /// verbatim onto every settlement's `GenerateSkeleton` work item — the + /// per-building `BuildingExteriorTag` draw happens at `assign_block_tags` + /// time (`atlas::trait_exterior`), never inside `FillChunk`. + exterior_catalog: &'a ExteriorCatalog, } /// A city's node degree in the T-1038 road/rail graph — the T-1003 swerve's @@ -534,6 +562,7 @@ fn build_skeleton_work_item( economic_role, population, founding_age_years, + exterior_catalog: vocab.exterior_catalog.clone(), } } @@ -790,12 +819,19 @@ mod tests { foreign: Vec::new(), heritage: Vec::new(), }; + // `ExteriorCatalog::default()` isn't a const fn (derived `Default`), + // so a `static` binding isn't available the way it is for + // `EMPTY_POOLS` above — leak a tiny one-off value instead (test-only, + // matches this function's existing 'static-returning contract). + let exterior_catalog: &'static ExteriorCatalog = + Box::leak(Box::new(ExteriorCatalog::default())); BodyVocabularyContext { trait_selection: &[], body_district_type_mix: &[], catalog: &[], eligible: &[], swerve_pools: &EMPTY_POOLS, + exterior_catalog, } } @@ -902,6 +938,7 @@ mod tests { base_weight: 10_000, weight_mods: BTreeMap::new(), zone_affinity: [(DistrictType::MixedUse, 10_000)].into_iter().collect(), + visual_bundle: Default::default(), } } let catalog = vec![tmpl("temp_a"), tmpl("temp_b")]; @@ -912,12 +949,14 @@ mod tests { foreign: vec![("foreign_x".to_string(), 10_000)], heritage: vec![("herit_y".to_string(), 10_000)], }; + let exterior_catalog = ExteriorCatalog::default(); let vocab = BodyVocabularyContext { trait_selection: &trait_selection, body_district_type_mix: &mix, catalog: &catalog, eligible: &eligible, swerve_pools: &pools, + exterior_catalog: &exterior_catalog, }; // City 1 sits in the road graph with degree 2; city 2 has no node (degree 0). diff --git a/server/src/atlas/shell.rs b/server/src/atlas/shell.rs index 4d91d80fa..95eb77a1a 100644 --- a/server/src/atlas/shell.rs +++ b/server/src/atlas/shell.rs @@ -54,7 +54,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::atlas::scale::{CHUNKS_PER_BLOCK, CHUNK_M, VOXELS_PER_CHUNK}; -use crate::simulation::generator::{BuildingPropertyTag, TileRect}; +use crate::simulation::generator::{BuildingPropertyTag, RoofForm, TileRect, WallMaterial}; /// One structural shell voxel material (D-230). /// @@ -63,7 +63,8 @@ use crate::simulation::generator::{BuildingPropertyTag, TileRect}; /// full four-way classification and so callers can match exhaustively. /// /// Integer-discriminant, append-only (D-010). Surface materials (D-235 `WallMaterial` -/// etc.) are a separate axis layered on top by T-988 — do not fold them in here. +/// etc.) are a separate axis layered on top — see [`SurfaceMaterial`] / T-988/T-959 — +/// do not fold them in here; this enum stays the pure structural shape. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[repr(u8)] pub enum ShellVoxel { @@ -78,6 +79,18 @@ pub enum ShellVoxel { Roof = 3, } +/// The D-235 surface material "layered on top of" a [`ShellVoxel::Wall`] or +/// [`ShellVoxel::Roof`] voxel (T-959/T-988) — read straight from the covering +/// building's frozen `BuildingPropertyTag.exterior`, never re-derived here +/// (this layer stays pure/cache-free, T-987). Absent for `Void`/`FloorSlab` +/// positions: D-235's vocabulary has no wall/roof token for interior air or a +/// floor slab's top surface (that is the separate D-228 `FloorMaterial` axis). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SurfaceMaterial { + Wall(WallMaterial), + Roof(RoofForm), +} + /// Chunk-local voxel coordinate: `(x, y)` in `0..64`, `z` quarter-ground-relative. /// /// `x`/`y` are chunk-local tile indices (D-243: a chunk is 64×64 voxels). `z` is the @@ -100,6 +113,14 @@ pub struct FilledChunk { pub sub_chunk: (u8, u8), /// Non-`Void` shell voxels, keyed by chunk-local position (D-010 ordered). pub voxels: BTreeMap, + /// D-235 surface material for every `Wall`/`Roof` entry in `voxels` + /// (T-959/T-988) — keyed identically, so a consumer joins the two maps by + /// position. Strictly additive over `voxels`: every key here has a + /// matching `Wall`/`Roof` entry there, but not vice versa is NOT + /// guaranteed to be exhaustive on a stale/foreign tag (see + /// `shell_derive_into`'s doc comment) — a missing entry means "render the + /// generic fallback for this axis," never a panic. + pub surface_material: BTreeMap, } impl FilledChunk { @@ -142,6 +163,7 @@ pub fn fill_chunk( "sub_chunk {sub_chunk:?} outside the block's {CHUNKS_PER_BLOCK}×{CHUNKS_PER_BLOCK} chunk grid" ); let mut voxels: BTreeMap = BTreeMap::new(); + let mut surface_material: BTreeMap = BTreeMap::new(); // Block-local tile range covered by this 64 m sub-chunk quadrant. let chunk_lo_x = sub_chunk.0 as i32 * CHUNK_M; @@ -152,6 +174,7 @@ pub fn fill_chunk( for tag in block_tags { shell_derive_into( &mut voxels, + &mut surface_material, tag, (chunk_lo_x, chunk_lo_y, chunk_hi_x, chunk_hi_y), ); @@ -162,17 +185,23 @@ pub fn fill_chunk( block_pos, sub_chunk, voxels, + surface_material, } } -/// Emit one building's shell voxels into `voxels`, clipped to the chunk's block-local -/// tile window `(lo_x, lo_y, hi_x, hi_y)` (hi exclusive). +/// Emit one building's shell voxels into `voxels` (plus their D-235 surface +/// material into `surface_material`, T-959/T-988), clipped to the chunk's +/// block-local tile window `(lo_x, lo_y, hi_x, hi_y)` (hi exclusive). /// /// Rectangle-containment (footprint ∩ chunk) × z-range (per-floor voxel bands from the /// [`FloorExtent`]), per D-230. Walls on the footprint perimeter for the full height, /// floor slabs on interior tiles at each floor base, a roof cap above the top floor. +/// Every `Wall`/`Roof` voxel this pass inserts gets a matching `surface_material` +/// entry from `tag.exterior` at the same position — the "layered on top of this +/// shell" T-988 promised. fn shell_derive_into( voxels: &mut BTreeMap, + surface_material: &mut BTreeMap, tag: &BuildingPropertyTag, window: (i32, i32, i32, i32), ) { @@ -239,6 +268,14 @@ fn shell_derive_into( continue; // interior air → Void, not stored }; voxels.insert((cx, cy, z), material); + if perimeter { + // D-235 (T-959/T-988): every Wall voxel carries the + // building's frozen exterior wall material alongside it. + surface_material.insert( + (cx, cy, z), + SurfaceMaterial::Wall(tag.exterior.wall_material), + ); + } } } } @@ -257,6 +294,10 @@ fn shell_derive_into( let cx = (tx - win_lo_x) as u8; let cy = (ty - win_lo_y) as u8; voxels.insert((cx, cy, rz), ShellVoxel::Roof); + // D-235 (T-959/T-988): every Roof voxel carries the building's + // frozen exterior roof form alongside it. + surface_material + .insert((cx, cy, rz), SurfaceMaterial::Roof(tag.exterior.roof_form)); } } } @@ -287,8 +328,9 @@ mod tests { use super::*; use crate::atlas::tile_condition::TileCondition; use crate::simulation::generator::{ - ArchitectureFlavorRef, BuildingEntryClass, ConstructionEra, EraCause, FloorExtent, - FloorHeightProfile, ZoneTypeId, + ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, ConstructionEra, EraCause, + FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, RoofForm, SetbackTier, + StreetSurface, WallMaterial, ZoneTypeId, }; /// Build a `BuildingPropertyTag` with the given block-local footprint and a @@ -312,6 +354,18 @@ mod tests { era: ConstructionEra::Founding, era_cause: EraCause::Original, initial_condition: TileCondition::Intact, + exterior: BuildingExteriorTag { + wall_material: WallMaterial::ConcreteWall, + roof_form: RoofForm::FlatRoof, + facade_rhythm: FacadeRhythm::RegularFacade, + setback_tier: SetbackTier::Standard, + color: HsvColor { + hue: 0, + sat: 0, + val: 5_000, + }, + street_surface: StreetSurface::Paved, + }, doors: Vec::new(), } } @@ -454,4 +508,76 @@ mod tests { fc.voxel_count() ); } + + // ── D-235 surface material — "layered on top of this shell" (T-959/T-988) ── + + #[test] + fn wall_voxels_carry_the_buildings_wall_material() { + let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + // Corner (2,2) is perimeter → Wall, per the existing shell test above. + assert_eq!(fc.get(2, 2, 0), ShellVoxel::Wall); + assert_eq!( + fc.surface_material.get(&(2, 2, 0)), + Some(&SurfaceMaterial::Wall(WallMaterial::ConcreteWall)) + ); + } + + #[test] + fn roof_voxels_carry_the_buildings_roof_form() { + let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + assert_eq!(fc.get(2, 2, 3), ShellVoxel::Roof); + assert_eq!( + fc.surface_material.get(&(2, 2, 3)), + Some(&SurfaceMaterial::Roof(RoofForm::FlatRoof)) + ); + } + + #[test] + fn floor_slab_and_void_carry_no_surface_material() { + let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + // Interior tile (3,3): FloorSlab at the base, Void above — see the + // shipped `single_storey_box_has_walls_floor_and_roof` test. + assert_eq!(fc.get(3, 3, 0), ShellVoxel::FloorSlab); + assert!(!fc.surface_material.contains_key(&(3, 3, 0))); + assert!(!fc.surface_material.contains_key(&(0, 0, 0))); // outside footprint (Void) + } + + #[test] + fn surface_material_count_matches_wall_plus_roof_voxels() { + let fc = fill_chunk( + 1, + (0, 0), + (0, 0), + &[tag((0, 0), (5, 5), 0, 3), tag((40, 40), (10, 8), -1, 2)], + ); + let wall_and_roof = fc + .voxels + .values() + .filter(|v| matches!(v, ShellVoxel::Wall | ShellVoxel::Roof)) + .count(); + assert_eq!(fc.surface_material.len(), wall_and_roof); + } + + #[test] + fn distinct_buildings_carry_their_own_distinct_materials() { + // Two buildings in the same chunk with different exterior tags must + // never bleed into each other's surface_material — this is per-tag + // data, not a chunk-wide constant. + let mut b1 = tag((0, 0), (5, 5), 0, 1); + b1.exterior.wall_material = WallMaterial::StoneWall; + b1.exterior.roof_form = RoofForm::VaultedRoof; + let mut b2 = tag((40, 40), (5, 5), 0, 1); + b2.exterior.wall_material = WallMaterial::TimberWall; + b2.exterior.roof_form = RoofForm::PitchedRoof; + + let fc = fill_chunk(1, (0, 0), (0, 0), &[b1, b2]); + assert_eq!( + fc.surface_material.get(&(0, 0, 0)), + Some(&SurfaceMaterial::Wall(WallMaterial::StoneWall)) + ); + assert_eq!( + fc.surface_material.get(&(40, 40, 0)), + Some(&SurfaceMaterial::Wall(WallMaterial::TimberWall)) + ); + } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 4040cb89a..b90577673 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -21,6 +21,8 @@ use crate::atlas::block_irregularity::block_irregularity; use crate::atlas::district_mix::{compute_district_mix, population_tier}; use crate::atlas::tile_condition::{tile_condition, TileCondition}; +use crate::atlas::trait_catalog_reader::ExteriorCatalog; +use crate::atlas::trait_exterior; use crate::atlas::trait_swerve::{roll_building_swerve, SwerveRates}; use crate::seed::splitmix64; use crate::seed::{SeedChain, SeedDomain}; @@ -30,9 +32,9 @@ use crate::simulation::generator::{ AccessKind, AccessPoint, ArchitectureFlavorRef, BlockPlacement, BlockSkeleton, BuildingEntryClass, BuildingPropertyTag, BulkClass, ChunkLayout, CityGenerationContext, ComplexityTier, ConstructionEra, CorridorSpine, DistrictLayoutMode, DistrictType, EraCause, - FloorExtent, FloorHeightProfile, FoundingOrientation, MorphologyZone, MultiBlockReservation, - PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, ReservationId, - SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType, + FloorExtent, FloorHeightProfile, FoundingOrientation, InterstitialCharacter, MorphologyZone, + MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, + ReservationId, SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType, }; // --------------------------------------------------------------------------- @@ -104,7 +106,12 @@ pub fn generate_quarter_skeleton( .first() .cloned() .unwrap_or(DistrictType::MixedUse); - let mut blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type); + let mut blocks = build_block_grid( + &mix.districts, + &block_reservation, + &primary_district_type, + &context.dominant_bulk_class, + ); // ── Street network (D-234) ──────────────────────────────────────────── // Local lattice modulation per block (Grid vs Organic), then the quarter's @@ -322,15 +329,24 @@ fn district_type_from_mix(primary: &DistrictType) -> DistrictType { /// /// Blocks are assigned row-major (index = row * 4 + col). /// Reserved blocks retain their zoning from the district mix but link to the reservation. +/// +/// `dominant_bulk_class` sets every block's `interstitial_character` (D-233, +/// T-1097): the settlement-wide bulk-class signal already drives +/// `roofed_coverage_pct`/`subdivide_block_footprints` uniformly across every +/// block regardless of that block's own zoning (an existing simplification, +/// not a new one) — the ops-surface classification follows the identical +/// granularity for consistency. fn build_block_grid( districts: &[DistrictType], block_reservation: &[[Option; 4]; 4], primary: &DistrictType, + dominant_bulk_class: &BulkClass, ) -> [[BlockSkeleton; 4]; 4] { // Pad or truncate district list to exactly 16. let district_iter: Vec<&DistrictType> = (0..16) .map(|i| districts.get(i).unwrap_or(primary)) .collect(); + let interstitial_character = interstitial_character_for(dominant_bulk_class); core::array::from_fn(|row| { core::array::from_fn(|col| { @@ -356,11 +372,24 @@ fn build_block_grid( era_cause: None, density_pct: density, landmark: None, + interstitial_character, } }) }) } +/// D-233 ops-surface classification (T-1097): bulk-industry settlements read +/// their blocks' non-roofed remainder as built economic infrastructure, never +/// generic interstitial open space. +fn interstitial_character_for(bulk: &BulkClass) -> InterstitialCharacter { + match bulk { + BulkClass::BulkSolid | BulkClass::BulkLiquid => InterstitialCharacter::OperationsSurface, + BulkClass::PrecisionDense | BulkClass::Perishable | BulkClass::NonPhysical => { + InterstitialCharacter::OpenSpace + } + } +} + /// Default build density percentage for a zoning type. fn density_for_zoning(zoning: &ZoningType) -> u8 { match zoning { @@ -859,6 +888,10 @@ fn subdivide_block_footprints( /// Tag every building footprint in one block (D-229). Reserved blocks (parks, /// terminals, plazas) are open/special-use and get no standard building fill. +/// +/// `exterior_catalog` is the D-235 exterior-grammar content (T-988), +/// pre-resolved at L3→L4 dispatch time alongside the rest of the D-232 catalog +/// data — this function never touches `systems.db` (T-987/D-230 purity). fn assign_block_tags( block: &BlockSkeleton, skeleton: &QuarterSkeleton, @@ -867,6 +900,7 @@ fn assign_block_tags( founding_age_years: u32, waterfront: Option, block_chain: SeedChain, + exterior_catalog: &ExteriorCatalog, ) -> Vec { if block.reservation.is_some() { return Vec::new(); @@ -937,6 +971,35 @@ fn assign_block_tags( Some(tag) => ArchitectureFlavorRef::Swerve(tag), None => district_dominant.clone(), }; + // D-235 (T-988): resolve the exterior grammar from this SAME + // finalized flavor_ref, at the same plan-time point — "Swerve(tag) + // refs resolve against the full catalog at the same point" (the + // T-994 phase-timing precedent this ticket follows). InVocabulary + // indexes into context.trait_selection; an out-of-bounds index or + // a Swerve tag absent from the catalog (a content gap, or a test + // fixture with no catalog wired) resolves to `None` — the + // derivation degrades to Generic/neutral rather than panicking. + let resolved_tag: Option<&str> = match &flavor_ref { + ArchitectureFlavorRef::InVocabulary(idx) => context + .trait_selection + .get(*idx as usize) + .map(String::as_str), + ArchitectureFlavorRef::Swerve(tag) => Some(tag.as_str()), + }; + let template = resolved_tag + .and_then(|tag| exterior_catalog.templates.iter().find(|t| t.tag == tag)); + let zone_bias = resolved_tag.and_then(|tag| { + exterior_catalog + .zone_bias + .get(&(tag.to_string(), zone_type_id.as_str().to_string())) + }); + let exterior = trait_exterior::derive_building_exterior( + template, + zone_bias, + &exterior_catalog.color_bands, + block.density_pct, + fp_chain, + ); BuildingPropertyTag { zone_type_id, footprint, @@ -946,6 +1009,7 @@ fn assign_block_tags( era, era_cause, initial_condition: initial, + exterior, doors: Vec::new(), // D-231 door derivation is #979 } }) @@ -954,12 +1018,16 @@ fn assign_block_tags( /// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag /// every non-reserved block's footprints. Keyed by 4×4 block grid position. +/// +/// `exterior_catalog` is the D-235 exterior-grammar content (T-988) — +/// threaded straight through to [`assign_block_tags`]; see its doc comment. pub fn assign_all_block_tags( skeleton: &QuarterSkeleton, context: &CityGenerationContext, economic_role: &str, founding_age_years: u32, chain: SeedChain, + exterior_catalog: &ExteriorCatalog, ) -> BTreeMap<(u8, u8), Vec> { // Water-facing quarter edge from the settlement's coastal founding // orientation (D-234b); blocks on it present flush to the quay. @@ -978,6 +1046,7 @@ pub fn assign_all_block_tags( founding_age_years, waterfront, block_chain, + exterior_catalog, ); if !tags.is_empty() { map.insert((row, col), tags); @@ -1685,7 +1754,14 @@ mod tests { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); let sk = generate_quarter_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42)); - let tags = assign_all_block_tags(&sk, &ctx, "financial", 300, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 300, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); assert!(!tags.is_empty(), "quarter should produce building tags"); // Park reservation blocks (1,2),(1,3),(2,2),(2,3) get no standard fill. for &reserved in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] { @@ -1715,8 +1791,22 @@ mod tests { let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional); let sk = generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5)); - let a = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5)); - let b = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5)); + let a = assign_all_block_tags( + &sk, + &ctx, + "manufacturing", + 250, + SeedChain::root(5), + &ExteriorCatalog::default(), + ); + let b = assign_all_block_tags( + &sk, + &ctx, + "manufacturing", + 250, + SeedChain::root(5), + &ExteriorCatalog::default(), + ); assert_eq!(a, b); } @@ -1738,7 +1828,14 @@ mod tests { ); let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); let mut found_residential = false; for (pos, block_tags) in &tags { @@ -1768,7 +1865,14 @@ mod tests { // the harmless pre-T-994 degenerate behaviour. let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); for block_tags in tags.values() { for tag in block_tags { assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0)); @@ -1791,8 +1895,22 @@ mod tests { ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)]; let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags_a = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); - let tags_b = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags_a = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); + let tags_b = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)"); let mut total = 0usize; @@ -1830,7 +1948,14 @@ mod tests { ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)]; // rates stay (0, 0) from make_context let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); for block_tags in tags.values() { for tag in block_tags { assert!(matches!( @@ -1841,6 +1966,252 @@ mod tests { } } + // ── D-235 BuildingExteriorTag integration (T-988) ──────────────────────── + + use crate::atlas::trait_catalog_reader::{TraitTemplate, VisualBundle}; + use crate::simulation::generator::{FacadeRhythm, RoofForm, StreetSurface, WallMaterial}; + + fn exterior_template(tag: &str, wall: WallMaterial, roof: RoofForm) -> TraitTemplate { + TraitTemplate { + tag: tag.to_string(), + corridor_pool: "cross_corridor".to_string(), + geographic_sector: None, + bulk_class_gate: Vec::new(), + production_ubiquity_gate: Vec::new(), + min_prosperity_bps: 0, + base_weight: 10_000, + weight_mods: BTreeMap::new(), + zone_affinity: BTreeMap::new(), + visual_bundle: VisualBundle { + wall: vec![wall], + roof: vec![roof], + facade: vec![FacadeRhythm::IndustrialGlazing], + street: vec![StreetSurface::HeavyHaul], + color_register: None, + fallback: BTreeMap::new(), + }, + } + } + + #[test] + fn assign_block_tags_resolves_exterior_from_in_vocabulary_flavor_ref() { + // The InVocabulary(0) path: flavor_ref indexes into trait_selection, + // which must resolve against exterior_catalog.templates to drive the + // frozen BuildingExteriorTag — the T-988 deliverable. + let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + ctx.trait_selection = vec!["camp".to_string()]; + ctx.district_dominant_by_type.insert( + DistrictType::Residential, + ArchitectureFlavorRef::InVocabulary(0), + ); + let exterior_catalog = ExteriorCatalog { + templates: vec![exterior_template( + "camp", + WallMaterial::SteelFrame, + RoofForm::CorrugatedRoof, + )], + zone_bias: BTreeMap::new(), + color_bands: BTreeMap::new(), + }; + + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &exterior_catalog, + ); + + let mut found_residential = false; + for (pos, block_tags) in &tags { + let block = &sk.blocks[pos.0 as usize][pos.1 as usize]; + if block.district_type != DistrictType::Residential { + continue; + } + found_residential = true; + let expected_setback = trait_exterior::derive_setback_tier(block.density_pct); + for tag in block_tags { + assert_eq!(tag.exterior.wall_material, WallMaterial::SteelFrame); + assert_eq!(tag.exterior.roof_form, RoofForm::CorrugatedRoof); + assert_eq!( + tag.exterior.setback_tier, expected_setback, + "setback_tier must follow the block's own density_pct (D-235 step 3)" + ); + } + } + assert!( + found_residential, + "expected at least one Residential block (D-194 pop-tier guarantee)" + ); + } + + #[test] + fn assign_block_tags_resolves_exterior_for_swerved_buildings_too() { + // "Swerve(tag) refs resolve against the full catalog at the same + // point" — a swerved building's exterior must come from ITS OWN + // (out-of-vocabulary) template, distinct from the district-dominant one. + let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + ctx.trait_selection = vec!["own".to_string()]; + ctx.swerve_rates_bps = (10_000, 0); // always swerve, foreign only + ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)]; + + let exterior_catalog = ExteriorCatalog { + templates: vec![ + exterior_template("own", WallMaterial::ConcreteWall, RoofForm::FlatRoof), + exterior_template( + "foreign_temple", + WallMaterial::TimberWall, + RoofForm::ClayTileRoof, + ), + ], + zone_bias: BTreeMap::new(), + color_bands: BTreeMap::new(), + }; + + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &exterior_catalog, + ); + + let mut any_swerved = false; + for block_tags in tags.values() { + for tag in block_tags { + if matches!(&tag.flavor_ref, ArchitectureFlavorRef::Swerve(t) if t == "foreign_temple") + { + any_swerved = true; + assert_eq!(tag.exterior.wall_material, WallMaterial::TimberWall); + assert_eq!(tag.exterior.roof_form, RoofForm::ClayTileRoof); + } + } + } + assert!( + any_swerved, + "swerve_rates_bps=10_000 must swerve every building" + ); + } + + #[test] + fn assign_block_tags_falls_back_to_generic_when_template_unresolvable() { + // An InVocabulary index with no matching catalog entry (empty + // ExteriorCatalog, e.g. no catalog reader wired) must degrade to + // Generic/neutral, never panic. + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); + assert!(!tags.is_empty()); + for block_tags in tags.values() { + for tag in block_tags { + assert_eq!(tag.exterior.wall_material, WallMaterial::Generic); + assert_eq!(tag.exterior.roof_form, RoofForm::Generic); + } + } + } + + #[test] + fn assign_block_tags_is_deterministic_including_exterior() { + let mut ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional); + ctx.trait_selection = vec!["camp".to_string()]; + let exterior_catalog = ExteriorCatalog { + templates: vec![exterior_template( + "camp", + WallMaterial::BrickWall, + RoofForm::PitchedRoof, + )], + zone_bias: BTreeMap::new(), + color_bands: BTreeMap::new(), + }; + let sk = + generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5)); + let a = assign_all_block_tags( + &sk, + &ctx, + "manufacturing", + 250, + SeedChain::root(5), + &exterior_catalog, + ); + let b = assign_all_block_tags( + &sk, + &ctx, + "manufacturing", + 250, + SeedChain::root(5), + &exterior_catalog, + ); + assert_eq!(a, b, "same seeds must produce identical exterior tags too"); + } + + // ── D-233 operations-surface (T-1097) ──────────────────────────────────── + + #[test] + fn bulk_industry_blocks_read_as_operations_surface() { + assert_eq!( + interstitial_character_for(&BulkClass::BulkSolid), + InterstitialCharacter::OperationsSurface + ); + assert_eq!( + interstitial_character_for(&BulkClass::BulkLiquid), + InterstitialCharacter::OperationsSurface + ); + assert_eq!( + interstitial_character_for(&BulkClass::PrecisionDense), + InterstitialCharacter::OpenSpace + ); + assert_eq!( + interstitial_character_for(&BulkClass::Perishable), + InterstitialCharacter::OpenSpace + ); + assert_eq!( + interstitial_character_for(&BulkClass::NonPhysical), + InterstitialCharacter::OpenSpace + ); + } + + #[test] + fn every_block_in_a_bulk_industry_settlement_reads_operations_surface() { + let mut ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional); + ctx.dominant_bulk_class = BulkClass::BulkSolid; + let sk = + generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5)); + for row in &sk.blocks { + for block in row { + assert_eq!( + block.interstitial_character, + InterstitialCharacter::OperationsSurface + ); + } + } + } + + #[test] + fn non_bulk_settlement_keeps_open_space() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + // make_context defaults dominant_bulk_class to NonPhysical. + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + for row in &sk.blocks { + for block in row { + assert_eq!( + block.interstitial_character, + InterstitialCharacter::OpenSpace + ); + } + } + } + // ── Street network (#957, D-234) ───────────────────────────────────────── use crate::simulation::generator::AccessKind; diff --git a/server/src/atlas/trait_catalog_reader.rs b/server/src/atlas/trait_catalog_reader.rs index d9cb97f0a..9d3796eff 100644 --- a/server/src/atlas/trait_catalog_reader.rs +++ b/server/src/atlas/trait_catalog_reader.rs @@ -14,17 +14,23 @@ //! table; it never writes to `systems.db`. //! //! **Scope (T-994):** only the fields the three-phase draw mechanism needs are -//! parsed — `allow_tags`/`block_tags`/`era_scope`/`visual_bundle`/ -//! `cultural_description` are D-235 (visual bundle resolution) territory and are -//! left unparsed here. +//! parsed — `allow_tags`/`block_tags`/`era_scope`/`cultural_description` stay +//! unparsed (never consumed downstream of the draw). `visual_bundle` — D-235 +//! visual-bundle resolution territory — is parsed as of T-988, alongside the +//! two sibling D-235 content tables (`architecture_zone_bias`, +//! `color_register_bands`) this reader also owns. +use std::collections::BTreeMap; use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; use rusqlite::{Connection, OpenFlags}; use thiserror::Error; -use crate::simulation::generator::{BulkClass, DistrictType, ProductionUbiquity}; +use crate::simulation::generator::{ + BulkClass, DistrictType, FacadeRhythm, ProductionUbiquity, RoofForm, StreetSurface, + WallMaterial, +}; // --------------------------------------------------------------------------- // Error type @@ -76,6 +82,35 @@ pub struct TraitTemplate { /// `DistrictType` → weight_bps. The phase-2 dominant-template pick's sole /// input (D-232: "district-dominant by zone_affinity"). pub zone_affinity: std::collections::BTreeMap, + /// D-235 visual bundle (T-988): the per-axis token set this template's + /// buildings are filtered to, plus the `color_register` they sample from. + /// Empty/`None` fields when the column is absent (bootstrap/test catalogs). + pub visual_bundle: VisualBundle, +} + +/// Parsed `visual_bundle` column (D-235, T-988) — the D-235-step-1 filtered +/// token set per axis, this template's color register, and the (illustrative, +/// non-exhaustive — D-235 amendment) per-template asset fallback chain. +/// +/// `wall`/`roof`/`facade`/`street` are the axes `BuildingExteriorTag` draws +/// from (`atlas::trait_exterior`); never mixed across axes (a template is a +/// coherent bundle, D-232). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct VisualBundle { + pub wall: Vec, + pub roof: Vec, + pub facade: Vec, + pub street: Vec, + /// Key into `color_register_bands` (T-988). `None` for a template with no + /// authored register (bootstrap/test catalogs) — callers fall back to a + /// neutral default color rather than treating it as an error. + pub color_register: Option, + /// Specific token → generic-parent token (D-235 asset-resolution fallback + /// chain). Illustrative/non-exhaustive per-template documentation, NOT the + /// validated fallback graph (that lives in `object_tag_vocabulary.toml`, + /// V-TT-04) — carried for completeness; Phase-4 generation never reads it + /// (render-fidelity degrade is a Phase 5+ asset-resolution concern). + pub fallback: BTreeMap, } /// Bias kind on an `atlas_body_trait_bias` row (D-232 hero-body wiki bias). @@ -98,6 +133,45 @@ pub struct TraitBias { pub weight_multiplier_bps: Option, } +/// One `(template, zone_type)` D-235 step-2 bias entry (T-988): per-axis +/// token weight overrides (basis points) within that template's own filtered +/// `visual_bundle`. Only axes/tokens the wiki authored a lean for are +/// present — an axis with no entry (or a token missing from a present axis +/// entry) uses the uniform baseline (`architecture_zone_bias.toml`'s header). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ZoneBias { + pub wall: BTreeMap, + pub roof: BTreeMap, + pub facade: BTreeMap, + pub street: BTreeMap, +} + +/// One integer HSV sampling band for a `color_register` value (D-235, +/// T-988). `hue` is centidegrees (0..36000); `sat`/`val` are basis points +/// (0..10000) — see `color_register_bands.toml`'s header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColorBand { + pub hue_min: u32, + pub hue_max: u32, + pub sat_min: u32, + pub sat_max: u32, + pub val_min: u32, + pub val_max: u32, +} + +/// The full D-235 exterior-grammar content bundle (T-988): the template +/// catalog plus the two sibling tables that bias and color-sample within it. +/// Read once per body dispatch ([`TraitCatalogReader::read_exterior_catalog`]) +/// and threaded verbatim through every settlement's `GenerateSkeleton` work +/// item — `assign_block_tags` (`atlas::skeleton_gen`) never touches +/// `systems.db` (T-987/D-230 purity). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ExteriorCatalog { + pub templates: Vec, + pub zone_bias: BTreeMap<(String, String), ZoneBias>, + pub color_bands: BTreeMap, +} + // --------------------------------------------------------------------------- // Reader // --------------------------------------------------------------------------- @@ -114,6 +188,12 @@ pub struct TraitCatalogReader { /// every later body dispatch clones the parsed rows. Per-body bias is NOT /// cached — it legitimately varies per body. catalog_cache: OnceLock>, + /// One-shot cache for `architecture_zone_bias` (T-988) — same immutable- + /// per-run rationale as `catalog_cache`. + zone_bias_cache: OnceLock>, + /// One-shot cache for `color_register_bands` (T-988) — same immutable- + /// per-run rationale as `catalog_cache`. + color_bands_cache: OnceLock>, } impl TraitCatalogReader { @@ -124,6 +204,8 @@ impl TraitCatalogReader { Ok(Self { conn: Arc::new(Mutex::new(conn)), catalog_cache: OnceLock::new(), + zone_bias_cache: OnceLock::new(), + color_bands_cache: OnceLock::new(), }) } @@ -150,7 +232,7 @@ impl TraitCatalogReader { .prepare( "SELECT tag, corridor_pool, geographic_sector, bulk_class_gate, production_ubiquity_gate, min_prosperity_bps, base_weight, - weight_mods, zone_affinity + weight_mods, zone_affinity, visual_bundle FROM trait_templates ORDER BY tag", ) @@ -167,6 +249,7 @@ impl TraitCatalogReader { row.get::<_, i64>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, )) }) .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; @@ -183,6 +266,7 @@ impl TraitCatalogReader { base_weight, weight_mods_json, zone_affinity_json, + visual_bundle_json, ) = r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; out.push(TraitTemplate { @@ -195,6 +279,7 @@ impl TraitCatalogReader { base_weight: base_weight.max(0) as u32, weight_mods: parse_weight_mods(weight_mods_json.as_deref(), &tag), zone_affinity: parse_zone_affinity(zone_affinity_json.as_deref(), &tag), + visual_bundle: parse_visual_bundle(visual_bundle_json.as_deref(), &tag), tag, corridor_pool, geographic_sector, @@ -249,6 +334,131 @@ impl TraitCatalogReader { } Ok(out) } + + /// Read the D-235 step-2 zone-type bias table (T-988), keyed by + /// `(template_tag, zone_type_id)`. Sparse by design (`architecture_zone_bias.toml`'s + /// own header): a missing key means "uniform draw", not an error — see + /// `atlas::trait_exterior`. Cached after the first successful read (H5, + /// same rationale as [`Self::read_catalog`]). + pub fn read_zone_bias( + &self, + ) -> Result, TraitCatalogReadError> { + if let Some(cached) = self.zone_bias_cache.get() { + return Ok(cached.clone()); + } + let map = self.read_zone_bias_uncached()?; + let _ = self.zone_bias_cache.set(map.clone()); + Ok(map) + } + + fn read_zone_bias_uncached( + &self, + ) -> Result, TraitCatalogReadError> { + let conn = self + .conn + .lock() + .map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?; + let mut stmt = conn + .prepare( + "SELECT template_tag, zone_type_id, bias + FROM architecture_zone_bias + ORDER BY template_tag, zone_type_id", + ) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + + let mut out = BTreeMap::new(); + for r in rows { + let (template_tag, zone_type_id, bias_json) = + r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let key_label = format!("{template_tag}.{zone_type_id}"); + let bias = parse_zone_bias(&bias_json, &key_label); + out.insert((template_tag, zone_type_id), bias); + } + Ok(out) + } + + /// Read the D-235 color-register HSV bands (T-988), keyed by + /// `color_register`. Cached after the first successful read (H5, same + /// rationale as [`Self::read_catalog`]). + pub fn read_color_register_bands( + &self, + ) -> Result, TraitCatalogReadError> { + if let Some(cached) = self.color_bands_cache.get() { + return Ok(cached.clone()); + } + let map = self.read_color_register_bands_uncached()?; + let _ = self.color_bands_cache.set(map.clone()); + Ok(map) + } + + fn read_color_register_bands_uncached( + &self, + ) -> Result, TraitCatalogReadError> { + let conn = self + .conn + .lock() + .map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?; + let mut stmt = conn + .prepare( + "SELECT color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max + FROM color_register_bands + ORDER BY color_register", + ) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + }) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + + let mut out = BTreeMap::new(); + for r in rows { + let (color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max) = + r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + out.insert( + color_register, + ColorBand { + hue_min: hue_min.max(0) as u32, + hue_max: hue_max.max(0) as u32, + sat_min: sat_min.max(0) as u32, + sat_max: sat_max.max(0) as u32, + val_min: val_min.max(0) as u32, + val_max: val_max.max(0) as u32, + }, + ); + } + Ok(out) + } + + /// Read the full D-235 exterior-grammar catalog in one call (T-988): + /// the template catalog plus its two sibling content tables. Convenience + /// wrapper for callers (`atlas::plugin`'s L3→L4 dispatch) that need all + /// three to build an [`ExteriorCatalog`] to thread through a settlement's + /// `GenerateSkeleton` work item. + pub fn read_exterior_catalog(&self) -> Result { + Ok(ExteriorCatalog { + templates: self.read_catalog()?, + zone_bias: self.read_zone_bias()?, + color_bands: self.read_color_register_bands()?, + }) + } } // --------------------------------------------------------------------------- @@ -394,6 +604,204 @@ fn parse_zone_affinity( .collect() } +// --------------------------------------------------------------------------- +// D-235 ObjectTag axis parsing (T-988) — patch tolerance: an unrecognized +// registry string never fails the read, it degrades to the axis `Generic` +// variant with a `tracing::warn!` (matches the wider "unknown -> safe +// fallback" convention already used above for bias_kind/BulkClass/etc., but +// these four are the ONLY axes where the safe fallback is a *type-level* +// enum variant rather than an omission from a Vec/map). +// --------------------------------------------------------------------------- + +fn parse_wall_material(s: &str, tag: &str) -> WallMaterial { + match s { + "concrete_wall" => WallMaterial::ConcreteWall, + "steel_frame" => WallMaterial::SteelFrame, + "brick_wall" => WallMaterial::BrickWall, + "rendered_wall" => WallMaterial::RenderedWall, + "stone_wall" => WallMaterial::StoneWall, + "timber_wall" => WallMaterial::TimberWall, + "stucco_wall" => WallMaterial::StuccoWall, + "glass_curtain_wall" => WallMaterial::GlassCurtainWall, + "composite_panel" => WallMaterial::CompositePanel, + "rammed_earth_wall" => WallMaterial::RammedEarthWall, + "generic_wall" => WallMaterial::Generic, + other => { + tracing::warn!( + tag, + axis = "wall", + value = other, + "unrecognized WallMaterial token — falling back to Generic" + ); + WallMaterial::Generic + } + } +} + +fn parse_roof_form(s: &str, tag: &str) -> RoofForm { + match s { + "flat_roof" => RoofForm::FlatRoof, + "pitched_roof" => RoofForm::PitchedRoof, + "corrugated_roof" => RoofForm::CorrugatedRoof, + "clay_tile_roof" => RoofForm::ClayTileRoof, + "terraced_roof" => RoofForm::TerracedRoof, + "vaulted_roof" => RoofForm::VaultedRoof, + "green_roof" => RoofForm::GreenRoof, + "generic_roof" => RoofForm::Generic, + other => { + tracing::warn!( + tag, + axis = "roof", + value = other, + "unrecognized RoofForm token — falling back to Generic" + ); + RoofForm::Generic + } + } +} + +fn parse_facade_rhythm(s: &str, tag: &str) -> FacadeRhythm { + match s { + "regular_facade" => FacadeRhythm::RegularFacade, + "ornamental_facade" => FacadeRhythm::OrnamentalFacade, + "industrial_glazing" => FacadeRhythm::IndustrialGlazing, + "arcade_facade" => FacadeRhythm::ArcadeFacade, + "shuttered_facade" => FacadeRhythm::ShutteredFacade, + "screen_facade" => FacadeRhythm::ScreenFacade, + "colonnade" => FacadeRhythm::Colonnade, + "lattice_screen" => FacadeRhythm::LatticeScreen, + "generic_facade" => FacadeRhythm::Generic, + other => { + tracing::warn!( + tag, + axis = "facade", + value = other, + "unrecognized FacadeRhythm token — falling back to Generic" + ); + FacadeRhythm::Generic + } + } +} + +fn parse_street_surface(s: &str, tag: &str) -> StreetSurface { + match s { + "paved" => StreetSurface::Paved, + "cobble" => StreetSurface::Cobble, + "packed_earth" => StreetSurface::PackedEarth, + "canal_way" => StreetSurface::CanalWay, + "elevated_walkway" => StreetSurface::ElevatedWalkway, + "heavy_haul" => StreetSurface::HeavyHaul, + "boardwalk" => StreetSurface::Boardwalk, + "generic_street" => StreetSurface::Generic, + other => { + tracing::warn!( + tag, + axis = "street", + value = other, + "unrecognized StreetSurface token — falling back to Generic" + ); + StreetSurface::Generic + } + } +} + +/// Raw shape of the `visual_bundle` JSON column — deserialized once, then +/// each axis's string tokens are mapped through the `parse_*` functions above. +#[derive(serde::Deserialize)] +struct RawVisualBundle { + #[serde(default)] + wall: Vec, + #[serde(default)] + roof: Vec, + #[serde(default)] + facade: Vec, + #[serde(default)] + street: Vec, + color_register: Option, + #[serde(default)] + fallback: BTreeMap, +} + +fn parse_visual_bundle(json: Option<&str>, tag: &str) -> VisualBundle { + let Some(json) = json else { + return VisualBundle::default(); + }; + let raw: RawVisualBundle = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + tracing::warn!(tag, error = %e, "malformed visual_bundle JSON — treating as empty"); + return VisualBundle::default(); + } + }; + VisualBundle { + wall: raw + .wall + .iter() + .map(|s| parse_wall_material(s, tag)) + .collect(), + roof: raw.roof.iter().map(|s| parse_roof_form(s, tag)).collect(), + facade: raw + .facade + .iter() + .map(|s| parse_facade_rhythm(s, tag)) + .collect(), + street: raw + .street + .iter() + .map(|s| parse_street_surface(s, tag)) + .collect(), + color_register: raw.color_register, + fallback: raw.fallback, + } +} + +/// Raw shape of one `architecture_zone_bias.bias` JSON column — each axis is +/// optional (a template/zone_type entry rarely biases every axis). +#[derive(serde::Deserialize, Default)] +struct RawZoneBias { + #[serde(default)] + wall: BTreeMap, + #[serde(default)] + roof: BTreeMap, + #[serde(default)] + facade: BTreeMap, + #[serde(default)] + street: BTreeMap, +} + +/// `label` is `"{template_tag}.{zone_type_id}"`, for warning context only. +fn parse_zone_bias(json: &str, label: &str) -> ZoneBias { + let raw: RawZoneBias = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + tracing::warn!(label, error = %e, "malformed architecture_zone_bias JSON — treating as empty"); + return ZoneBias::default(); + } + }; + ZoneBias { + wall: raw + .wall + .into_iter() + .map(|(k, w)| (parse_wall_material(&k, label), w)) + .collect(), + roof: raw + .roof + .into_iter() + .map(|(k, w)| (parse_roof_form(&k, label), w)) + .collect(), + facade: raw + .facade + .into_iter() + .map(|(k, w)| (parse_facade_rhythm(&k, label), w)) + .collect(), + street: raw + .street + .into_iter() + .map(|(k, w)| (parse_street_surface(&k, label), w)) + .collect(), + } +} + // --------------------------------------------------------------------------- // Bevy resource wrapper // --------------------------------------------------------------------------- @@ -466,12 +874,13 @@ mod tests { "INSERT INTO trait_templates (tag, label, corridor_pool, geographic_sector, bulk_class_gate, production_ubiquity_gate, min_prosperity_bps, base_weight, - weight_mods, zone_affinity) + weight_mods, zone_affinity, visual_bundle) VALUES ('extraction_camp', 'Extraction Camp', 'cross_corridor', NULL, '[\"BulkSolid\",\"BulkLiquid\"]', '[\"MonopolySource\",\"Specialist\"]', 0, 12000, '{\"economic_role\":{\"mining\":20000},\"geographic_sector\":{\"east_reach\":13000}}', - '{\"Industrial\":20000,\"LogisticsHub\":13000}')", + '{\"Industrial\":20000,\"LogisticsHub\":13000}', + '{\"wall\":[\"steel_frame\",\"concrete_wall\"],\"roof\":[\"corrugated_roof\"],\"facade\":[\"industrial_glazing\"],\"street\":[\"heavy_haul\"],\"color_register\":\"oxide_and_dust\",\"fallback\":{\"steel_frame\":\"generic_wall\"}}')", [], ) .expect("insert extraction_camp"); @@ -489,6 +898,39 @@ mod tests { ) .expect("insert bias 2"); + // T-988: the two sibling D-235 content tables. + conn.execute_batch( + "CREATE TABLE architecture_zone_bias ( + template_tag TEXT NOT NULL, + zone_type_id TEXT NOT NULL, + bias TEXT NOT NULL, + PRIMARY KEY (template_tag, zone_type_id) + ); + CREATE TABLE color_register_bands ( + color_register TEXT PRIMARY KEY, + hue_min INTEGER NOT NULL, + hue_max INTEGER NOT NULL, + sat_min INTEGER NOT NULL, + sat_max INTEGER NOT NULL, + val_min INTEGER NOT NULL, + val_max INTEGER NOT NULL + );", + ) + .expect("create D-235 content tables"); + conn.execute( + "INSERT INTO architecture_zone_bias (template_tag, zone_type_id, bias) + VALUES ('extraction_camp', 'extraction_platform', '{\"wall\":{\"steel_frame\":18000}}')", + [], + ) + .expect("insert zone bias"); + conn.execute( + "INSERT INTO color_register_bands + (color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max) + VALUES ('oxide_and_dust', 2000, 3000, 1800, 3200, 3200, 4800)", + [], + ) + .expect("insert color band"); + drop(conn); path } @@ -580,4 +1022,117 @@ mod tests { .expect("read bias"); assert!(bias.is_empty()); } + + // ── D-235 visual_bundle parsing (T-988) ────────────────────────────────── + + #[test] + fn read_catalog_parses_visual_bundle() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let catalog = reader.read_catalog().expect("read catalog"); + let camp = catalog + .iter() + .find(|t| t.tag == "extraction_camp") + .expect("extraction_camp present"); + assert_eq!( + camp.visual_bundle.wall, + vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall] + ); + assert_eq!(camp.visual_bundle.roof, vec![RoofForm::CorrugatedRoof]); + assert_eq!( + camp.visual_bundle.facade, + vec![FacadeRhythm::IndustrialGlazing] + ); + assert_eq!(camp.visual_bundle.street, vec![StreetSurface::HeavyHaul]); + assert_eq!( + camp.visual_bundle.color_register.as_deref(), + Some("oxide_and_dust") + ); + assert_eq!( + camp.visual_bundle.fallback.get("steel_frame"), + Some(&"generic_wall".to_string()) + ); + + // generic_baseline has no visual_bundle column value in this fixture — + // must degrade to an empty bundle, never panic. + let baseline = catalog + .iter() + .find(|t| t.tag == "generic_baseline") + .expect("generic_baseline present"); + assert_eq!(baseline.visual_bundle, VisualBundle::default()); + } + + #[test] + fn parse_wall_material_unknown_falls_back_to_generic() { + assert_eq!( + parse_wall_material("nonexistent_token", "test"), + WallMaterial::Generic + ); + assert_eq!( + parse_roof_form("nonexistent_token", "test"), + RoofForm::Generic + ); + assert_eq!( + parse_facade_rhythm("nonexistent_token", "test"), + FacadeRhythm::Generic + ); + assert_eq!( + parse_street_surface("nonexistent_token", "test"), + StreetSurface::Generic + ); + } + + // ── architecture_zone_bias / color_register_bands readers (T-988) ─────── + + #[test] + fn read_zone_bias_parses_sparse_table() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let zone_bias = reader.read_zone_bias().expect("read zone bias"); + let key = ( + "extraction_camp".to_string(), + "extraction_platform".to_string(), + ); + let entry = zone_bias.get(&key).expect("entry present"); + assert_eq!(entry.wall.get(&WallMaterial::SteelFrame), Some(&18000)); + assert!(entry.roof.is_empty(), "unauthored axis stays empty"); + // A (template, zone_type) pair never authored is simply absent. + assert!(!zone_bias.contains_key(&( + "extraction_camp".to_string(), + "residential_surface".to_string() + ))); + } + + #[test] + fn read_color_register_bands_parses_table() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let bands = reader + .read_color_register_bands() + .expect("read color bands"); + let band = bands.get("oxide_and_dust").expect("band present"); + assert_eq!( + *band, + ColorBand { + hue_min: 2000, + hue_max: 3000, + sat_min: 1800, + sat_max: 3200, + val_min: 3200, + val_max: 4800, + } + ); + } + + #[test] + fn read_exterior_catalog_bundles_all_three() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let bundle = reader + .read_exterior_catalog() + .expect("read exterior catalog"); + assert_eq!(bundle.templates.len(), 2); + assert_eq!(bundle.zone_bias.len(), 1); + assert_eq!(bundle.color_bands.len(), 1); + } } diff --git a/server/src/atlas/trait_draw.rs b/server/src/atlas/trait_draw.rs index 5583e465e..6e16ed1b0 100644 --- a/server/src/atlas/trait_draw.rs +++ b/server/src/atlas/trait_draw.rs @@ -460,6 +460,7 @@ mod tests { base_weight, weight_mods: BTreeMap::new(), zone_affinity: zone_affinity.iter().cloned().collect(), + visual_bundle: Default::default(), } } diff --git a/server/src/atlas/trait_exterior.rs b/server/src/atlas/trait_exterior.rs new file mode 100644 index 000000000..f18d0b062 --- /dev/null +++ b/server/src/atlas/trait_exterior.rs @@ -0,0 +1,443 @@ +//! D-235 building-exterior visual grammar — the `BuildingExteriorTag` +//! derivation (T-988). +//! +//! **3-step derivation, era-free** (D-235 amendment / D-232 reframe — era is +//! never a material gate, it reads as maintenance/wear via the D-217 +//! condition layer instead): +//! +//! 1. The resolved trait template's `visual_bundle` filters each axis +//! (`wall`/`roof`/`facade`/`street`) to its own coherent token set — a +//! template is drawn together, never mixed across axes (D-232). +//! 2. The building's own `zone_type_id` biases the pick within that filtered +//! set (`architecture_zone_bias.toml`, sparse — uniform where unauthored). +//! 3. The block's `density_pct` sets `setback_tier` ([`derive_setback_tier`]). +//! +//! `color` is seed-picked uniformly within the template's `color_register` +//! band (`color_register_bands.toml`), independently of the four token axes. +//! +//! Like [`crate::atlas::trait_swerve`] and [`crate::atlas::trait_draw`], this +//! module is pure: the template/zone-bias/color-band lookups are resolved by +//! the caller ([`crate::atlas::skeleton_gen::assign_block_tags`]) from data +//! already threaded onto the work item (`ExteriorCatalog`, +//! [`crate::atlas::trait_catalog_reader`]) — this module never touches +//! `systems.db`, and the per-building roll happens at `GenerateSkeleton` plan +//! time, never inside `FillChunk` (T-987 purity). +//! +//! All numbers are integer (D-010): weights are basis points, color +//! components are centidegrees/basis-points, and every RNG draw is an +//! independent [`SeedDomain::TraitExterior`] sub-chain per axis — sharing one +//! chain across axes previously correlated unrelated fields elsewhere in this +//! cascade (`skeleton_gen::assign_block_tags`'s own doc comment), so each of +//! wall/roof/facade/street/hue/sat/val gets its own derive here too. + +use std::collections::BTreeMap; + +use crate::atlas::trait_catalog_reader::{ColorBand, TraitTemplate, ZoneBias}; +use crate::seed::{AtlasRng, SeedChain, SeedDomain}; +use crate::simulation::generator::{ + BuildingExteriorTag, FacadeRhythm, HsvColor, RoofForm, SetbackTier, StreetSurface, WallMaterial, +}; + +/// Neutral fallback color (mid-grey, zero saturation) — used when a template +/// has no resolvable `color_register` (missing template, unauthored register, +/// or a register absent from `color_register_bands` — a content gap the +/// importer's V-TT-07 guards against for real content, but generation must +/// still degrade gracefully rather than panic on a stale/foreign template). +const NEUTRAL_COLOR: HsvColor = HsvColor { + hue: 0, + sat: 0, + val: 5_000, +}; + +/// Step 3 (D-235): setback tier from a block's build density. +/// +/// The five-tier vocabulary (`zero_lot | tight | standard | generous | +/// campus`) is fixed by D-235; these density-band **thresholds are +/// calibration placeholders** (T-988) pending Nigel/Araminta tuning — same +/// status as the swerve-rate constants (`trait_swerve`). Bands mirror the +/// density tables already used elsewhere in this cascade +/// (`skeleton_gen::floor_extent`, `roofed_coverage_pct`): Dense/Compressed +/// blocks (high `density_pct`) read tight/zero-lot; Frontier blocks (low +/// `density_pct`) read campus. +pub fn derive_setback_tier(density_pct: u8) -> SetbackTier { + match density_pct { + 0..=20 => SetbackTier::Campus, + 21..=45 => SetbackTier::Generous, + 46..=65 => SetbackTier::Standard, + 66..=85 => SetbackTier::Tight, + _ => SetbackTier::ZeroLot, + } +} + +/// Weighted pick of one token from `tokens` (a template's filtered +/// `visual_bundle.` list), biased by `bias` (the zone-type's authored +/// overrides for this axis, `None`/missing-token defaulting to the uniform +/// 10 000 bps baseline per `architecture_zone_bias.toml`'s own model). +/// +/// `None` only when `tokens` is empty (a template with zero tokens on this +/// axis — not expected from a well-formed catalog, but the caller must not +/// panic on a content gap or an unresolved template). +fn weighted_axis_pick( + tokens: &[T], + bias: Option<&BTreeMap>, + rng: &mut AtlasRng, +) -> Option { + if tokens.is_empty() { + return None; + } + let weight_of = + |t: &T| -> u64 { bias.and_then(|b| b.get(t)).copied().unwrap_or(10_000) as u64 }; + let total: u64 = tokens.iter().map(weight_of).sum(); + let mut roll = (rng.next_u32() as u64) % total.max(1); + for t in tokens { + let w = weight_of(t); + if roll < w { + return Some(*t); + } + roll -= w; + } + tokens.last().copied() +} + +/// Uniform integer sample within the inclusive range `[lo, hi]` (D-010 +/// integer-only). `lo` if the range is degenerate (`hi <= lo`). +fn sample_in_range(lo: u32, hi: u32, rng: &mut AtlasRng) -> u32 { + if hi <= lo { + return lo; + } + let span = (hi - lo) as u64 + 1; + lo + (rng.next_u32() as u64 % span) as u32 +} + +/// Color sub-step: seed-pick a single (hue, sat, val) point uniformly within +/// `color_register`'s band (D-235 — "always within the template's register"). +/// Falls back to [`NEUTRAL_COLOR`] when the template has no register, or the +/// register isn't in `color_bands` (with a warning in the latter case — a +/// genuine content gap the importer's V-TT-07 guards against). +fn sample_color( + color_register: Option<&str>, + color_bands: &BTreeMap, + chain: SeedChain, +) -> HsvColor { + let Some(register) = color_register else { + return NEUTRAL_COLOR; + }; + let Some(band) = color_bands.get(register) else { + tracing::warn!( + register, + "color_register not found in color_register_bands — falling back to neutral" + ); + return NEUTRAL_COLOR; + }; + let mut hue_rng = chain.derive(SeedDomain::TraitExterior, 4).atlas_rng(); + let mut sat_rng = chain.derive(SeedDomain::TraitExterior, 5).atlas_rng(); + let mut val_rng = chain.derive(SeedDomain::TraitExterior, 6).atlas_rng(); + HsvColor { + hue: sample_in_range(band.hue_min, band.hue_max, &mut hue_rng), + sat: sample_in_range(band.sat_min, band.sat_max, &mut sat_rng), + val: sample_in_range(band.val_min, band.val_max, &mut val_rng), + } +} + +/// Derive the frozen [`BuildingExteriorTag`] for one building (T-988). +/// +/// `template` is the building's resolved trait template — `None` when the +/// `flavor_ref` couldn't be resolved against the catalog (empty catalog, +/// out-of-bounds `InVocabulary` index, or a `Swerve` tag absent from the +/// catalog): every token axis degrades to `Generic`, color to +/// [`NEUTRAL_COLOR`], and `setback_tier` is still computed (it only needs +/// `density_pct`, not the template). `zone_bias` is the pre-looked-up +/// `(template_tag, zone_type_id)` entry from `ExteriorCatalog.zone_bias` — +/// `None` for the overwhelming majority of pairs (the table is sparse by +/// design), which is identical to an entry with no override for any token. +/// +/// `chain` should be a per-footprint sub-chain (mirroring the `fp_chain` used +/// for `flavor_ref`/`zone_type_id`/era/extent in `assign_block_tags`) — this +/// function derives its own `SeedDomain::TraitExterior` streams, one per axis, +/// from it. +pub fn derive_building_exterior( + template: Option<&TraitTemplate>, + zone_bias: Option<&ZoneBias>, + color_bands: &BTreeMap, + density_pct: u8, + chain: SeedChain, +) -> BuildingExteriorTag { + let setback_tier = derive_setback_tier(density_pct); + + let Some(template) = template else { + tracing::debug!( + "no resolved trait template for this building — exterior falls back to Generic/neutral" + ); + return BuildingExteriorTag { + wall_material: WallMaterial::default(), + roof_form: RoofForm::default(), + facade_rhythm: FacadeRhythm::default(), + setback_tier, + color: NEUTRAL_COLOR, + street_surface: StreetSurface::default(), + }; + }; + let vb = &template.visual_bundle; + + let mut wall_rng = chain.derive(SeedDomain::TraitExterior, 0).atlas_rng(); + let wall_material = + weighted_axis_pick(&vb.wall, zone_bias.map(|z| &z.wall), &mut wall_rng).unwrap_or_default(); + + let mut roof_rng = chain.derive(SeedDomain::TraitExterior, 1).atlas_rng(); + let roof_form = + weighted_axis_pick(&vb.roof, zone_bias.map(|z| &z.roof), &mut roof_rng).unwrap_or_default(); + + let mut facade_rng = chain.derive(SeedDomain::TraitExterior, 2).atlas_rng(); + let facade_rhythm = + weighted_axis_pick(&vb.facade, zone_bias.map(|z| &z.facade), &mut facade_rng) + .unwrap_or_default(); + + let mut street_rng = chain.derive(SeedDomain::TraitExterior, 3).atlas_rng(); + let street_surface = + weighted_axis_pick(&vb.street, zone_bias.map(|z| &z.street), &mut street_rng) + .unwrap_or_default(); + + let color = sample_color(vb.color_register.as_deref(), color_bands, chain); + + BuildingExteriorTag { + wall_material, + roof_form, + facade_rhythm, + setback_tier, + color, + street_surface, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn template_with_bundle(tag: &str, color_register: Option<&str>) -> TraitTemplate { + use crate::atlas::trait_catalog_reader::VisualBundle; + TraitTemplate { + tag: tag.to_string(), + corridor_pool: "cross_corridor".to_string(), + geographic_sector: None, + bulk_class_gate: Vec::new(), + production_ubiquity_gate: Vec::new(), + min_prosperity_bps: 0, + base_weight: 10_000, + weight_mods: BTreeMap::new(), + zone_affinity: BTreeMap::new(), + visual_bundle: VisualBundle { + wall: vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall], + roof: vec![RoofForm::CorrugatedRoof], + facade: vec![FacadeRhythm::IndustrialGlazing, FacadeRhythm::RegularFacade], + street: vec![StreetSurface::HeavyHaul, StreetSurface::Paved], + color_register: color_register.map(str::to_string), + fallback: BTreeMap::new(), + }, + } + } + + fn oxide_band() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert( + "oxide_and_dust".to_string(), + ColorBand { + hue_min: 2000, + hue_max: 3000, + sat_min: 1800, + sat_max: 3200, + val_min: 3200, + val_max: 4800, + }, + ); + m + } + + // ── derive_setback_tier ─────────────────────────────────────────────── + + #[test] + fn setback_tier_bands_match_density() { + assert_eq!(derive_setback_tier(10), SetbackTier::Campus); + assert_eq!(derive_setback_tier(30), SetbackTier::Generous); + assert_eq!(derive_setback_tier(50), SetbackTier::Standard); + assert_eq!(derive_setback_tier(75), SetbackTier::Tight); + assert_eq!(derive_setback_tier(95), SetbackTier::ZeroLot); + } + + #[test] + fn setback_tier_dense_to_frontier_orders_tight_to_campus() { + // D-235: "density sets setback_tier (Dense -> zero_lot -> ... -> + // Frontier -> campus)". + assert!(derive_setback_tier(95) < derive_setback_tier(10)); + } + + // ── derive_building_exterior: template present ─────────────────────── + + #[test] + fn resolved_template_picks_only_from_its_own_visual_bundle() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + for seed in 0..50u64 { + let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed)); + assert!(matches!( + tag.wall_material, + WallMaterial::SteelFrame | WallMaterial::ConcreteWall + )); + assert_eq!(tag.roof_form, RoofForm::CorrugatedRoof); + assert!(matches!( + tag.facade_rhythm, + FacadeRhythm::IndustrialGlazing | FacadeRhythm::RegularFacade + )); + assert!(matches!( + tag.street_surface, + StreetSurface::HeavyHaul | StreetSurface::Paved + )); + } + } + + #[test] + fn color_samples_within_the_registers_band() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + for seed in 0..50u64 { + let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed)); + assert!((2000..=3000).contains(&tag.color.hue)); + assert!((1800..=3200).contains(&tag.color.sat)); + assert!((3200..=4800).contains(&tag.color.val)); + } + } + + #[test] + fn missing_color_register_falls_back_to_neutral() { + let t = template_with_bundle("no_register", None); + let tag = + derive_building_exterior(Some(&t), None, &BTreeMap::new(), 50, SeedChain::root(1)); + assert_eq!(tag.color, NEUTRAL_COLOR); + } + + #[test] + fn unbanded_color_register_falls_back_to_neutral() { + // color_register is set but color_bands is empty (content gap) — must + // degrade, not panic. + let t = template_with_bundle("orphan", Some("nonexistent_register")); + let tag = + derive_building_exterior(Some(&t), None, &BTreeMap::new(), 50, SeedChain::root(1)); + assert_eq!(tag.color, NEUTRAL_COLOR); + } + + #[test] + fn no_template_falls_back_to_generic_everywhere_but_still_computes_setback() { + let tag = derive_building_exterior(None, None, &BTreeMap::new(), 90, SeedChain::root(1)); + assert_eq!(tag.wall_material, WallMaterial::Generic); + assert_eq!(tag.roof_form, RoofForm::Generic); + assert_eq!(tag.facade_rhythm, FacadeRhythm::Generic); + assert_eq!(tag.street_surface, StreetSurface::Generic); + assert_eq!(tag.color, NEUTRAL_COLOR); + // setback_tier only needs density_pct, not the template. + assert_eq!(tag.setback_tier, SetbackTier::ZeroLot); + } + + // ── zone bias skews the draw ────────────────────────────────────────── + + #[test] + fn zone_bias_skews_the_weighted_pick() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + let mut bias = ZoneBias::default(); + // Overwhelming weight toward SteelFrame (vs. ConcreteWall's uniform + // 10_000 baseline) — the draw must skew heavily toward it. + bias.wall.insert(WallMaterial::SteelFrame, 200_000); + + let mut steel = 0; + let mut concrete = 0; + for seed in 0..200u64 { + let tag = + derive_building_exterior(Some(&t), Some(&bias), &bands, 70, SeedChain::root(seed)); + match tag.wall_material { + WallMaterial::SteelFrame => steel += 1, + WallMaterial::ConcreteWall => concrete += 1, + other => panic!("unexpected wall material {other:?}"), + } + } + assert!( + steel > concrete * 5, + "expected steel_frame to dominate under a 200_000 bps bias, got steel={steel} concrete={concrete}" + ); + } + + #[test] + fn no_zone_bias_is_uniform_across_a_large_sample() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + let mut steel = 0; + let mut concrete = 0; + for seed in 0..400u64 { + let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed)); + match tag.wall_material { + WallMaterial::SteelFrame => steel += 1, + WallMaterial::ConcreteWall => concrete += 1, + other => panic!("unexpected wall material {other:?}"), + } + } + // Roughly even split expected (generous band — this is a uniformity + // sanity check, not a distribution test). + assert!( + (steel as i64 - concrete as i64).abs() < 120, + "expected roughly even split without bias, got steel={steel} concrete={concrete}" + ); + } + + // ── determinism + independence ──────────────────────────────────────── + + #[test] + fn same_chain_same_inputs_is_deterministic() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + let a = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(99)); + let b = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(99)); + assert_eq!(a, b); + } + + #[test] + fn axes_draw_independent_entropy_not_a_shared_roll() { + // Regression guard for the "one shared chain correlates every field" + // bug class already fixed once in `assign_block_tags` — wall/roof/ + // facade/street must vary independently across seeds, not lock-step. + let t = TraitTemplate { + visual_bundle: crate::atlas::trait_catalog_reader::VisualBundle { + wall: vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall], + roof: vec![RoofForm::FlatRoof, RoofForm::PitchedRoof], + facade: vec![FacadeRhythm::RegularFacade, FacadeRhythm::Colonnade], + street: vec![StreetSurface::Paved, StreetSurface::Cobble], + color_register: None, + fallback: BTreeMap::new(), + }, + ..template_with_bundle("mixed", None) + }; + let mut saw_wall_a_roof_b = false; + let mut saw_wall_a_roof_a = false; + for seed in 0..100u64 { + let tag = derive_building_exterior( + Some(&t), + None, + &BTreeMap::new(), + 50, + SeedChain::root(seed), + ); + if tag.wall_material == WallMaterial::SteelFrame { + if tag.roof_form == RoofForm::PitchedRoof { + saw_wall_a_roof_b = true; + } else { + saw_wall_a_roof_a = true; + } + } + } + assert!( + saw_wall_a_roof_b && saw_wall_a_roof_a, + "wall and roof picks must vary independently, not track each other" + ); + } +} diff --git a/server/src/atlas/trait_swerve.rs b/server/src/atlas/trait_swerve.rs index c6240d9bf..fb57efe36 100644 --- a/server/src/atlas/trait_swerve.rs +++ b/server/src/atlas/trait_swerve.rs @@ -286,6 +286,7 @@ mod tests { base_weight, weight_mods: BTreeMap::new(), zone_affinity: BTreeMap::new(), + visual_bundle: Default::default(), } } diff --git a/server/src/seed.rs b/server/src/seed.rs index 657aea0db..517a313df 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -139,6 +139,13 @@ pub enum SeedDomain { /// domain so the rare-wildcard roll can never correlate with the zone/era/ /// extent draws sharing that chain. TraitSwerve = 15, + /// Per-building D-235 exterior-grammar draw (`BuildingExteriorTag`, T-988): + /// wall/roof/facade/street token picks + color HSV sample. Derived off the + /// footprint's own chain (like `TraitSwerve`), keyed per axis (0=wall, + /// 1=roof, 2=facade, 3=street, 4=hue, 5=sat, 6=val) so each axis draws + /// independent entropy — the `assign_block_tags` lesson (distinct + /// sub-chains per field, not one shared roll) applies here too. + TraitExterior = 16, } /// A position in the deterministic seed tree (D-224). @@ -303,6 +310,7 @@ mod tests { assert_eq!(SeedDomain::TraitVocabulary as u64, 13); assert_eq!(SeedDomain::TraitDistrict as u64, 14); assert_eq!(SeedDomain::TraitSwerve as u64, 15); + assert_eq!(SeedDomain::TraitExterior as u64, 16); } #[test] diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 71c3df7a3..f496cf0a4 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -850,6 +850,172 @@ pub enum ArchitectureFlavorRef { Swerve(String), } +// --------------------------------------------------------------------------- +// D-235 building exterior visual grammar (T-988) +// --------------------------------------------------------------------------- +// +// The vocabulary below is the RATIFIED ObjectTag palette (T-995, resolving +// Q-049) — `wiki/economics/object_tag_vocabulary.toml` — not the illustrative +// example lists in D-235's original body text (superseded by the 2026-07-07 +// amendment). Each axis carries its specific tokens plus one `Generic` +// fallback-terminal placeholder (the D-235 asset-resolution degrade target). +// +// Integer-discriminant, append-only (D-010) — these values round-trip through +// `systems.db`-backed derivation today and are candidates for savegame +// persistence in Phase 5+ (D-227), so historical discriminants must never be +// renumbered, only appended to. Unknown registry strings parse to the axis's +// `Generic` variant with a `tracing::warn!` (patch tolerance) — see +// `atlas::trait_catalog_reader`. + +/// Wall material token (D-235 `wall` axis, 10 specific + `Generic`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum WallMaterial { + ConcreteWall = 0, + SteelFrame = 1, + BrickWall = 2, + RenderedWall = 3, + StoneWall = 4, + TimberWall = 5, + StuccoWall = 6, + GlassCurtainWall = 7, + CompositePanel = 8, + RammedEarthWall = 9, + /// Fallback-terminal placeholder (`generic_wall`) — renders until the + /// specific asset ships (D-235), and the "unrecognized token" parse target. + #[default] + Generic = 10, +} + +/// Roof form token (D-235 `roof` axis, 7 specific + `Generic`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum RoofForm { + FlatRoof = 0, + PitchedRoof = 1, + CorrugatedRoof = 2, + ClayTileRoof = 3, + TerracedRoof = 4, + VaultedRoof = 5, + GreenRoof = 6, + /// Fallback-terminal placeholder (`generic_roof`) — see [`WallMaterial::Generic`]. + #[default] + Generic = 7, +} + +/// Facade rhythm token (D-235 `facade` axis, 8 specific + `Generic`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum FacadeRhythm { + RegularFacade = 0, + OrnamentalFacade = 1, + IndustrialGlazing = 2, + ArcadeFacade = 3, + ShutteredFacade = 4, + ScreenFacade = 5, + Colonnade = 6, + LatticeScreen = 7, + /// Fallback-terminal placeholder (`generic_facade`) — see [`WallMaterial::Generic`]. + #[default] + Generic = 8, +} + +/// Street surface token (D-235 `street` axis, 7 specific + `Generic`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum StreetSurface { + Paved = 0, + Cobble = 1, + PackedEarth = 2, + CanalWay = 3, + ElevatedWalkway = 4, + HeavyHaul = 5, + Boardwalk = 6, + /// Fallback-terminal placeholder (`generic_street`) — see [`WallMaterial::Generic`]. + #[default] + Generic = 7, +} + +/// Building setback tier (D-235 step 3): derived from `BlockSkeleton.density_pct` +/// bands — Dense blocks read `ZeroLot`, Frontier blocks read `Campus`. Drives +/// the interstitial-space character (`void`/`court`/`garden`/`plaza`/ +/// `dock_slip`/`market_pad`/`open_lawn`, D-235) a future fill pass reads. +/// +/// The five-tier vocabulary is fixed by D-235 (`zero_lot | tight | standard | +/// generous | campus`); the density-band **thresholds** that map onto it are +/// **calibration placeholders** (T-988) pending Nigel/Araminta tuning, same +/// status as the swerve-rate constants (`trait_swerve`). Declaration order is +/// the tier's natural density ordering (tightest → loosest), so `Ord` +/// comparisons read correctly; not currently used as a save-critical +/// discriminant, so it is not `#[repr(u8)]`-pinned like the axis tokens above. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum SetbackTier { + /// Buildings meet the lot line — no yard, shared walls common. + ZeroLot, + Tight, + Standard, + Generous, + /// Buildings sit well back within generous open grounds. + Campus, +} + +/// A single sampled point within a D-235 `color_register` HSV band (T-988). +/// +/// Integer-only (D-010, save-critical under D-227): `hue` is centidegrees +/// (degrees × 100, 0..36000); `sat`/`val` are basis points (0..10000). Sampled +/// once per building at plan time and frozen — never re-derived, no drift. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct HsvColor { + pub hue: u32, + pub sat: u32, + pub val: u32, +} + +/// The D-235 visible-form layer over a building's tags (T-988) — the frozen +/// output of the 3-step derivation: **(1)** the resolved trait template's +/// `visual_bundle` filters each axis's available token set; **(2)** the +/// building's `zone_type_id` biases the pick within that filtered set +/// (`architecture_zone_bias.toml`, uniform where unauthored); **(3)** the +/// block's `density_pct` sets `setback_tier`. `color` is seed-picked uniformly +/// within the template's `color_register` band. +/// +/// **Era is deliberately absent** (D-232 reframe, D-235 amendment): the Reach +/// has no material-technology ladder, so era is never a material gate — it +/// reads as maintenance/wear via the D-217 condition layer instead. +/// +/// Resolved once inside `GenerateSkeleton` (the same plan-time phase as +/// `flavor_ref`/`zone_type_id`) and stored frozen on `BuildingPropertyTag`; +/// `FillChunk` only reads it (T-987 purity) — see `atlas::trait_exterior`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildingExteriorTag { + pub wall_material: WallMaterial, + pub roof_form: RoofForm, + pub facade_rhythm: FacadeRhythm, + pub setback_tier: SetbackTier, + pub color: HsvColor, + pub street_surface: StreetSurface, +} + +/// Character of a block's non-roofed remainder — the space between building +/// footprints (D-233, T-1097). +/// +/// Bulk-industry blocks (`BulkClass::BulkSolid`/`BulkLiquid`) read this space +/// as **built economic infrastructure** (haul roads, ore pads, conveyor runs, +/// tank berms) — functional ground the settlement's economy depends on, never +/// generic open space. Every other `BulkClass` keeps the pre-T-1097 default: +/// informal interstitial space (yards, parks, lots) whose specific character +/// (`void`/`court`/`garden`/`plaza`/…) is the separate `SetbackTier`-driven +/// axis (D-235, T-988) — the two systems are distinct derivation axes sharing +/// the same physical space, not alternatives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum InterstitialCharacter { + /// Generic open space — yards, parks, informal lots. + #[default] + OpenSpace, + /// Built economic infrastructure (D-233) — never open space. + OperationsSurface, +} + /// A single building footprint tag — the frozen step-3 output placed on every /// building footprint at plan time (D-229). /// @@ -874,6 +1040,9 @@ pub struct BuildingPropertyTag { /// Frozen-amber condition snapshot from `prosperity_baseline_bps` (D-197/D-217). /// The rolling condition overlay (D-198) paints over this; never mutates the tag. pub initial_condition: crate::atlas::tile_condition::TileCondition, + /// D-235 visible-form layer (T-988): wall/roof/facade/street materials, + /// setback tier, and color — resolved once here, frozen thereafter. + pub exterior: BuildingExteriorTag, /// Doors into / out of this building (D-231). At least one `Main` door. /// /// Using `Vec` rather than `SmallVec<[DoorSpec; 4]>` for now; @@ -1232,6 +1401,11 @@ pub struct BlockSkeleton { /// Integer to avoid f32 non-determinism (D-010). pub density_pct: u8, pub landmark: Option, + /// Character of this block's non-roofed remainder (D-233, T-1097): + /// `OperationsSurface` for bulk-industry blocks, `OpenSpace` otherwise. + /// Derived once from `CityGenerationContext.dominant_bulk_class` at the + /// same plan-time pass as the rest of this skeleton. + pub interstitial_character: InterstitialCharacter, } /// Floor zone within a multi-level reservation. diff --git a/tooling/economy-db/economy_import/migration.py b/tooling/economy-db/economy_import/migration.py index a77bd3831..308023cf7 100644 --- a/tooling/economy-db/economy_import/migration.py +++ b/tooling/economy-db/economy_import/migration.py @@ -255,6 +255,26 @@ CREATE TABLE IF NOT EXISTS atlas_body_trait_bias ( CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_body ON atlas_body_trait_bias(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_tag ON atlas_body_trait_bias(template_tag); +-- D-235 exterior-grammar content (T-988). Mirrors the canonical DDL in +-- systems-schema.sql; here so the migration path (existing DBs) gets the +-- tables, not just fresh systems-schema.sql builds. +CREATE TABLE IF NOT EXISTS architecture_zone_bias ( + template_tag TEXT NOT NULL REFERENCES trait_templates(tag) ON DELETE CASCADE, + zone_type_id TEXT NOT NULL, + bias TEXT NOT NULL, + PRIMARY KEY (template_tag, zone_type_id) +); +CREATE INDEX IF NOT EXISTS idx_architecture_zone_bias_template ON architecture_zone_bias(template_tag); +CREATE TABLE IF NOT EXISTS color_register_bands ( + color_register TEXT PRIMARY KEY, + hue_min INTEGER NOT NULL, + hue_max INTEGER NOT NULL, + sat_min INTEGER NOT NULL, + sat_max INTEGER NOT NULL, + val_min INTEGER NOT NULL, + val_max INTEGER NOT NULL +); + -- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911). -- Idempotent: each UPDATE is a no-op if the old value is already gone. UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture'); diff --git a/tooling/economy-db/economy_import/paths.py b/tooling/economy-db/economy_import/paths.py index 69d65370e..00c46d3ff 100644 --- a/tooling/economy-db/economy_import/paths.py +++ b/tooling/economy-db/economy_import/paths.py @@ -11,6 +11,8 @@ from pathlib import Path from generator_sources import ( ARCHITECTURE_TRAIT_BIAS_TOML, ARCHITECTURE_TRAIT_CATALOG_TOML, + ARCHITECTURE_ZONE_BIAS_TOML, + COLOR_REGISTER_BANDS_TOML, GENERATE_BRANDS_WRAPPER, OBJECT_TAG_VOCABULARY_TOML, REPO_ROOT, @@ -21,8 +23,10 @@ from generator_sources import ( __all__ = [ "ARCHITECTURE_TRAIT_BIAS_TOML", "ARCHITECTURE_TRAIT_CATALOG_TOML", + "ARCHITECTURE_ZONE_BIAS_TOML", "BRANDS_TOML", "CHAINS_TOML", + "COLOR_REGISTER_BANDS_TOML", "COMMODITIES_TOML", "CORPORATIONS_DIR", "CURRENCY_ZONES_TOML", diff --git a/tooling/economy-db/economy_import/traits.py b/tooling/economy-db/economy_import/traits.py index 1cc63f629..36af4e143 100644 --- a/tooling/economy-db/economy_import/traits.py +++ b/tooling/economy-db/economy_import/traits.py @@ -8,6 +8,8 @@ from .errors import ImportAborted from .paths import ( ARCHITECTURE_TRAIT_BIAS_TOML, ARCHITECTURE_TRAIT_CATALOG_TOML, + ARCHITECTURE_ZONE_BIAS_TOML, + COLOR_REGISTER_BANDS_TOML, OBJECT_TAG_VOCABULARY_TOML, ) @@ -250,6 +252,202 @@ def populate_trait_templates(conn: sqlite3.Connection, dry_run: bool) -> int: return len(rows) +def populate_architecture_zone_bias(conn: sqlite3.Connection, dry_run: bool) -> int: + """Bake the D-235 step-2 zone-type bias table (T-988, resolves the ticket's + "REAL authored table" requirement). + + Reads ARCHITECTURE_ZONE_BIAS_TOML (`[bias..]` + stanzas; each is an optional `{axis: {token: weight_bps}}` sub-table per + axis — wall/roof/facade/street). Sparse/fallback model: a (template, + zone_type) pair absent here — or a token absent within a listed entry — + uses a uniform draw across the template's own `visual_bundle.` (the + generator's job, not this baker's). + + V-TT-06: every referenced token must already appear in THAT template's own + `visual_bundle.` (checked against the just-baked `trait_templates` + row, not the catalog TOML in memory, so the check can never silently pass + against a stale in-process parse) — this file never expands a template's + palette, only re-weights within it. Must run AFTER populate_trait_templates + (FK + visual_bundle lookup). Absent source -> 0 rows. + """ + if not ARCHITECTURE_ZONE_BIAS_TOML.exists(): + if not dry_run: + conn.execute("DELETE FROM architecture_zone_bias") + return 0 + with open(ARCHITECTURE_ZONE_BIAS_TOML, "rb") as f: + data = tomllib.load(f) + bias_root = data.get("bias", {}) + + known_tags: set[str] = set() + template_axis_tokens: dict[str, dict[str, set[str]]] = {} + for tag, vb_json in conn.execute("SELECT tag, visual_bundle FROM trait_templates"): + known_tags.add(tag) + vb = json.loads(vb_json) if vb_json else {} + template_axis_tokens[tag] = {axis: set(vb.get(axis) or []) for axis in _TAG_AXES} + + errors: list[str] = [] + rows: list[tuple] = [] + for template_tag, zone_map in bias_root.items(): + if template_tag not in known_tags: + errors.append(f"architecture_zone_bias '{template_tag}': not in trait_templates") + continue + if not isinstance(zone_map, dict): + errors.append( + f"architecture_zone_bias '{template_tag}': expected a table of zone_type_id entries" + ) + continue + for zone_type_id, axes in zone_map.items(): + if not isinstance(axes, dict): + errors.append( + f"architecture_zone_bias '{template_tag}.{zone_type_id}': expected a table of axis entries" + ) + continue + for axis, weights in axes.items(): + if axis not in _TAG_AXES: + errors.append( + f"architecture_zone_bias '{template_tag}.{zone_type_id}': axis '{axis}' " + f"not one of {_TAG_AXES}" + ) + continue + if not isinstance(weights, dict): + errors.append( + f"V-TT-06: architecture_zone_bias '{template_tag}.{zone_type_id}.{axis}': " + f"each axis value must be a {{token = weight_bps}} table, got " + f"{type(weights).__name__}" + ) + continue + allowed = template_axis_tokens.get(template_tag, {}).get(axis, set()) + for token, weight in weights.items(): + if token not in allowed: + errors.append( + f"V-TT-06: architecture_zone_bias '{template_tag}.{zone_type_id}.{axis}': " + f"token '{token}' not in this template's visual_bundle.{axis} {sorted(allowed)}" + ) + # bool is an int subclass — exclude it explicitly, matching + # populate_color_register_bands' guard on its own values. + if isinstance(weight, bool) or not isinstance(weight, int) or weight <= 0: + errors.append( + f"architecture_zone_bias '{template_tag}.{zone_type_id}.{axis}.{token}': " + f"weight_bps must be a positive integer, got {weight!r}" + ) + try: + bias_json = json.dumps(axes) + except (TypeError, ValueError): + errors.append( + f"architecture_zone_bias '{template_tag}.{zone_type_id}': not JSON-serialisable" + ) + continue + rows.append((template_tag, zone_type_id, bias_json)) + + if errors: + print(f" ARCHITECTURE ZONE BIAS ERRORS ({len(errors)}):") + for e in errors: + print(f" - {e}") + raise ImportAborted() + + if not dry_run: + conn.execute("DELETE FROM architecture_zone_bias") + conn.executemany( + "INSERT INTO architecture_zone_bias (template_tag, zone_type_id, bias) VALUES (?,?,?)", + rows, + ) + return len(rows) + + +# D-235 numeric HSV axis bounds (T-988): hue is centidegrees (0..36000 = 0-360 +# deg x 100); sat/val are basis points (0..10000 = 0-100.00%). +_COLOR_AXIS_BOUNDS: dict[str, int] = {"hue": 36000, "sat": 10000, "val": 10000} + + +def populate_color_register_bands(conn: sqlite3.Connection, dry_run: bool) -> int: + """Bake the D-235 numeric color-register HSV sampling bands (T-988). + + Reads COLOR_REGISTER_BANDS_TOML (`[register.]` stanzas; each of + `hue`/`sat`/`val` a 2-element `[min, max]` integer array). The fill seed + samples a single (hue, sat, val) point uniformly within a template's + register band per building (D-235) — this baker only validates + stores + the bands. + + V-TT-07: (1) every band's ranges are in-bounds (hue 0..36000, sat/val + 0..10000) with `min < max`; (2) every `color_register` referenced by + `trait_templates.visual_bundle.color_register` (checked against the + just-baked table, same self-consistency reasoning as V-TT-06) has a band + here — coverage, not just shape. Must run AFTER populate_trait_templates. + Absent source -> 0 rows. + """ + if not COLOR_REGISTER_BANDS_TOML.exists(): + if not dry_run: + conn.execute("DELETE FROM color_register_bands") + return 0 + with open(COLOR_REGISTER_BANDS_TOML, "rb") as f: + data = tomllib.load(f) + registers = data.get("register", {}) + + errors: list[str] = [] + rows: list[tuple] = [] + for name, band in registers.items(): + values: dict[str, tuple[int, int]] = {} + for axis, bound in _COLOR_AXIS_BOUNDS.items(): + pair = band.get(axis) if isinstance(band, dict) else None + if not (isinstance(pair, list) and len(pair) == 2): + errors.append( + f"V-TT-07: color_register_bands '{name}': '{axis}' must be a 2-element " + "[min, max] array" + ) + continue + lo, hi = pair + if isinstance(lo, bool) or isinstance(hi, bool) or not ( + isinstance(lo, int) and isinstance(hi, int) + ): + errors.append( + f"V-TT-07: color_register_bands '{name}.{axis}': min/max must be integers, " + f"got {pair!r}" + ) + continue + if not (0 <= lo < hi <= bound): + errors.append( + f"V-TT-07: color_register_bands '{name}.{axis}': range [{lo}, {hi}] must " + f"satisfy 0 <= min < max <= {bound}" + ) + continue + values[axis] = (lo, hi) + if len(values) == 3: + rows.append(( + name, + values["hue"][0], values["hue"][1], + values["sat"][0], values["sat"][1], + values["val"][0], values["val"][1], + )) + + # Coverage half of V-TT-07: every color_register the catalog actually + # references must have a band. + referenced: set[str] = set() + for (vb_json,) in conn.execute("SELECT visual_bundle FROM trait_templates"): + vb = json.loads(vb_json) if vb_json else {} + reg = vb.get("color_register") + if reg: + referenced.add(reg) + banded = {r[0] for r in rows} + for reg in sorted(referenced - banded): + errors.append(f"V-TT-07: color_register '{reg}' referenced by trait_templates but has no band") + + if errors: + print(f" COLOR REGISTER BAND ERRORS ({len(errors)}):") + for e in errors: + print(f" - {e}") + raise ImportAborted() + + if not dry_run: + conn.execute("DELETE FROM color_register_bands") + conn.executemany( + """INSERT INTO color_register_bands + (color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max) + VALUES (?,?,?,?,?,?,?)""", + rows, + ) + return len(rows) + + def populate_atlas_body_trait_bias(conn: sqlite3.Connection, dry_run: bool) -> int: """Bake the sparse per-body hero pins into atlas_body_trait_bias (#993). diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 43aaa2080..51e6eb031 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -228,10 +228,22 @@ def main() -> None: print(f" {n_tilt} bodies updated with axial_tilt_deg") # 17. biosphere_class from body frontmatter override + two-gate default (D-247, T-1085) - print(" [17/17] Populating biosphere_class (D-247)...") + print(" [17/19] Populating biosphere_class (D-247)...") n_bio = bodies.populate_biosphere_class(conn, args.dry_run) print(f" {n_bio} bodies updated with biosphere_class") + # 18. Architecture zone-type bias table (T-988, D-235 step 2) — must + # follow trait_templates (V-TT-06 validates against its visual_bundle). + print(" [18/19] Baking architecture_zone_bias table (D-235)...") + n_zone_bias = traits.populate_architecture_zone_bias(conn, args.dry_run) + print(f" {n_zone_bias} zone-bias rows") + + # 19. Color register bands (T-988, D-235) — numeric HSV sampling bands + # per trait-template color_register; also follows trait_templates (V-TT-07). + print(" [19/19] Baking color_register_bands table (D-235)...") + n_color_bands = traits.populate_color_register_bands(conn, args.dry_run) + print(f" {n_color_bands} color register bands") + # Validate structural integrity (FK, chain refs, chain completeness). # These errors indicate broken imported data — do NOT commit. print("\n Validating structural integrity...") diff --git a/tooling/economy-db/test_traits.py b/tooling/economy-db/test_traits.py index 03eb86b65..ac9da5d13 100644 --- a/tooling/economy-db/test_traits.py +++ b/tooling/economy-db/test_traits.py @@ -3,14 +3,16 @@ Unit tests for economy_import.traits validation (T-995, PR #173 review H2). Covers the failure branches of the ObjectTag registry loader/validator -(V-TT-03 existence/axis, V-TT-04 fallback-graph) and the V-TT-05 pin bound — -the `make test-tooling` dry-run only exercises the happy path against the -committed, already-valid registry. +(V-TT-03 existence/axis, V-TT-04 fallback-graph), the V-TT-05 pin bound, the +V-TT-06 zone-bias token-existence check, and the V-TT-07 color-register band +shape/coverage checks (T-988) — the `make test-tooling` dry-run only +exercises the happy path against the committed, already-valid content. Stdlib only (unittest) — run directly or via `make test-tooling`: python3 tooling/economy-db/test_traits.py """ +import json import sqlite3 import sys import tempfile @@ -309,5 +311,191 @@ class PinBoundTests(unittest.TestCase): self.populate(self.bias_toml(6)) +class ZoneBiasValidationTests(unittest.TestCase): + """populate_architecture_zone_bias: V-TT-06 token-existence check (T-988).""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self._orig_zone_bias = traits.ARCHITECTURE_ZONE_BIAS_TOML + self.conn = sqlite3.connect(":memory:") + self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY, visual_bundle TEXT)") + self.conn.execute( + """CREATE TABLE architecture_zone_bias ( + template_tag TEXT NOT NULL, zone_type_id TEXT NOT NULL, bias TEXT NOT NULL, + PRIMARY KEY (template_tag, zone_type_id))""" + ) + self.conn.execute( + "INSERT INTO trait_templates (tag, visual_bundle) VALUES (?, ?)", + ( + "test_template", + json.dumps({ + "wall": ["brick_wall", "rendered_wall"], + "roof": ["flat_roof"], + "facade": ["regular_facade"], + "street": ["paved"], + "color_register": "neutral_grey", + }), + ), + ) + + def tearDown(self): + traits.ARCHITECTURE_ZONE_BIAS_TOML = self._orig_zone_bias + self.conn.close() + self._tmp.cleanup() + + def populate(self, body: str): + path = self.tmp / "architecture_zone_bias.toml" + path.write_text(body, encoding="utf-8") + traits.ARCHITECTURE_ZONE_BIAS_TOML = path + return traits.populate_architecture_zone_bias(self.conn, dry_run=True) + + def test_valid_bias_within_visual_bundle_passes(self): + n = self.populate( + '[bias.test_template.commercial_market]\n' + 'wall = { brick_wall = 15000 }\n' + 'street = { paved = 13000 }\n' + ) + self.assertEqual(n, 1) + + def test_token_outside_visual_bundle_is_rejected(self): + # steel_frame is a real ObjectTag, but never appears in test_template's + # own visual_bundle.wall above — V-TT-06 must reject it. + import contextlib + import io + + out = io.StringIO() + with contextlib.redirect_stdout(out): + with self.assertRaises(ImportAborted): + self.populate( + '[bias.test_template.industrial_freight]\n' + 'wall = { steel_frame = 16000 }\n' + ) + self.assertIn("V-TT-06", out.getvalue()) + self.assertIn("steel_frame", out.getvalue()) + + def test_unknown_template_tag_is_rejected(self): + with self.assertRaises(ImportAborted): + self.populate( + '[bias.no_such_template.commercial_market]\n' + 'wall = { brick_wall = 15000 }\n' + ) + + def test_non_positive_weight_is_rejected(self): + with self.assertRaises(ImportAborted): + self.populate( + '[bias.test_template.commercial_market]\n' + 'wall = { brick_wall = 0 }\n' + ) + + def test_non_dict_axis_value_reports_vtt06_not_a_traceback(self): + # A bare scalar or the array shape (a plausible copy-paste from the + # sibling catalog's `visual_bundle.wall = [...]`) must produce a clean + # V-TT-06 error, not an AttributeError crash on `.items()`. + import contextlib + import io + + for bad in ("wall = 15000\n", 'wall = ["brick_wall"]\n'): + out = io.StringIO() + with contextlib.redirect_stdout(out): + with self.assertRaises(ImportAborted): + self.populate(f"[bias.test_template.commercial_market]\n{bad}") + self.assertIn("V-TT-06", out.getvalue()) + self.assertIn("must be a", out.getvalue()) + + def test_bool_weight_is_rejected(self): + # bool is an int subclass; `true` must not slip through as weight 1. + with self.assertRaises(ImportAborted): + self.populate( + '[bias.test_template.commercial_market]\n' + 'wall = { brick_wall = true }\n' + ) + + def test_missing_source_yields_zero_rows(self): + traits.ARCHITECTURE_ZONE_BIAS_TOML = self.tmp / "missing.toml" + self.assertEqual(traits.populate_architecture_zone_bias(self.conn, dry_run=True), 0) + + +class ColorRegisterBandTests(unittest.TestCase): + """populate_color_register_bands: V-TT-07 shape + coverage checks (T-988).""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self._orig_bands = traits.COLOR_REGISTER_BANDS_TOML + self.conn = sqlite3.connect(":memory:") + self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY, visual_bundle TEXT)") + self.conn.execute( + """CREATE TABLE color_register_bands ( + color_register TEXT PRIMARY KEY, hue_min INTEGER NOT NULL, hue_max INTEGER NOT NULL, + sat_min INTEGER NOT NULL, sat_max INTEGER NOT NULL, + val_min INTEGER NOT NULL, val_max INTEGER NOT NULL)""" + ) + self.conn.execute( + "INSERT INTO trait_templates (tag, visual_bundle) VALUES (?, ?)", + ("test_template", json.dumps({"color_register": "neutral_grey"})), + ) + + def tearDown(self): + traits.COLOR_REGISTER_BANDS_TOML = self._orig_bands + self.conn.close() + self._tmp.cleanup() + + def populate(self, body: str): + path = self.tmp / "color_register_bands.toml" + path.write_text(body, encoding="utf-8") + traits.COLOR_REGISTER_BANDS_TOML = path + return traits.populate_color_register_bands(self.conn, dry_run=True) + + def test_valid_band_covering_the_referenced_register_passes(self): + n = self.populate( + '[register.neutral_grey]\n' + 'hue = [20000, 21200]\n' + 'sat = [400, 1200]\n' + 'val = [4800, 6400]\n' + ) + self.assertEqual(n, 1) + + def test_missing_coverage_for_referenced_register_is_rejected(self): + import contextlib + import io + + out = io.StringIO() + with contextlib.redirect_stdout(out): + with self.assertRaises(ImportAborted): + # A band for a DIFFERENT register — neutral_grey (referenced by + # test_template above) has no band at all. + self.populate( + '[register.some_other_register]\n' + 'hue = [0, 100]\n' + 'sat = [0, 100]\n' + 'val = [0, 100]\n' + ) + self.assertIn("V-TT-07", out.getvalue()) + self.assertIn("neutral_grey", out.getvalue()) + + def test_hue_out_of_bounds_is_rejected(self): + with self.assertRaises(ImportAborted): + self.populate( + '[register.neutral_grey]\n' + 'hue = [30000, 40000]\n' # 40000 > 36000 bound + 'sat = [400, 1200]\n' + 'val = [4800, 6400]\n' + ) + + def test_min_not_less_than_max_is_rejected(self): + with self.assertRaises(ImportAborted): + self.populate( + '[register.neutral_grey]\n' + 'hue = [20000, 20000]\n' # min == max, not min < max + 'sat = [400, 1200]\n' + 'val = [4800, 6400]\n' + ) + + def test_missing_source_yields_zero_rows(self): + traits.COLOR_REGISTER_BANDS_TOML = self.tmp / "missing.toml" + self.assertEqual(traits.populate_color_register_bands(self.conn, dry_run=True), 0) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tooling/generator_sources.py b/tooling/generator_sources.py index d62aa8a6b..453352743 100644 --- a/tooling/generator_sources.py +++ b/tooling/generator_sources.py @@ -88,6 +88,17 @@ ARCHITECTURE_TRAIT_BIAS_TOML: Path = ( OBJECT_TAG_VOCABULARY_TOML: Path = ( REPO_ROOT / "wiki" / "economics" / "object_tag_vocabulary.toml" ) +# D-235 exterior-grammar content (T-988): per-(template, zone_type) axis-token +# bias overrides, and per-color_register integer HSV sampling bands. Both are +# baked alongside trait_templates (traits.py: populate_architecture_zone_bias, +# populate_color_register_bands), so an edit to either must flip the stamp +# exactly like the catalog/registry above. +ARCHITECTURE_ZONE_BIAS_TOML: Path = ( + REPO_ROOT / "wiki" / "economics" / "architecture_zone_bias.toml" +) +COLOR_REGISTER_BANDS_TOML: Path = ( + REPO_ROOT / "wiki" / "economics" / "color_register_bands.toml" +) def _economy_import_modules() -> tuple[Path, ...]: @@ -124,6 +135,8 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = ( ARCHITECTURE_TRAIT_CATALOG_TOML, ARCHITECTURE_TRAIT_BIAS_TOML, OBJECT_TAG_VOCABULARY_TOML, + ARCHITECTURE_ZONE_BIAS_TOML, + COLOR_REGISTER_BANDS_TOML, ) # --------------------------------------------------------------------------- diff --git a/wiki/economics/architecture_zone_bias.toml b/wiki/economics/architecture_zone_bias.toml new file mode 100644 index 000000000..a246569b4 --- /dev/null +++ b/wiki/economics/architecture_zone_bias.toml @@ -0,0 +1,380 @@ +# ========================================================================== +# Architecture zone-type bias table (T-988, D-235 step 2) +# +# D-235's BuildingExteriorTag derivation is 3 steps: (1) the district's +# dominant trait template (D-232) filters the available token set per axis +# via its own `visual_bundle`; (2) THIS FILE biases which of those +# already-filtered tokens a building is likely to draw, keyed on the +# building's FUNCTION (D-142 zone_type) — "an industrial block stays stone, +# not corrugated metal, if the template's allow-list says so"; (3) density +# sets setback_tier. This table is step 2 only. +# +# SPARSE / FALLBACK MODEL. This is deliberately NOT a full 28-template x +# 31-zone_type x 4-axis matrix (3,472 cells). It authors only the cases +# where cultural or functional logic genuinely demands a lean — a civic +# building reaching for stone and colonnade, a market stall staying in the +# lighter/cheaper register, a fishing quay pulling boardwalk over cobble. +# Every (template, zone_type) pair NOT listed here — and every token within +# a listed entry not given a weight — falls back to a UNIFORM draw across +# the template's visual_bundle for that axis. Absence of an entry is not an +# oversight; it means the template's own visual_bundle already tells the +# whole story for that function, or (rarely, see note at end) the template +# has no per-axis choice left to bias. +# +# OWNERSHIP. Same co-maintenance split as object_tag_vocabulary.toml +# (Q-049's resolution: split by concern, not by file) — +# - Miri owns the CULTURAL/FUNCTIONAL rationale: which zone_type leans +# toward which already-allowed token, and why (the inline comment above +# each entry below). +# - Araminta owns VISUAL-COHERENCE tuning of the bps weights themselves +# (does the resulting draw distribution actually read well against the +# shipped art, does a "boost" ever starve a token to invisibility). +# This pass (T-988 content deliverable) is Miri's cultural-logic +# authoring pass with initial weights; Araminta's visual-coherence review +# of the weights is a expected follow-up, same pattern as her parallel +# `color_register_bands.toml` pass in this same ticket. +# +# SCHEMA. Integer basis points (D-010): 10000 = 1.0x the uniform baseline. +# A weight above 10000 boosts a token above uniform likelihood; a weight +# below 10000 (never authored as 0 — no hard excludes here, that's what +# the template's own allow/block already did) suppresses it toward rare. +# Only tokens already present in the template's own visual_bundle. +# may appear (importer-validated, V-TT check) — this file never expands a +# template's palette, only re-weights within it. Unlisted tokens/axes/zones +# keep the uniform baseline. +# +# [bias..] +# wall = { = , ... } # optional per axis +# roof = { = , ... } +# facade = { = , ... } +# street = { = , ... } +# ========================================================================== + + +# ========================================================================== +# A. CROSS-CORRIDOR POOL +# ========================================================================== + +# mixed_market_vernacular: brick reads load-bearing/durable shopfront vs. +# rendered's softer domestic finish upstairs. +[bias.mixed_market_vernacular.commercial_market] +wall = { brick_wall = 15000 } +street = { cobble = 14000 } + +[bias.mixed_market_vernacular.residential_surface] +wall = { rendered_wall = 14000 } +street = { paved = 13000 } + +# speculative_boomtown: freight gets the rawest register (first thing +# thrown up); the market street is the town's "arrived" storefront. +[bias.speculative_boomtown.industrial_freight] +wall = { steel_frame = 16000 } +roof = { corrugated_roof = 16000 } +street = { packed_earth = 15000 } + +[bias.speculative_boomtown.commercial_market] +wall = { composite_panel = 14000 } +roof = { flat_roof = 13000 } +street = { paved = 13000 } + +# extraction_camp: the works itself is steel; worker housing is poured +# concrete, not rig-grade steel. +[bias.extraction_camp.extraction_platform] +wall = { steel_frame = 18000 } + +[bias.extraction_camp.residential_surface] +wall = { concrete_wall = 14000 } + +# industrial_utilitarian: the production floor is steel/corrugated/heavy- +# haul; the district's few storefronts keep the older brick frontage. +[bias.industrial_utilitarian.industrial_manufacturing] +wall = { steel_frame = 15000 } +roof = { corrugated_roof = 14000 } +street = { heavy_haul = 15000 } + +[bias.industrial_utilitarian.commercial_market] +wall = { brick_wall = 13000 } +roof = { flat_roof = 12000 } +street = { paved = 13000 } + +# cold_chain_works: the plant is permanent concrete with truck access; the +# produce-market front is the lighter composite-panel register. +[bias.cold_chain_works.industrial_processing] +wall = { concrete_wall = 14000 } +street = { heavy_haul = 15000 } + +[bias.cold_chain_works.commercial_market] +wall = { composite_panel = 13000 } +street = { paved = 13000 } + +# precision_arcology: labs get the showcase glass + green roof + clean +# raised walkways; the production floor behind it stays flat/paved/panel. +[bias.precision_arcology.research_station] +roof = { green_roof = 15000 } +street = { elevated_walkway = 14000 } + +[bias.precision_arcology.industrial_manufacturing] +wall = { composite_panel = 13000 } +roof = { flat_roof = 14000 } +street = { paved = 13000 } + +# information_spire: civic seats keep institutional restraint even at the +# top of the spire; retail/market level gets the reactive screen facade. +[bias.information_spire.administrative_civil] +facade = { regular_facade = 15000 } +street = { elevated_walkway = 13000 } + +[bias.information_spire.commercial_market] +facade = { screen_facade = 14000 } +street = { paved = 13000 } + +# civic_monumental: the courts get the fullest ceremonial treatment of any +# building in the catalog; a mere checkpoint in an Administrative district +# is NOT a courthouse — utilitarian concrete, ornament suppressed hard. +[bias.civic_monumental.administrative_civil] +wall = { stone_wall = 16000 } +roof = { vaulted_roof = 15000 } +facade = { colonnade = 15000 } +street = { cobble = 14000 } + +[bias.civic_monumental.administrative_judicial] +wall = { stone_wall = 16000 } +roof = { vaulted_roof = 16000 } +facade = { colonnade = 16000 } +street = { cobble = 14000 } + +[bias.civic_monumental.security_checkpoint] +wall = { concrete_wall = 15000 } +roof = { flat_roof = 15000 } +facade = { ornamental_facade = 6000 } + +# foreign_quarter: the transplant that draws a crowd gets the grand vaulted/ +# lattice register; the quiet domestic import stays plainer. +[bias.foreign_quarter.entertainment_venue] +roof = { vaulted_roof = 15000 } +facade = { lattice_screen = 14000 } + +[bias.foreign_quarter.residential_surface] +roof = { clay_tile_roof = 13000 } +facade = { screen_facade = 13000 } + + +# ========================================================================== +# B. PER-CORRIDOR BASELINE POOLS +# ========================================================================== + +# core_cosmopolitan: the old ministries stay stone/ornamental; modern retail +# layers glass over the old core; ordinary old-town housing stays brick. +[bias.core_cosmopolitan.administrative_civil] +wall = { stone_wall = 15000 } +facade = { ornamental_facade = 14000 } + +[bias.core_cosmopolitan.commercial_market] +wall = { glass_curtain_wall = 14000 } +facade = { regular_facade = 13000 } + +[bias.core_cosmopolitan.residential_surface] +wall = { brick_wall = 13000 } +roof = { pitched_roof = 13000 } + +# north_anglo_frontier: civic restraint in brick vs. timber farm sheds with +# corrugated roofs on packed-earth farm tracks. +[bias.north_anglo_frontier.administrative_civil] +wall = { brick_wall = 14000 } +facade = { regular_facade = 14000 } +street = { paved = 13000 } + +[bias.north_anglo_frontier.rural_agricultural] +wall = { timber_wall = 14000 } +roof = { corrugated_roof = 15000 } +street = { packed_earth = 15000 } + +# south_lusophone: the praça-facing arcade for the market vs. the shuttered +# private house. +[bias.south_lusophone.commercial_market] +facade = { arcade_facade = 15000 } +street = { cobble = 14000 } + +[bias.south_lusophone.residential_surface] +facade = { shuttered_facade = 14000 } +wall = { stucco_wall = 13000 } + +# west_germanic_ordered: engineered brick precision for manufacturing vs. +# the plain rendered residential street. +[bias.west_germanic_ordered.industrial_manufacturing] +wall = { brick_wall = 14000 } +street = { paved = 14000 } + +[bias.west_germanic_ordered.residential_surface] +wall = { rendered_wall = 13000 } +street = { cobble = 12000 } + +# west_compact_cooperative: the civic hall is visibly held in common +# (colonnade + green roof) vs. plain cooperative housing. +[bias.west_compact_cooperative.administrative_civil] +facade = { colonnade = 16000 } +roof = { green_roof = 14000 } + +[bias.west_compact_cooperative.residential_surface] +facade = { regular_facade = 13000 } +wall = { rendered_wall = 13000 } + +# east_dense_utilitarian: signage-saturated screen facade at the retail/ +# transit level vs. lattice-screened concrete housing above. +[bias.east_dense_utilitarian.commercial_market] +facade = { screen_facade = 15000 } +street = { elevated_walkway = 14000 } + +[bias.east_dense_utilitarian.residential_surface] +facade = { lattice_screen = 14000 } +wall = { concrete_wall = 13000 } +street = { paved = 13000 } + +# frontier_surname_settlement: the founder-family's one formal stone +# building vs. rammed-earth farm outbuildings on packed earth. +[bias.frontier_surname_settlement.administrative_civil] +wall = { stone_wall = 16000 } +facade = { regular_facade = 14000 } +street = { cobble = 15000 } + +[bias.frontier_surname_settlement.rural_agricultural] +wall = { rammed_earth_wall = 14000 } +roof = { flat_roof = 13000 } +street = { packed_earth = 15000 } + +# frontier_hardscrabble: only the wall axis has any choice here — a +# checkpoint gets the standardized defensive concrete; a frontier outpost +# gets the locally hand-built rammed earth. +[bias.frontier_hardscrabble.security_checkpoint] +wall = { concrete_wall = 15000 } + +[bias.frontier_hardscrabble.wilderness_frontier] +wall = { rammed_earth_wall = 14000 } + + +# ========================================================================== +# C. HERITAGE SUB-POOLS +# ========================================================================== + +# scottish_highland_vernacular: scattered crofts (pitched roof, packed +# earth track) vs. the one village market building (tile roof, cobble). +[bias.scottish_highland_vernacular.residential_dispersed] +roof = { pitched_roof = 14000 } +street = { packed_earth = 14000 } + +[bias.scottish_highland_vernacular.commercial_market] +roof = { clay_tile_roof = 13000 } +street = { cobble = 13000 } + +# west_african_compound: the market-facing arcade vs. the private family +# compound behind its shuttered rammed-earth wall. +[bias.west_african_compound.commercial_market] +facade = { arcade_facade = 15000 } +street = { cobble = 13000 } + +[bias.west_african_compound.residential_surface] +facade = { shuttered_facade = 14000 } +wall = { rammed_earth_wall = 13000 } +street = { packed_earth = 14000 } + +# iberian_hacienda: the estate-office colonnade reads institutional vs. the +# veranda-fronted domestic hacienda. +[bias.iberian_hacienda.administrative_civil] +facade = { colonnade = 15000 } +wall = { stone_wall = 14000 } + +[bias.iberian_hacienda.residential_surface] +facade = { arcade_facade = 14000 } +wall = { stucco_wall = 13000 } + +# atlantic_creole_maritime: the working waterfront (timber, boardwalk) vs. +# inland creole housing (render, cobble). +[bias.atlantic_creole_maritime.port_fishing] +wall = { timber_wall = 14000 } +street = { boardwalk = 15000 } + +[bias.atlantic_creole_maritime.residential_surface] +wall = { rendered_wall = 13000 } +street = { cobble = 13000 } + +# nordic_timber: the coastal fishing quay pulls boardwalk; inland housing +# keeps the turf/green-roof domestic register on cobble. +[bias.nordic_timber.port_fishing] +street = { boardwalk = 15000 } + +[bias.nordic_timber.residential_surface] +roof = { green_roof = 13000 } +street = { cobble = 13000 } + +# central_european_blok: civic solidity keeps its ornament in reserve (but +# still reaches for it) vs. the plain perimeter-block housing stock. +[bias.central_european_blok.administrative_civil] +facade = { ornamental_facade = 14000 } +wall = { brick_wall = 14000 } + +[bias.central_european_blok.residential_surface] +facade = { regular_facade = 13000 } +wall = { rendered_wall = 13000 } + +# east_asian_temple_enclave: ceremonial reception gets the full vaulted- +# colonnade register; the ordinary residential quarter around the temple +# stays in the softer timber/lattice domestic register. +[bias.east_asian_temple_enclave.diplomatic_elite] +roof = { vaulted_roof = 16000 } +facade = { colonnade = 15000 } + +[bias.east_asian_temple_enclave.residential_surface] +wall = { timber_wall = 14000 } +roof = { clay_tile_roof = 13000 } +facade = { lattice_screen = 13000 } +street = { cobble = 13000 } + +# vietnamese_water_village: working water trade backs onto the canal; +# land-fronting tube-houses face the boardwalk instead. +[bias.vietnamese_water_village.port_fishing] +wall = { timber_wall = 14000 } +street = { canal_way = 15000 } + +[bias.vietnamese_water_village.rural_aquaculture] +street = { canal_way = 15000 } + +[bias.vietnamese_water_village.residential_surface] +wall = { rendered_wall = 13000 } +street = { boardwalk = 13000 } + +# afrikaans_kraal: the werf/farmstead register (render, pitched roof, dirt +# track) vs. the more formal Cape civic building (stone, tile, cobble). +[bias.afrikaans_kraal.rural_pastoral] +wall = { rendered_wall = 14000 } +roof = { pitched_roof = 14000 } +street = { packed_earth = 14000 } + +[bias.afrikaans_kraal.administrative_civil] +wall = { stone_wall = 14000 } +roof = { clay_tile_roof = 13000 } +street = { cobble = 13000 } + +# arab_oasis_qanat: the shaded souk-adjacent market lane vs. the courtyard +# house with its wind-tower privacy screen. +[bias.arab_oasis_qanat.commercial_market] +wall = { stucco_wall = 14000 } +facade = { lattice_screen = 14000 } +street = { cobble = 14000 } + +[bias.arab_oasis_qanat.residential_surface] +wall = { rammed_earth_wall = 14000 } +roof = { terraced_roof = 14000 } +facade = { screen_facade = 14000 } +street = { packed_earth = 14000 } + + +# ========================================================================== +# NOT BIASED — flagged, not an oversight +# ========================================================================== +# generic_baseline is the one template with ZERO degrees of freedom: every +# axis (wall/roof/facade/street) carries exactly one token in its +# visual_bundle. There is nothing left to bias — any zone_type drawing this +# template gets that one token per axis regardless. This is correct: it is +# the corridor-neutral fallback the draw can always reach (catalog line 57), +# and a single-token axis is definitionally already "uniform." +# ========================================================================== diff --git a/wiki/economics/color_register_bands.toml b/wiki/economics/color_register_bands.toml new file mode 100644 index 000000000..2b57f691d --- /dev/null +++ b/wiki/economics/color_register_bands.toml @@ -0,0 +1,330 @@ +# ========================================================================== +# Color register bands (T-988, D-235) — numeric HSV bands per D-232 +# trait-template `color_register` label. Turns the free-form palette-cue +# string documented in object_tag_vocabulary.toml ("a palette cue, NOT an +# ObjectTag") into an authored, sampleable numeric range. +# +# THE MODEL: each color_register gets ONE integer HSV band. The fill seed +# picks a single (hue, sat, val) point uniformly within the band per +# building — same register, same family feel, different building each time. +# This is the register acting as intended (D-235): "always within the +# template's register," never a fixed single color, never unbounded. +# +# INTEGER UNITS (D-010 — no floats, ever; determinism is save-critical +# under D-227): +# hue 0..36000 centidegrees (degrees x 100; standard 0-360 deg wheel) +# sat 0..10000 basis points (0-100.00%) +# val 0..10000 basis points (0-100.00%) +# All three axes are closed [min, max] integer ranges, min < max, sampled +# uniformly (no wraparound support — no band crosses the hue 0/36000 seam). +# +# SATURATION CEILING (visual-hierarchy discipline, not a hard schema rule): +# every band in this file stays at or below sat = 3600 (36%). This keeps +# building material color — structure, the least important read — clearly +# under the saturation the project reserves for foreground signal (entity / +# relationship color has historically run 40-60% in this project's palette +# work). Buildings should never visually compete with the things standing +# in front of them. Even the two "brightest allowed" cultural-accent +# registers (bright_painted, falu_red_and_pine) stay under this ceiling. +# +# OWNERSHIP (Q-049 split, same as object_tag_vocabulary.toml): Miri owns +# whether a register's cultural placement is right for its template/corridor +# (the WHY); Araminta owns the numeric HSV authoring (the WHAT it looks +# like) and the cross-register visual discipline (distinctness, saturation +# hierarchy, mood). Changes to a band's numbers are Araminta's call; changes +# to which register a template carries are Miri's. Add both together when a +# new template ships a new color_register value. +# +# COVERAGE: every color_register value referenced by +# architecture_trait_catalog.toml's `visual_bundle.color_register` MUST have +# a `[register.]` entry here — the importer validates this (T-988). +# 28 values are banded below, one per current template. Two are flagged in +# the section comments as the most interpretive calls in the set — real +# entries, not placeholders, but worth a cultural sanity-check from Miri. +# +# The fill seed samples (h, s, v) once per building and stores the result as +# three plain integers on BuildingExteriorTag — no re-derivation, no drift. +# ========================================================================== + + +# ========================================================================== +# I. COOL NEUTRAL / INSTITUTIONAL & TECH — cool-cast hues (~190-220 deg), +# the lowest-saturation family in the file. Differ mainly by value +# (how pale) and a hair of saturation (how "designed" vs "clinical"). +# ========================================================================== + +[register.neutral_grey] +hue = [20000, 21200] # centidegrees (200-212 deg, cool blue-grey) +sat = [400, 1200] # bps (4-12%) +val = [4800, 6400] # bps (48-64%) +# generic_baseline — corridor-neutral concrete default; the fallback every +# other register is a deviation FROM. Mid-value, barely-there cool tint. + +[register.clean_pale] +hue = [19000, 20200] # centidegrees (190-202 deg, near-neutral, faint cool) +sat = [200, 800] # bps (2-8%) -- the lowest sat band in the catalog +val = [7600, 9000] # bps (76-90%) +# cold_chain_works — food/pharma hygiene register. Clinical pale, almost +# no color at all; reads as "clean" through value and absence of tint. + +[register.cool_clean] +hue = [20000, 21400] # centidegrees (200-214 deg, cool blue) +sat = [600, 1600] # bps (6-16%) +val = [7000, 8400] # bps (70-84%) +# precision_arcology — cleanroom/fab register. Quiet, controlled, a shade +# more saturated and a shade darker than clean_pale (expensive, not sterile). + +[register.glass_and_light] +hue = [20400, 21800] # centidegrees (204-218 deg, cool blue, glazing tint) +sat = [1000, 2200] # bps (10-22%) +val = [7200, 8800] # bps (72-88%) +# information_spire — glass curtain-wall office towers. Brightest, most +# reflective register in the set; the sat bump over cool_clean is the +# tinted-glazing read, not "more colorful," just "more glass." + +[register.neon_over_grey] +hue = [19200, 20400] # centidegrees (192-204 deg, cool cyan-grey) +sat = [1200, 2600] # bps (12-26%) +val = [4400, 6000] # bps (44-60%) +# east_dense_utilitarian — cyberpunk-density baseline. The grey base carries +# a restrained cool cast; actual neon is signage/lighting, a different +# system (environmental neutrality) -- this band stays a paint color, not a +# light source, so it never reads as garish. + + +# ========================================================================== +# II. WARM CIVIC PALE — the warm-hue counterpart to section I. One entry: +# civic authority reads warm and dignified, not cool and clinical. +# ========================================================================== + +[register.pale_stone] +hue = [3800, 5000] # centidegrees (38-50 deg, warm stone tan) +sat = [400, 1400] # bps (4-14%) +val = [6800, 8200] # bps (68-82%) +# civic_monumental — colonnades and institutional gravitas. Pale and low-sat +# like section I, but warm instead of cool: authority, not sterility. + + +# ========================================================================== +# III. INDUSTRIAL OXIDE & RAW — warm rust-to-greige hues, low-mid sat, +# low-mid value. The heavy-process / unfinished / hostile-world family. +# ========================================================================== + +[register.oxide_and_dust] +hue = [2000, 3000] # centidegrees (20-30 deg, rust orange) +sat = [1800, 3200] # bps (18-32%) +val = [3200, 4800] # bps (32-48%, dark and dusty) +# extraction_camp — mine/rig/well-is-the-town register. Darkest and +# grittiest of the industrial family; rust-streaked steel, ground dust. + +[register.oxide_and_steel] +hue = [1600, 2800] # centidegrees (16-28 deg, rust diluted toward neutral) +sat = [1000, 2200] # bps (10-22%, lower than oxide_and_dust) +val = [3600, 5200] # bps (36-52%) +# industrial_utilitarian — large-span sheds, tank farms. Steel dilutes the +# rust the extraction camp shows raw; less dust, more exposed structure. + +[register.raw_provisional] +hue = [3000, 4200] # centidegrees (30-42 deg, warm greige) +sat = [600, 1600] # bps (6-16%) +val = [5200, 6800] # bps (52-68%) +# speculative_boomtown — thrown-up-fast composite/steel. Low commitment +# color for a settlement still deciding what it is; unfinished, provisional. + +[register.hardened_earth] +hue = [2800, 3800] # centidegrees (28-38 deg, earth-neutral) +sat = [600, 1600] # bps (6-16%) +val = [3600, 5000] # bps (36-50%, dark) +# frontier_hardscrabble — hardened against a hostile primary. Dense concrete +# and rammed earth, minimal exposed surface; the darkest earth-family band. + + +# ========================================================================== +# IV. EARTH & MASONRY BASELINES — warm brown-tan mid-tones, the "everyday +# vernacular" family. Six corridor baselines that must read as siblings +# (all timber/brick/render/stone) while staying individually legible. +# ========================================================================== + +[register.mixed_warm] +hue = [1600, 3000] # centidegrees (16-30 deg, brick-orange) +sat = [1200, 2400] # bps (12-24%) +val = [4600, 6200] # bps (46-62%) +# mixed_market_vernacular — organic ground-floor-trade townscape. Warmest +# and most saturated of this section; decades of layered commercial signage. + +[register.muted_practical] +hue = [2600, 3800] # centidegrees (26-38 deg, muted brown) +sat = [800, 1800] # bps (8-18%) +val = [4600, 6000] # bps (46-60%) +# north_anglo_frontier — Commonwealth-frontier civic restraint. Weatherboard +# and brick, orderly and unshowy; the corridor's practical default. + +[register.ordered_earth] +hue = [3000, 4000] # centidegrees (30-40 deg, earth tan) +sat = [900, 1900] # bps (9-19%) +val = [5000, 6400] # bps (50-64%) +# west_germanic_ordered — disciplined massing, engineered tidiness. +# Everything has its place; the color is as restrained as the geometry. + +[register.communal_warm] +hue = [2800, 3800] # centidegrees (28-38 deg, warm, same family as ordered_earth) +sat = [1200, 2400] # bps (12-24%) +val = [5600, 7200] # bps (56-72%, brighter than ordered_earth) +# west_compact_cooperative — shared halls, civic infrastructure held in +# common. Brighter and a touch warmer than the Germanic baseline it sits +# beside -- "legible to its members" reads as more open, not more muted. + +[register.sober_masonry] +hue = [2600, 3600] # centidegrees (26-36 deg, muted brick-brown) +sat = [900, 1900] # bps (9-19%) +val = [4400, 5800] # bps (44-58%) +# central_european_blok — Polish/Czech masonry perimeter blocks. Ornament +# held in reserve; so is the color -- solid, civic, unornamented. + +[register.local_stone] +hue = [3200, 4400] # centidegrees (32-44 deg, warm stone-earth) +sat = [800, 1800] # bps (8-18%) +val = [5200, 6800] # bps (52-68%) +# frontier_surname_settlement — built from whatever the world offered. +# Self-reliant and coherent across centuries; unglamorous by design. + + +# ========================================================================== +# V. OCHRE / TERRACOTTA / SAND — warm orange-tan, mid-to-high sat and +# value, the sun-baked family. Distinguished from section IV by being +# more saturated (IV's earths are muted; these are declarative). +# ========================================================================== + +[register.warm_ochre] +hue = [3200, 4200] # centidegrees (32-42 deg, ochre-orange) +sat = [1800, 3000] # bps (18-30%) +val = [5400, 7000] # bps (54-70%) +# west_african_compound — rendered ochre walls around shared courtyards. +# Warmer and more saturated than the frontier-earth registers in section IV. + +[register.terracotta_and_lime] +hue = [1800, 2800] # centidegrees (18-28 deg, terracotta red-orange) +sat = [1600, 2800] # bps (16-28%) +val = [6000, 7600] # bps (60-76%) +# iberian_hacienda — stucco courtyards, clay-tile roofs, arcaded verandas. +# The band carries the terracotta warmth; lime-washed brightness lives at +# the high end of value. (One HSV point can't hold two named materials at +# once -- terracotta is the primary read here, lime is the value lift.) + +[register.sand_and_shade] +hue = [3600, 4600] # centidegrees (36-46 deg, sandy tan) +sat = [600, 1600] # bps (6-16%) +val = [6200, 7600] # bps (62-76%) +# arab_oasis_qanat — rammed-earth courtyard houses under harsh dryland +# light. Paler and less saturated than warm_ochre/terracotta -- sun-bleached +# rather than declarative. + + +# ========================================================================== +# VI. DEEP SATURATED CULTURAL ACCENTS — the most saturated bands in the +# file, still capped well under the entity-color saturation floor. +# Each is a specific, real-world-anchored callback, used sparingly by +# the heritage/import deviation system (D-232), not a corridor default. +# ========================================================================== + +[register.falu_red_and_pine] +hue = [600, 1400] # centidegrees (6-14 deg, deep barn red) +sat = [2200, 3400] # bps (22-34%) +val = [3200, 4600] # bps (32-46%, dark and saturated) +# nordic_timber — the iconic Scandinavian falu-red timber callback. Dark, +# saturated, unmistakably itself; pine restraint keeps it from drifting +# toward orange. + +[register.lacquer_and_timber] +hue = [200, 1000] # centidegrees (2-10 deg, deep lacquer red) +sat = [2000, 3200] # bps (20-32%) +val = [3000, 4400] # bps (30-44%, dark and lacquered) +# east_asian_temple_enclave — CJK temple-precinct callback. Darker and a +# touch closer to true red than falu_red_and_pine -- the two must not be +# confused for each other despite the shared "deep saturated red" family. + +[register.bright_painted] +hue = [1400, 2400] # centidegrees (14-24 deg, warm coral-orange) +sat = [2400, 3600] # bps (24-36%, the highest sat in the file) +val = [6400, 8000] # bps (64-80%) +# atlantic_creole_maritime — Cape-Verdean/Afro-Atlantic bright-painted +# timber and deep porches. Deliberately the boldest register in the whole +# set -- capped at 3600 bps (36%) to stay clear of the entity-color +# saturation range, so it reads as "the brightest building" without ever +# competing with a relationship-colored character standing in front of it. + + +# ========================================================================== +# VII. PASTEL & WHITEWASH — very low saturation, high value, pale-painted +# feel. Three whitewash-family callbacks that must stay distinct from +# each other at a glance. +# ========================================================================== + +[register.whitewash_and_azulejo] +hue = [3400, 4400] # centidegrees (34-44 deg, warm white cast) +sat = [300, 1000] # bps (3-10%) +val = [8000, 9200] # bps (80-92%, very pale) +# south_lusophone — whitewashed render around the praça. The azulejo blue +# tile trim is a facade/accent detail outside a single HSV band's scope; +# this band is the wall's warm-white base. + +[register.cape_whitewash] +hue = [3800, 4600] # centidegrees (38-46 deg, warm-neutral white) +sat = [300, 1000] # bps (3-10%) +val = [7800, 9000] # bps (78-90%) +# afrikaans_kraal — Cape gabled werf whitewash. Same mechanism as +# whitewash_and_azulejo; hue nudged a few degrees warmer and value nudged +# down slightly so the two whitewash registers stay tellable apart. + +[register.weathered_pastel] +hue = [16000, 18000] # centidegrees (160-180 deg, soft aqua-green pastel) +sat = [800, 1800] # bps (8-18%) +val = [6800, 8200] # bps (68-82%) +# vietnamese_water_village — canalside tube-houses. The one green-family +# register in the set, chosen for canal/water association and to stay +# clearly distinct from the file's several warm-whitewash bands above. +# FLAGGED FOR MIRI: real-world Vietnamese heritage townscapes (Hoi An) skew +# warm ochre/yellow more often than aqua -- this is a legibility trade-off +# (distinctness against 3 other pale-warm registers) over strict real-world +# color-matching. Worth a cultural sanity check; easy to re-hue if wrong. + + +# ========================================================================== +# VIII. VIOLET-COOL OUTLIER — the only violet-hued band in the file. Used +# once, deliberately, so it stays a signature rather than a habit. +# ========================================================================== + +[register.grey_stone_heather] +hue = [26000, 28000] # centidegrees (260-280 deg, violet-grey) +sat = [500, 1400] # bps (5-14%) +val = [4200, 5800] # bps (42-58%) +# scottish_highland_vernacular — drystone and slate against weather. The +# violet lean is a literal read of "heather" over grey stone; low-sat +# enough to stay a grey with a cast, not a purple building. +# FLAGGED FOR MIRI: the most interpretive hue placement in the file (the +# only band outside the neutral/warm-earth/cool-tech families) -- confirm +# it reads as "Highland stone" and not just "purple." + + +# ========================================================================== +# IX. DELIBERATELY WIDE / PLURAL — the two registers whose whole cultural +# point is variety, not a single tone. Hue bands here are 3-4x the width +# used everywhere else in this file, on purpose. +# ========================================================================== + +[register.layered_patina] +hue = [2000, 6000] # centidegrees (20-60 deg, warm brown through olive) +sat = [1000, 2400] # bps (10-24%) +val = [4800, 6800] # bps (48-68%) +# core_cosmopolitan — centuries-deep, no single grammar dominant, old +# infrastructure carrying new uses. The widest hue band in the file is the +# point: a "layered patina" city block should NOT sample as one fixed tone. + +[register.imported_accent] +hue = [1000, 5000] # centidegrees (10-50 deg, warm red-orange through tan) +sat = [1600, 3200] # bps (16-32%) +val = [5200, 7200] # bps (52-72%) +# foreign_quarter — the import-swerve template: a coherent transplant +# carried whole from another corridor (the Shinto temple in Amsterdam). +# Wide by design for the same reason as layered_patina -- it can arrive +# from anywhere, so it shouldn't sample as anywhere in particular.