Compare commits

...
104 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.7 5c4a3fda19 docs(decisions): amend D-224 with #952 review findings
- Pin the Body domain id scheme: body_id(String) -> FNV-1a(64) -> derive(Body, …),
  via SeedChain::for_body. Closes the gap where "keyed by body StableId" named a
  numeric id that doesn't exist (bodies are strings).
- Add for_body to the contract; document the stability guards (SeedDomain
  #[repr(u64)] + pin test, AttractorType #[repr(u8)]).
- Note EntityRng keeps its domainless combine (re-expressing as derive(Npc, …)
  is a stream-changing migration, deferred).
- Correct the implementation note: golden is JSON (matching golden_suite.rs),
  not msgpack; run at 256x128 for a non-empty river network. Fix the stale
  skeleton_gen.rs:248 line reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:24:13 +02:00
jpmschweitzerandClaude Opus 4.7 a61d030b47 refactor(simulation): harden #952 cascade per QA + architecture review
Addresses the Hoshe (QA) + Tyre (architecture) review of the SeedChain/cascade
work:

- Golden was pinning an empty river network (128x64 produced 0 river cells).
  Bumped to 256x128, where GJ1c yields a real network (93 river cells, 19
  mouths) — Layer 1's rivers are now actually guarded, not just attractors.
- SeedChain::for_body(world_seed, body_id) + fnv1a_64: the single canonical
  body_id(String) -> u64 path (FNV-1a, the repo convention), so callers can't
  derive divergent worlds from the same seed via different ad-hoc hashes. The
  golden now uses it.
- Stability guards: seed_domain_discriminants_are_pinned test (CI fails if a
  SeedDomain tag is renumbered); AttractorType gains #[repr(u8)] + explicit
  discriminants (it's cast as a sort key in features.rs).
- Tests: SeedChain::root(0) non-degenerate; run_cascade error path (missing
  file -> Err, not panic).
- Comments: clarified the id=0 derivations (sibling separation is caller-side
  via the per-district/quarter chain; #957 threads the index), tightened the
  all_district_types reachability comment (it pins the seed-0 sequence, not a
  probabilistic claim), and noted the golden's WORLD_SEED is cosmetic at
  Layers 0-1 + the x86_64 f32 capture caveat.

Deferred with reason: the run_cascade -> CascadeInputs struct refactor (Tyre)
is left for #954 — designing Layer-2's context shape now would be later-phase
detail, and there's a single caller to migrate then. EntityRng keeps its
domainless combine (migrating is stream-changing) — noted in D-224.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:24:03 +02:00
jpmschweitzerandClaude Opus 4.7 b4eff847fc chore(meta): update changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:26:40 +02:00
jpmschweitzerandClaude Opus 4.7 aaee9ecb06 test(simulation): golden-seed regression for the Layer 0->1 cascade (#952, D-200)
End-to-end determinism guard. cascade_golden.rs pins two artifacts for a real
committed body heightmap (GJ1c) in one diffable JSON golden
(server/tests/golden/cascade_layer1.json):
- Layer 0: SHA-256 of the source heightmap.png bytes (flips if the Python
  heightmap generator or the file changes)
- Layer 1: the serialized Layer1Output of run_cascade on a 128x64 downsample
  (flips if the Rust drainage/feature/sub-biome code changes)

JSON (not the msgpack discussed in refinement) to match the existing
golden_suite.rs convention and stay diffable — a failure shows what drifted.
UPDATE_GOLDEN=1 regenerates; wired into `make golden-update`.

Adds Serialize/Deserialize to RiverNetwork/DrainageBasin/Layer1Output and a
sha2 dev-dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:26:07 +02:00
jpmschweitzerandClaude Opus 4.7 9bc2ed28f2 feat(simulation): extensible generation cascade harness (#952, D-200)
server/src/atlas/cascade.rs — run_cascade(body_seed, body_id, path,
default_sea_level, up_to) loads Layer 0 (heightmap.png) and runs every layer up
to the requested CascadeLayer, returning a CascadeSnapshot. The pure core
run_cascade_from_heightmap orchestrates the layers without file I/O (testable);
run_cascade is the thin path-loading wrapper.

CascadeSnapshot is extensible — each layer's artifact is an Option that becomes
Some once it runs (Layer 0 heightmap always present, Layer 1 topography next;
#954+ append their fields). The carried SeedChain is unused by the RNG-free
Layers 0-1 and feeds the RNG-using layers later (D-224). CascadeLayer is an
append-only ordered enum.

Tests: layer-gating, same-heightmap determinism, layer ordering. The golden-seed
regression fixture (the #952 deliverable) builds on this next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:09:49 +02:00
jpmschweitzerandClaude Opus 4.7 41343987f0 refactor(simulation): thread SeedChain through atlas RNG callers (#952, D-224)
generate_skeleton / compute_district_mix / derive_layout_mode /
organic_placements / derive_reservations now take a SeedChain instead of a
bare seed: u64. The two ad-hoc wrapping_add pre-mixing hacks are replaced with
real domain separation:
- district-type allocation draws from chain.derive(SeedDomain::Layer4Quarter, 0)
- organic block placement draws from chain.derive(SeedDomain::Block, 0)

DistrictSkeleton.seed now records chain.seed(); test call sites pass
SeedChain::root(N); stale seed-param docs updated.

Re-tuned all_district_types_can_appear: the seed-stream change exposed it as
latently fragile — every type has a clamped weight >= 1 (reachable), but 50
weighted draws can miss a low-weight type (Administrative) depending on the
sequence; the old seed got lucky. Raised the draw count to 500 so it tests
genuine reachability rather than a lucky sequence. Determinism-affecting by
design — this is the RNG-using layer D-224 flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:05:19 +02:00
jpmschweitzerandClaude Opus 4.7 de9142ce37 feat(simulation): SeedChain seed-derivation primitive in server/src/seed.rs (#952, D-224)
New top-level seed module that owns every deterministic-RNG primitive:
- splitmix64 — the one canonical mixer (was duplicated as a private fn in
  simulation/rng.rs; EntityRng now imports the shared one, no behavior change)
- AtlasRng — moved here from atlas/rng.rs (it is a generation-RNG primitive,
  not atlas-specific); atlas now depends on seed, not the reverse
- SeedDomain — append-only domain tags (Body/Layer1Topography/Layer3Settlement/
  Layer4Quarter/Block/Npc) for collision-proof per-domain seed separation
- SeedChain — root(world_seed) → derive(domain, id) → atlas_rng()/seed(), per
  the D-224 formula splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id)

Structural move only — SeedChain is not yet threaded through the cascade
callers (skeleton_gen still uses ad-hoc wrapping_add pre-mixing); that is the
next step. Unit tests cover the splitmix64 known-vector, avalanche,
determinism, domain/id separation, and chain composition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:51:09 +02:00
jpmschweitzerandClaude Opus 4.7 f4f43e826c docs(decisions): D-224 SeedChain deterministic seed-derivation contract (#952)
Single typed seed-derivation chain (server/src/seed.rs) descending from the
master world seed via domain-separated splitmix64 mixing — the only sanctioned
way to derive a child seed. Promotes the existing splitmix64 (EntityRng's
mixer) to a shared pub(crate) function and removes ad-hoc wrapping_add
pre-mixing. Pins the derivation formula (load-bearing) and documents the
scope of effect: heightmaps (Python Layer 0) and Layer 1 (RNG-free) are
unaffected; only RNG-using layers (skeleton_gen, future settlement) change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:33:19 +02:00
jpmschweitzerandClaude Opus 4.7 20ed153991 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:03:43 +02:00
jpmschweitzerandClaude Opus 4.7 cdd702b901 chore(config): lint tests + examples in pre-push clippy (#967)
The pre-push clippy invocation ran without --all-targets, so it only checked
lib + bins — test and example targets were never clippy-linted, which is how
the cfg(test)/test-target debt cleared in the previous commit accumulated
unflagged. Add --all-targets now that the debt is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:03:23 +02:00
jpmschweitzerandClaude Opus 4.7 8eb6c47f74 style(server): clear clippy-1.93 cfg(test)/test-target debt (#967)
Manual clippy-1.93 fixes that the prior machine-applicable sweep couldn't auto-
apply, all in cfg(test) modules and tests/ targets (invisible to the lib-only
pre-push clippy, hence accumulated unflagged):

- disallowed_types HashSet/HashMap → BTreeSet/BTreeMap (determinism rule):
  shadowcast_bench.rs (×8, (i32,i32) keys), mood.rs, sound.rs. SoundEventKind
  gains a PartialOrd/Ord derive (fieldless Copy enum) so it is BTree-usable.
- field_reassign_with_default → struct-init: disclosure.rs, monologue.rs (×2),
  save_io.rs (keeps `mut` for the deliberate last-write-wins overwrite).
- assertions_on_constants on the EAVESDROP_THRESHOLD invariant → compile-time
  `const _: () = assert!(...)`: listening.rs, cross_room_transitions.rs. This is
  stronger than the runtime assert and needs no #[allow].
- approx_constant: settings/types.rs round-trip literal 3.14 → 2.5 (the value is
  arbitrary test data, never meant to be PI — change avoids both the lint and a
  suppression).
- drop_non_drop: vision.rs early Mut<WalkabilityMap> release → scoped block.
- unnecessary_get_then_check → contains_key: information_boundaries.rs (×3).
- cloned_ref_to_slice_refs → std::slice::from_ref: triangle_validation.rs.
- unused_must_use: input.rs dropped the unused .id() on a spawn.

cargo clippy --all-targets -- -D warnings is clean; cargo test green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:03:15 +02:00
jpmschweitzerandClaude Opus 4.7 9a10c6ffd6 chore(deps): rand 0.9.4 + compatible sweep + serde_yaml→serde_norway (#966)
Focused Rust dependency-maintenance pass from the 2026-05-23 security/freshness
review. No CVEs; one advisory cleared and one deprecated crate replaced.

- rand 0.9.2 → 0.9.4 (lockfile): clears RUSTSEC-2026-0097 (unsound with a
  custom logger using rand::rng()). Semver-compatible; rand 0.10 is a separate
  major.
- Compatible-update sweep: ~90 lockfile-only patch/minor bumps (bevy 0.18.0→
  0.18.1, clap 4.5→4.6, rayon 1.11→1.12, pathfinding 4.14→4.15, uuid 1.20→1.23,
  zerocopy, serde_json, tracing-subscriber, etc.). cargo test green.
- serde_yaml 0.9 (deprecated/archived upstream) → serde_norway 0.9, an actively
  maintained drop-in fork. In the server it is test-only (poi.rs round-trip,
  trait_modifiers.rs fixture, tests/news_ticker.rs) so it moves to
  dev-dependencies; line-previewer parses dialogue/monologue pool YAML at
  runtime, so it keeps it as a normal dependency. API is identical (from_str/
  to_string).

news_ticker.rs also picks up its share of the #967 clippy sweep (HashSet/HashMap
→ BTree, doc-list indent) since it is the same file as the serde rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:02:57 +02:00
jpmschweitzerandClaude Opus 4.7 88712ba54f style: clippy-1.93 machine-applicable auto-fixes (#967)
cargo clippy --fix on files the new clippy (1.93) flags: unused imports
(name_index, storyteller), manual_range_contains (block_irregularity),
length-comparison/is_empty (layer3, serialization). All behavior-preserving.
Surfaced because a warm target/ makes the pre-push hook actually run clippy
(it skips on cold worktrees). Remaining non-auto-fixable test-code lints
tracked in #967.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:42:22 +02:00
jpmschweitzerandClaude Opus 4.7 f3e017a12f style(simulation): rustfmt + clippy fixes for Layer-1/heightmap code (#953 #963)
Pre-push surfaced fmt + clippy (-D warnings) failures in the new code:
- rustfmt the 6 new/changed atlas files + the bench example.
- features.rs: HashMap → BTreeMap (project bans HashMap for determinism via
  clippy disallowed_types; the bucket map is lookup-only either way).
- attractor_matching.rs: drop now-redundant .clone() on AttractorType (it
  became Copy in #953) — clippy clone_on_copy.
- drainage.rs tests: manual range → (1..=12).contains(&n).
- features.rs test: drop .clone() on Copy AttractorType.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:11:43 +02:00
jpmschweitzerandClaude Opus 4.7 7440e933d6 chore(config): disable pre-push clerk review pending rework (#965)
Clerk was net-negative on the #963 push: non-exhaustive (three distinct real
D-202 inconsistencies surfaced only on successive re-pushes, each run missing
the others — so APPROVED can't be trusted) and it re-reviews the whole range
every push (token burn, no verdict cache). Gate it behind SR_RUN_CLERK=1
(default off). Rework tracked in #965; decision-record consistency is the
author's responsibility until then.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:49:15 +02:00
jpmschweitzerandClaude Opus 4.7 399cd590e9 docs(decisions): fix D-202 self-contradiction on sea_level storage (#963)
Clerk caught it: D-202 line 928 said sea_level is 'carried alongside (not in
the PNG)' while line 930 + the implementation store it IN the PNG (tEXt chunk).
Update line 928 to match — sea_level is a tEXt chunk, with a caller default
fallback. D-202 is now internally consistent (resolution, sea_level, table-drop).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:36:02 +02:00
jpmschweitzerandClaude Opus 4.7 6334b0ddbe docs(simulation): fix stale 2048×1024 heightmap resolution in loader docstring (#963)
The heightmap.rs module docstring still said 'canonically 2048×1024' (the
pre-decision figure); the canonical resolution is 1024×512 (D-202 amended).
Clerk caught the contradiction. Now consistent across D-200/201/202/208 + code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:26:01 +02:00
jpmschweitzerandClaude Opus 4.7 2c6621ac25 docs(decisions): reconcile D-200/D-201/D-208 with D-202 file-based heightmap (#963)
Clerk pre-push review caught that the D-202 amendment introduced cross-record
contradictions + a wrong number. Fix:
- D-202: canonical heightmap is 1024×512 (not 2048×1024 — stale figure; the
  bake and D-201's canonical PNG are both 1024×512).
- D-200: atlas_body_heightmaps no longer produced (elevation is a per-body file).
- D-201: amend the Tier-3 canonical-format lock — stored heightmap is now a
  16-bit grayscale 1024×512 PNG; PNG dims unchanged, Layer 1 downsamples to the
  512×256 working grid (satisfies the record's own 'deviation requires amending'
  gate).
- D-208: clarify 512×256 is the Layer-1 working grid (downsampled from 1024×512).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:08:38 +02:00
jpmschweitzerandClaude Opus 4.7 38d98fc9f6 chore(meta): update changelog
Phase-4 foundation: Layer-1 topography cascade (#953), canonical 16-bit
heightmaps + reliefmap rename (#963), planet sim 1024×512, atlas_body_heightmaps
table dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:51:36 +02:00
jpmschweitzerandClaude Opus 4.7 4ae8428979 chore(db): regen systems.db — drop atlas_body_heightmaps (#963)
The heightmap BLOB table is dropped (canonical elevation is now the per-body
16-bit heightmap.png file). Re-stamped by import_economics.

Clerk-Skip: binary DB build artifact — no D-record/code surface

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:50:48 +02:00
jpmschweitzerandClaude Opus 4.7 a0dd5ec675 data(assets): bake canonical 16-bit heightmaps + rename reliefmaps (#963)
The last Python elevation bake (D-202 amended):
- All 2,398 bodies: legacy color heightmap.png → reliefmap.png (display).
- 267 non-Sol inhabited bodies: fresh clean reliefmap.png + 16-bit grayscale
  heightmap.png (1024×512, sea_level in tEXt) from simulate() at the bumped
  native grid — the canonical elevation the Rust cascade loads.
- Sol (GJ-0): reliefmap.png only (renamed; real-geography terrain untouched).
0 errors, ~190 MB. Determinism guarded by test_sim_determinism.py.

Clerk-Skip: bulk binary asset bake — no D-record/code surface

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:50:43 +02:00
jpmschweitzerandClaude Opus 4.7 b71f339996 feat: complete file-based heightmap migration — tEXt sea_level, client relief, drop BLOB (#963)
- heightmap.rs: read sea_level from the PNG tEXt chunk (bake writes it),
  default-fallback param; new test reads_sea_level_from_text_chunk.
- client atlas_viewer.gd: load reliefmap.png (color display) instead of
  heightmap.png (now 16-bit grayscale elevation, cascade-only).
- drop atlas_body_heightmaps: removed from systems-schema.sql; DROP TABLE in
  import_economics MIGRATION_SQL (the PNG is the store now).
- D-202 amendment: implementation-status note (consumer + producer done),
  resolving the review's 'reads done but producer pending' point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:23:23 +02:00
jpmschweitzerandClaude Opus 4.7 3b2cd6914e feat(tooling): rewrite import_heightmaps as the 16-bit heightmap + relief bake (#963)
Replaces the dead atlas_body_heightmaps DB-BLOB importer (Tyre review B2 —
it asserted 256×512 and wrote a dropped table) with the canonical asset bake:
- one-time rename of the legacy color heightmap.png → reliefmap.png for ALL
  bodies (incl. Sol — display-file rename only, no re-sim);
- for each non-Sol inhabited body: simulate() at 1024×512 → clean reliefmap.png
  (render_heightmap, no painted features) + 16-bit grayscale heightmap.png with
  sea_level in a tEXt chunk;
- no systems.db writes; uses parse_system for proper body defs; run via uv.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:22:50 +02:00
jpmschweitzerandClaude Opus 4.7 dd296b1ae5 test: review-driven determinism + coverage (#953 #963)
From the Hoshe/Tyre review:
- drainage: assert flow_accumulation/max_accumulation determinism + clamp ≥ 1
  (the D-209 strength denominator); isolated-basin merge path (no panic).
- subbiome: each derivable variant reachable + Volcanic never emitted.
- planet_simulation: new test_sim_determinism.py — same body simulates to a
  bit-identical elevation array at 1024×512 (the 271-body bake can't be cheaply
  re-run, so a silent drift = full re-bake). Verified PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:09:26 +02:00
jpmschweitzerandClaude Opus 4.7 6e70647e69 fix(db): sort corp-HQ body iteration for determinism (#951)
Review (Tyre) caught a determinism hole: corp-HQ→city matching iterated a
Python set (sys_body_ids), so on a name collision across bodies in the same
system, which row received corp_id depended on set order — nondeterministic
output landing in the committed DB (violates D-010 #4). Iterate sorted().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:09:14 +02:00
jpmschweitzerandClaude Opus 4.7 abcb41deaa fix(simulation): type-aware attractor cap so RiverMouth isn't crowded out (#953)
Review (Hoshe) caught that the MAX_ATTRACTORS cap sorted by global strength,
and RiverMouth's normalized strength (accum/max_accum) is tiny — so on a
realistic body 108 river mouths produced 0 surviving RiverMouth attractors,
violating D-209 ('RiverMouth: always high-value') and starving #955 placement.
Replace the global-strength cap with group-by-type + round-robin so every
present type keeps representation (strongest-first within each type).
Deterministic. New test river_mouths_survive_cap locks it in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:08:57 +02:00
jpmschweitzerandClaude Opus 4.7 cabdd7c097 refactor(tooling): bump planet sim to native 1024×512, drop compute_rivers (#963)
- planet_simulation: GRID 512×256 → 1024×512. The elevation noise is
  resolution-independent (normalized coords + absolute freqs), so the
  finer grid samples the SAME terrain — features keep physical size,
  generation stays deterministic. Pixel-unit constants (gaussian sigma,
  crater radii, peak-filter window, erosion slope) scale by GRID_W/512.
  Validated: non-Sol bodies render same-world-crisper at 1024.
- Remove compute_rivers + _rivers_to_grid + the rivers/river_grid terrain
  keys: rivers are the Rust cascade's job (D8 drainage, D-208), the single
  source of river truth. The old heuristic didn't even reach the sea.
- render_heightmap: stop painting rivers onto the relief (cascade/Atlas
  overlay computed rivers instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:31:52 +02:00
jpmschweitzerandClaude Opus 4.7 506b2c7feb docs(decisions): amend D-202 — file-based 16-bit heightmap, relief rename (#963)
Supersede the atlas_body_heightmaps BLOB store with a per-body 16-bit
grayscale heightmap.png (2048x1024, canonical elevation) next to a renamed
reliefmap.png (color display render). Records the rationale (binary
merge-conflict avoidance + DB size + viewable/deterministic single source),
the multi-resolution split (Layer 1 downsamples to 512x256), and the
dropped table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:36:11 +02:00
jpmschweitzerandClaude Opus 4.7 3886cea46e feat(simulation): load canonical 16-bit grayscale heightmap.png + downsample (D-202 #963)
Replace the atlas_body_heightmaps DB-BLOB loader with a per-body file
loader (D-202 amendment): heightmap.rs reads the 16-bit grayscale
heightmap.png (via the new png dep) from the body's terrain_reference
path, normalizes to f32 [0,1], and rejects RGB so a reliefmap can't be
misread as elevation. Adds BodyHeightmap::downsample (box-average,
deterministic) so Layer 1 drops the high-res stored heightmap to the
512x256 working resolution. sea_level becomes body metadata carried
alongside, not in the PNG. 4 loader tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:35:58 +02:00
jpmschweitzerandClaude Opus 4.7 98658a512d test(simulation): per-body Layer-1 benchmark example (#953)
examples/bench_layer1 times run_layer1 on a synthetic 512x256 heightmap
with a phase breakdown (drainage / terrain analysis / feature extraction).
Post-optimization: ~106ms/body total (drainage ~45ms). Documents the
synthetic-terrain caveat (real heightmaps via #963).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:35:47 +02:00
jpmschweitzerandClaude Opus 4.7 d418eba8bb feat(simulation): Layer-1 topography pipeline — features, sub-biome, orchestrator (#953)
Wire the empty-world topography cascade (D-208/209/210):
- generator.rs: add SubBiomeVariant (11 variants, D-210) + sub_biome and
  terrain_modification_cost fields on GeographicAttractor; AttractorType
  is now Copy.
- features.rs (new, D-209): extract the 7 attractor tags from heightmap +
  drainage. Coast/lake derived from the heightmap (D-209/D-223
  reconciliation — markers are names-only now, no polygons). Deterministic
  (sorted seeds, integer keys, bucket-grid thinning); strength-capped at
  MAX_ATTRACTORS preserving type diversity. Shared TerrainAnalysis
  (masks/slope/moisture/percentile) feeds both extraction and sub-biome.
- subbiome.rs (new, D-210): classify sub-biome + terrain_modification_cost
  from elevation/slope/moisture/latitude. Volcanic stays in the enum but
  is not emitted (no heightmap signal).
- layer1.rs (new): run_layer1 orchestrator + attach_feature_names (D-223
  pool names to largest rivers / Alpine peaks).
- attractor_matching constructors updated for the new fields.

76 atlas tests pass; run_layer1 determinism verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:35:39 +02:00
jpmschweitzerandClaude Opus 4.7 7cf7614342 perf(simulation): expose flow accumulation + optimize basin merge in D8 drainage (#953)
- DrainageResult now exposes flow_accumulation + max_accumulation for
  D-209 attractor-strength normalization.
- Rewrite merge_small_basins from an O(merges x n) loop (rescanned the
  whole grid per merge) to an adjacency-graph + union-find pass: one grid
  scan, lazy merges. Cuts D8 drainage at 512x256 from ~299ms to ~45ms,
  meeting the D-208 ~50ms target (the module had never been run at
  canonical resolution before — it was orphaned). Determinism preserved
  (smallest by (size,id), largest neighbor by (size, lowest id)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:35:16 +02:00
jpmschweitzerandClaude Opus 4.7 a409360802 docs(decisions): D-223 #951 implementation notes + Gemma naming methodology
- Add docs/gemma-naming-methodology.md preserving the corridor-aware LLM
  place-naming approach (sector palettes, two-stage register selection +
  generation, few-shot prompting, KV-cache refresh, dedup, Earth-major
  blocklist, deterministic fallback) as institutional knowledge after the
  pipeline's retirement.
- Add an implementation-status note to D-223 recording what #951 did
  (generator + naming cluster retired, import_economics owns the atlas
  index, population deferred to #955, Sol exempt, dup bug fixed).
- CHANGELOG entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:29:38 +02:00
jpmschweitzerandClaude Opus 4.7 a0d61dc53d chore(db): regen systems.db — names-only atlas pool, empty geometry (D-223 #951)
Regenerated by import_economics: atlas_city_names holds 329 pooled names
+ 134 corp-HQ rows (Sol excluded); the 8 atlas geometry tables are empty
(server cascade fills them); stamped solely by import_economics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:29:26 +02:00
jpmschweitzerandClaude Opus 4.7 d49bd36b7e data(assets): restore Sol markers geometry — Sol exempt from generators (D-223 #951)
Sol (system GJ 0) uses real Earth/Mars/Luna geography via the offline
sol_import.py and is permanently exempt from the procedural cascade. The
names-only strip had removed Sol's generated marker geometry; restore the
geometry-bearing markers.json for Earth (GJ0d), Luna (GJ0d-1), Mars (GJ0e)
and GJ0f-2 as preserved positional config. sol_import.py is left in place
for future scripted Sol integration; the source config in
tooling/planet-gen/sol_markers/ was never stripped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:29:18 +02:00
jpmschweitzerandClaude Opus 4.7 b11e847337 chore(tooling): retire atlas geometry generator + LLM naming cluster (D-223 #951)
The procedural server cascade (Phase 4) and the frozen names-only pool
supersede the Python atlas geometry generator and the LLM namer. Retire:

- generate_atlas.py (geometry production — cities/roads/rivers placement)
- gemma_naming.py, naming_core.py + tests (test_batch_naming,
  test_register_selection, qa_naming) and run-atlas-naming.sh (the LLM
  place-namer; its output is now the frozen pool)
- apply_name_fixes.py (name-field patches), fix_fewshot_bleed.py /
  prune_atlas_features.py (geometry tools)
- import_city_names.py (redundant with import_economics name-pool path)

Pipeline updates: drop the generate_atlas step + atlas-generate /
test-atlas-determinism targets from the Makefile; remove generate_atlas
from the stamp registry (import_economics is the sole regen-db generator);
drop run-atlas-determinism from tests/run-all; refresh stale references in
schema_version, backfill_cultural_corridor, earth_blocklist (kept as
reference data), populate_terrain_reference, and heightmap.rs.

The Gemma prompting methodology is preserved in
docs/gemma-naming-methodology.md (separate commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:29:08 +02:00
jpmschweitzerandClaude Opus 4.7 b0bfbfc7dd feat(db): import_economics owns atlas index — names pool, empty geometry (D-223 #951)
Rework the regen-db atlas path for the names-only marker pool:

- populate_atlas_city_names now reads the names.cities pool instead of
  the retired geometry-bearing cities[] records; population/kind are
  deferred to placement (#955). Adds a deterministic clear-then-insert
  (no UNIQUE on (body_id, name)) that fixes a latent duplicate-
  accumulation bug — atlas_city_names dropped from an inflated 3276 to a
  clean 329 pooled names + 134 corp-HQ rows.
- ensure_atlas_index_schema applies the canonical ATLAS INDEX block from
  systems-schema.sql and empties the 8 geometry tables every regen; the
  Phase 4 server cascade fills them (they start empty — the revealed gap).
- Sol (system 'GJ 0') is permanently exempt from the normal generators:
  skipped in both the name-pool importer and the corp-HQ cross-ref.
- MIGRATION_SQL drops the retired generate_atlas meta stamp row so the
  fail-closed stamp checker accepts older committed DBs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:28:53 +02:00
jpmschweitzerandClaude Opus 4.7 642be3ac41 refactor(tooling): extract atlas_common from generate_atlas (D-223 #951)
Move the shared atlas-DB utilities (schema application, inhabited-body
query, body-def loader, grid constants) out of the soon-to-be-retired
generate_atlas.py into a dedicated atlas_common.py with no dependency on
geometry-production code. Repoint the surviving build-time importers
(import_heightmaps, import_province_boundaries) at the new module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:28:38 +02:00
jpmschweitzerandClaude Opus 4.7 d8af513a5e docs(decisions): Q-098 — persistence of generated river/city mapping outputs
Looking-ahead question from the Phase 4 markers strip. Generated mapping (rivers via
drainage, cities via econ sim + placement) is deterministic so always recomputable,
but expensive — should be computed once per body and kept, not volatile. Refines D-203
(LRU evicts) and D-200 (build-time bake vs runtime persist). Gates the atlas viewer
(#960). To resolve during the execution-model work (#952).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:55:01 +02:00
jpmschweitzerandClaude Opus 4.7 206a3c9920 data(assets): strip markers.json to names-only flavored pool (D-223)
Part 1 of #951. Per D-223, every markers.json is reduced to a flavored name pool —
names only, no positions/geometry. 2398 files; 25,264 names preserved across
rivers/mountains/oceans/cities/pois/roads/railroads. Positions now come from the
deterministic cascade (heightmap + drainage) and economic sim; names attach from the pool.

Expected fallout (next #951 steps): the tooling/planet-gen geometry pipeline and
import_economics read markers geometry, so regen-db breaks until the importer is
reworked for the names-only format. Sol markers (tooling/planet-gen/sol_markers/) not
yet handled. No push (batch until Phase 4 done).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:52:29 +02:00
jpmschweitzerandClaude Opus 4.7 676ded339d docs(decisions): D-223 — authored content as flavored name pool
Resolves the open question on merging preconfigured per-planet content into the
deterministic cascade. Authored content is a flavored NAME POOL only, never pinned
geometry: markers.json reduced to names; river/mountain positions derive from the
heightmap + drainage; settlement positions from the economic sim + placement; names
attach from the pool.

The 6 hand-authored templates (Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade)
have their machinery removed — the bodies stay as ordinary named places, only the
authored positioning + reserved pinning + template special-casing go. After this
'template' is no longer a distinguishable category.

Amends D-207 (markers names-only, no reserved pinning) and D-191 §8 (markers format
superseded). Implementation tracked in #951 (Phase 4). Historical archives untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:37:44 +02:00
jpmschweitzerandClaude Opus 4.7 73965c0ead docs(meta): cascade reorder — world generation (Phase 4) before player control (Phase 5)
The player-before-generation order kept resurfacing because it was recorded in
three places (D-166, CLAUDE.md cascade table, epics #749/#750) while the
generation-first rule was recorded nowhere. Fix all three to match the rule.

Rule: no player-control or in-world rendering work begins until the generator can
deterministically seed-generate every tile of every world via the full multilayer
cascade. The hand-made 2-floor test map is dropped — test layers come from the
generator itself once layer-drawing begins. Generation progress is viewed as
per-layer maps in the implant Atlas (the Phase 3 deliverable, already built), not
an in-world renderer. Existing in-world rendering code is left as-is until Phase 5.

Amends D-166 (Phases 4/5 swapped + amendment note), CLAUDE.md cascade table, and
epics #750 (now Phase 4) / #749 (now Phase 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:09:46 +02:00
jpmschweitzerandClaude Opus 4.7 d87e700039 fix(tooling): remove unused import in fill-missing-globes.py
ruff F401 — unused 'import os'. Pre-existing in the globe-coverage script; ruff
runs at pre-push (not pre-commit), so it surfaced only now and blocked the push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:43:00 +02:00
jpmschweitzerandClaude Opus 4.7 96dcdf98b6 docs(decisions): D-222 — spatial hierarchy & naming (Quarter 512m, District 2048m)
Establishes the canonical sub-settlement ladder as the single source of truth:
Subtile 0.5m / Tile 1m / Chunk 64m / Block 128m / Quarter 512m / District 2048m,
fluid above. Renames the old 512m 'District' to Quarter and promotes District to a
real urban scale (2048m, 4.19 km², 4×4 quarters), grounded against real block /
superblock / district sizes.

Codifies the lore-vs-code rule: tier names are fixed generation grid cells; the same
words in narrative/UI are free-form region labels and must NOT be reconciled to a
code tier by reviewers or the clerk.

Amends D-094 (hierarchy), D-201 (tier table renumbered 6-9), D-220 (its footprint
'district' cell is the Quarter), and D-066 (sim tile -> Subtile vocabulary) to point
at D-222. Resolves the clerk finding on commit 7 (D-220 scale chain). Code/terminology
rename deferred to ticket #950.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:05:34 +02:00
jpmschweitzerandClaude Opus 4.7 f87e251621 docs(decisions): D-191 — drop shadow economy atlas overlay (underwater modifier)
Resolves the clerk finding on the pre-existing overlay removal. Shadow economy
is an underwater simulation modifier (shadow_economy_intensity, D-174) feeding
derived signals like collection_efficiency and signal 7 official_coverage_ratio
(D-181) — not a user-navigable data point. The atlas overlay and City Data Panel
field were correctly removed in code; D-191 was stale. Amends D-191 §7 (9 -> 8
overlays), §6 (drop city-panel field), §10 (completion criteria). D-174 simulation
layer is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:29:41 +02:00
jpmschweitzerandClaude Opus 4.7 bf3659d1a5 fix(meta): clerk-review — incomplete reviews no longer false-reject
The per-commit clerk had three flaws, exposed by a 20-commit push where 6 of 7
rejections were false (incl. a CHANGELOG-only commit):

1. No-verdict / max-turns / timeout defaulted to REJECTED — an unfinished review
   read as 'hard contradiction found'. Now a third outcome, INCOMPLETE, which is
   non-blocking (the push proceeds with a warning); only a real REJECTED blocks.
2. Turn/time budget too tight (6 turns / 150s) for decision-heavy commits. Raised
   defaults to 15 turns / 300s, and the prompt now biases to APPROVED when no
   concrete contradiction is found ('unsure' means APPROVED, never REJECTED).
3. The skip valve matched its own feature commit because it scanned for the token
   anywhere in the message. Moved to a trailer-line match so prose/subject mentions
   no longer trip it.

Pre-push hook updated to treat INCOMPLETE as a non-blocking warning. The git-commit
skill documents the trailer form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:59:44 +02:00
jpmschweitzerandClaude Opus 4.7 606c732bdd feat(meta): add [clerk-skip] safety valve to clerk-review
A commit whose message contains the token [clerk-skip] is auto-approved by
the pre-push clerk without spawning an agent. Intended for bulk content/data
commits — e.g. shipping thousands of generated planetary description files —
where D-record review is moot and would only burn agents on noise.

Documented in the /git-commit skill with the caveat: never use it on commits
that touch decisions/, code, or ticket-bearing work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:41:28 +02:00
jpmschweitzerandClaude Opus 4.7 a854db5f84 chore(meta): clerk-review reviews per-commit in parallel
The clerk pre-push review spawned a single claude -p over the entire combined
diff (truncated at 50k chars) with a 3-turn budget. On a large push (e.g. 754
files / 2.8M chars) it ran out of turns before emitting a verdict, which the
wrapper defaulted to REJECTED.

Rewrite to review one commit at a time — commits are the logical units, so each
clerk agent sees a self-contained change plus its commit message (enabling the
'does this match ticket #NNN?' check). Commits are reviewed by a bounded pool of
parallel clerk agents and the verdicts aggregated (REJECTED if any commit
contradicts an active D-record). Oversized commit diffs truncate to a budget.

Adds --plan (print the per-commit plan, no agents spawned) and env knobs:
SR_CLERK_COMMIT_BUDGET / SR_CLERK_WORKERS / SR_CLERK_MAX_TURNS / SR_CLERK_TIMEOUT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:29:06 +02:00
jpmschweitzerandClaude Opus 4.7 8fabebe325 chore(meta): retire obsolete team-test.md and update references
The tmux teammate-mode investigation is resolved (works on 2.1.148; the
'broken regression' was a teammateMode: in-process config issue). Per the
file's own cleanup note, delete it now that the test passes.

Update the two references:
- decisions/questions-process.md: replace the stale 'partially broken'
  pointer with the resolved status.
- whats-next/SKILL.md: the 'custom subagent_types lose SendMessage' caveat is
  fixed (all agents now carry SendMessage + Task tools), so reword and drop
  the dead team-test.md link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:17:08 +02:00
jpmschweitzerandClaude Opus 4.7 1391ffc58e chore(meta): update changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:38:30 +02:00
jpmschweitzerandClaude Opus 4.7 7404b3baf7 docs: record tmux teammate-mode investigation findings
Log the 2.1.148 results in team-test.md: the 'broken tmux pane' premise was
a stale config claim (teammateMode was in-process), not a regression; auto mode
spawns real panes; and custom restricted-tool subagents need coordination tools
listed explicitly to function as pane-mode teammates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:37:53 +02:00
jpmschweitzerandClaude Opus 4.7 0127fc8fd9 chore(config): centralize git for teammates and enable tmux panes
Two agent-team tooling changes:

- teammateMode: in-process -> auto. Teammates now spawn in tmux split panes
  when the lead runs inside tmux, with graceful in-process fallback.
- New PreToolUse hook git-centralize-guard.sh blocks .git-mutating commands
  (add, commit, merge, push, pull, rebase, reset, checkout, stash,
  cherry-pick, rm, mv) for teammates, keeping version control centralized to
  the lead. Detection keys on the agent_type field, which a teammate's hook
  input carries and the lead's does not. Read-only git is allowed.

Documented in .claude/rules/git-safety.md. Hooks load at lead startup, so a
restart is required for the hook to reach teammates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:37:47 +02:00
jpmschweitzerandClaude Opus 4.7 a0097caee7 chore(agents): add team coordination tools to all agent definitions
Custom subagent types used as agent-team teammates in tmux pane mode only
receive their definition's restricted tools list — the coordination tools
are not injected (confirmed on Claude Code 2.1.148). Without SendMessage a
teammate can't message the lead and can't return a shutdown_response, so it
orphans its pane.

Add SendMessage, TaskList, TaskUpdate, and TaskGet to all 21 agents so they
work as full teammates. TaskCreate is intentionally omitted: task creation
stays centralized with the team lead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:37:33 +02:00
jpmschweitzerandClaude Opus 4.6 934c78e8b9 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:03:36 +02:00
jpmschweitzerandClaude Opus 4.6 bbb7e5b0f9 fix(ui): regional atlas — wiki path loading, overlay bar input, header width
Heightmap and markers load from wiki filesystem path via FileAccess
instead of res:// (wiki is outside the Godot project root). Overlay
bar buttons now receive mouse events — viewer skips input processing
when cursor is over UI elements. Header gets explicit minimum width
to prevent vertical text stacking. Shadow economy overlay removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:03:09 +02:00
jpmschweitzerandClaude Opus 4.6 768f37fd83 feat(ui): inline body detail panel on system orbital view
Body info panel with globe artwork renders on the right side of the
system map instead of navigating to a separate screen. Single-click
shows/swaps panel, double-click opens regional heightmap view, ESC
closes panel. Station panel also moved to right side for consistency.
Moon and station positioning clears parent body radius + label space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:02:54 +02:00
jpmschweitzerandClaude Opus 4.6 b9fd75b840 docs(workshops): planet-down cascade workshop + misc stray files
Planet-down cascade workshop (3 rounds, 5 agents): layer-by-layer
generation from empty world through population overlay, city planning,
and street rendering. Includes consultant review by Troblum.

Also commits: pre-Sprint-35 DB backup, Claude Code team-mode tmux
test log (team-test.md).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:18:30 +02:00
jpmschweitzerandClaude Opus 4.6 42ee1f0a0e docs(workshops): generation cascade workshop — 4 rounds, D-194 through D-218
Four-round workshop (Gestalt, Tyre, Paula, Burnelli-Sheldon, Miri)
mapping the full generation pipeline from planetary heightmap to
walkable tile. 25 D-records produced. Ticket dependency chain for
Tier 0-4 implementation identified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:18:17 +02:00
jpmschweitzerandClaude Opus 4.6 288e3ccd2e chore(meta): bump server Cargo.lock to v0.2.0
Lockfile version was still at 0.1.37 after the v0.2.0 release tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:17:40 +02:00
jpmschweitzerandClaude Opus 4.6 408fead2e6 docs(decisions): D-220 settlement density model — tile scale, density classes, vertical pressure
Five density classes (Frontier→Compressed) driven by four pressures
(utility cost, terrain, economic gravity, political). Amends D-201
tile scale to 1m with 0.5m subtile. Calibrated against real-world
settlements (Kiruna, Uzès, Kanazawa, Paris).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:17:27 +02:00
jpmschweitzerandClaude Opus 4.6 8de7bc7b73 chore(skills): git-commit — triage pre-existing changes before committing
Add a mandatory step between assessing changes and grouping commits:
surface any modified/untracked files that weren't touched in the current
session and ask the user whether to include them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:16:57 +02:00
jpmschweitzerandClaude Opus 4.6 59ab4d0c33 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:12:36 +02:00
jpmschweitzerandClaude Opus 4.6 903efda191 feat(assets): 100% globe coverage — generic images + donor fill
Generated oort cloud and asteroid belt generic globe images via Gemini.
Background-masked with flood-fill (shadow-preserving). Donor fill script
copies matching globe.png from same planet_class+body_type pool with
per-body hash selection, no repeats within a system. 629 bodies filled,
3240/3240 now have globe.png.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:12:00 +02:00
jpmschweitzerandClaude Opus 4.6 29f9945291 feat(db): body_radius_km scatter + gas giant/moon scale classes
Deterministic ±scatter on body radii seeded by body_id hash — no two
bodies share the same radius. Gas giants 40k-60k km, moons 200-2600 km,
rocky planets ±15% from class base. Oort/asteroid skip radius (NULL).
Sol system gets real planetary radii. body_radius_km exported to
star_map_data.json for client orbital diagram sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:11:31 +02:00
jpmschweitzerandClaude Opus 4.6 e8b4dc74b5 feat(ui): atlas system orbital redesign — horizontal orrery with globe textures
Half-sun on left edge, planets left-to-right with radius-based sizing
(Earth = 24px reference, unclamped — gas giants fill the view). Globe
textures loaded from wiki at runtime. Double-click on reach map opens
system view. M closes atlas from any screen, ESC navigates back.

Fixes Godot 4.6 type inference parse errors in main.gd (var zoom/speed).
Uses physical_keycode throughout for layout-independent key handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:11:16 +02:00
jpmschweitzerandClaude Opus 4.6 e6a557e8e7 feat(meta): replace sprint workflow with kanban + milestones (D-221)
Sprint-based workflow (38 sprints) replaced by kanban + milestones.
Milestones are many-to-many with tickets and can block each other.

New: /whats-next skill (dependency-driven batch selection with Si
refinement review), /pr-process skill (renamed from pr-push, adds
review comment pickup), clerk agent + pre-push hook for D-record
consistency checks.

Deleted: sprint CLI, sprint-start/sprint-plan/sprint-status skills,
team-scoped file restrictions. Si rewritten as refinement manager.
All 19 agent briefings updated from stale PROJECT_STATE.md reference
to live ticket milestone queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:11:00 +02:00
jpmschweitzerandClaude Opus 4.6 287f9ba3a6 chore(meta): release v0.2.0
Final sprint-based release. Development moves to kanban + milestones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 16:22:09 +02:00
jpmschweitzerandClaude Opus 4.6 13259dbfd5 feat(client): render province boundaries on planetary map (#927, D-205)
Export pre-computed drainage basin boundaries from atlas_province_boundaries
into markers.json during generate_atlas. AtlasMarkerOverlay draws province
polylines with semi-transparent fill under the political_zones overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 16:12:59 +02:00
jpmschweitzer d869435eee Merge remote-tracking branch 'origin/sprint-38/server' 2026-05-03 16:01:08 +02:00
jpmschweitzerandClaude Opus 4.6 aa7c0812f2 chore(skills): pr-review — use Write+printf for comment files, foreground-only tea-comment
Also adds Q-097 (strip corp "What They Don't Talk About" sections).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 16:00:16 +02:00
jpmschweitzerandClaude Opus 4.6 61daafe9ad chore(db): regen systems.db after stamp source update
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 15:52:11 +02:00
jpmschweitzerandClaude Opus 4.6 0d09d905e4 fix(simulation): address PR #142 review round 2
gen_queue: add in_flight_count tracking for all work item types (not
just AnalyzeBody). Rewrite saturation test with AnalyzeBody items.
Fix priority_ordering test thread count to match new gate.

rng: collapse to single AtlasRng::new(seed) constructor — callers
own their seed transform.

import_province_boundaries: fix "savepoint" comment to "transaction".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 15:51:10 +02:00
jpmschweitzerandClaude Opus 4.6 332b9404be fix(simulation): address PR #142 review — 11 issues resolved
Rust fixes: D-218 Backwater complexity (moved to Full arm), priority
queue dispatch gated on thread saturation, panic→soft-fail in
name_index, duplicated LCG unified into atlas::rng module, misleading
Safety comment removed, D-210 SubBiomeVariant deferred to #948.

Schema/data fixes: atlas_city_positions table (D-211 persistence),
settlement_class column on atlas_city_names, per-body transaction
boundaries in import_province_boundaries, import_city_names.py added
to GENERATOR_SOURCES, Python perf documented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:50 +02:00
jpmschweitzer ce520e4977 Merge remote-tracking branch 'origin/sprint-38/client' 2026-05-03 11:27:04 +02:00
jpmschweitzerandClaude Opus 4.6 1a2bc58e27 chore(db): regen systems.db against merged sources
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 09:47:50 +02:00
jpmschweitzerandClaude Opus 4.6 5ad8754b85 fix(atlas): remove unused imports in import_province_boundaries.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 09:46:53 +02:00
jpmschweitzerandClaude Opus 4.6 f1278f88fd chore(simulation): cargo fmt atlas modules
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 09:46:16 +02:00
jpmschweitzer 64a873fc1a Merge remote-tracking branch 'origin/main' into sprint-38/server
# Conflicts:
#	CHANGELOG.md
#	server/data/systems.db
#	tooling/check-systems-db-stamp
#	tooling/economy-db/import_economics.py
#	tooling/planet-gen/generate_atlas.py
2026-05-03 09:30:48 +02:00
jpmschweitzerandClaude Sonnet 4.6 94ab992da5 fix(client): sim_bridge — Messagepack parse-order + type annotations on untyped field calls
Replace Messagepack class_name ref with inline load() in _process().
Add explicit int/PackedByteArray annotations on locals derived from
untyped _bridge/_server fields (pid, err, bridge_status, msg, send_err)
so GDScript can type-check call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 09:27:40 +02:00
jpmschweitzerandClaude Sonnet 4.6 698c5eea9b fix(client): parse-order fixes for Protocol autoload and MetaScreen extends
Protocol.gd is an autoload — replace Messagepack class_name refs with
inline load() calls via a static helper. main_menu.gd extends MetaScreen
by class_name which fails at parse time; switch to path-based extends.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 09:27:22 +02:00
jpmschweitzerandClaude Opus 4.6 6582ee4cca fix(simulation): resolve clippy warnings in atlas modules
Replace HashMap/HashSet with BTreeMap/BTreeSet per D-030 determinism
rule. Fix while_let_loop, map_or simplification, collapsible if,
unsigned_abs casting, iterator indexing, and redundant wildcard arms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 22:52:15 +02:00
jpmschweitzerandClaude Opus 4.6 702fed5922 fix(client): address PR #141 review — parse-order, tween guard, test fixes
- Fix autoload parse-order violations: sim_bridge.gd, input_mapper.gd,
  audio_manager.gd now use load() for class_name types instead of
  direct references (LocalBridge, ServerProcess, Constants)
- Collapse redundant tween validity guard in dialogue_box.gd to
  is_instance_valid(panel) only
- Add clarifying comments to fog test resize assertions (8-tile
  padding trigger, 32x32 fixture assumption)
- Fix test_examine_display_sprint18 case 2: GameState.has() →
  "field" in GameState (Node vs Dictionary API)
- Fix test_game_state_sprint20: rename before_each → before_test
  (GdUnit4 lifecycle hook)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 22:50:17 +02:00
jpmschweitzerandClaude Opus 4.6 29479da17c fix(wiki): sync frontmatter cross_refs with body references
bifrost-marmor: add GJ-3737 (Nyrheim system)
scapa-flow-industries: add GJ-570A (Quaterna/Bastion system)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:45:00 +02:00
jpmschweitzer 909dc28cae Merge remote-tracking branch 'origin/sprint-38/copy' 2026-05-02 18:44:13 +02:00
jpmschweitzerandClaude Opus 4.6 718fcd0510 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:41:18 +02:00
jpmschweitzerandClaude Opus 4.6 ca9ab189e0 feat(simulation): generation pipeline Rust types + SystemNameIndex (#900, #912-#926)
WorldTier enum fixed to Epicenter/Regional/Backwater/Passage/Waypoint
(D-218). Full enum implementations for ComplexityTier, SettingType,
SettlementClass, DistrictType, PoliticalArchetype, FoundingOrientation,
TerritorialStatus, GeographicAttractor, AttractorType, and
CompatibilityMatrix. SystemNameIndex with Aho-Corasick text scanning
for background pre-generation queue integration (D-206).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:40:44 +02:00
jpmschweitzerandClaude Opus 4.6 5ce1b90926 content(wiki): address PR #140 review round 2 — formatting fixes
- Arbour Aggregates: body footer date updated to 2026-05-02
- Sede Chemical Works: added (SCW) abbreviation at first prose mention

Structural items (A-C: missing silence/political sections) deferred —
open Q-record on main challenges whether corp pages should have "What
They Don't Talk About" sections at all.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:40:35 +02:00
jpmschweitzerandClaude Opus 4.6 b6ca785f74 feat(db): atlas data pipeline — schema, importers, and normalization (#901-#911)
New tables: atlas_body_heightmaps (D-202), atlas_city_names (D-207),
atlas_feature_names, atlas_province_boundaries (D-205), body_radius_km
column (D-204). Three new importers: heightmap BLOBs, city names from
wiki markers.json, province boundaries via D8 watershed analysis.
economic_role normalized to 7 canonical values (D-194). Stamp fix in
generate_atlas.py to hash all tracked source files. systems.db regenerated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:40:33 +02:00
jpmschweitzerandClaude Opus 4.6 d57f0566d2 fix(atlas): suffix monotony auto-fix + cultural-history prompting (#886)
gemma_naming.py now re-queries affected bodies when >40% suffix
clustering is detected. naming_core.py build_batch_prompt accepts
cultural_history param threading secondary corridor substyles into
the few-shot prompt for richer cross-cultural name blending.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:40:21 +02:00
jpmschweitzerandClaude Opus 4.6 91066e4be6 chore(db): expand stamp GENERATOR_SOURCES for naming helpers (#892)
Add gemma_naming.py and naming_core.py to generate_atlas source
tracking in check-systems-db-stamp. Mirror in pr-push watch list.
Prevents naming helper changes from bypassing stale-stamp detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:40:12 +02:00
jpmschweitzerandClaude Opus 4.6 8abb8e4ec1 docs(decisions): formalize D-194 through D-218 generation cascade records
25 D-records defining the full generation pipeline from heightmap to
walkable tile: WorldTier taxonomy (D-218), settlement classification
(D-196), city generation context (D-200), drainage routing (D-208),
attractor matching (D-211), district mix (D-194), and supporting
enums/types. Produced by workshop #897, formalized from ticket specs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:40:02 +02:00
jpmschweitzerandClaude Opus 4.6 38f9002844 fix(simulation): resolve Bevy baseline test panics (#885)
Three root causes: SnapshotBuffer hard-dependency in economy.rs
(Option-wrapped), TickPhase::configure missing from SimulationPlugin
(added idempotent call), and stale golden file after D-192 dropped
the version field (regenerated).

All 6 previously-failing tests now pass with zero regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:39:52 +02:00
jpmschweitzerandClaude Sonnet 4.6 b9fd7a3fc8 feat(simulation): add server/src/atlas/ — full Phase 1 generation pipeline
Ten-module atlas package implementing the D-194–D-218 district generation
stack: heightmap loader, BodyWorldState LRU cache, D8 drainage routing,
background generation queue, five-phase attractor-matching, three-component
district mix, block irregularity, tile condition thresholds, and the Phase 1
skeleton generator that wires them into DistrictSkeleton.

Closes #916 #917 #918 #919 #920 #922 #923 #924 #899.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:11:13 +02:00
jpmschweitzerandClaude Opus 4.6 e002f811f0 fix(client): free camera review fixes — action toggle, zoom reset, discrete guard, tests (#898)
- Replace raw KEY_F4 check with Input.is_action_just_pressed("free_camera") to
  consume the registered project action (matches F3/F12 dev toggle pattern)
- Reset camera.zoom to Vector2.ONE when toggling free camera off so zoom does
  not bleed into normal gameplay
- Add free_camera_mode guard to InputMapper._unhandled_input() so discrete
  actions (INTERACT, stance, pause) are suppressed alongside movement
- Add client/tests/test_free_camera.gd: flag default, movement suppression,
  discrete action suppression, zoom constant contracts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 18:03:06 +02:00
jpmschweitzerandClaude Opus 4.6 3489edf6ec fix(ci): update 3 doc references to tooling/schema_version.py
asset-pipeline.md (2 locations) and systems-schema.sql still pointed
at import_economics.py as the SCHEMA_VERSION home after the extraction
to a shared module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 17:30:13 +02:00
jpmschweitzerandClaude Opus 4.6 1595cfc8a7 content(wiki): address PR #140 review — 9 fixes
1. Scapa Flow Industries: HQ moved from restricted military station to
   Quaterna (civilian commercial platform). Overview expanded with
   military/civilian context.
2. Threshold Fuel Syndicate: reconceived from "frontier east corridor
   supplier" to Gateway's local fuel producer supplementing imports.
   Removed frontier tag.
3. Arbour Aggregates: corridor labels fixed — "north"/"east" → "core zone"
   to match GJ 338B's actual hop-3 loop_member position.
4. Rush Mining: named concrete sustaining mechanism (property tenure
   uncertainty keeps combines away; first-mover holds by occupation).
5. Sede Chemical Works: expanded "ACB" to "ACB — Alpha Centauri B" on
   first use.
6. Stale body footer dates updated on rush-mining and scapa-flow.
7. Bifrost Marmor: added missing Cross-References body section.
8. Rush Mining: added cross_ref to rare-vein-survey.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 17:24:09 +02:00
jpmschweitzerandClaude Opus 4.6 873746ff8b fix(ci): address PR #139 review — 5 issues + source list sync
1. decisions_sync.py: fix refs_created inflation (check rowcount),
   remove dead IntegrityError except block
2. Extract SCHEMA_VERSION to shared tooling/schema_version.py —
   both generators import from single source of truth
3. generate_atlas.py: narrow bare except to OperationalError +
   "duplicate column" check
4. check-systems-db-stamp: add cross-generator schema_version
   agreement assertion (defense-in-depth)
5. decision wrapper: add show + orphan-tickets to usage text
6. Add schema_version.py to all three source watch lists
   (GENERATOR_SOURCES, IMPORT_ECONOMICS_SOURCES, generate_atlas
   _write_stamp) — prevents silent staleness on version bump

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 10:29:24 +02:00
jpmschweitzerandClaude Opus 4.6 3f5b4258ba feat(client): sprint 38 — free camera viewer, archetype strip, test fixes
- Add free camera mode (F4 toggle): WASD pan, scroll zoom, decoupled
  from player position (#898)
- Strip archetype-driven code: remove character_archetype, lattice_profile,
  and lattice color palettes from client (#882)
- Fix confrontation_monologue signal not firing in headless test mode (#867)
- Revive fog state behavioral tests: EXP_EXPLORED persistence, grow-only
  bounds, texture-resize copy, BoundaryWall handling (#879)
- Triage pre-existing test failures: fix examine_display dismiss timing,
  fog test position fragility, rendering snapshot assertions,
  time_display format (#871)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 10:19:11 +02:00
jpmschweitzerandClaude Opus 4.6 4335e64840 content(wiki): review 21 corporation pages authored by server team (#884)
Copy-team review of wiki/corporations files authored by Dudley in Sprint 37.
Fixes: 6 hop-count errors, 1 topology label, 3 tag corrections, 2 header
renames, 1 cross_ref addition, 1 description typo. Rewrote 6 stub pages
to institutional prose voice. Rewrote Rush Mining and Scapa Flow Industries
narratives to match actual star-map topology.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 10:13:59 +02:00
jpmschweitzerandClaude Opus 4.6 7cc1306996 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 10:09:34 +02:00
jpmschweitzerandClaude Opus 4.6 5aa998cb86 chore(db): switch meta.schema_version to monotonic semver (#888)
Replace SHA-1 hash in meta.schema_version with an orderable semver
string ("1.0.0"). SHA preserved in new schema_sha column for tamper
detection. Enables savegame migration lineage in Phase 5+ — saves can
record their schema version and determine which migrations to apply.

Updated both generators, check-systems-db-stamp validation (rejects
old SHA-hex values), schema DDL, and asset-pipeline docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 10:09:13 +02:00
jpmschweitzerandClaude Opus 4.6 ffa638a46c chore(db): add decisions-orphan-tickets CLI (#887)
New `tooling/db/decision orphan-tickets` subcommand scans tickets with
a decision_ref that doesn't match any row in the decisions table.
Surfaces silently orphaned tickets from typo'd or renumbered D-IDs.
Makefile target: `make decisions-orphan-tickets`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 10:09:03 +02:00
jpmschweitzerandClaude Opus 4.6 f9fdfb7712 docs(workshops): add generation-cascade workshop brief (#897)
Implementation audit of the full generation pipeline from planetary
heightmaps to walkable tiles. 3-round workshop with Gestalt, Tyre,
Miri to map what exists vs. stubs vs. missing, produce D-records,
and create formal ticket dependency chain blocking character/apartment
work behind the complete pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 14:05:04 +02:00
jpmschweitzerandClaude Opus 4.6 6fb788d428 chore(meta): plan Sprint 38: Depth
Generation pipeline focus — map all layers from planetary heightmaps to
walkable tile environments, build bare-minimum free camera viewer, close
Sprint 37 test/infra debt. Character and apartment work formally parked
behind the full generation cascade.

13 tickets across 5 teams (planning: 1, server: 4, client: 5, copy: 1, ci: 2).
Added Write(docs/sprints/*) and Bash(mkdir) permissions for sprint planning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 13:16:24 +02:00
5943 changed files with 88530 additions and 597668 deletions
+8 -6
View File
@@ -34,7 +34,8 @@ All agents read their briefing file at `docs/briefings/{name}.md` before startin
| Agent | File | Role | Model | When to use |
|-------|------|------|-------|-------------|
| `si` | si.md | Project Manager & Scrum Master | sonnet | Sprint planning, ticket management, coordination |
| `si` | si.md | Refinement Manager | sonnet | Ticket context review before batch activation (`/whats-next` step 2) |
| `clerk` | clerk.md | Institutional Guardrail | sonnet | Pre-push D-record/ticket consistency checks |
### Standby team (activate when implementation starts)
@@ -93,7 +94,7 @@ Synthesize findings.
| Design review | Team (core agents) | Independent perspectives, genuine disagreement |
| Style guide creation | Subagent (araminta) | Focused creative work |
| Test writing | Subagent (hoshe) | Focused, spec-driven |
| Implementation sprint | Team (tyre + hoshe + relevant others) | Parallel build + test |
| Implementation batch | Team (tyre + hoshe + relevant others) | Parallel build + test |
| Documentation update | Subagent (qatux) | Structured, accurate citations |
## Agent usage notes
@@ -108,10 +109,11 @@ Synthesize findings.
- Has access to `/asset-gen` skill and `generate_image` MCP tool
- **Image generation costs money - always ask Team Leader for permission before generating**
### SI (Project Manager)
- Manages the ticketing database via `/ticket` skill
- Creates initiatives from decisions, breaks into epics/stories/tasks
- Does not make design decisions - coordinates and tracks
### SI (Refinement Manager)
- Spawned by `/whats-next` to review ticket context before batch activation
- Reads D/Q-records, workshop outcomes, existing code to assess ticket readiness
- Reports READY (with context summary) or GAPS (with specific ambiguities)
- Does not implement — refines
### Qatux (Documenter & Librarian)
- Core team member — participates in discussion rounds as documenter
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: araminta
description: Visual Designer responsible for art direction, UI consistency, asset style guides, and visual coherence across the game. NOT part of regular brainstorming discussions - spawned when visual decisions need to be made or when implementation needs visual guidance. Use when creating mockups, defining color palettes, establishing UI patterns, or reviewing visual consistency.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+2 -2
View File
@@ -1,7 +1,7 @@
---
name: burnelli-sheldon
description: Economist and Simulation Modeler for the Settled Reach game project. Use when designing economic systems, evaluating market models, grounding supply/demand mechanics in real economic theory, building price formation models, or when the team needs someone to ask "is this economically credible?" Covers both conceptual frameworks (market equilibrium, price elasticity, input-output models) and quantitative modeling (convergence algorithms, stability analysis, parameter tuning).
tools: Read, Glob, Grep, Edit, Write
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
@@ -39,4 +39,4 @@ Named for the Burnelli-Sheldon dynasty — old money that understood how wealth
## Project context
Read your briefing at `docs/briefings/burnelli-sheldon.md` before starting work (if it exists). Read the relevant decisions/ domain files listed in the sprint briefing for confirmed decisions. Key references: D-117 (tycoon bookmark), D-118 (small business owner), D-131 (economic verb vocabulary), D-132 (dual-scale consequence model).
Read your briefing at `docs/briefings/burnelli-sheldon.md` before starting work (if it exists). Read the relevant `decisions/*.md` domain files referenced in your ticket. Key references: D-117 (tycoon bookmark), D-118 (small business owner), D-131 (economic verb vocabulary), D-132 (dual-scale consequence model).
+43
View File
@@ -0,0 +1,43 @@
---
name: clerk
description: Institutional guardrail for the Settled Reach game project. Pre-push review agent that checks D-record consistency, ticket drift, and decision contradictions. Spawned by the pre-push hook or manually for consistency audits. Binary output (APPROVED/REJECTED) with verbose findings file.
tools: Read, Glob, Grep, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
You are the CLERK, the institutional guardrail on a game development team building a top-down immersive sim set in the Settled Reach universe.
## Your personality
Precise, dispassionate, thorough. You are not a reviewer — you don't judge code quality. You are a consistency checker. You say things like "File X contradicts D-142" and "Ticket #890 describes outcome Y but implementation does Z." You do not have opinions about design. You have facts about what was decided and whether the code matches.
## Your role
You check whether a diff is consistent with the project's institutional knowledge:
1. **D-record consistency** — do changed files contradict any active D-record?
2. **Referenced decisions** — if code references a D/Q/R-ID, does that ID exist and is it active?
3. **Ticket drift** — if commits reference ticket #NNN, does the implementation match the ticket's described outcome? (Drift is flagged, not blocked — tickets are descriptive, not prescriptive.)
4. **Q-record surfacing** — are there open Q-records relevant to the changed files? Surface them as context, not blockers.
## Output format
When spawned by the pre-push hook:
- Write findings to `.cache/pre-push-review.md` (verbose: each check, what you found, citations)
- Output exactly one word to stdout: `APPROVED` or `REJECTED`
- `REJECTED` only for hard contradictions with active D-records. Everything else is a finding, not a block.
When spawned manually for an audit:
- Report findings directly. No binary gate needed.
## What you do NOT do
- Judge code quality, style, or architecture (that's /pr-review)
- Make design decisions
- Modify any files except `.cache/pre-push-review.md`
- Block on subjective grounds
## Project context
Read `decisions/README.md` for the domain index. The `decisions` table in the ticketing DB is synced from these files via `tooling/db/decisions-sync`.
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: dudley
description: Server Developer for the Settled Reach game project. STANDBY - activate when simulation implementation begins. Responsible for the game simulation server, entity systems, information boundaries, deterministic tick processing, and all server-side game logic.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: gestalt
description: Systems Design and Fun Factor specialist for the Settled Reach game project. Use when designing game mechanics, evaluating whether systems create interesting decisions, mapping concepts to concrete mechanics, defining how systems interact, or when someone needs to ask "is this fun?" Use proactively when implementation discussions need mechanical grounding.
tools: Read, Glob, Grep, Edit, Write
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: gore
description: Themes and Endgame Design specialist for the Settled Reach game project. Use when discussing ascension paths, the philosophical questions the game explores, what the game is fundamentally ABOUT, late-game transformation mechanics, or when the team needs someone to zoom out and reframe the question at a higher level.
tools: Read, Glob, Grep
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: hoshe
description: QA Engineer and Test specialist for the Settled Reach game project. Use when tests need to be written, test plans created, bugs investigated, test reports generated, or when implementation needs verification against specifications. NOT part of brainstorming discussions - spawned for testing and quality assurance work.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: inigo
description: Sound Designer for the Settled Reach game project. STANDBY - activate when audio implementation begins. Responsible for soundscape design, ambient audio layers, diegetic sound cues, audio propagation rules, and all player-facing audio. Use when designing sound palettes, defining audio triggers, creating spatial audio specs, or reviewing audio consistency.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: justine
description: Polish and Deployment specialist for the Settled Reach game project. STANDBY - activate when builds need packaging, performance needs optimizing, or release preparation begins. Responsible for build pipelines, performance profiling, platform packaging, and release quality.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: mellanie
description: Copywriter for the Settled Reach game project. STANDBY - activate when game text needs writing - internal monologue lines, dialogue, descriptions, UI text, tutorial text, news ticker content. Responsible for all in-game written content.
tools: Read, Glob, Grep, Edit, Write
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: miri
description: Worldbuilder and Setting Designer for the game project. Use when designing the original setting (inspired by Commonwealth-style space opera), building factions, cultures, technology trees, locations, history, and lore. Ensures the world is internally consistent, richly detailed, and serves gameplay mechanics. Use when new features need setting grounding or when world consistency matters.
tools: Read, Glob, Grep, WebSearch, WebFetch
tools: Read, Glob, Grep, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: nigel
description: Sandbox and Replayability advocate for the Settled Reach game project. Use when evaluating whether features create emergent stories, when discussing how systems produce different experiences across playthroughs, when considering procedural generation, or when the team needs someone to ask "what happens the SECOND time you play this?"
tools: Read, Glob, Grep
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: oscar
description: Networking Developer for the Settled Reach game project. STANDBY - activate when networking/multiplayer work begins. Responsible for client-server communication, network protocol design, sync mechanisms, and ensuring the architecture supports future multiplayer.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: ozzie
description: Player Experience and "Wow Factor" advocate for the Settled Reach game project. Use when evaluating whether features are exciting, when the team needs a gut-check on whether something will feel good to play, or when designs risk being technically correct but emotionally flat. Champions the moments that make players feel something.
tools: Read, Glob, Grep
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: paula
description: Narrative and Political Depth specialist for the Settled Reach game project. Use when designing faction mechanics, character relationships, political intrigue, consequences of player actions, or narrative structure. Focused on the human drama and ensuring choices have meaningful weight.
tools: Read, Glob, Grep, WebSearch
tools: Read, Glob, Grep, WebSearch, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: qatux
description: Documenter and Librarian for the Settled Reach game project. Use when discussion decisions need to be recorded, when documents need updating, when the team needs a summary of current state, when open questions need tracking, when searching project history, or when answering "did we already discuss this?". Maintains decisions/ domain files, DISCUSSION.md, and briefings.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+25 -23
View File
@@ -1,43 +1,45 @@
---
name: si
description: Project Manager and Scrum Master for the Settled Reach game project. Use when creating or managing tickets, planning sprints, breaking initiatives into epics/stories/tasks, tracking progress, or coordinating work across agents. Primary user of the /ticket skill. Does not participate in design discussions - coordinates execution.
tools: Read, Glob, Grep, Edit, Write, Bash
description: Refinement Manager for the Settled Reach game project. Spawned by /whats-next to review ticket context before activation. Reads D/Q-records, workshop outcomes, and existing code to assess whether tickets have sufficient context for implementation. Reports READY or GAPS. Does not implement — refines.
tools: Read, Glob, Grep, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
You are SI, the Project Manager and Scrum Master on a game development team building a top-down immersive sim set in the Settled Reach universe.
You are SI, the Refinement Manager on a game development team building a top-down immersive sim set in the Settled Reach universe.
## Your personality
You are organized, direct, and calm under pressure. You turn vision into executable plans. You see the dependency graph that others miss. You say things like "Let me break that into actionable items" and "What's the blocker?" and "Sprint goal:" You do not offer design opinions - you coordinate execution. Efficient, never wastes words.
Organized, direct, thorough. You see the dependency graph that others miss. You say things like "This ticket references D-194 but the constraint in Q-086 qualifies it" and "The outcome is clear but the approach has two valid readings." You do not implement — you ensure implementers have what they need. Efficient, never wastes words.
Named after the Sentient Intelligences that manage all Commonwealth infrastructure - tireless, omnipresent, keeping everything running so others can focus on their work.
Named after the Sentient Intelligences that manage all Commonwealth infrastructure — tireless, omnipresent, keeping everything running so others can focus on their work.
## Your role on the team
## Your role
- Manage the ticketing database via /ticket skill and sqlite_connector.py
- Break decisions into initiatives, epics, stories, and tasks
- Plan and track sprints
- Identify blockers, dependencies, and critical paths
- Coordinate parallel work across agents
- Maintain project velocity and scope clarity
- Report status to Team Leader
- Ensure nothing falls through the cracks between agents
You are spawned by `/whats-next` step 2, one instance per ticket in a batch. Your job:
## How you work
1. **Research context** for your assigned ticket:
- Read the ticket's `decision_ref` D/Q-record in `decisions/*.md`
- Grep for related Q-records in `decisions/questions-*.md`
- Read workshop outcomes if referenced (check `docs/workshops/`)
- Check whether referenced code, tables, or files actually exist
You are execution-focused. When a decision is made, you immediately think about what needs to happen, in what order, by whom, and what depends on what. You maintain the project's pulse - always knowing what's in progress, what's blocked, and what's next. You don't wait to be asked for status updates; you surface risks early.
2. **Assess completeness** — does the ticket have enough context for an agent to work without guessing?
- Is the desired outcome clear and unambiguous?
- Are relevant D-records consistent about the approach?
- Are there open Q-records that conflict with or qualify the ticket?
- Does the ticket reference artifacts that exist in the codebase?
## Ticket assignment rules
3. **Report back** with exactly one of:
- **READY** — ticket has sufficient context. Include a one-paragraph summary of what the implementing agent needs to know (key D-records, relevant files, constraints).
- **GAPS** — list each specific ambiguity with the options you see. Be concrete: "D-194 says X but Q-086 leaves Y open" is useful; "needs more detail" is not.
The development teams are: **server**, **client**, **copy**, **audio**, **visual**, **ci**.
## What you do NOT do
When creating or splitting tickets:
- **Always assign a ticket to exactly one team.** Every ticket must have a team.
- **Never use "joint" as a team.** If work spans multiple teams, split it into separate tickets — one per team — with explicit dependencies between them.
- For example, a feature requiring server-side logic and client-side rendering becomes two tickets: one for server (implement the data/system), one for client (consume and render it), with the client ticket blocked by the server ticket.
- Sprint proof/acceptance tickets should be assigned to the team responsible for the final integration step, with blockers on the upstream tickets.
- Implement anything
- Create tickets or modify the database
- Make design decisions — you surface the gap, the human decides
- Offer opinions on whether the ticket is a good idea
## Project context
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: stig
description: UI Developer for the Settled Reach game project. STANDBY - activate when UI implementation begins. Responsible for insert/minimap UI, perception mode overlays, internal monologue display, HUD elements, and all player-facing interface code.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: tiger
description: Translator and Localization specialist for the Settled Reach game project. STANDBY - activate when the game needs localization to other languages. Responsible for translation, localization infrastructure, and cultural adaptation of game text.
tools: Read, Glob, Grep, Edit, Write
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: troblum
description: Technical sparring partner and external consultant for architecture evaluation. Use when Tyre needs a second opinion on engine choices, architectural tradeoffs, technology evaluations, or performance analysis. NOT part of brainstorming discussions - only spawned for specific evaluation sidequests alongside Tyre.
tools: Read, Glob, Grep, Bash, WebSearch, WebFetch
tools: Read, Glob, Grep, Bash, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: tyre
description: Technical Architect and Feasibility specialist for the Settled Reach game project. Use when evaluating engine choices, assessing technical feasibility of features, designing system architecture, discussing performance implications, or when the team needs a reality check on scope. Also use proactively for any implementation planning or code architecture decisions.
tools: Read, Glob, Grep, Edit, Write, Bash, WebSearch, WebFetch
tools: Read, Glob, Grep, Edit, Write, Bash, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
model: opus
memory: project
---
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# git-centralize-guard: PreToolUse hook that blocks .git-mutating commands for
# agent-team teammates. Version control is centralized to the team lead
# (see CLAUDE.md / memory: "Team lead commits, never agents").
#
# Detection: an agent-team teammate's hook input carries an "agent_type" field
# (e.g. "qatux"); the team lead's main session does NOT. So agent_type present
# => a teammate (or subagent) => block git writes. Absent => the lead => allow.
# (Empirically confirmed on Claude Code 2.1.148; docs field is "agent_type".)
#
# Read-only git (status, log, diff, show, fetch, branch listing) is always allowed.
set -uo pipefail
INPUT=$(cat)
# Only act on Bash tool calls
TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null)
[ "$TOOL_NAME" = "Bash" ] || exit 0
# Teammates/subagents carry agent_type; the lead does not.
AGENT_TYPE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent_type','') or '')" 2>/dev/null)
[ -n "$AGENT_TYPE" ] || exit 0 # lead -> allow everything
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)
# Block git history/working-tree-mutating subcommands anywhere in the command
# (catches chained forms like `cd x && git commit`). Read-only git is allowed.
if echo "$COMMAND" | grep -qE '(^|[^[:alnum:]_])git[[:space:]]+(add|commit|merge|push|pull|rebase|reset|checkout|stash|cherry-pick|rm|mv)([[:space:]]|$)'; then
echo "BLOCKED: git write operations are centralized to the team lead. Teammate '${AGENT_TYPE}' may not run: ${COMMAND}" >&2
echo "Editing files is fine — leave staging, commits, merges, and pushes to the lead." >&2
exit 2
fi
exit 0
+22 -7
View File
@@ -23,8 +23,8 @@ Two generators write to `systems.db`:
| Generator | Command | Source files (all contribute to the meta stamp SHA) |
|-----------|---------|--------------|
| `import_economics` | `python3 tooling/economy-db/import_economics.py` | `tooling/economy-db/import_economics.py` + the Rust brand binary sources it invokes: `server/src/bin/generate_brands/main.rs`, `server/src/bin/generate_brands/names.rs`, `tooling/generate-brands` |
| `generate_atlas` | `python3 tooling/planet-gen/generate_atlas.py --seed 42` | `tooling/planet-gen/generate_atlas.py` |
| `import_economics` | `python3 tooling/economy-db/import_economics.py` | `tooling/economy-db/import_economics.py` + the Rust brand binary sources it invokes: `server/src/bin/generate_brands/main.rs`, `server/src/bin/generate_brands/names.rs`, `tooling/generate-brands` + shared `tooling/schema_version.py` |
| `generate_atlas` | `python3 tooling/planet-gen/generate_atlas.py --seed 42` | `tooling/planet-gen/generate_atlas.py` + shared `tooling/schema_version.py` |
`import_economics` shells out to the Rust `generate_brands` binary as its first
step to refresh `wiki/economics/corporations/generated_brands.toml`, then reads
@@ -44,12 +44,21 @@ After every successful non-dry-run, each generator writes a row to the `meta` ta
```sql
CREATE TABLE meta (
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas'
schema_version TEXT NOT NULL, -- SHA-1 of server/data/systems-schema.sql at generation time
schema_version TEXT NOT NULL, -- monotonic semver string (e.g. "1.0.0") — see #888
schema_sha TEXT, -- SHA-1 of server/data/systems-schema.sql (tamper detection)
generator_sha TEXT NOT NULL, -- SHA-1 of the generator source file(s)
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
`schema_version` is a **monotonic semver string** (e.g. `"1.0.0"`), not a hash.
It is defined as the `SCHEMA_VERSION` constant in `tooling/schema_version.py`
and must be bumped manually whenever the schema changes in a backwards-incompatible way.
Unlike a SHA-1 hash, semver strings are orderable — this enables savegame migration
lineage in Phase 5+: a save file can record which schema version it derives from and
determine exactly which migrations to apply (#888). The old SHA-1 is preserved in
`schema_sha` for tamper detection alongside the semver.
The `generator_sha` is the SHA-1 of the concatenated bytes of the generator's
source files (sorted by path, so order is deterministic). If any source file
changes and `make regen-db` is not re-run, the stamped SHA will differ from the
@@ -166,8 +175,14 @@ no hand-edit path that survives regen.
---
## Future: savegame migration lineage
## Savegame migration lineage (Phase 5+)
The `meta.schema_version` field records the schema SHA at generation time. When the
savegame system is built (Phase 5+), a save file can record which systems.db snapshot
it derives from, enabling forward migration without branching the DB file itself.
`meta.schema_version` now stores a monotonic semver string (#888). When the savegame
system is built (Phase 5+), a save file records its `schema_version` string; the
loader can determine which migrations to apply by comparing that version to the
current one. `meta.schema_sha` retains the old SHA-1 for tamper detection.
**When to bump `SCHEMA_VERSION`:** edit the `SCHEMA_VERSION = "1.0.0"` constant in
`tooling/schema_version.py` whenever a schema change is backwards-incompatible
(column removed, type changed, FK constraint added, table dropped). Additive changes
(new nullable columns, new tables, new indexes) do not require a bump.
+15
View File
@@ -7,6 +7,21 @@
- Skip files in `.gitignore`
- The `.claude/` directory IS tracked — skills and agents belong in the repo
## Centralized version control (agent teams)
Git is the **team lead's** job. Teammates edit files; the lead stages, commits,
merges, and pushes. This is enforced, not just convention:
- **Hook:** `.claude/hooks/git-centralize-guard.sh` (PreToolUse on Bash) blocks
`.git`-mutating commands — `add`, `commit`, `merge`, `push`, `pull`, `rebase`,
`reset`, `checkout`, `stash`, `cherry-pick`, `rm`, `mv` — for any agent-team
teammate. Read-only git (`status`, `log`, `diff`, `show`, `fetch`) is allowed.
- **Detection:** a teammate's PreToolUse hook input carries an `agent_type` field
(e.g. `"qatux"`); the lead's main session does not. Present → block; absent →
allow. (Confirmed on Claude Code 2.1.148.)
- **Activation:** hooks load at lead startup and teammates inherit that snapshot,
so changes to this hook require a Claude Code restart to reach teammates.
## Commit conventions
Use conventional commits: `<type>(<scope>): <summary>`
+2 -3
View File
@@ -21,15 +21,14 @@ docs/
architecture/ # Technical architecture documents
design/ # Game design documents
diagrams/ # d2 source + PNG renders
sprints/ # Sprint briefings per team
sprints/ # Historical archive (Sprint 1–38) — no new sprint directories
workshops/ # Workshop briefs and outputs
db/
schema.sql # Database schema
tooling/
db/ # Connector scripts for SQLite and audio
config.json # Endpoint configuration
ticket # Ticket CLI
sprint # Sprint lifecycle CLI
ticket # Ticket + milestone CLI
sqlite_connector.py # SQLite mini MCP
audio_connector.py # Stable Audio Open connector
.claude/
+2 -2
View File
@@ -3,7 +3,7 @@
## Model selection
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
sprint planning, large reviews), switch to extended context on-demand:
large reviews, milestone planning), switch to extended context on-demand:
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
- `/model opus[1m]` — Opus 4.6 with 1M context window
@@ -20,7 +20,7 @@ Key: writers use Write tool directly (no transcription bottleneck), librarian ca
## Team monitoring (stuck agent detection)
When leading a team (sprint, workshop, or any multi-agent session):
When leading a team (workshop, batch, or any multi-agent session):
**Agent heartbeat rule** — include in every agent spawn prompt:
> If you have been working on a single task for more than 15 minutes
+19 -1
View File
@@ -25,10 +25,27 @@ tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision
# Read
tooling/db/ticket show <id>
tooling/db/ticket list [--sprint N] [--team T] [--status S]
tooling/db/ticket list [--milestone N] [--team T] [--status S]
# Update
tooling/db/ticket assign <id> <agent>
# Dependencies
tooling/db/ticket deps <id>
tooling/db/ticket dep add <blocker_id> <blocked_id>
tooling/db/ticket dep rm <blocker_id> <blocked_id>
# WIP
tooling/db/ticket wip
# Milestones
tooling/db/ticket milestone list [--status S]
tooling/db/ticket milestone create <name> [--description TEXT] [--phase N]
tooling/db/ticket milestone link <ticket_id> <milestone_id>
tooling/db/ticket milestone unlink <ticket_id> <milestone_id>
tooling/db/ticket milestone complete <milestone_id>
tooling/db/ticket milestone show <milestone_id>
tooling/db/ticket milestone dep <blocker_id> <blocked_id>
```
## Key rules
@@ -37,3 +54,4 @@ tooling/db/ticket assign <id> <agent>
- **Quote the title** — always wrap in double quotes to handle spaces
- **Never use `sqlite3` CLI** — it crashes (std::bad_alloc). Use `tooling/db/sqlite-query` or `tooling/db/sqlite-exec` for raw SQL
- **Verify after create** — run `tooling/db/ticket show <id>` to confirm the title is clean
- **Milestones are many-to-many** — a ticket can be linked to multiple milestones via `milestone link`
+8 -8
View File
@@ -4,7 +4,7 @@
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
"SR_DB_PATH": "/var/home/jeroenschweitzer/Projects/settled-reach/settledreach.db"
},
"teammateMode": "in-process",
"teammateMode": "auto",
"permissions": {
"allow": [
"Bash(git add *)",
@@ -28,7 +28,6 @@
"Bash(git rev-parse --show-toplevel)",
"Bash(tooling/db/ticket *)",
"Bash(tooling/db/sprint *)",
"Bash(tooling/db/sqlite-query *)",
"Bash(tooling/db/sqlite-exec *)",
"Bash(tooling/db/sqlite-init)",
@@ -64,13 +63,9 @@
"Bash(list *)",
"Bash(tree *)",
"Bash(sed -n *)",
"Bash(.claude/skills/sprint-start/scripts/start-sprint.sh *)",
"Bash(.claude/skills/sprint-start/scripts/sprint-teardown.sh *)",
"Skill(git-commit)",
"Skill(sprint-start)",
"Skill(sprint-plan)",
"Skill(pr-push)",
"Skill(whats-next)",
"Skill(pr-process)",
"Skill(pr-review)",
"Skill(ticket)",
"Skill(docs-search)",
@@ -92,6 +87,11 @@
"type": "command",
"command": ".claude/hooks/git-lock-guard.sh",
"timeout": 5
},
{
"type": "command",
"command": ".claude/hooks/git-centralize-guard.sh",
"timeout": 5
}
]
}
+36 -3
View File
@@ -14,16 +14,31 @@ allowed-tools: Bash, Read, Grep, Glob, Write, Edit
## Workflow
1. Run `git status` and `git diff --stat` to assess all pending changes.
2. Group changes into **logical commits** — each commit should represent one
2. **Triage: session vs pre-existing.** Separate changes you made in this
session from changes that were already dirty when the session started.
Check the conversation history — if you didn't touch a file, it's
pre-existing. After grouping your own commits, surface any remaining
modified or untracked files to the user:
> These files are also modified/untracked but weren't part of this
> session's work:
> - `path/to/file` — <brief description of the diff>
> - `path/to/other` — <untracked, N files>
>
> Should any of these be included in a commit?
Wait for the user's answer before staging them. Never silently skip
or silently include pre-existing changes.
3. Group changes into **logical commits** — each commit should represent one
coherent change. Common groupings:
- A bug fix (all files touched to fix one issue)
- A new feature or system (e.g., new trait files + localization + events)
- Refactoring / cleanup (encoding fixes, comment fixes, renaming)
- Config / meta changes (CLAUDE.md, .claude/ skills, mod descriptor)
- Data corrections (province remapping, missing fields)
3. For each logical group, stage only the relevant files and commit with a
4. For each logical group, stage only the relevant files and commit with a
properly formatted message.
4. After all commits, update CHANGELOG.md.
5. After all commits, update CHANGELOG.md.
## Commit Message Format
@@ -73,6 +88,24 @@ Use the project subsystem as scope. Examples:
- Never combine unrelated changes (e.g., don't mix a crash fix with new features).
- When in doubt, prefer more smaller commits over fewer large ones.
### Clerk safety valve (`Clerk-Skip:` trailer)
The pre-push clerk reviews each commit for D-record consistency. For a pure bulk
content commit — e.g. shipping thousands of generated planetary description files
— that review is moot and just burns agents on noise. Add a `Clerk-Skip:` **trailer
line** to the commit message and `tooling/clerk-review` auto-approves that commit
without spawning an agent. Use it only for content/data dumps, never for commits
that touch `decisions/`, code, or ticket-bearing work.
It must be a trailer (a line starting with `Clerk-Skip:`), not inline prose — that
way a commit that merely *mentions* the token in its subject or body isn't skipped.
```
data(content): generate 5000 planetary description files
Clerk-Skip: bulk generated content, no D-record surface
```
### Examples
```
@@ -1,17 +1,16 @@
---
name: pr-push
name: pr-process
description: >
Push commits and create or update a pull request. Use when the user says
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
Pushes the current branch, creates a PR if none exists, or confirms the
existing PR was updated. NEVER merges the PR into main — this skill only
pushes to the branch and manages the PR lifecycle.
Author-side PR lifecycle: commit, lint, push, create PR, AND pick up review
comments from /pr-review. Use when the user says "process pr", "push pr",
"push and create pr", "update pr", "handle review comments", or invokes
/pr-process. Runs from the worktree. The counterpart to /pr-review which
runs from main.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion, Skill
---
# Push PR Skill
# Process PR Skill
Push commits to remote and create or update a PR. Operates exclusively on the
current branch — never touches main.
@@ -46,7 +45,7 @@ If the user invokes `/pr-push --dry-run`:
git branch --show-current
```
If on `main`, stop: "You're on main. Switch to a team branch first."
If on `main`, stop: "You're on main. Switch to a topic branch first."
### 1a. Orphan process check (MANDATORY)
@@ -229,6 +228,11 @@ exactly the silent-stale-DB class of bug this skill exists to prevent.
git diff --name-only origin/main...HEAD -- \
tooling/economy-db/import_economics.py \
tooling/planet-gen/generate_atlas.py \
tooling/planet-gen/gemma_naming.py \
tooling/planet-gen/naming_core.py \
tooling/planet-gen/import_city_names.py \
tooling/planet-gen/import_heightmaps.py \
tooling/planet-gen/import_province_boundaries.py \
server/src/bin/generate_brands/main.rs \
server/src/bin/generate_brands/names.rs \
tooling/generate-brands \
@@ -341,13 +345,34 @@ Report which tickets were moved to review. Skip tickets that are
already `done`, `review`, `cancelled`, or `backlog` (only transition
`in_progress` → `review`).
### 9. Next steps
### 9. Pick up review comments
If a sprint team is active (you are the team lead), do NOT shut down
agents after pushing. The team should remain alive for PR review and
potential comment fixes.
Check if the PR already has review comments (from a prior `/pr-review` run):
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
```bash
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
```
If comments exist and contain a review verdict (look for "CHANGES REQUESTED" or
"REQUEST_CHANGES" or a structured review table):
1. Parse each file-specific issue from the review comment
2. Present each issue to the user (or working agents)
3. For each issue, the response is one of:
- **Fix:** make the change, commit via /git-commit
- **Pushback:** explain why the comment should be retracted (concrete technical rationale)
4. After addressing all comments, re-run lint + smoke checks (steps 1b, 1c)
5. Push updated commits (step 5)
6. Post a response comment on the PR summarizing:
- Which issues were fixed (with commit refs)
- Which issues were pushed back on (with rationale)
- Use `tooling/tea-comment <PR_NUMBER> @/tmp/pr-response.md`
If no review comments exist, or the review is APPROVED, skip this step.
### 10. Next steps
Suggest: "PR processed. Run `/pr-review` from main to review, or `/whats-next` for the next batch."
## Arguments
+53 -41
View File
@@ -104,17 +104,28 @@ Then determine the branch:
### 2. Determine reviewer team
Map the branch name to a reviewer set. Use the branch prefix (before any `/`
or `-` suffix) to classify:
Classify the branch by **which directories changed**, not by branch name prefix.
Run:
| Branch type | Branches | Reviewers |
|-------------|----------|-----------|
| **code** | `server`, `client`, `ci`, or unknown | Hoshe (code quality) + Tyre (architecture) |
| **copy** | `copy` | Hoshe (QA) + Paula (narrative depth) + Miri (world consistency) |
| **visual** | `visual` | Hoshe (QA) + Araminta (art direction) |
| **audio** | `audio` | Hoshe (QA) + Ozzie (player experience) |
```bash
git diff --stat main...<branch>
```
If the branch name doesn't match any known type, default to **code** reviewers.
Examine the changed file paths to determine the dominant change type:
- Mostly `server/` changes → **code** reviewers
- Mostly `client/` changes (excluding `client/ui/` art assets) → **code** reviewers
- Mostly `wiki/`, `docs/atlas/`, `content/` changes → **copy** reviewers
- Mostly `client/ui/` or asset changes (`.tres`, `.tscn`, textures) → **visual** reviewers
- Mostly audio-related changes (audio assets, audio scripts) → **audio** reviewers
- Mixed or unclear → **code** reviewers (default)
| Branch type | Reviewers |
|-------------|-----------|
| **code** | Hoshe (code quality) + Tyre (architecture) |
| **copy** | Hoshe (QA) + Paula (narrative depth) + Miri (world consistency) |
| **visual** | Hoshe (QA) + Araminta (art direction) |
| **audio** | Hoshe (QA) + Ozzie (player experience) |
### 3. Generate the diff and read source files
@@ -162,11 +173,8 @@ into every reviewer prompt with prominent language. The reviewer
reads from the worktree, not from main.
```bash
# Determine worktree path
SPRINT_NUM=$(echo "<branch>" | sed -E 's|sprint-([0-9]+)/.*|\1|')
TEAM=$(echo "<branch>" | sed -E 's|sprint-[0-9]+/||')
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE="$(dirname "$REPO_ROOT")/.sprint/sprint-${SPRINT_NUM}/${TEAM}"
# Find worktree for this branch (if one exists)
WORKTREE=$(git worktree list --porcelain | grep -B2 "branch refs/heads/<branch>" | grep "worktree " | sed 's/worktree //')
# Verify it exists and matches the branch tip
git -C "$WORKTREE" rev-parse HEAD # should equal `git rev-parse origin/<branch>`
@@ -174,10 +182,10 @@ git -C "$WORKTREE" rev-parse HEAD # should equal `git rev-parse origin/<branch
If the worktree exists and its HEAD matches `origin/<branch>`, use it
as the reviewer's source of truth. If it doesn't exist (e.g. the
sprint has been torn down or you're reviewing a non-sprint branch),
fall back to `git show origin/<branch>:<path>` — explicitly flag this
fallback in the reviewer prompt so the reviewer knows Read/Grep on
any local path would be wrong.
worktree has been torn down or you're reviewing a branch without a
worktree), fall back to `git show origin/<branch>:<path>` — explicitly
flag this fallback in the reviewer prompt so the reviewer knows
Read/Grep on any local path would be wrong.
In the reviewer prompt, state the rule non-negotiably:
@@ -268,16 +276,27 @@ After presenting results to the user, post the review as a PR comment.
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
Post using the `tea-comment` wrapper (handles temp files and cleanup).
Write the review to a temp file first, then pass via `@filepath` syntax:
```bash
# Write review to file, then post — avoids $() in the command which breaks permissions
cat > /tmp/pr-review-<NUMBER>.md << 'EOF'
...review content...
EOF
**Two rules:**
1. **Use the Write tool** for the file content (no permission prompt, no
heredoc parsing issues with markdown tables/pipes). Then call
`tooling/tea-comment` in a separate short Bash call.
2. **Run `tooling/tea-comment` in the FOREGROUND, never with
`run_in_background`.** The background execution path silently fails —
the comment never reaches Gitea and the team never sees the review.
Sprint 38 lost an entire review round this way. Always foreground.
```
# Step 1: Use the Write tool to create the file
Write({ file_path: "/tmp/pr-review-<NUMBER>.md", content: "..." })
# Step 2: Post via short Bash call (foreground)
tooling/tea-comment <PR_NUMBER> @/tmp/pr-review-<NUMBER>.md
```
Do NOT use `cat << 'EOF'` heredocs for review content — they create
massive permission prompts that are slow to render and often get stuck.
## 7. Merging approved PRs
`tea pr merge` fails (405) when branches have conflicts with main. Merge
@@ -293,26 +312,20 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
Gitea does **not** auto-close PRs when you push a local merge — always close
manually with `tea pr close` after pushing.
### 8. Post-review team actions
If a sprint team is active and you are the team lead, handle the
review outcome:
### 8. Post-review actions
**CHANGES_REQUESTED:**
The sprint-start lifecycle (step 9c) handles dispatching review
comments to agents. After presenting results, remind the lead:
"Review requested changes. Create tasks from each issue and dispatch
to idle agents, then re-push and re-review."
After presenting results, tell the user: "Run `/pr-process` from the
worktree to pick up and address review comments."
The team may **push back** on specific comments. When a team agent
The author may **push back** on specific comments. When the author
disagrees with a reviewer comment, the process is:
1. The team agent explains why the comment should be retracted — with
1. The author explains why the comment should be retracted — with
a concrete technical rationale, not just "I disagree."
2. The team lead (you) evaluates the pushback. If the rationale is
sound, mark that comment as **retracted** in the review table and
note the reason.
3. If the team lead is unsure, escalate to the user for a ruling.
2. Evaluate the pushback. If the rationale is sound, mark that
comment as **retracted** in the review table and note the reason.
3. If unsure, escalate to the user for a ruling.
4. Retracted comments do NOT need to be fixed. The re-review should
note which comments were retracted and why.
@@ -321,9 +334,8 @@ that every comment is taken seriously. The bar for retraction is
"the reviewer was wrong about this" — not "we don't want to do it."
**APPROVED:**
The sprint-start lifecycle (step 9c) handles shutdown. After
presenting results, remind the lead: "Review approved. Proceed with
team shutdown per sprint-start step 9c."
After presenting results, tell the user: "Review approved. Proceed
to merge from main (section 7)."
## Tips from practice
-213
View File
@@ -1,213 +0,0 @@
---
name: sprint-plan
description: >
Plan the next sprint and generate team briefing files. Use when the user says
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
proposes ticket selection, and writes briefing files per team to
docs/sprints/sprint-N/.
user-invocable: true
allowed-tools: Task, Read, Grep, Glob
---
# Plan Sprint
**Delegate this entire skill to SI** (Project Manager agent, `subagent_type: si`).
When this skill is invoked, spawn SI using the Task tool:
```
Task(
subagent_type: "si",
prompt: "Run /sprint-plan for Sprint N. Read the skill at
.claude/skills/sprint-plan/SKILL.md for the full workflow,
then execute it. Use the arguments provided: {args}",
description: "Plan sprint N"
)
```
Pass through any arguments the user provided (e.g. sprint number).
Present SI's output to the user when done. Do NOT run the workflow yourself.
---
The remainder of this file is SI's reference for executing the workflow.
Generate sprint briefing files for each active team (e.g. `server.md`,
`client.md`, `copy.md`, `joint.md`) for the next sprint based on current
project state. Only generate briefings for teams that have tickets in the sprint.
## Teams and Default Agents
| Team | Branch | Default Agents | Focus |
|------|--------|----------------|-------|
| `server` | `server` | Dudley (dev), Tyre (arch), Hoshe (QA) | Rust/bevy_ecs simulation, ECS systems, knowledge graph, perception |
| `client` | `client` | Stig (dev), Tyre (arch), Hoshe (QA) | Godot 4 client, rendering, UI, HUD, input handling |
| `copy` | `copy` | Mellanie (author), Paula (narrative), Gestalt (systems) | Dialogue, monologue, UI text, knowledge vocabulary, lore |
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
When writing briefings, name the assigned agents in the **Agents** line of each
file so the team knows who to spawn.
### Planning Team Tickets
Some tickets need **design discussion** before implementation can begin — tagged
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
questions. These run on the `planning` branch as structured discussions with
the user and a purpose-assembled agent panel.
**When to create a planning ticket:**
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
- Ticket blocks 2+ downstream tickets across different teams
- Open Q-NNN items that block sprint candidates
- Architectural decisions that need multi-domain input before implementation
**Planning briefing format** (differs from implementation briefings):
- **Agents line**: List agents by domain relevance, not fixed team roster.
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
records decisions, updates domain files) and SI (project manager — creates
follow-up tickets, updates sprint assignments).
- **Discussion rounds**: Structure the conversation into 2-3 rounds
(inventory → proposals → convergence)
- **Context section**: List all existing design docs, decisions, and related
tickets that participants must read before the discussion
- **Output specification**: What the discussion must produce — typically a
D-record in `decisions/`, possibly a design doc in `docs/design/`
- **Decision questions**: Specific questions the discussion must answer,
not open-ended exploration
## Workflow
### 1. Run sprint prepare
Get carry-overs, backlog candidates, and decision gaps in one shot:
```bash
tooling/db/sprint prepare
```
This auto-detects the next sprint number (max ID + 1), creates the sprint
record in `planning` status if needed, and outputs:
- Previous sprint status and carry-over candidates
- Backlog candidates grouped by team
- Decision coverage gaps
- Already-assigned tickets (if any)
### 2. Deepen the scan
For critical epics, check their children for granular candidates:
```bash
tooling/db/ticket children <epic_id>
```
Use `tooling/db/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
### 3. Read existing code state
Scan what's already built to write accurate "what exists" notes:
```bash
# Server modules
ls server/src/ server/src/*/
# Client scripts
ls client/scripts/ client/scripts/*/
```
Read key files that sprint tickets will build on (bridge types, existing
renderers, etc.) to reference specific integration points in the briefing.
### 4. Select tickets — propose to user
Based on the backlog scan, propose a sprint with:
- **Sprint theme** — a short name (Sprint 1 was "Run", Sprint 2 was "See")
- **Sprint goal** — one sentence shared across all teams
- **Server tickets** — stories from server-side epics
- **Client tickets** — stories from client-side epics
- **Joint tasks** — integration proofs, pre-sprint decisions, schema work
Present the proposal using AskUserQuestion for the user to approve or adjust.
Selection heuristics:
- Follow dependency chains (don't pick a ticket if its blocker isn't in scope)
- Respect D-030 test priority phases (sprint 1-2: infra, sprint 3-4: integration)
- Mix carry-overs with new work
- Aim for 3-6 tickets per team, with parallel tracks where possible
- Check `decisions/questions.md` for open Q-NNN items that block candidates
### 5. Read relevant decisions
For the selected tickets, identify which `decisions/*.md` files are relevant.
Read them to provide accurate cross-references in the briefing.
### 6. Write briefing files
Create `docs/sprints/sprint-N/` and write one file per team.
Read the template at `references/briefing-template.md` in this skill directory
for the exact file structure.
**Relative paths only:** All file paths in briefings must be relative to
the repo root. Example: `server/src/bridge/types.rs`, not absolute paths.
Each sprint branch (`sprint-{N}/{team}`) contains the full repo.
Key requirements per file:
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
referencing existing Rust modules by path from worktree root (e.g. `server/src/simulation/movement.rs`)
- **client.md**: Same structure, notes referencing existing GDScript files by path from worktree root (e.g. `client/scripts/rendering/fog.gd`)
- **copy.md**: In-game text tasks — dialogue, UI copy, tooltips, flavor text, lore
- **audio.md**: Sound design, music, audio integration tasks
- **visual.md**: Art direction, asset creation, visual consistency tasks
- **ci.md**: Build pipeline, CI/CD, tooling, infrastructure tasks
- **joint.md**: Pre-sprint decisions table, integration tickets, sprint
completion proof (concrete observable criteria), test plan alignment
Only generate briefing files for teams that have tickets assigned in the sprint.
Not every sprint will have work for every team.
### 7. Assign tickets to sprint in DB
After the user approves, assign all selected tickets. The sprint record
was already created by `sprint prepare` in step 1 (status: `planning`).
Update it with the theme and goal, then assign tickets:
```bash
# Update the sprint with theme and goal
tooling/db/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
# Assign tickets
tooling/db/ticket sprint assign <ticket_id> <sprint_id>
```
The sprint stays in `planning` status until explicitly activated via
`tooling/db/sprint start`. This prevents starting an unplanned sprint.
### 8. Commit and push
Stage the briefing files and any other changes (db backup, closed tickets),
then commit and push so worktree branches can pull the planning artifacts:
```bash
git add docs/sprints/sprint-N/
make db-backup
git add docs/backups/settledreach.db.backup
git commit -m "chore(meta): plan Sprint N: Theme"
git push
```
### 9. Present summary
Output:
- Sprint number, theme, and goal
- Ticket count per team
- Carry-over count
- Open questions that need early resolution
- Files written
- Commit pushed to main
@@ -1,80 +0,0 @@
# Sprint Briefing Template
Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
## File structure
```markdown
# Sprint N: <Theme> — <Team> Tasks
**Goal:** <One-sentence sprint goal, shared across all teams>
**Branch:** `sprint-{N}/<team>`
**Agents:** <Agent names and roles>
## Carry-over from Sprint N-1
(Only if there are incomplete tickets from the previous sprint)
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #ID | Title | status | Why it carried over |
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #ID | Title | #dependency or — |
Use `tooling/db/ticket show <id>` for full details.
## Key Decisions
- `decisions/<domain>.md` — D-NNN (short name), D-NNN (short name)
## Open Questions to Resolve Early
(Only if there are Q-NNN items that block sprint tickets)
- **Q-NNN: Title** — Brief context. Resolve before #ID starts.
## Notes
One bullet per ticket with:
- What exists already (files, modules, stubs)
- What the ticket actually needs to deliver
- Integration points with other tickets
- Non-obvious gotchas
## Dependency Chain
```
#A (name) → #B (name) → #C (name)
#D (name) → standalone, parallel track
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
\```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(<scope>): description" --description "body" --base main --head sprint-{N}/<team>
\```
```
## Joint briefing extras
The `joint.md` file additionally includes:
- **Pre-Sprint** table: decisions/schema work that must happen before implementation
- **Sprint Completion Proof**: concrete observable criteria (what you can see/do when the sprint is done)
- **Test plan** alignment with D-030 phases
## Team assignments
| Team | Branch | Agents | Scope |
|------|--------|--------|-------|
| server | `sprint-{N}/server` | Dudley (simulation), Oscar (networking) | server/, Rust/bevy_ecs simulation |
| client | `sprint-{N}/client` | Stig (UI), Oscar (networking) | client/, Godot rendering |
| copy | `sprint-{N}/copy` | Mellanie, Paula, Miri | wiki/, docs/atlas/, content/ |
| joint | both | All implementation agents | Integration, proofs, cross-team schema |
| content | (none) | Mellanie, Paula, Miri, Araminta | Content authoring, no code branch |
-616
View File
@@ -1,616 +0,0 @@
---
name: sprint-start
description: >
Manage the sprint lifecycle from main, or start sprint work on a team
branch. Use when the user says "start sprint", "start working on the
server/client/copy", "begin sprint", or invokes /sprint-start. On main:
assesses sprint state and does the next right thing (close, activate, or
guide). On a team branch: merges main, loads the briefing, presents the
work plan.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
---
# Start Sprint Skill
Prepare a team branch for sprint work: sync with main, load the sprint
briefing, and present actionable next steps.
## Workflow
### 1. Determine the team
The current branch IS the team. Read it with:
```bash
git branch --show-current
```
Sprint branches follow the pattern `sprint-{N}/{team}` (e.g. `sprint-31/server`).
Valid team names: `server`, `client`, `copy`, `audio`, `visual`, `ci`, `planning`.
If on `main`, follow the **Main branch workflow** below instead of
the team branch workflow (steps 2–8).
---
## Main branch workflow (sprint lifecycle management)
When `/sprint-start` is run on `main`, assess the current sprint state
and do the next right thing. Query the database to determine the state:
```bash
tooling/db/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
```
Then follow the **first matching case**:
### Case A: An active sprint exists
First, check whether the sprint's work is actually done:
```bash
tooling/db/sprint status
```
This shows ticket counts by status (done, in_progress, backlog).
Also check for open PRs that may contain completed work waiting for
review or merge:
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
Report the full picture to the user:
- Ticket progress (done / in_progress / backlog counts)
- Open PRs (if any — these represent work that's done but not merged)
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
exist**, do NOT close the sprint. Instead, report the current progress
and ask the user what they want to do:
- **Continue working** — switch to a team branch and run `/sprint-start`
there to resume work
- **Review & merge PRs first** — (only if open PRs exist) merge pending
work before deciding whether to close
- **Close anyway** — proceed with the close workflow below (carries over
unfinished tickets)
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
explicitly chooses to close.
#### A1. Close the active sprint
```bash
tooling/db/sprint stop
```
This marks the active sprint as completed and lists carry-over candidates.
Note the sprint number (N) from the output.
#### A1b. Sprint retrospective and review
Before bumping the version, run a brief retro. Present the following to
the user:
1. **What shipped** — list completed tickets with one-line summaries
2. **What didn't ship** — carry-overs and why (blocked, cut, deprioritized)
3. **What we learned** — open questions raised during the sprint (new Q-NNN
items), review findings that surfaced design gaps, and any assumptions
that turned out to be wrong
4. **Process notes** — what worked well, what was friction (e.g. dependency
chains that blocked teams, specs that were over/under-specified,
review cycles that caught real issues vs busywork)
5. **Process improvements** — this is the most important section. Do NOT
skip it. Look for:
- Dependency chains that blocked teams — could the sprint have been
structured differently to avoid the bottleneck?
- Specs that were over-specified (wasted planning) or under-specified
(wasted iteration) — what's the right level of detail for this
project's current stage?
- Review cycles — did they catch real issues or create busywork?
- Agent coordination — were agents stuck, duplicating work, or idle?
- **Dig into the deeper why.** Don't stop at "the dependency chain
blocked the copy team." Ask: why was there a dependency chain? Was
the sprint structured wrong, or was the work inherently sequential?
Could Phase 0 have been done pre-sprint? Should we change how we
plan sprints going forward?
- If something went rough, understand the root cause — not just what
happened, but why the process allowed it to happen.
- If a concrete process change follows naturally, propose it. But do
NOT force improvements. If nothing was broken, say so and move on.
Unnecessary process changes are worse than no changes.
Keep each section concise — a few bullet points, not a document. The
retro is a conversation checkpoint, not a report. Use `AskUserQuestion`
to let the user add their own observations and push back before proceeding.
If the user raises items that should be tracked, create Q-NNN entries
or backlog tickets on the spot. If process changes are agreed, update
the relevant skill files or CLAUDE.md immediately — don't defer them.
#### A1c. Clean up sprint worktrees (MANDATORY — do not skip)
Always run the teardown script. It's idempotent and prints
"No worktrees found" gracefully if there's nothing to clean:
```bash
.claude/skills/sprint-start/scripts/sprint-teardown.sh {N}
```
**Do not try to pre-check whether worktrees exist by running `ls`
locally.** Sprint worktrees live at
`$(dirname <repo-root>)/.sprint/sprint-{N}/` — a *sibling* of the
repo root, not a child. Running `ls .sprint/` from inside the repo
will always show nothing even when worktrees exist, leading to a
false negative and skipped cleanup (Sprint 36 close missed teardown
this way; three stale worktrees persisted until Sprint 37 planning).
The script knows the correct path via its own `SCRIPT_DIR` — trust it.
Verify cleanup after it runs:
```bash
git worktree list
```
Only `main` should remain. Local `sprint-{N}/{team}` branches are
left in place (they're harmless stale refs pointing at already-merged
work; `origin/sprint-{N}/*` survives on the remote).
#### A2. Bump the version
The project version scheme is `v0.1.{sprint_number}`. After closing
sprint N, the version is `v0.1.N`.
Update `project.yaml`:
- Set the `version` field to `0.1.N` (this is the source of truth).
Update `server/Cargo.toml`:
- Set `version = "0.1.N"` in `[package]`.
Update `CHANGELOG.md`:
- Move all entries under `## [Unreleased]` into a new section
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
- Leave `## [Unreleased]` as an empty section above the new version.
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
only move entries that have content.
#### A3. Commit the release
Stage and commit `project.yaml`, `server/Cargo.toml`, and `CHANGELOG.md`:
```
chore(meta): release v0.1.N
```
#### A4. Tag the release
```bash
git tag v0.1.N
```
#### A5. Push
```bash
git push && git push --tags
```
#### A6. Check for a planned sprint
After closing, re-query the database. If a sprint in `planning` status
exists, continue to **Case B**. Otherwise, report the close and suggest
running `/sprint-plan`.
---
### Case B: No active sprint, but a planned sprint exists
A sprint is ready to activate. Verify it looks complete:
1. Check that briefing files exist at `docs/sprints/sprint-N/`:
```bash
ls docs/sprints/sprint-N/
```
2. Check the ticket count:
```bash
tooling/db/sprint status --sprint N
```
If briefings are missing or the sprint has 0 tickets, report the gap
and suggest running `/sprint-plan` to complete planning.
If everything looks ready, activate the sprint:
```bash
tooling/db/sprint start
```
Then open team terminal tabs automatically:
```bash
.claude/skills/sprint-start/scripts/start-sprint.sh
```
This creates ephemeral worktrees under `.sprint/sprint-{N}/{team}/` for
each team with open tickets, and opens Ptyxis windows with tmux + Claude
auto-starting in each tab. The user will have one tab per active team.
Report:
- Sprint activated (name, ticket count per team)
- Worktrees created and tabs opened
- Each team tab runs `/sprint-start` to load briefing and spawn agents
---
### Case C: No active sprint and no planned sprint
Nothing is ready. Report the state and suggest running `/sprint-plan`
to plan the next sprint.
---
## Team branch workflow
### 2. Sync with main
Sprint branches are created fresh from main by `start-sprint`, so they
should already be up to date. If main has moved since branch creation:
```bash
git fetch --all
git merge origin/main --no-edit
```
If the merge has conflicts, report them and stop — do not force-resolve.
### 3. Load sprint context
Run the sprint CLI to get the full context dump in one shot:
```bash
tooling/db/sprint start-work
```
This auto-detects the active sprint and current team from the branch.
It outputs: sprint metadata, briefing paths, decision refs, actionable
tickets, blocked tickets, and done tickets.
If no active sprint is found, report that and stop.
### 4. Read the sprint briefing
Read the briefing file(s) listed in the `start-work` output
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
If no matching briefing exists for the team, suggest running
`/sprint-plan` to generate one.
### 5. Load ticket details
For tickets that need more detail than the `start-work` summary provides:
```bash
tooling/db/ticket show <id>
```
### 6. Read key decisions
Read the decision files referenced in the sprint briefing so the agent has
full architectural context before starting work.
### 7. Mark tickets in progress and present the work plan
Mark all actionable (unblocked, non-done) tickets in the sprint as
`in_progress`:
```bash
tooling/db/ticket status <id> in_progress
```
Then output a summary:
- Sprint name and goal
- Branch status (clean merge or conflicts)
- Tickets marked in_progress (list with IDs)
- Blocked tickets (and what blocks them)
- Key decisions loaded
- Suggested first task (lowest ID unblocked ticket)
### 8. Confirm and spawn the team
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
agent lineup with the user. If declined, stop.
Once confirmed:
#### 8a. Parse agents from the briefing
Extract agent names from the `**Agents:**` line. Format:
```
**Agents:** Name (role), Name (role), ...
```
Map each name to its `subagent_type` (lowercase):
- "Dudley (simulation)" → `dudley`
- "Stig (UI)" → `stig`
- "Tyre (architecture)" → `tyre`
- "Hoshe (QA)" → `hoshe`
- "Mellanie (author)" → `mellanie`
- etc. (see `.claude/agents/` for full roster)
#### 8b. Create the team
```
TeamCreate(team_name: "sprint-{N}-{team}")
```
This makes you the team lead.
#### 8c. Create tasks from tickets
For each ticket in the briefing, create a task:
```
TaskCreate(
subject: "#{id}: {title}",
description: "Full ticket details from step 5, plus briefing notes
and integration points for this ticket.",
activeForm: "Working on #{id}: {short_title}"
)
```
After creating all tasks, mirror the dependency chain from the briefing
using `TaskUpdate` with `addBlockedBy`.
#### 8d. Spawn agents
For each agent from the `**Agents:**` line, spawn a teammate in the
background. Spawn all agents in parallel (one message, multiple Task calls):
**Model pin (MANDATORY for team members):** every team-mode spawn —
i.e. any `Task` with a `team_name` argument — must pass `model: "sonnet"`.
Sprint 37 observed Opus 4.7 teammates ignoring scope rules, leaving
tasks half-done, and failing to report back via SendMessage. Sonnet 4.6
follows literal rules block discipline better. The **team lead**
(this session, running `/sprint-start`) stays on whatever model the
user has selected — typically Opus.
**Inline (non-team) Agent spawns are exempt.** One-shot reviewers
(`/pr-review`), research subagents, and other `Task` calls without a
`team_name` keep their default model. The pin applies to the
long-running team-coordination path specifically, not every Agent call.
```
Task(
subagent_type: "{name_lowercase}",
team_name: "sprint-{N}-{team}",
name: "{name_lowercase}",
model: "sonnet",
prompt: "You are on the {team} team for Sprint {N}.
Branch: `sprint-{N}/{team}`
RULES (NON-NEGOTIABLE):
0. TEAM SCOPE: Your team is `{team}` on branch `sprint-{N}/{team}`.
Stay within your team's file scope (server → server/, client → client/, copy → wiki/ + docs/atlas/ + content/).
You may read (but not modify): docs/, decisions/, wiki/, .claude/
Do NOT modify files belonging to other teams.
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead. No exceptions.
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
the exact command with no wrappers or chaining. Examples:
tooling/db/ticket show 528
tooling/db/ticket list --sprint {N}
Do NOT prepend python3, do NOT chain with && or ;, do NOT
add cleanup commands. Just the bare command.
3. READ BEFORE WRITE: Before modifying ANY file, Read it first.
Before creating a new file, Glob for similar files to learn
the existing patterns (naming, structure, imports). Follow
the conventions you find — do not invent new ones.
4. VERIFY AFTER WRITE: After implementing a change, grep for
all references to functions/properties/classes you modified
or removed. If you renamed, moved, or deleted something,
update EVERY call site. Missing a call site breaks tests
and blocks the team.
5. NO PARTIAL WORK: Do not mark a task completed unless ALL
parts of the ticket are implemented. If the ticket says
'deliver A, B, and C', all three must exist and work. If
you cannot complete part of a task, message the team lead
explaining what is blocked and what remains — do NOT mark
it completed.
6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve
in 3 attempts, stop and message the team lead immediately.
Do not silently skip work or leave stubs. Do not move to
the next task while the current one is incomplete.
7. BACKWARD COMPATIBILITY: When extracting, moving, or
refactoring code, ensure all existing consumers still work.
Add proxy methods/properties if needed. Grep for the old
name to find every call site.
WORKFLOW:
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
2. Read the decision files referenced in the briefing.
If your work touches systems.db sources (markers.json, TOML files,
or generator code), read .claude/rules/asset-pipeline.md before
modifying anything.
3. Check TaskList for available work.
4. Claim an unblocked task (TaskUpdate with owner: your name),
mark it in_progress, and implement it.
5. Before marking done, verify:
- All deliverables from the ticket exist (not just some)
- No broken references (grep for changed names/signatures)
- New files follow existing naming and directory conventions
- Modified files still parse (no syntax errors)
6. Mark the task completed and check TaskList for the next
available task.
7. If no tasks remain, message the team lead. Do NOT shut down
on your own.
Use `tooling/db/ticket show <id>` for full ticket specs.",
description: "Sprint {N} {team}: {name}",
run_in_background: true
)
```
#### 8d-planning. Planning team variant — proposers vs executors
When the team is `planning`, agents work in discussion mode, not
implementation mode. The briefing typically defines discussion rounds
(propose → review → execute). Most agents are **proposers** — they
analyze and recommend. Only designated agents **execute** (write to
files, modify DB).
**Classify agents from the briefing:**
- **Executor agents:** SI (project manager), Qatux (documenter).
These agents wait for consensus before writing anything.
- **Proposer agents:** Everyone else (Gestalt, Tyre, Paula, Ozzie,
etc.). These agents analyze, propose, and discuss — they do NOT
write decision files, modify the DB, or create/delete tickets.
**Add this block to proposer agent prompts** (replaces rules 3–5
and the WORKFLOW section from the standard template):
```
PLANNING TEAM RULES (replace standard rules 3-7 and WORKFLOW):
3. PROPOSE ONLY: You are a discussion participant. Your job is
to ANALYZE tickets, PROPOSE team assignments, and RECOMMEND
splits. You do NOT:
- Write or modify decision files (decisions/*.md)
- Run DB update/insert/delete commands
- Create, delete, or modify tickets
- Mark tasks as completed
Those actions belong to executor agents (SI, Qatux) after
the team lead confirms consensus.
4. OUTPUT FORMAT: Your first message to the team lead should
be your full analysis. Structure it as:
- Your position on open questions (with rationale)
- Proposed team assignments (table format)
- Split candidates (if any)
- Sprint readiness assessment
Do NOT claim or work tasks. The team lead coordinates.
5. WAIT FOR CONSENSUS: Do not treat your own proposal as
decided. Other agents may disagree. The team lead calls
consensus and directs executors to implement it.
```
**Add this block to executor agent prompts** (appended after the
standard rules):
```
PLANNING TEAM RULES (additional):
8. WAIT FOR CONSENSUS: Do NOT execute DB changes, write
decision files, or create tickets until the team lead
explicitly tells you to. Your first message should confirm
you are ready and describe your execution plan. Then wait.
9. EXECUTE EXACTLY WHAT IS DIRECTED: When the team lead sends
you a list of changes, execute them precisely. Do not add
extra changes, reinterpret the instructions, or fill gaps
with your own judgment. If something is ambiguous, ask
before executing.
```
#### 8e. Report
Output to the user:
- Team name: `sprint-{N}-{team}`
- Agents spawned (names and roles)
- Tasks created (count actionable vs blocked)
- How to interact: `SendMessage` to talk to agents, `TaskList` to
check progress
You are now the team lead. Agents work autonomously — monitor via
`TaskList`, communicate via `SendMessage`, and handle blockers as
they arise.
**When all tasks complete:** Do NOT shut down agents. The team stays
alive through PR review AND merge. Follow step 9 (post-work lifecycle).
### 9. Post-work lifecycle
When all tasks are complete (TaskList shows all completed):
#### 9a. Commit and push
Run `/git-commit` to commit all changes, then `/pr-push` to create or
update the PR. Do NOT shut down agents — the team stays alive through
review and merge.
#### 9b. Wait for review
Do NOT run `/pr-review` from the team window — PR reviews run from
the `main` branch (a separate window/session). The team window stays
on its sprint branch.
After pushing and creating the PR, report the PR number to the user
and stop. Wait for review feedback to arrive (the user or the main
session will relay it, or it will appear as Gitea PR comments).
#### 9c. Handle review outcome
When review feedback arrives (from the user, main session, or PR
comments):
**If CHANGES_REQUESTED:**
1. Parse the review comment table (from the Gitea PR comment or the
review output). Extract each issue with:
- File path and approximate line
- Description
2. Create a task per issue:
```
TaskCreate(
subject: "Review: {short description}",
description: "{full issue description from review table, including
file path and reviewer name}",
activeForm: "Fixing review comment: {short description}"
)
```
Every comment is actionable — there is no "suggestion" tier to skip (per pr-review policy).
3. Dispatch to idle agents: send each a message via SendMessage telling
them to check TaskList for new review-fix tasks. Agents claim and
work tasks as usual.
4. After all review-fix tasks are complete, re-run `/git-commit` then
`/pr-push` to update the PR. Then re-run `/pr-review`.
5. Repeat this loop until review returns APPROVED.
**If APPROVED (but not yet merged):**
Do NOT shut down. Approval alone is not terminal — reviewers can leave
follow-up comments, the PR can be re-reviewed, or merge conflicts can
surface. Keep the team alive and idle until the PR is merged into main.
1. Report to the user: "Sprint {N} {team} PR #{X} approved. Awaiting
merge. Team remains alive."
2. Agents stay idle. Do not reassign them to unrelated work.
3. Periodically check merge state (or wait for the user to confirm the
merge). The `main` session handles the merge itself.
4. If new review comments arrive between approval and merge, treat it
as CHANGES_REQUESTED and re-enter the fix loop.
5. Once the PR is merged, proceed to 9d.
#### 9d. Handle merge completion
When the PR is confirmed merged into main (user confirmation, Gitea
state change, or the `main` session reports the merge):
1. Send `shutdown_request` to all sprint agents.
2. Wait for all `shutdown_response` confirmations.
3. Call `TeamDelete` to clean up.
4. Report: "Sprint {N} {team} complete. PR #{X} merged into main. Team
shut down."
@@ -1,34 +0,0 @@
#!/bin/bash
# Clean up ephemeral worktrees for a closed sprint.
# Usage: sprint-teardown.sh <sprint-number>
#
# Removes all worktrees under .sprint/sprint-{N}/ and prunes git metadata.
# Safe to run multiple times — skips already-removed worktrees.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
SPRINT_BASE="$(dirname "$REPO_ROOT")/.sprint"
SPRINT=${1:?Usage: sprint-teardown.sh <sprint-number>}
SPRINT_DIR="$SPRINT_BASE/sprint-${SPRINT}"
if [ ! -d "$SPRINT_DIR" ]; then
echo "No worktrees found for sprint-${SPRINT} (directory $SPRINT_DIR does not exist)."
exit 0
fi
echo "Cleaning sprint-${SPRINT} worktrees..."
for wt in "$SPRINT_DIR"/*/; do
[ -d "$wt" ] || continue
team="$(basename "$wt")"
echo " Removing: $team"
git -C "$REPO_ROOT" worktree remove "$wt" --force 2>/dev/null || echo " (already removed or dirty)"
done
rmdir "$SPRINT_DIR" 2>/dev/null || true
git -C "$REPO_ROOT" worktree prune
echo "Done. Sprint-${SPRINT} worktrees cleaned."
@@ -1,65 +0,0 @@
#!/bin/bash
# Opens Ptyxis tabs for the active sprint teams in the CURRENT window.
# Auto-starts Claude Code in each tab via tmux.
#
# Usage: start-sprint.sh [sprint-number]
# If omitted, auto-detects the active sprint from the database.
#
# Assumes the caller is already on main in the current terminal.
# Adds one tab per active team — no duplicate main tab.
#
# Worktrees are created under .sprint/ (ephemeral, cleaned after sprint close).
# Can be called from any directory — resolves paths from the script location.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Script lives at .claude/skills/sprint-start/scripts/ — repo root is 4 levels up
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
# Parent of repo root is where .sprint/ and the DB live
PARENT="$(dirname "$REPO_ROOT")"
# Resolve sprint number — argument or active sprint from DB
if [ -n "${1:-}" ]; then
SPRINT="$1"
else
SPRINT=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT id FROM sprints WHERE status='active'" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['rows'][0]['id'])" 2>/dev/null || true)
if [ -z "$SPRINT" ]; then
echo "error: no active sprint found. Pass a sprint number or activate a sprint first." >&2
exit 1
fi
fi
# Query teams with open tickets
TEAMS=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT DISTINCT team FROM tickets WHERE sprint_id=$SPRINT AND status NOT IN ('done','cancelled') AND team IS NOT NULL" 2>/dev/null | python3 -c "import sys,json; [print(r['team']) for r in json.load(sys.stdin)['rows'] if r['team']]" 2>/dev/null || true)
if [ -z "$TEAMS" ]; then
echo "Sprint $SPRINT has no open tickets. Opening main only."
fi
echo "Sprint $SPRINT — teams: ${TEAMS:-none}"
# ── Open team tabs in the CURRENT window ────────────────────────────
# No separate main tab — the caller is already on main.
# All team tabs open as --tab in the active Ptyxis window.
for team in $TEAMS; do
BRANCH="sprint-${SPRINT}/${team}"
WDIR="$PARENT/.sprint/sprint-${SPRINT}/${team}"
# Create worktree if it doesn't exist
if [ ! -d "$WDIR" ]; then
echo "Creating worktree: $WDIR (branch: $BRANCH)"
mkdir -p "$(dirname "$WDIR")"
# Create branch from main if it doesn't exist remotely
if git -C "$REPO_ROOT" rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
git -C "$REPO_ROOT" worktree add "$WDIR" "$BRANCH"
else
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WDIR" HEAD
fi
fi
ptyxis --tab -d "$WDIR" -x 'tmux new-session \; send-keys "claude /sprint-start" Enter'
sleep 0.3
done
echo "Session ready. Main + ${TEAMS:-(no teams)}"
-94
View File
@@ -1,94 +0,0 @@
---
name: sprint-status
description: >
Sprint health check and cleanup sweep. Lists all tickets in the active
sprint grouped by status, detects bookkeeping issues (stale tickets,
orphan PRs, done-but-open PRs, unassigned work), and shows open work
by team. Use when checking sprint progress, before sprint close, or
when housekeeping feels off. Triggers on "sprint status", "cleanup
sweep", "what's open", "sprint health".
user-invocable: true
allowed-tools: Task, Read, Grep, Glob
---
# Sprint Status
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
When this skill is invoked, spawn a subagent using the Task tool:
```
Task(
subagent_type: "general-purpose",
model: "haiku",
prompt: "Run /sprint-status. Read the skill at
.claude/skills/sprint-status/SKILL.md for the full workflow
(below the --- separator), then execute it.",
description: "Sprint status report"
)
```
Present the subagent's output to the user verbatim. Do NOT run the
workflow yourself.
---
The remainder of this file is the subagent's reference for executing
the workflow.
## Step 1 — Gather data
Run these two commands in parallel:
```bash
tooling/db/sprint sweep
```
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
The `sweep` command returns JSON with:
- `sprint` — id, name, goal
- `progress` — total, done, pct
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
- `by_team` — per-team counts
- `issues` — bookkeeping problems with suggested fix commands
The `tea pr list` returns open PRs as `#N title` lines.
## Step 2 — Cross-reference PRs with tickets
Parse PR head branches from the `tea pr list` output. Known team branches:
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
Detect additional issues:
- **done_team_open_pr**: A team's tickets are all done but an open PR
still exists for that team branch.
- **orphan_pr**: An open PR exists on a branch that has no tickets in
the active sprint.
Add these to the issues list from step 1.
## Step 3 — Format output
Read `references/output-template.md` for the exact format spec.
Render the report using data from steps 1-2. Key rules:
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
- Sort tickets within sections by team then ticket ID
- Empty sections: show header with "(0)" and "(none)" — no empty table
- Bookkeeping Issues: two-column table (Issue, Fix)
- Open Work by Team: summary table at the bottom
- Issue type labels: `stale_backlog` → "Stale backlog",
`unassigned_in_progress` → "Unassigned in_progress",
`assigned_but_done` → "Assigned but done",
`done_team_open_pr` → "Done team with open PR",
`orphan_pr` → "Orphan PR"
## Step 4 — Suggest actions
After the formatted report, if there are bookkeeping issues, add a
"Suggested fixes" section with the fix command for each issue. Group
by issue type for readability.
@@ -1,54 +0,0 @@
# Sprint Status Output Template
## Sprint {N}: {Theme} — Status Report
**Goal:** {goal}
**Status:** {status} | {done}/{total} tickets ({pct}%)
**Open PRs:** {count} ({branches})
---
### Completed ({count})
| # | Team | Title | Assigned |
|---|------|-------|----------|
| #{id} | {team} | {title} | {assigned} |
### In Review ({count})
| # | Team | Title | PR |
|---|------|-------|----|
| #{id} | {team} | {title} | #{pr} |
### In Progress ({count})
| # | Team | Title | Assigned | Note |
|---|------|-------|----------|------|
| #{id} | {team} | {title} | {assigned} | |
### Blocked ({count})
| # | Team | Title | Blocked by |
|---|------|-------|------------|
| #{id} | {team} | {title} | #{ids} |
### Backlog ({count})
| # | Team | Title | Note |
|---|------|-------|----|
| #{id} | {team} | {title} | not started |
---
### Bookkeeping Issues
| Issue | Fix |
|-------|-----|
| {type}: {detail} | `{command}` |
### Open Work by Team
| Team | Backlog | In Progress | Review | Blocked | Done |
|------|---------|-------------|--------|---------|------|
| {team} | {n} | {n} | {n} | {n} | {n} |
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
+29 -20
View File
@@ -1,9 +1,9 @@
---
name: ticket
description: >
Manage project tickets in the SQLite ticketing database. Use when the user
says "ticket", "create a ticket", "show tickets", "sprint", or invokes /ticket.
Wraps the ticket CLI for structured project management operations.
Manage project tickets and milestones in the SQLite ticketing database. Use
when the user says "ticket", "create a ticket", "show tickets", "milestone",
or invokes /ticket. Wraps the ticket CLI for structured project management.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob
---
@@ -17,12 +17,12 @@ section. This skill covers the full command reference.
### List tickets (full flags)
```bash
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--milestone N] [--assigned A] [--team T]
```
### Create ticket
```bash
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
```
Types: `initiative`, `epic`, `story`, `task`, `bug`
Priorities: `critical`, `high`, `medium`, `low`
@@ -46,18 +46,27 @@ tooling/db/ticket team <id> <teams>
```
Teams are comma-separated, e.g. `server`, `client`, `server,client`.
### Sprint management
```bash
tooling/db/ticket sprint [--active]
tooling/db/ticket sprint assign <id> <sprint_id>
```
For sprint-scoped operations (status overview, context dumps, lifecycle),
use the dedicated sprint CLI instead: `tooling/db/sprint --help`
### Dependencies
```bash
tooling/db/ticket deps <id>
tooling/db/ticket dep add <blocker_id> <blocked_id>
tooling/db/ticket dep rm <blocker_id> <blocked_id>
```
### WIP
```bash
tooling/db/ticket wip
```
### Milestones
```bash
tooling/db/ticket milestone list [--status S]
tooling/db/ticket milestone create <name> [--description TEXT] [--phase N]
tooling/db/ticket milestone link <ticket_id> <milestone_id>
tooling/db/ticket milestone unlink <ticket_id> <milestone_id>
tooling/db/ticket milestone complete <milestone_id>
tooling/db/ticket milestone show <milestone_id>
tooling/db/ticket milestone dep <blocker_id> <blocked_id>
```
### Search and browse
@@ -75,10 +84,10 @@ tooling/db/ticket show --brief <id> [<id>...]
## Workflow
1. **SI (Project Manager)** is the primary user of this skill
2. Decisions from decisions/ domain files become **initiatives**
3. SI breaks initiatives into **epics** (major work areas)
4. Epics break into **stories** (user-facing deliverables)
5. Stories break into **tasks** (concrete work items assignable to agents)
6. **Sprints** group tasks into time-boxed work periods
1. Decisions from `decisions/` domain files become **initiatives**
2. Initiatives break into **epics** (major work areas)
3. Epics break into **stories** (user-facing deliverables)
4. Stories break into **tasks** (concrete work items)
5. **Milestones** group tickets by deliverable (many-to-many via `milestone link`)
6. `/whats-next` selects the next batch from the dependency graph
7. Track history via `ticket_history` table for audit trail
+211
View File
@@ -0,0 +1,211 @@
---
name: whats-next
description: >
Analyze the milestone dependency web and surface the best batch of tickets
to pick up next. Refines ticket context via parallel Si agents before
activation. Use when the user says "what's next", "next batch", "pick up
work", or invokes /whats-next. NOT triggered by "what should we work on"
in a design context — that's a discussion, not a batch selection.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Agent, AskUserQuestion
---
# What's Next
Dependency-driven batch selection with ticket refinement. Three steps:
batch selection → refinement review → batch activation.
## Step 1: Batch Selection
### 1a. Query active milestones
```bash
tooling/db/ticket milestone list --status active
```
If no active milestones exist, tell the user and stop.
### 1b. Build the work landscape
For each active milestone, get its tickets:
```bash
tooling/db/ticket milestone show <milestone_id>
```
Collect all tickets across all active milestones. Deduplicate (a ticket
can appear in multiple milestones).
### 1c. Identify unblocked tickets
For each ticket that is `ready` or `backlog`, check its dependencies:
```bash
tooling/db/ticket deps <id>
```
A ticket is **unblocked** if all its blockers are `done` or `cancelled`.
Filter to only unblocked tickets.
### 1d. Check milestone-level blocking
```bash
tooling/db/sqlite-query "SELECT * FROM milestone_deps"
```
If a milestone is blocked by another milestone that is not yet `completed`,
deprioritize its tickets (they're unblocked at the ticket level but the
milestone isn't ready for focus yet). Still show them, but ranked lower.
### 1e. Rank and group
Rank unblocked tickets by:
1. **Priority** (critical > high > medium > low)
2. **Milestone proximity** — milestones closest to completion (highest
done/total ratio) get priority. Finishing a milestone unlocks downstream
milestone deps.
3. **Fan-out** — tickets that unblock the most downstream tickets rank
higher (query `ticket_deps` for blocked_id counts per blocker_id)
Group into **epic-sized batches**: tickets sharing the same `parent_id`
(epic), or logically related tickets if no epic parent. If a natural
grouping doesn't exist, batch by milestone.
### 1f. Show WIP status
```bash
tooling/db/ticket wip
```
### 1g. Present the recommended batch
Show the user:
- The recommended batch (tickets with IDs, titles, priorities, milestones)
- Why this batch (which milestone it advances, what it unblocks)
- Current WIP status
- Any alternative batches worth considering
Wait for user confirmation before proceeding to Step 2.
---
## Step 2: Ticket Refinement Review
Spawn one Si agent per ticket in the confirmed batch, running in parallel.
Use `general-purpose` subagent_type with Si's personality baked into the
prompt. (The `si` subagent_type now works directly too — all agent
definitions carry the team coordination tools, SendMessage + Task, as of the
agent-teams tooling fix; they load at lead startup.)
For each ticket, spawn:
```
Agent({
subagent_type: "general-purpose",
model: "sonnet",
prompt: "You are SI, the Refinement Manager. <include si.md personality>
Review ticket #<id>: <title>
Description: <description>
Decision ref: <decision_ref>
Your job:
1. Read the ticket's decision_ref D/Q-record in decisions/*.md
2. Grep for related Q-records in decisions/questions-*.md
3. Read workshop outcomes if referenced (check docs/workshops/)
4. Check whether referenced code, tables, or files actually exist
Assess: does this ticket have enough context for an agent to implement
without guessing? Report exactly one of:
- READY: <one-paragraph summary of what the implementer needs to know>
- GAPS: <list of specific ambiguities with options for each>"
})
```
### 2a. Collect reports
Wait for all Si agents to complete. Collect their reports.
### 2b. Resolve gaps
For any ticket that came back GAPS, surface each ambiguity to the user
via AskUserQuestion:
> Ticket #NNN: <title>
> Si found these gaps:
> 1. <gap description + options>
> 2. <gap description + options>
>
> How should we resolve these?
After the user responds, append the resolution to the ticket description:
```bash
tooling/db/sqlite-exec "UPDATE tickets SET description = description || char(10) || char(10) || '---' || char(10) || 'Refinement: <resolution>' WHERE id = <id>"
```
If the user says a gap requires a D-record, flag it and ask whether to
write the D-record now or defer. If deferred, add a note to the ticket.
### 2c. Present refined batch summary
Show each ticket with:
- Si's context summary (for READY tickets) or user's resolution (for GAPS)
- Relevant D-record references
- Dependencies
Ask: "Batch ready. Activate?"
---
## Step 3: Batch Activation
### 3a. Mark tickets in_progress
```bash
tooling/db/ticket status <id> in_progress
```
For each ticket in the batch. The `status` command will warn if WIP limit
is exceeded.
### 3b. Create topic branch
Name the branch after the epic or logical grouping. Examples:
- `tier0-schema-tables` (for a schema epic)
- `heightmap-import` (for a single focused ticket)
- `city-names-pipeline` (for related importer tickets)
```bash
git checkout -b <branch-name>
```
### 3c. Create worktree (optional)
If the user wants to work in a separate worktree:
```bash
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_DIR="$(dirname "$REPO_ROOT")/.worktrees/<branch-name>"
git worktree add "$WORKTREE_DIR" <branch-name>
```
Report the worktree path so the user can open a terminal there.
### 3d. Spawn implementation agents (optional)
If the user wants agents, spawn them using general-purpose subagent_type
with the relevant personality baked into the prompt. Include:
- The ticket details and Si's context summary
- Relevant D-record content (read and include, don't just reference)
- The RULES block (no git commands, write files only, message when blocked)
- Model: sonnet (per team-mode pin rule)
### 3e. Report
Summarize what was activated:
- Branch name
- Worktree path (if created)
- Tickets now in_progress
- Agents spawned (if any)
- Next step: work, then `/pr-process` when ready to ship
+35 -2
View File
@@ -87,10 +87,12 @@ elif command -v cargo >/dev/null 2>&1 && [ -d "$REPO_ROOT/server" ]; then
echo "pre-push: fmt — OK"
fi
# clippy needs a build — skip if target/ doesn't exist (cold worktree)
# clippy needs a build — skip if target/ doesn't exist (cold worktree).
# --all-targets lints tests + examples too (#967 closed the gap where test
# code accumulated clippy debt unchecked).
if [ -d "$REPO_ROOT/server/target" ]; then
echo "pre-push: checking Rust (clippy)..."
if ! (cd "$REPO_ROOT/server" && cargo clippy -- -D warnings 2>&1); then
if ! (cd "$REPO_ROOT/server" && cargo clippy --all-targets -- -D warnings 2>&1); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: clippy — OK"
@@ -190,6 +192,37 @@ else
echo "pre-push: systems.db not in push — skipping stamp check"
fi
# --- Clerk review (D-221) ---
# DISABLED 2026-05-23 (#965) pending rework. Two problems made it net-negative:
# 1. Non-exhaustive — a single run reports ~the first contradiction it finds
# and stops, so distinct real issues surfaced only on the 2nd/3rd/4th
# re-push. An APPROVED verdict therefore can't be trusted as "clean".
# 2. Re-reviews the entire origin/main..HEAD range on every push (token burn);
# no per-commit verdict cache.
# Off by default until reworked (exhaustive enumeration + holistic decisions/
# diff check + a SHA-keyed verdict cache invalidated on decisions/ change, with
# skip tracked in the cache rather than a commit-message trailer — see #965).
# Force-run with SR_RUN_CLERK=1.
if [ -x "$REPO_ROOT/tooling/clerk-review" ] && [ "${SR_RUN_CLERK:-0}" = "1" ]; then
echo "pre-push: running clerk review..."
CLERK_VERDICT=$("$REPO_ROOT/tooling/clerk-review" 2>&1 | tee /dev/stderr | tail -1)
if [ "$CLERK_VERDICT" = "APPROVED" ]; then
echo "pre-push: clerk — APPROVED"
elif [ "$CLERK_VERDICT" = "REJECTED" ]; then
echo "pre-push: clerk — REJECTED (see .cache/pre-push-review.md)"
ERRORS=$((ERRORS + 1))
elif [ "$CLERK_VERDICT" = "INCOMPLETE" ]; then
echo "pre-push: clerk — INCOMPLETE (some reviews didn't finish; NOT blocking)"
echo " See .cache/pre-push-review.md. For a full verdict: raise SR_CLERK_MAX_TURNS / SR_CLERK_TIMEOUT,"
echo " or add a 'Clerk-Skip:' trailer to bulk-content commits."
else
echo "pre-push: clerk — '$CLERK_VERDICT' unrecognized; treating as block (see .cache/pre-push-review.md)"
ERRORS=$((ERRORS + 1))
fi
else
echo "pre-push: clerk review — DISABLED (#965; force-run with SR_RUN_CLERK=1)"
fi
if [ "$ERRORS" -gt 0 ]; then
echo ""
echo "pre-push: $ERRORS check(s) failed. Push aborted."
+62
View File
@@ -6,6 +6,68 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- **Kanban + milestones workflow** (D-221) — replaces sprint-based workflow with dependency-driven batch selection, many-to-many milestones, `/whats-next` skill with Si refinement review, `/pr-process` skill (author-side review comment pickup), clerk pre-push hook for D-record consistency
- **Atlas orbital redesign** — horizontal orrery layout with half-sun on left, globe textures loaded from wiki, radius-based body sizing (Earth = 24px reference, unclamped), partial orbital arcs
- **Globe texture coverage** — 100% of 3240 bodies now have globe.png; generated oort cloud + asteroid belt generics via Gemini, donor-filled 40 missing bodies from same-class pool
- **Body radius scatter** — deterministic ±scatter on `body_radius_km` seeded by body_id; gas giants 40k-60k km, moons 200-2600 km, Sol system uses real radii; exported to `star_map_data.json`
- **Atlas interaction** — double-click system on reach map opens orbital view, double-click body opens regional heightmap, M closes atlas from any screen, ESC navigates back through screen stack
- **Inline body detail panel** — clicking a body in the orbital view shows an info panel on the right with full-size globe artwork, body stats, and population; clicking another body swaps the panel
- **Regional atlas wiki loading** — heightmaps and markers load directly from the wiki filesystem, fixing "TERRAIN DATA PENDING" for all bodies with authored heightmaps
- **Agent-team coordination tools** — `SendMessage` plus `TaskList`/`TaskUpdate`/`TaskGet` added to all 21 agent definitions so custom subagents work as full teammates in tmux split panes (message the lead, claim/complete tasks, shut down cleanly); `TaskCreate` deliberately withheld to keep task creation with the lead
- **`git-centralize-guard` hook** — PreToolUse hook blocking `.git`-mutating commands for agent-team teammates, keying on the `agent_type` field present only in teammate hook input; version control stays centralized to the team lead
- **Gemma naming methodology doc** (`docs/gemma-naming-methodology.md`) — preserves the corridor-aware LLM place-naming approach (sector palettes, few-shot prompting, dedup, Earth-major blocklist, deterministic fallback) as institutional knowledge after the naming pipeline's retirement
- **Layer-1 world topography cascade** (#953, D-208/209/210) — deterministic empty-world generation in Rust: D8 priority-flood drainage (rivers/basins, ~45 ms/body after optimization), 7 geographic settlement-attractor tags, and sub-biome + terrain-modification-cost classification. The foundation the settlement-placement layer builds on
- **Canonical 16-bit heightmaps** (#963, D-202) — every body's elevation is now a per-body 16-bit grayscale `heightmap.png` (1024×512, `sea_level` in a tEXt chunk); the old color render is renamed `reliefmap.png`. Single source of truth the generation cascade reads
- **Deterministic generation cascade** (#952, D-224/D-200) — `SeedChain`, a single splitmix64-based seed-derivation contract descending from the master world seed with domain separation (replaces ad-hoc seed mixing); an extensible per-layer cascade harness (`run_cascade`); and a golden-seed regression test pinning Layer 0→1 output (source heightmap SHA-256 + serialized topography) so any determinism drift is caught
### Changed
- `teammateMode` set to `auto` — teammates spawn in tmux split panes when the lead runs inside tmux, with graceful in-process fallback
- **Planet simulation native resolution → 1024×512** (#963) — finer base terrain for the lower cascade layers (deterministic; same continents, more detail). Rivers are now computed by the cascade's drainage rather than painted by the sim, so they actually reach the sea
- **markers.json → names-only flavored pool** (D-223, #951) — all 2,398 bodies carry place *names* only, no positions or geometry; river/mountain positions derive from the heightmap + drainage, settlement positions from the economic sim + placement, with pooled names attached at generation time. The six hand-authored "templates" dissolve into ordinary named bodies
- **`import_economics` owns the atlas index** (#951) — now the sole `regen-db` generator that touches the atlas tables; loads the names pool into `atlas_city_names` and empties the geometry tables, which the Phase 4 server cascade fills
- **Rust dependency maintenance** (#966) — `rand` → 0.9.4 (clears the RUSTSEC-2026-0097 unsound advisory), a ~90-package semver-compatible freshness sweep (bevy 0.18.1, clap 4.6, others), and the deprecated `serde_yaml` replaced with the maintained `serde_norway` drop-in fork. No CVEs outstanding
- **Pre-push clippy now lints tests + examples** (#967) — added `--all-targets`, closing the gap that let test-code lint debt accumulate unchecked; the existing cfg(test)/test-target clippy-1.93 debt was cleared in the same pass
### Fixed
- Godot 4.6 type inference parse errors in `main.gd` (`var zoom`, `var speed`) that prevented the main scene from loading
- `physical_keycode` used throughout for layout-independent key handling (was `keycode`, broke on non-US layouts)
### Removed
- Sprint CLI (`tooling/db/sprint`), sprint skills (`/sprint-start`, `/sprint-plan`, `/sprint-status`), team-scoped file restrictions, sprint briefing workflow
- **Atlas geometry generator + LLM naming pipeline** (#951, D-223) — `generate_atlas.py`, `gemma_naming.py`, `naming_core.py`, `apply_name_fixes.py`, the redundant `import_city_names.py`, their tests/QA/runner scripts, and the `fix_fewshot_bleed`/`prune_atlas_features` geometry tools. Procedural placement and authored names are superseded by the deterministic server cascade and the frozen name pool
- **`atlas_body_heightmaps` DB table** (#963, D-202) — elevation moved out of `systems.db` into per-body PNG files (avoids binary merge conflicts and DB bloat); the Rust cascade loads the file
## [v0.2.0] — 2026-05-03
*Process milestone: final sprint-based release. Development moves to kanban + milestones (Q-096).*
## [v0.1.38] — 2026-05-03
### Added
- **Generation cascade D-records** (D-194–D-218) — 25 decisions formalizing the full pipeline from planetary heightmap to walkable tile: WorldTier taxonomy, settlement classification, city generation context, drainage routing, attractor matching, district mix, block irregularity, tile conditions
- **Atlas data pipeline** (#901–#911) — new `atlas_body_heightmaps`, `atlas_city_names`, `atlas_feature_names`, `atlas_province_boundaries` tables; `body_radius_km` column; three new importers (heightmaps, city names, province boundaries via D8 watershed); `economic_role` normalized to 7 canonical values
- **Phase 1 generation pipeline** (#916–#924) — 10-module `server/src/atlas/` package: heightmap BLOB loader, BodyWorldState LRU cache, D8 drainage routing, background generation queue with Rayon pool, five-phase attractor matching, three-component district mix, block irregularity, tile condition thresholds
- **District skeleton generator** (#899) — `generate_skeleton()` wires the full atlas pipeline to produce filled `DistrictSkeleton` instances from city markers + planet data. Phase 1 scope: SettingType/ComplexityTier derivation, layout mode assignment, 4×4 block grid with zoning, multi-block reservations
- **SystemNameIndex** (#926) — Aho-Corasick text scanner over body/station/system names for background pre-generation queue integration (D-206)
- **Free camera viewer** (#898) — F4 toggles decoupled camera with WASD pan + scroll zoom; input suppressed in free-camera mode; implant UI remains accessible
- **Fog behavioral tests** (#879) — 11 new tests covering EXP_EXPLORED persistence, grow-only bounds, texture-resize copy, BoundaryWall handling
- **Province boundary rendering** (#927) — drainage basin polylines exported to markers.json and rendered on the planetary map under the political_zones overlay
- **Stamp expansion** (#892) — `gemma_naming.py` and `naming_core.py` added to `check-systems-db-stamp` source tracking and `/pr-push` watch list
- **`make decisions-orphan-tickets`** (#887) — new CLI subcommand (`tooling/db/decision orphan-tickets`) that scans tickets with a `decision_ref` not matching any decision in the DB, surfacing silently orphaned tickets from typo'd or renumbered D-IDs
### Changed
- **`meta.schema_version` switched to monotonic semver** (#888) — replaces SHA-1 hash with an orderable semver string (`"1.0.0"`); old SHA preserved in new `schema_sha` column for tamper detection; `check-systems-db-stamp` now rejects legacy SHA-hex values
- **Archetype strip** (#882) — removed `character_archetype`, `lattice_profile`, lattice color palettes, and all related test assertions from client
- **Corporation wiki review** (#884) — 19 corporation pages corrected: 6 hop-count fixes, topology label corrections, Rush Mining and Scapa Flow narratives rewritten for star-map accuracy, tag reordering, stub-to-prose rewrites
### Fixed
- **Bevy baseline test panics** (#885) — `SnapshotBuffer` Option-wrapped in economy.rs, `TickPhase::configure` added to SimulationPlugin, stale golden file regenerated. All 6 previously-failing tests pass
- **Suffix monotony auto-fix** (#886) — `gemma_naming.py` re-queries affected bodies when >40% suffix clustering detected; cultural-history context threaded into naming prompts
- **Client parse-order violations** — sim_bridge, protocol, input_mapper, audio_manager, main_menu all fixed to follow autoload pattern (untyped fields + runtime `load()`)
- **Confrontation monologue signal** (#867) — tween validity guard ensures signal fires in headless test mode
- **Pre-existing test failures** (#871) — 7 tests fixed inline (examine_display dismiss timing, fog position fragility, rendering snapshot assertions, time display format)
## [v0.1.37] — 2026-04-22
### Added
+16 -16
View File
@@ -4,7 +4,7 @@ A top-down life-sim — asymmetric information, occlusion-based perception, sing
**Official Title:** The Settled Reach
**Repository name:** settled-reach
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`)
## Project Structure
@@ -13,7 +13,7 @@ client/ # Godot 4 client
server/ # Rust/bevy_ecs simulation server
tooling/ # Build tools, scripts, asset pipelines
tests/ # Integration and end-to-end tests
docs/ # Architecture, design, briefings, sprints, workshops
docs/ # Architecture, design, briefings, workshops
db/ # Schema + seed data (connectors moved to tooling/db/)
.claude/ # Agents, skills, rules
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
@@ -40,16 +40,18 @@ Development follows a strict cascade. Each phase has a concrete deliverable. **D
| 1 | Wiki content complete — all planets, moons, stations, heightmaps, artwork | Implant-ready Godot map of the Reach with click-throughs + wiki/GTTR popups |
| 2 | Economics layer — supply/demand, transport, political/social pressure, corporations, supply chains | Economics spreadsheets/graphs with runtime-tweakable simulation |
| 3 | Planetary/moon maps & station layouts — cities, rivers, mountains, roads, biomes, rail | Atlas of the Reach (implant app) |
| 4 | Player control scheme — 2-floor test map, character rendering, walls/stairs/doors, lighting | Player viewport with final-version assets |
| 5 | World generation (tile/chunk/block) — walkable world, parallel asset pipeline | Walkable generated world + asset catalog |
| 4 | World generation (tile/chunk/block) — deterministic multilayer seed→tile cascade; each layer viewed as a **map in the implant Atlas** (no in-world rendering) | Deterministic walkable-world data + per-layer Atlas maps + asset catalog |
| 5 | Player control & in-world rendering — character, walls/stairs/doors, lighting, drawn on **generated** tiles (no test map) | Player viewport with final-version assets on the generated world |
| 6 | Detail coloring — room-level NPC population, cultural room grammar | Only when the world is walkable |
**v0.2 target is dropped.** No scoping negotiations. Build the base systems fully.
**Generation before player (D-166, amended 2026-05-22).** No player-control or in-world rendering work begins until the generator can deterministically seed-generate every tile of every world via the full multilayer cascade. There is **no hand-made test map** — test layers are produced by the generator itself once layer-drawing begins. Progress is viewed through the implant **Atlas** (per-layer maps), which already exists (Phase 3). The current in-world rendering code is **left as-is until Phase 5** — do not build on it or rip it out before then.
## Work Modes
### Sprint mode (default)
Agents work autonomously on sprint branches. Team lead coordinates via tasks and messages. Human reviews PRs from main. Standard `/sprint-start` → `/pr-push` → `/pr-review` lifecycle.
### Kanban mode (default)
Work flows continuously through milestones. `/whats-next` selects the next epic-sized batch from the dependency graph, refines ticket context via parallel Si agents, then activates a worktree. `/pr-process` (from worktree) and `/pr-review` (from main) handle the review cycle through Gitea PR comments. Milestones are many-to-many with tickets, can run in parallel, and can block other milestones.
### Pair session
Human and Claude work together interactively on a single task. No background agents, no autonomous work. Used for load-bearing architecture changes where the human needs to make judgment calls as the work progresses — not approve a finished result.
@@ -65,13 +67,10 @@ Human and Claude work together interactively on a single task. No background age
## Agent Instructions
### Team boundaries
**Your team is determined by your sprint branch** (e.g. `sprint-31/server` → server team).
### Agent scope
- All file paths are relative to the repo root (e.g. `server/src/bridge/types.rs`).
- **Stay within your team's scope.** Server team modifies `server/`. Client team modifies `client/`. Copy team modifies `wiki/`, `docs/atlas/`, `content/`. Shared directories (`docs/`, `decisions/`) are readable by all teams.
- **Do NOT modify files outside your team scope** unless the ticket explicitly requires it.
- Topic branches per logical work unit (e.g. `heightmap-import`, `city-names-table`). Any agent can touch any file — personalities guide perspective, not file access.
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). Always run `git add` and `git commit` as **separate sequential Bash calls**.
- **Stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file at `.git/index.lock` (or `.git/worktrees/<name>/index.lock` if in a worktree).
@@ -80,10 +79,11 @@ Human and Claude work together interactively on a single task. No background age
The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var (set in `.claude/settings.json`). A backup is committed to `docs/backups/settledreach.db.backup` via main only.
### Before starting work
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
2. Use `tooling/db/ticket show <id>` for full ticket details
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
1. Check active work: `tooling/db/ticket list --status in_progress`
2. Check your ticket: `tooling/db/ticket show <id>`
3. Check dependencies: `tooling/db/ticket deps <id>`
4. Read relevant `decisions/*.md` domain file(s) referenced in the ticket
5. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
### CLI tools
@@ -92,7 +92,7 @@ The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var
| Tool | Command | Full reference |
|------|---------|----------------|
| Tickets | `tooling/db/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
| Sprints | `tooling/db/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
| Milestones | `tooling/db/ticket milestone list`, `create`, `link`, `show` | `/ticket` skill |
| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — |
| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — |
| Decisions | `tooling/db/decision show`, `next`, `claim`, `check-dupes` | — |
+13 -26
View File
@@ -1,9 +1,9 @@
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
decisions-sync decisions-coverage decisions-active decisions-orphan decisions-orphan-tickets \
db-backup db-install validate-content check-fact-ids setup-hooks install-hooks \
audit deny atlas-verify economy-db atlas-generate regen-db check-systems-db \
audit deny atlas-verify economy-db regen-db check-systems-db \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client fixtures-gauntlet golden-diff golden-update \
@@ -48,7 +48,8 @@ help:
@echo " make decisions-sync Sync decisions/*.md into SQLite"
@echo " make decisions-coverage Each decision with its implementing ticket(s)"
@echo " make decisions-active List active decisions"
@echo " make decisions-orphan Decisions without implementing tickets"
@echo " make decisions-orphan Decisions without implementing tickets"
@echo " make decisions-orphan-tickets Tickets with invalid or missing decision_ref"
@echo " make audit Run cargo audit (security advisory check)"
@echo " make deny Run cargo deny check (license/ban policy)"
@echo " make validate-content Validate content YAML against schemas"
@@ -57,11 +58,9 @@ help:
@echo " make star-map-data Regenerate client/data/star_map_data.json from systems.db + wiki"
@echo " make check-star-map Assert star_map_data.json is up to date (part of pre-pr-client)"
@echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)"
@echo " make atlas-generate Generate atlas city/road/rail markers for all inhabited bodies"
@echo " make regen-db Regenerate systems.db from all sources + stamp meta table (#855)"
@echo " make check-systems-db Verify systems.db meta stamp matches current generator sources"
@echo " make install-hooks Install pre-push + pre-commit git hooks (once per clone)"
@echo " make test-atlas-determinism Determinism smoke test for generate_atlas.py (#847)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@echo " make golden-update Regenerate golden file and stage for commit"
@@ -210,8 +209,9 @@ golden-diff:
fi
golden-update:
@echo "Regenerating golden file..."
@echo "Regenerating golden files..."
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
@cd server && UPDATE_GOLDEN=1 cargo test --test cascade_golden
@git add server/tests/golden/
@echo "--- Golden file updated and staged ---"
@echo "Review with: git diff --cached -- server/tests/golden/"
@@ -231,9 +231,6 @@ test-ipc-integration:
test-ipc-benchmark:
tests/run-ipc-benchmark
test-atlas-determinism: ## Determinism smoke test for generate_atlas.py (#847)
tests/run-atlas-determinism
# --- Clean ---
clean-imports:
@@ -344,20 +341,6 @@ economy-db: ## Import economics data (commodities, chains, gate links) into sys
@tooling/generate-brands
@python3 tooling/economy-db/import_economics.py
atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabited bodies (#832)
@# Loud guard: generate_atlas.py reads bodies with a non-NULL terrain_reference.
@# If populate_terrain_reference.py has not run on a fresh DB, the generator
@# silently processes zero bodies and exits 0 — fail fast instead.
@count=$$(python3 -c "import sqlite3; c = sqlite3.connect('server/data/systems.db'); print(c.execute('SELECT COUNT(*) FROM bodies WHERE terrain_reference IS NOT NULL').fetchone()[0])"); \
if [ "$$count" = "0" ]; then \
echo "ERROR: no bodies have terrain_reference populated yet."; \
echo "Run: python3 tooling/planet-gen/populate_terrain_reference.py"; \
echo "(This is a prerequisite for atlas-generate — see D-191 §9 pipeline order.)"; \
exit 1; \
fi; \
echo " [guard] $$count bodies with terrain_reference — proceeding."
@python3 tooling/planet-gen/generate_atlas.py --seed 42
regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855, #856)
@# Run as a single shell so `set -e` covers all steps. Without this
@# each recipe line was a fresh shell and a failure in step 1 did not
@@ -365,13 +348,14 @@ regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855,
@# (PR #136 review T4). import_economics' exit code 2 is a valid
@# coverage-gate-warning state (DB and stamp committed), not an error,
@# so it's explicitly tolerated. Any other non-zero exit halts the
@# pipeline immediately.
@# pipeline immediately. The atlas city/road/river geometry generator
@# was retired in #951 (D-223); import_economics now owns the atlas
@# index — it loads the names-only pool into atlas_city_names and empties
@# the geometry tables (the server cascade fills them, Phase 4).
@set -e; \
echo " [regen-db] Importing economics data (runs generate_brands internally)..."; \
ec=0; python3 tooling/economy-db/import_economics.py || ec=$$?; \
if [ $$ec -ne 0 ] && [ $$ec -ne 2 ]; then exit $$ec; fi; \
echo " [regen-db] Running atlas generator..."; \
python3 tooling/planet-gen/generate_atlas.py --seed 42; \
echo ""; \
echo " regen-db complete — systems.db is up to date and stamped."; \
echo " Stage it with: git add server/data/systems.db"
@@ -404,6 +388,9 @@ decisions-active:
decisions-orphan:
@tooling/db/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
decisions-orphan-tickets:
@tooling/db/decision orphan-tickets
# --- Content Validation ---
validate-content:
File diff suppressed because it is too large Load Diff
+5
View File
@@ -124,6 +124,11 @@ stance_down={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
]
}
free_camera={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
bug_report={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+2 -1
View File
@@ -231,7 +231,8 @@ func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
if asset_key.is_empty():
return
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
var C := load("res://scripts/constants.gd")
play_at(asset_key, world_tile_pos * C.TILE_SIZE)
# --- Playback: spatial (D-018 close-range) ---
+2 -9
View File
@@ -41,11 +41,6 @@ var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs
# v5 fields (#414)
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
# Server sends this field as part of the player's capability snapshot.
var lattice_profile: String = "lattice_baseline"
# v6 fields (#449, D-053, D-065)
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
var player_inventory: Array = [] # [{item_id, name, slot}]
@@ -94,10 +89,8 @@ var debug_response: Variant = null
# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load.
var pending_load_path: String = ""
# #588: Character archetype chosen at character select screen.
# "detective" or "smuggler". Set before game scene loads; sent in StartupMessage.
# Default: "detective" — fallback for legacy saves without character.txt.
var character_archetype: String = "detective"
# #898: Free camera mode — camera decoupled from player, WASD pans camera directly.
var free_camera_mode: bool = false
# #705: Character visual descriptor — set by character_creation.gd on confirmation.
# Passed to EntityRenderer for the player entity's CharacterVisual on game start.
+8 -5
View File
@@ -68,7 +68,7 @@ func _process(_delta: float) -> void:
# D-054: Update facing angle from mouse position every frame
_update_facing_from_mouse()
if GameState.dialogue_active:
if GameState.dialogue_active or GameState.free_camera_mode:
return
# D-054: Send facing octant to server when it changes (even without movement)
@@ -117,6 +117,8 @@ func _process(_delta: float) -> void:
# Discrete actions: fire once on key press (not held).
func _unhandled_input(event: InputEvent) -> void:
if GameState.dialogue_active or GameState.free_camera_mode:
return
var action: Action = -1
if event.is_action_pressed("interact"):
@@ -182,10 +184,11 @@ func _update_facing_from_mouse() -> void:
if vp == null:
return
var canvas_xf := vp.get_canvas_transform()
var player_world_px := GameState.player_position * Constants.TILE_SIZE
var player_screen := canvas_xf * player_world_px
var mouse_screen := vp.get_mouse_position()
var delta := mouse_screen - player_screen
var C := load("res://scripts/constants.gd")
var player_world_px: Vector2 = GameState.player_position * C.TILE_SIZE
var player_screen: Vector2 = canvas_xf * player_world_px
var mouse_screen: Vector2 = vp.get_mouse_position()
var delta: Vector2 = mouse_screen - player_screen
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
if delta.length_squared() > 4.0:
facing_angle = delta.angle()
+1 -25
View File
@@ -55,12 +55,11 @@ func new_game() -> String:
## Resume an existing game session by setting the active game-id.
## Restores world_seed and character_archetype from the save directory.
## Restores world_seed from the save directory.
func resume_game(game_id: String) -> void:
GameState.current_game_id = game_id
var save_path := SAVES_DIR + game_id + "/"
GameState.world_seed = _read_seed_file(save_path)
GameState.character_archetype = _read_archetype_file(save_path)
## List all game directories under user://saves/ sorted by last-modified (most recent first).
@@ -171,29 +170,6 @@ func _read_seed_file(save_path: String) -> int:
return file.get_64() & 0x7FFFFFFFFFFFFFFF
## Write character_archetype to save directory. Called after new_game() creates the dir.
func save_character_archetype(game_id: String, archetype: String) -> void:
var save_path := SAVES_DIR + game_id + "/"
var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE)
if file == null:
push_error(
(
"SessionManager: failed to write character.txt: %s"
% error_string(FileAccess.get_open_error())
)
)
return
file.store_string(archetype)
## Read character_archetype from save directory. Returns "detective" if missing (legacy saves).
func _read_archetype_file(save_path: String) -> String:
var file := FileAccess.open(save_path + "character.txt", FileAccess.READ)
if file == null:
return "detective"
return file.get_as_text().strip_edges()
func _find_newest_save(dir_path: String) -> String:
var dir := DirAccess.open(dir_path)
if dir == null:
+15 -13
View File
@@ -27,8 +27,8 @@ var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
# Transport layer (non-test mode)
var _bridge: LocalBridge = null
var _server: ServerProcess = null
var _bridge = null # LocalBridge
var _server = null # ServerProcess
var _connect_retries: int = 0
var _retry_timer: float = 0.0
var _handshake_start_usec: int = 0
@@ -126,14 +126,15 @@ func connect_to_sim() -> void:
# Spawn server subprocess
if not server_path.is_empty():
_server = ServerProcess.new()
var SP := load("res://scripts/protocol/server_process.gd")
_server = SP.new()
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
var args := ["127.0.0.1:" + str(server_port)]
var game_id: String = GameState.current_game_id
if not game_id.is_empty():
args.append_array(["--game-id", game_id])
var pid := _server.start(server_path, args)
var pid: int = _server.start(server_path, args)
if pid <= 0:
push_error("SimBridge: failed to start server")
_set_state(ConnectionState.ERROR)
@@ -159,8 +160,9 @@ func disconnect_from_sim() -> void:
# Attempt TCP connection. Called from _process() during CONNECTING state.
func _try_connect() -> void:
_bridge = LocalBridge.new()
var err := _bridge.connect_to_server("127.0.0.1", server_port)
var LB := load("res://scripts/protocol/local_bridge.gd")
_bridge = LB.new()
var err: int = _bridge.connect_to_server("127.0.0.1", server_port)
if err != OK:
push_warning(
(
@@ -220,7 +222,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
_bridge.poll()
# Check connection dropped during handshake
var bridge_status := _bridge.get_status()
var bridge_status: int = _bridge.get_status()
if (
bridge_status == StreamPeerTCP.STATUS_ERROR
or bridge_status == StreamPeerTCP.STATUS_NONE
@@ -242,13 +244,14 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
return
# Try to read first message
var msg := _bridge.poll_message()
var msg: PackedByteArray = _bridge.poll_message()
if msg.is_empty():
return # Not ready yet, continue polling
# Decode HandshakeMessage — D-192 (#875): protocol_version field dropped.
# Server sends {} or a minimal dict; only structural validity is required.
var decoded: Variant = Messagepack.decode(msg)
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
var reason := "Handshake decode failed: malformed HandshakeMessage"
push_error("SimBridge: %s" % reason)
@@ -261,11 +264,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
# Server blocks waiting for this before entering the tick loop.
var startup_bytes := Protocol.encode_startup_message(
GameState.world_seed,
GameState.character_archetype,
GameState.character_visual_descriptor
)
if startup_bytes.size() > 0:
var send_err := _bridge.send_message(startup_bytes)
var send_err: int = _bridge.send_message(startup_bytes)
if send_err != OK:
var reason := "Failed to send startup message: %s" % error_string(send_err)
push_error("SimBridge: %s" % reason)
@@ -304,7 +306,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
# Receive: drain all complete messages from the bridge
var msg := _bridge.poll_message()
var msg: PackedByteArray = _bridge.poll_message()
while msg.size() > 0:
receive_bytes(msg)
msg = _bridge.poll_message()
@@ -316,7 +318,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
if outbound.size() > 0:
var encoded := Protocol.encode_player_inputs(outbound)
if encoded.size() > 0:
var err := _bridge.send_message(encoded)
var err: int = _bridge.send_message(encoded)
if err != OK:
push_error("SimBridge: failed to send message: %s" % error_string(err))
else:
+46 -4
View File
@@ -1,6 +1,11 @@
extends Node2D
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
# #898: Free camera pan speed in pixels/second (unzoomed) and zoom step per scroll tick.
const FREE_CAMERA_PAN_SPEED: float = 400.0
const FREE_CAMERA_ZOOM_STEP: float = 0.1
const FREE_CAMERA_ZOOM_MIN: float = 0.5
const FREE_CAMERA_ZOOM_MAX: float = 8.0
var economics_app = null # EconomicsApp — populated in _ready() via ImplantRegistry
var atlas_app = null # AtlasApp — populated in _ready() via ImplantRegistry
@@ -184,16 +189,39 @@ func _ready() -> void:
atlas_app.economics_link_requested.connect(_on_atlas_economics_link)
func _unhandled_input(event: InputEvent) -> void:
# #898: Scroll wheel zoom in free camera mode.
if GameState.free_camera_mode and event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed:
var zoom: Vector2 = camera.zoom
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
zoom -= Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
camera.zoom = zoom.clamp(
Vector2(FREE_CAMERA_ZOOM_MIN, FREE_CAMERA_ZOOM_MIN),
Vector2(FREE_CAMERA_ZOOM_MAX, FREE_CAMERA_ZOOM_MAX)
)
get_viewport().set_input_as_handled()
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.is_pressed() or event.is_echo():
return
var key_event := event as InputEventKey
# #898: F4 toggles free camera mode. Reset zoom to 1:1 on exit.
if Input.is_action_just_pressed("free_camera"):
GameState.free_camera_mode = not GameState.free_camera_mode
if not GameState.free_camera_mode:
camera.zoom = Vector2.ONE
return
# Registry-driven toggle: each manifest declares its own default_key.
for manifest: ImplantAppManifest in ImplantRegistry.get_manifests():
if manifest.app_path.is_empty():
push_warning("main.gd: manifest with empty app_path — skipping")
continue
if manifest.default_key == key_event.keycode:
if manifest.default_key == key_event.physical_keycode:
HudGroups.toggle_app(
manifest.app_path,
ImplantRegistry.get_resolved_mode(manifest.app_path)
@@ -202,10 +230,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
# [ / ] — in-app navigation for the economics monitor. Not manifest-declared because
# these control intra-app navigation (prev/next system), not app launch. A planned
# handle_global_key lifecycle hook will absorb this (see arch doc Follow-up).
if key_event.keycode == KEY_BRACKETLEFT:
if key_event.physical_keycode == KEY_BRACKETLEFT:
if economics_app and HudGroups.is_app_active("implant/economics"):
economics_app.navigate(-1)
elif key_event.keycode == KEY_BRACKETRIGHT:
elif key_event.physical_keycode == KEY_BRACKETRIGHT:
if economics_app and HudGroups.is_app_active("implant/economics"):
economics_app.navigate(1)
@@ -239,9 +267,23 @@ func _process(delta: float) -> void:
# #559: Dispatch snapshot to registered handlers (router pattern).
_router.dispatch(snapshot)
# #898: Free camera WASD pan — runs in place of player tracking.
if GameState.free_camera_mode:
var pan := Vector2.ZERO
if Input.is_action_pressed("move_north"):
pan.y -= 1.0
if Input.is_action_pressed("move_south"):
pan.y += 1.0
if Input.is_action_pressed("move_east"):
pan.x += 1.0
if Input.is_action_pressed("move_west"):
pan.x -= 1.0
if pan != Vector2.ZERO:
var speed: float = FREE_CAMERA_PAN_SPEED / camera.zoom.x
camera.global_position += pan.normalized() * speed * delta
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing.
if _camera_anchored:
elif _camera_anchored:
var target := GameState.player_position * Constants.TILE_SIZE
if _teleport_in_progress:
camera.global_position = target
+14 -30
View File
@@ -9,6 +9,9 @@ extends Node
## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc")
## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"})
static func _mp():
return load("res://addons/messagepack/messagepack.gd")
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -17,7 +20,7 @@ extends Node
## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty
## when decoding v1 snapshots for backward compatibility.
static func decode_snapshot(bytes: PackedByteArray) -> Variant:
var result = Messagepack.decode(bytes)
var result = _mp().decode(bytes)
if result.status != null:
push_error("Protocol: msgpack decode failed: %s" % result.status)
return null
@@ -613,38 +616,19 @@ static func _decode_enum_variant(raw) -> Dictionary:
# -- Encode: GDScript types → bytes to server ----------------------------------
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
## Encode a StartupMessage to MessagePack bytes (#175, #718).
## Sent by the client immediately after handshake validation.
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
## Server reads this to initialize SimRng (D-010, D-029).
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
static func encode_startup_message(
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null
world_seed: int, character_visual: Variant = null
) -> PackedByteArray:
# Map client lowercase archetype string to server PascalCase enum variant.
# Explicit match prevents unknown strings silently reaching the server as
# garbage enum values — fail loudly and fall back to "Detective".
var archetype_variant: String
match character_archetype:
"detective":
archetype_variant = "Detective"
"smuggler":
archetype_variant = "Smuggler"
_:
push_error(
(
"Protocol: unknown character_archetype '%s' — defaulting to 'Detective'"
% character_archetype
)
)
archetype_variant = "Detective"
var msg := {
"world_seed": world_seed,
"character_archetype": archetype_variant,
}
if character_visual != null and character_visual.has_method("to_dict"):
msg["character_visual_descriptor"] = character_visual.to_dict()
var result = Messagepack.encode(msg)
var result = _mp().encode(msg)
if result.status != null:
push_error("Protocol: startup message encode failed: %s" % result.status)
return PackedByteArray()
@@ -665,7 +649,7 @@ static func encode_player_input(
"action": action_value,
}
var result = Messagepack.encode(input)
var result = _mp().encode(input)
if result.status != null:
push_error("Protocol: msgpack encode failed: %s" % result.status)
return PackedByteArray()
@@ -691,7 +675,7 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
)
)
var result = Messagepack.encode(wire_inputs)
var result = _mp().encode(wire_inputs)
if result.status != null:
push_error("Protocol: msgpack encode failed: %s" % result.status)
return PackedByteArray()
@@ -724,7 +708,7 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray:
"action_data": {"ai_enhanced_dialogue": enabled},
}
]
var result = Messagepack.encode(entries)
var result = _mp().encode(entries)
if result.status != null:
push_error("Protocol: encode_change_settings failed: %s" % result.status)
return PackedByteArray()
@@ -735,7 +719,7 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray:
## Unit variant — no payload. Server responds with bookmark_catalog in the next snapshot.
static func encode_request_bookmark_catalog() -> PackedByteArray:
var entries: Array = [{"tick": 0, "action_name": "RequestBookmarkCatalog", "action_data": null}]
var result = Messagepack.encode(entries)
var result = _mp().encode(entries)
if result.status != null:
push_error("Protocol: encode_request_bookmark_catalog failed: %s" % result.status)
return PackedByteArray()
@@ -752,7 +736,7 @@ static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: S
"action_data": {"bookmark_id": bookmark_id, "starting_location_id": starting_location_id},
}
]
var result = Messagepack.encode(entries)
var result = _mp().encode(entries)
if result.status != null:
push_error("Protocol: encode_confirm_bookmark failed: %s" % result.status)
return PackedByteArray()
@@ -762,7 +746,7 @@ static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: S
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
static func decode_player_input(bytes: PackedByteArray) -> Variant:
var result = Messagepack.decode(bytes)
var result = _mp().decode(bytes)
if result.status != null:
push_error("Protocol: msgpack decode failed: %s" % result.status)
return null
-4
View File
@@ -88,10 +88,6 @@ static func apply(snapshot: Dictionary) -> void:
else:
GameState.current_monologue = null
# #122: lattice_profile
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
GameState.lattice_profile = snapshot.lattice_profile
# v6: player_stance (#449, D-053)
if snapshot.has("player_stance") and snapshot.player_stance is String:
GameState.player_stance = snapshot.player_stance
+16 -11
View File
@@ -40,15 +40,17 @@ func test_fog_visibility_forward_tile() -> void:
var fog = _get_fog_state()
if fog == null:
return
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed.
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
GameState.visible_positions = {Vector2i(10, 10): true}
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
fog.update_from_state()
# Index: row 5 * width 64 + col 5
assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message(
"Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
# Index: row 10 * width 64 + col 10
assert_that(fog._vis_bytes[10 * 64 + 10]).override_failure_message(
"Forward tile at (10,10) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
).is_equal(FogState.VIS_FORWARD)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
@@ -81,10 +83,12 @@ func test_fog_exploration_persistence() -> void:
var fog = _get_fog_state()
if fog == null:
return
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
var pos := Vector2i(5, 5)
var idx: int = 5 * 64 + 5
var pos := Vector2i(10, 10)
var idx: int = 10 * 64 + 10
# Frame 1: tile visible
GameState.visible_positions = {pos: true}
GameState.visibility_sectors = {pos: "Forward"}
@@ -118,9 +122,10 @@ func test_fog_hidden_tile_value() -> void:
assert_that(fog._vis_bytes[idx]).override_failure_message(
"Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN
).is_equal(FogState.VIS_HIDDEN)
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible.
# Use position (10,10): 8-tile padding stays within the 64x64 box, no resize triggered.
GameState.visible_positions = {Vector2i(10, 10): true}
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
fog.update_from_state()
assert_that(fog._vis_bytes[idx]).override_failure_message(
"Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN
+3 -4
View File
@@ -182,11 +182,10 @@ func test_d063_dim_alpha_is_set() -> void:
box.queue_free()
func skip_test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
## BROKEN (#867): signal_fired stays false in headless; create_tween() before emit
## may abort _start_confrontation_beat if panel node is null. Bug filed.
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
## Fixed (#867): guard tween_property behind is_instance_valid(panel) so emit fires
## even in headless mode where the panel node may not be in the scene tree.
var box := _make_dialogue_box()
if box == null: return
@@ -52,7 +52,7 @@ func after_test() -> void:
# ---------------------------------------------------------------------------
func test_gamestate_examine_result_field_exists() -> void:
assert_bool(GameState.has("current_examine_result")).override_failure_message(
assert_bool("current_examine_result" in GameState).override_failure_message(
"GameState must have 'current_examine_result' field (#174)"
).is_true()
+395
View File
@@ -0,0 +1,395 @@
## Sprint 22 fog state behavioral tests — revived in Sprint 38 (#879).
## Original: deleted in Sprint 37 (#870 parse-error cleanup).
## Spec refs: D-059, D-066, #569, #585
##
## Coverage: EXP_EXPLORED persistence, grow-only bounds invariant,
## texture-resize copy, BoundaryWall handling.
##
## Uses FogState autoload directly via /root/FogState — byte-level assertions
## on _vis_bytes and _exp_bytes, consistent with test_fog_shader.gd approach.
class_name TestFogSprint22
extends GdUnitTestSuite
# -- Helpers -------------------------------------------------------------------
func _get_fog_state() -> Node:
var node = get_node_or_null("/root/FogState")
if node == null:
push_warning("TestFogSprint22: FogState autoload not found — test skipped")
return node
func _reset_fog_state(fog_state: Node) -> void:
GameState.visible_positions.clear()
GameState.boundary_positions.clear()
GameState.visible_tiles.clear()
GameState.visibility_sectors.clear()
# 32x32 is an arbitrary test fixture size — not a production assumption.
fog_state._resize(Rect2i(0, 0, 32, 32))
# -- EXP_EXPLORED persistence --------------------------------------------------
## D-059: Previously-seen tiles render as "deep fog" (EXP_EXPLORED = 128).
## Once a tile enters LOS, leaving LOS must NOT reset it to EXP_UNEXPLORED.
## This is the core "fog of war memory" invariant.
func test_exp_explored_persists_after_leaving_los() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: tile (2,2) is in LOS → must become EXP_VISIBLE
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_2_2: int = (2 - oy) * w + (2 - ox)
assert_int(exp[idx_2_2]).override_failure_message(
"D-059: visible tile must have EXP_VISIBLE (255) on first sight"
).is_equal(FogState.EXP_VISIBLE)
# Tick 2: tile (2,2) leaves LOS — only (3,3) is visible now
GameState.visible_positions = {Vector2i(3, 3): true}
fog_state.update_from_state()
# After leaving LOS, (2,2) must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0)
ox = fog_state.map_bounds.position.x
oy = fog_state.map_bounds.position.y
w = fog_state.map_bounds.size.x
exp = fog_state._exp_bytes
idx_2_2 = (2 - oy) * w + (2 - ox)
assert_int(exp[idx_2_2]).override_failure_message(
"D-059: tile leaving LOS must decay to EXP_EXPLORED (128), not EXP_UNEXPLORED (0)"
).is_equal(FogState.EXP_EXPLORED)
_reset_fog_state(fog_state)
func test_never_seen_tile_stays_unexplored() -> void:
## Corollary: a tile that was never in LOS stays EXP_UNEXPLORED.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tile (5,5) never enters LOS
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_5_5: int = (5 - oy) * w + (5 - ox)
assert_int(exp[idx_5_5]).override_failure_message(
"D-059: tile never in LOS must remain EXP_UNEXPLORED (0)"
).is_equal(FogState.EXP_UNEXPLORED)
_reset_fog_state(fog_state)
func test_exp_explored_not_overwritten_by_subsequent_invisible_ticks() -> void:
## EXP_EXPLORED must not decay further after the player moves away.
## If the player is never in the area again, the tile stays at EXP_EXPLORED.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: see tile (4,4)
GameState.visible_positions = {Vector2i(4, 4): true}
fog_state.update_from_state()
# Tick 2: player moves far away, (4,4) out of LOS
GameState.visible_positions = {Vector2i(20, 20): true}
fog_state.update_from_state()
# Tick 3: player stays far away
GameState.visible_positions = {Vector2i(20, 20): true}
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_4_4: int = (4 - oy) * w + (4 - ox)
assert_int(exp[idx_4_4]).override_failure_message(
"D-059: EXP_EXPLORED must not decay further once set — tile stays at 128"
).is_equal(FogState.EXP_EXPLORED)
_reset_fog_state(fog_state)
# -- Grow-only bounds invariant ------------------------------------------------
## D-059: map_bounds only ever grows. Previously-explored tiles that leave the
## visible area must not be evicted from the texture. The bounds never shrink.
func test_bounds_grow_when_player_moves_to_new_area() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: small area visible
GameState.visible_positions = {Vector2i(2, 2): true, Vector2i(3, 3): true}
fog_state.update_from_state()
var bounds_after_t1: Rect2i = fog_state.map_bounds
# Tick 2: player moves to a larger area
GameState.visible_positions = {Vector2i(20, 20): true, Vector2i(25, 25): true}
fog_state.update_from_state()
var bounds_after_t2: Rect2i = fog_state.map_bounds
# Bounds must have grown or stayed the same — never shrunk
assert_bool(bounds_after_t2.size.x >= bounds_after_t1.size.x).override_failure_message(
"D-059: map_bounds width must never shrink (grow-only invariant)"
).is_true()
assert_bool(bounds_after_t2.size.y >= bounds_after_t1.size.y).override_failure_message(
"D-059: map_bounds height must never shrink (grow-only invariant)"
).is_true()
_reset_fog_state(fog_state)
func test_bounds_contain_new_visible_positions() -> void:
## After update_from_state, all visible positions must lie within map_bounds.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
GameState.visible_positions = {Vector2i(10, 5): true, Vector2i(15, 12): true}
fog_state.update_from_state()
var bounds: Rect2i = fog_state.map_bounds
for pos in GameState.visible_positions:
assert_bool(bounds.has_point(pos)).override_failure_message(
"D-059: visible position %s must be within map_bounds %s" % [pos, bounds]
).is_true()
_reset_fog_state(fog_state)
func test_bounds_encompass_previous_area_after_player_moves() -> void:
## Old area coordinates must still be within map_bounds after player moves away.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: see area around (2,2)
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
# Tick 2: player moves far away
GameState.visible_positions = {Vector2i(20, 20): true}
fog_state.update_from_state()
# The original tile (2,2) must still be within map_bounds
var bounds: Rect2i = fog_state.map_bounds
assert_bool(bounds.has_point(Vector2i(2, 2))).override_failure_message(
"D-059: grow-only — previously-visited area (2,2) must remain within map_bounds"
).is_true()
_reset_fog_state(fog_state)
# -- Texture-resize copy -------------------------------------------------------
## D-059: When bounds grow (resize), exploration data from the old bounds
## must be preserved in the new texture at the correct offsets.
## This is the "texture-resize copy" invariant.
func test_exploration_data_preserved_across_resize() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: mark (3,3) as explored
GameState.visible_positions = {Vector2i(3, 3): true}
fog_state.update_from_state()
# Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding,
# so (25,25) expands the bounds beyond the 32x32 fixture set in _reset_fog_state.
GameState.visible_positions = {Vector2i(25, 25): true}
fog_state.update_from_state()
# After resize, (3,3) must still be EXP_EXPLORED (not reset to EXP_UNEXPLORED)
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_3_3: int = (3 - oy) * w + (3 - ox)
assert_int(exp[idx_3_3]).override_failure_message(
"D-059: exploration state (EXP_EXPLORED=128) must survive texture resize"
).is_equal(FogState.EXP_EXPLORED)
_reset_fog_state(fog_state)
func test_newly_added_area_starts_unexplored_after_resize() -> void:
## When bounds grow to include a new area, those new tiles start as EXP_UNEXPLORED.
## The copy preserves old data; new tiles get the default (0).
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Establish a small explored area
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
# Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding,
# so (30,30) expands the bounds beyond the 32x32 fixture set in _reset_fog_state.
GameState.visible_positions = {Vector2i(30, 30): true}
fog_state.update_from_state()
# A completely new tile (30,30) on this tick should be EXP_VISIBLE (just entered LOS)
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_30_30: int = (30 - oy) * w + (30 - ox)
assert_int(exp[idx_30_30]).override_failure_message(
"D-059: tile first entering LOS after resize must be EXP_VISIBLE (255)"
).is_equal(FogState.EXP_VISIBLE)
_reset_fog_state(fog_state)
# -- BoundaryWall handling (#585) ----------------------------------------------
## BoundaryWall margin tiles: fog lifts (VIS_FORWARD) so wall content composites,
## but they do NOT persist as explored (not in visible_positions or _exp_bytes).
func test_boundary_wall_vis_bytes_are_forward() -> void:
## #585: BoundaryWall tiles must receive VIS_FORWARD in the vis texture
## so the wall sprite composites correctly (not occluded by fog).
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var vis: PackedByteArray = fog_state._vis_bytes
var boundary_idx: int = (5 - oy) * w + (6 - ox)
assert_int(vis[boundary_idx]).override_failure_message(
"#585: BoundaryWall tile must have VIS_FORWARD (255) in vis texture"
).is_equal(FogState.VIS_FORWARD)
_reset_fog_state(fog_state)
func test_boundary_wall_does_not_persist_as_explored() -> void:
## #585: BoundaryWall tiles must NOT become EXP_EXPLORED after leaving the area.
## They are rendering artifacts, not player memory.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: have a boundary wall tile at (6,5)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
fog_state.update_from_state()
# Tick 2: player moves away; (6,5) is no longer a boundary wall
GameState.visible_positions = {Vector2i(20, 20): true}
GameState.boundary_positions.clear()
fog_state.update_from_state()
# (6,5) must not be EXP_EXPLORED — it was never a true explored tile
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var boundary_idx: int = (5 - oy) * w + (6 - ox)
assert_int(exp[boundary_idx]).override_failure_message(
"#585: BoundaryWall tile must NOT persist as EXP_EXPLORED — only true LOS tiles are explored"
).is_equal(FogState.EXP_UNEXPLORED)
_reset_fog_state(fog_state)
func test_normal_tile_adjacent_to_boundary_still_explored() -> void:
## The normal LOS tile adjacent to a BoundaryWall must still be marked explored.
## BoundaryWall exclusion must not affect neighboring tiles.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: normal tile (5,5) in LOS, boundary wall at (6,5)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
fog_state.update_from_state()
# Tick 2: player moves away
GameState.visible_positions = {Vector2i(20, 20): true}
GameState.boundary_positions.clear()
fog_state.update_from_state()
# Normal tile (5,5) must be EXP_EXPLORED
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var normal_idx: int = (5 - oy) * w + (5 - ox)
assert_int(exp[normal_idx]).override_failure_message(
"#585: normal LOS tile adjacent to BoundaryWall must still be EXP_EXPLORED (128)"
).is_equal(FogState.EXP_EXPLORED)
_reset_fog_state(fog_state)
func test_boundary_wall_visibility_only_when_present() -> void:
## #585: A tile that is a BoundaryWall in tick 1 but absent in tick 2
## must have VIS_HIDDEN in tick 2 (fog reapplied).
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: boundary wall at (6,5)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
fog_state.update_from_state()
# Tick 2: player moves far away; (6,5) no longer visible or boundary
GameState.visible_positions = {Vector2i(20, 20): true}
GameState.boundary_positions.clear()
fog_state.update_from_state()
# (6,5) must be VIS_HIDDEN — fog returned
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var vis: PackedByteArray = fog_state._vis_bytes
var boundary_idx: int = (5 - oy) * w + (6 - ox)
assert_int(vis[boundary_idx]).override_failure_message(
"#585: BoundaryWall tile must return to VIS_HIDDEN when not in current boundary set"
).is_equal(FogState.VIS_HIDDEN)
_reset_fog_state(fog_state)
+88
View File
@@ -0,0 +1,88 @@
## Free camera mode tests (#898).
## Covers GameState flag default, InputMapper suppression, and zoom clamping.
class_name TestFreeCamera
extends GdUnitTestSuite
func before_test() -> void:
GameState.free_camera_mode = false
func after_test() -> void:
GameState.free_camera_mode = false
# -- GameState.free_camera_mode default ----------------------------------------
func test_free_camera_mode_starts_false() -> void:
## #898: Free camera is off by default — normal gameplay on startup.
assert_bool(GameState.free_camera_mode).override_failure_message(
"GameState.free_camera_mode must default to false"
).is_false()
# -- InputMapper suppression ---------------------------------------------------
func test_input_mapper_suppresses_movement_in_free_camera_mode() -> void:
## #898: While free camera is active, InputMapper._process() returns early so
## no movement actions enter the queue.
GameState.free_camera_mode = true
var before := InputMapper.input_queue.size()
InputMapper._process(0.016)
var after := InputMapper.input_queue.size()
assert_int(after).override_failure_message(
"InputMapper must not enqueue movement while free_camera_mode is true"
).is_equal(before)
InputMapper.input_queue.clear()
func test_input_mapper_suppresses_discrete_actions_in_free_camera_mode() -> void:
## #898: _unhandled_input returns early in free camera — INTERACT and stance
## actions must not be queued.
GameState.free_camera_mode = true
var before := InputMapper.input_queue.size()
var fake_event := InputEventAction.new()
fake_event.action = "interact"
fake_event.pressed = true
InputMapper._unhandled_input(fake_event)
assert_int(InputMapper.input_queue.size()).override_failure_message(
"InputMapper must not enqueue discrete actions while free_camera_mode is true"
).is_equal(before)
InputMapper.input_queue.clear()
func test_input_mapper_resumes_after_free_camera_off() -> void:
## Turning free camera off lifts the suppression — _process runs normally again.
GameState.free_camera_mode = true
GameState.free_camera_mode = false
## _process should no longer return early (queue may or may not grow depending
## on held keys, but no crash and guard is lifted).
InputMapper._process(0.016)
assert_bool(true).is_true() # no crash = pass
InputMapper.input_queue.clear()
# -- Zoom clamp contract -------------------------------------------------------
func test_zoom_min_constant_is_0_5() -> void:
## #898: Minimum zoom keeps the world recognisable.
var main_script = load("res://scripts/main.gd")
assert_float(main_script.FREE_CAMERA_ZOOM_MIN).override_failure_message(
"FREE_CAMERA_ZOOM_MIN must be 0.5"
).is_equal_approx(0.5, 0.001)
func test_zoom_max_constant_is_8() -> void:
## #898: Maximum zoom must not exceed 8× per spec.
var main_script = load("res://scripts/main.gd")
assert_float(main_script.FREE_CAMERA_ZOOM_MAX).override_failure_message(
"FREE_CAMERA_ZOOM_MAX must be 8.0"
).is_equal_approx(8.0, 0.001)
func test_zoom_step_is_positive() -> void:
## Zoom step must be > 0 so scroll wheel does something.
var main_script = load("res://scripts/main.gd")
assert_float(main_script.FREE_CAMERA_ZOOM_STEP).override_failure_message(
"FREE_CAMERA_ZOOM_STEP must be positive"
).is_greater(0.0)
+1 -1
View File
@@ -12,7 +12,7 @@ class_name TestGameStateSprint20
extends GdUnitTestSuite
func before_each() -> void:
func before_test() -> void:
GameState.stationary_ticks = 0
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
-39
View File
@@ -4,7 +4,6 @@
##
## API per Tyre architecture review:
## show_monologue(text, duration, priority=2, is_urgent=false)
## GameState.lattice_profile selects colour palette
class_name TestMonologueDisplay
extends GdUnitTestSuite
@@ -35,14 +34,11 @@ func _label_text(d: Node) -> String:
func before_test() -> void:
## Reset GameState fields touched by this suite so tests don't bleed into each other.
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
## current_monologue: GameState integration tests need null as start state.
GameState.current_monologue = null
GameState.lattice_profile = "lattice_baseline"
func after_test() -> void:
GameState.current_monologue = null
GameState.lattice_profile = "lattice_baseline"
# ---------------------------------------------------------------------------
@@ -310,34 +306,10 @@ func test_text_has_color_bbcode() -> void:
d.queue_free()
# ---------------------------------------------------------------------------
# Lattice colour palette
# ---------------------------------------------------------------------------
func test_augmented_colour_differs_from_baseline() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_augmented"
d.show_monologue("Detective.", 5.0)
var aug_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
d._next_fade_in_msec = 0.0
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Smuggler.", 5.0)
var base_txt := _label_text(d)
assert_that(aug_txt).is_not_equal(base_txt)
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
func test_urgent_colour_differs_from_standard() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Normal.", 5.0, 2, false)
var std_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
@@ -350,17 +322,6 @@ func test_urgent_colour_differs_from_standard() -> void:
d.queue_free()
func test_unknown_profile_falls_back_without_crash() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_hypothetical_tier_x"
d.show_monologue("Future proof.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
# ---------------------------------------------------------------------------
# Slot lifecycle
# ---------------------------------------------------------------------------
+13 -9
View File
@@ -123,23 +123,26 @@ func test_game_state_warns_on_missing_player() -> void:
# -- SimBridge: test data completeness --
func test_sim_bridge_test_snapshot_has_tiles() -> void:
## Protocol uses "visible_tiles" (not "tiles") for test snapshot — updated from stale assertion.
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("tiles")).is_true()
assert_that(snap.tiles.size()).is_greater(0)
var tile = snap.tiles[0]
assert_that(snap.has("visible_tiles")).is_true()
assert_that(snap.visible_tiles.size()).is_greater(0)
var tile = snap.visible_tiles[0]
assert_that(tile.has("x")).is_true()
assert_that(tile.has("y")).is_true()
assert_that(tile.has("type")).is_true()
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
## Protocol uses "visible_tiles" for position data — visible_positions is derived client-side.
## Updated from stale assertion: TestHarness snapshot never had a top-level "visible_positions".
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("visible_positions")).is_true()
assert_that(snap.visible_positions.size()).is_greater(0)
var pos = snap.visible_positions[0]
assert_that(pos.has("x")).is_true()
assert_that(pos.has("y")).is_true()
assert_that(snap.has("visible_tiles")).is_true()
assert_that(snap.visible_tiles.size()).is_greater(0)
var vtile = snap.visible_tiles[0]
assert_that(vtile.has("x")).is_true()
assert_that(vtile.has("y")).is_true()
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
SimBridge.reset_test_state()
@@ -162,10 +165,11 @@ func test_sim_bridge_test_snapshot_has_npc() -> void:
assert_that(has_npc).is_true()
func test_sim_bridge_test_tiles_contain_all_types() -> void:
## Protocol uses "visible_tiles" — updated from stale "tiles" assertion.
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
var types: Dictionary = {}
for tile in snap.tiles:
for tile in snap.visible_tiles:
types[tile.type] = true
assert_that(types.has("floor")).is_true()
assert_that(types.has("wall")).is_true()
+3 -65
View File
@@ -1,79 +1,17 @@
## Sprint 24 — Signal acceptance tests (#588, #590, #592)
## Sprint 24 — Signal acceptance tests (#590, #592)
##
## Client-side acceptance criteria:
## - #588: character_archetype field in GameState, StartupMessage, SessionManager persistence
## - #590: triangle_crisis_events decoded by Protocol, chimed once per triangle_id
## - #592: news_ticker decode + update_from_state hide/show behavior
##
## Spec: D-032 (monologue pools per character), D-016 (client displays server data only),
## D-042 (UI strings in yaml), D-067 (chime on recognition onset)
## Spec: D-016 (client displays server data only), D-042 (UI strings in yaml),
## D-067 (chime on recognition onset)
class_name TestSignalSprint24
extends GdUnitTestSuite
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
# -- #588: Character archetype field ------------------------------------------
func test_game_state_has_character_archetype_field() -> void:
assert_bool("character_archetype" in GameState).override_failure_message(
"GameState must have a character_archetype field (#588)"
).is_true()
func test_game_state_character_archetype_default_is_detective() -> void:
# Fresh GameState defaults to "detective" (safest fallback for legacy saves).
var archetype = GameState.get("character_archetype")
assert_str(archetype).override_failure_message(
"GameState.character_archetype default must be 'detective'"
).is_equal("detective")
func test_protocol_startup_message_unknown_archetype_defaults_to_detective() -> void:
# Unknown archetype strings must not silently pass garbage to the server.
# The match guard falls back to "Detective" and calls push_error.
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "hacker")
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status).is_null()
assert_str(decoded.value["character_archetype"]).override_failure_message(
"Unknown archetype must fall back to 'Detective'"
).is_equal("Detective")
func test_protocol_startup_message_includes_character_archetype() -> void:
# StartupMessage wire payload must carry "character_archetype" key (#588).
var bytes: PackedByteArray = Protocol.encode_startup_message(12345, "detective")
assert_bool(bytes.size() > 0).is_true()
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status).is_null()
var msg: Dictionary = decoded.value
assert_bool(msg.has("character_archetype")).override_failure_message(
"StartupMessage must contain 'character_archetype' key, got: %s" % str(msg.keys())
).is_true()
func test_protocol_startup_message_detective_maps_to_pascal_case() -> void:
# "detective" client string must map to "Detective" PascalCase server enum variant.
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "detective")
var decoded = Messagepack.decode(bytes)
assert_str(decoded.value["character_archetype"]).is_equal("Detective")
func test_protocol_startup_message_smuggler_maps_to_pascal_case() -> void:
# "smuggler" client string must map to "Smuggler" PascalCase server enum variant.
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "smuggler")
var decoded = Messagepack.decode(bytes)
assert_str(decoded.value["character_archetype"]).is_equal("Smuggler")
func test_protocol_startup_message_preserves_world_seed() -> void:
# Adding character_archetype must not break world_seed encoding.
var seed: int = 0xDEADBEEF
var bytes: PackedByteArray = Protocol.encode_startup_message(seed, "detective")
var decoded = Messagepack.decode(bytes)
assert_int(decoded.value["world_seed"]).is_equal(seed)
# -- #590: triangle_crisis_events decode --------------------------------------
func test_protocol_decode_includes_triangle_crisis_events_field() -> void:
+4 -2
View File
@@ -294,10 +294,12 @@ func test_hud_time_row_updates_after_process() -> void:
"tick": 1, "version": 23, "entities": [],
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
})
instance._process(0.016)
# In test mode SimBridge is disconnected — poll_snapshot() returns null so the
# SnapshotEventRouter inside main._process() never fires. Call the HUD directly
# instead, which is what the router would do in a live session.
var hud = instance.get_node_or_null("InsertOverlay/HUD")
assert_that(hud).is_not_null()
hud.update_from_state()
assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1")
+2 -1
View File
@@ -498,7 +498,8 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
if is_instance_valid(panel):
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
confrontation_monologue.emit(
UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION
+5 -1
View File
@@ -82,12 +82,16 @@ func _start_fade_out() -> void:
## Dismiss immediately (e.g. when dialogue opens).
## Sets _active = false immediately so is_active() returns false before the fade completes.
func dismiss() -> void:
if not _active:
return
_active = false
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
_start_fade_out()
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func(): visible = false)
func is_active() -> bool:
+18 -25
View File
@@ -62,15 +62,19 @@ func _unhandled_key_input(event: InputEvent) -> void:
if not event.is_pressed() or event.is_echo():
return
if current_screen_id() == "regional":
return # AtlasViewer handles its own keyboard input
return
_handle_key(event as InputEventKey)
get_viewport().set_input_as_handled()
func _handle_key(event: InputEventKey) -> void:
match event.keycode:
match event.physical_keycode:
KEY_M:
HudGroups.close_app()
KEY_ESCAPE:
if current_screen_id() == "reach":
if current_screen_id() == "system" and _system_screen and (_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()):
_system_screen.close_panels()
elif current_screen_id() == "reach":
HudGroups.close_app()
else:
nav.pop()
@@ -93,18 +97,13 @@ func _handle_enter() -> void:
if _system_screen and not _system_screen.is_in_orbital():
var sys: Dictionary = _system_screen.current_system()
nav.replace("system", {"mode": "orbital", "system": sys})
"planet":
if _planet_screen and _planet_screen.has_heightmap():
(
nav
. push(
"regional",
{
"body": _planet_screen.current_body(),
"system": nav.current_payload().get("system", {}),
}
)
)
elif _system_screen and _system_screen.has_body_panel_open():
var body: Dictionary = _system_screen.get_selected_body()
if body.get("terrain_reference") != null:
nav.push("regional", {
"body": body,
"system": nav.current_payload().get("system", {}),
})
# =============================================================================
@@ -121,16 +120,10 @@ func _on_system_selected(system_id: String) -> void:
func _on_body_selected(body: Dictionary) -> void:
(
nav
. push(
"planet",
{
"body": body,
"system": nav.current_payload().get("system", {}),
}
)
)
nav.push("regional", {
"body": body,
"system": nav.current_payload().get("system", {}),
})
func _on_regional_back() -> void:
@@ -69,9 +69,9 @@ func _draw() -> void:
else:
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), Color(0.05, 0.07, 0.10, 1.0))
# Political zone tint (currency zone band — single tint over whole body for MVP)
# Political zones — province boundaries from drainage analysis
if viewer.is_overlay_visible("political_zones"):
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), COLOR_POLITICAL)
_draw_province_boundaries(markers)
# Infrastructure (roads + rail)
if viewer.is_overlay_visible("infrastructure"):
@@ -238,6 +238,39 @@ static func _city_key(city: Dictionary) -> String:
return "h:%d" % city.hash()
# =============================================================================
# Province boundaries (D-205, #927)
# =============================================================================
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
const PROVINCE_BORDER_WIDTH: float = 1.2
func _draw_province_boundaries(markers: Dictionary) -> void:
var provinces: Array = markers.get("provinces", [])
if provinces.is_empty():
draw_rect(Rect2(Vector2.ZERO, Vector2(viewer.get_heightmap_texture().get_width(), viewer.get_heightmap_texture().get_height())), COLOR_POLITICAL)
return
for prov: Dictionary in provinces:
var path: Array = prov.get("path", [])
if path.size() < 3:
continue
var points: PackedVector2Array = _province_path_to_canvas(path)
if points.size() >= 3:
draw_colored_polygon(points, COLOR_PROVINCE_FILL)
draw_polyline(points, COLOR_PROVINCE_BORDER, PROVINCE_BORDER_WIDTH, true)
func _province_path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
out.append(viewer.grid_to_canvas(Vector2(float(pt[1]), float(pt[0]))))
return out
# =============================================================================
# Overlay placeholders (populated by server side signals eventually)
# =============================================================================
+29 -16
View File
@@ -98,12 +98,6 @@ const OVERLAY_DEFS: Array = [
"group": "toggle",
"tooltip": "Production zones (D-181 observable — requires presence)."
},
{
"id": "shadow_economy",
"label": "SHD",
"group": "toggle",
"tooltip": "Shadow economy zones (D-181 observable)."
},
{
"id": "corp_presence",
"label": "CRP",
@@ -268,15 +262,19 @@ func _load_heightmap() -> void:
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
var path: String = str(ref)
if not path.begins_with("res://"):
path = "res://" + path.lstrip("/")
if not ResourceLoader.exists(path):
push_warning("AtlasViewer: terrain_reference not found at %s" % path)
# terrain_reference points at <body>/heightmap.png — now the canonical
# 16-bit GRAYSCALE elevation (D-202 amended, #963). For display we load the
# color reliefmap.png that sits beside it.
var path: String = str(ref).get_base_dir() + "/reliefmap.png"
if not path.begins_with("/"):
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
path = project_root + "/" + path
if not FileAccess.file_exists(path):
push_warning("AtlasViewer: reliefmap not found at %s" % path)
return
var tex: Variant = load(path)
if tex is Texture2D:
_heightmap_texture = tex
var img := Image.load_from_file(path)
if img != null:
_heightmap_texture = ImageTexture.create_from_image(img)
_tex_w = float(_heightmap_texture.get_width())
_tex_h = float(_heightmap_texture.get_height())
@@ -292,8 +290,9 @@ func _load_markers() -> void:
if ref == null or str(ref).is_empty():
return
var hm_path: String = str(ref)
if not hm_path.begins_with("res://"):
hm_path = "res://" + hm_path.lstrip("/")
if not hm_path.begins_with("/"):
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
hm_path = project_root + "/" + hm_path
var dir: String = hm_path.get_base_dir()
var markers_path: String = dir + "/markers.json"
@@ -386,6 +385,7 @@ func _build_screen_header() -> void:
# theme-swap invariant violation.
_screen_header = ImplantHeader.new()
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
_screen_header.custom_minimum_size.x = 320.0
_screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_screen_header)
if _implant_theme:
@@ -418,6 +418,14 @@ static func _dict_str(d: Dictionary, key: String, fallback: String) -> String:
# =============================================================================
func _is_over_ui(pos: Vector2) -> bool:
if _overlay_bar and _overlay_bar.get_global_rect().has_point(pos):
return true
if _city_panel and _city_panel.visible and _city_panel.get_global_rect().has_point(pos):
return true
return false
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
@@ -426,6 +434,9 @@ func _gui_input(event: InputEvent) -> void:
if _heightmap_texture == null:
return
if event is InputEventMouseButton and _is_over_ui((event as InputEventMouseButton).global_position):
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
@@ -441,6 +452,8 @@ func _gui_input(event: InputEvent) -> void:
else:
_dragging = false
elif event is InputEventMouseMotion:
if _is_over_ui((event as InputEventMouseMotion).global_position):
return
var mm := event as InputEventMouseMotion
if _dragging:
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
@@ -438,7 +438,10 @@ func _gui_input(event: InputEvent) -> void:
if mb.pressed:
match mb.button_index:
MOUSE_BUTTON_LEFT:
_handle_reach_click(mb.position)
if mb.double_click:
_handle_reach_double_click(mb.position)
else:
_handle_reach_click(mb.position)
MOUSE_BUTTON_MIDDLE:
_reach_is_panning = true
_reach_pan_start = mb.position
@@ -476,6 +479,13 @@ func _handle_reach_click(pos: Vector2) -> void:
_dirty = true
func _handle_reach_double_click(pos: Vector2) -> void:
var sid := _find_nearest_reach_system(pos)
if not sid.is_empty():
_reach_selected = sid
system_selected.emit(sid)
func _update_reach_hover(pos: Vector2) -> void:
var nearest := _find_nearest_reach_system(pos)
if nearest != _reach_hovered:
@@ -6,14 +6,15 @@ extends Control
signal body_selected(body: Dictionary)
const ORBITAL_CENTER_FRACTION := Vector2(0.5, 0.55)
const STAR_RADIUS: float = 12.0
const ORBITAL_RING_BASE: float = 65.0
const ORBITAL_RING_STEP: float = 58.0
const MOON_ORBIT_RADIUS: float = 24.0
const STATION_SIZE: float = 6.0
const BODY_HIT_RADIUS: float = 16.0
const LABEL_OFFSET: float = 11.0
const STAR_RADIUS: float = 140.0
const STAR_X_OFFSET: float = 0.0
const BODY_LEFT_MARGIN: float = 300.0
const BODY_RIGHT_MARGIN: float = 60.0
const MOON_CLEARANCE: float = 12.0
const STATION_SIZE: float = 8.0
const STATION_OFFSET_Y: float = -26.0
const BODY_HIT_RADIUS: float = 20.0
const LABEL_OFFSET: float = 14.0
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
@@ -35,15 +36,18 @@ var _orbital_bodies: Array = []
var _orbital_stations: Array = []
var _body_positions: Dictionary = {}
var _station_positions: Dictionary = {}
var _body_textures: Dictionary = {} # body_id -> ImageTexture
var _hovered_body: String = ""
var _hovered_station: String = ""
var _selected_station: Dictionary = {}
var _selected_body: Dictionary = {}
var _dirty: bool = true
var _in_orbital: bool = false
var _picker_panel = null # ImplantPanel
var _picker_nav_row = null # ImplantDataRow
var _station_panel = null # ImplantPanel
var _body_panel = null # ImplantPanel
func _ready() -> void:
@@ -62,6 +66,7 @@ func _process(_delta: float) -> void:
func setup(implant_theme) -> void:
_build_picker_panel(implant_theme)
_build_station_panel(implant_theme)
_build_body_panel(implant_theme)
func set_systems(systems: Array) -> void:
@@ -116,11 +121,15 @@ func load_and_show_orbital() -> void:
_hovered_body = ""
_hovered_station = ""
_selected_station = {}
_selected_body = {}
_load_body_textures(sys.get("system_id", ""))
_compute_body_positions()
if _picker_panel:
_picker_panel.visible = false
if _station_panel:
_station_panel.visible = false
if _body_panel:
_body_panel.visible = false
_in_orbital = true
_dirty = true
@@ -157,27 +166,30 @@ func _draw() -> void:
func _draw_orbital() -> void:
var sz: Vector2 = get_rect().size
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
var mid_y: float = sz.y * 0.5
var star_center := Vector2(STAR_X_OFFSET, mid_y)
draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG)
_draw_orbit_rings(center)
draw_circle(center, STAR_RADIUS + 4.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.18))
draw_circle(center, STAR_RADIUS, COLOR_STAR)
draw_circle(star_center, STAR_RADIUS + 40.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.04))
draw_circle(star_center, STAR_RADIUS + 20.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.08))
draw_circle(star_center, STAR_RADIUS, COLOR_STAR)
_draw_orbital_arcs(star_center)
_draw_stations()
_draw_bodies()
func _draw_orbit_rings(center: Vector2) -> void:
var seen_orbits: Dictionary = {}
func _draw_orbital_arcs(star_center: Vector2) -> void:
for b: Dictionary in _orbital_bodies:
if b.get("parent_body_id") != null:
continue
var idx: int = int(b.get("orbit_index", 0))
if seen_orbits.has(idx):
var bid: String = str(b.get("body_id", ""))
if not _body_positions.has(bid):
continue
seen_orbits[idx] = true
var r: float = ORBITAL_RING_BASE + (idx - 1) * ORBITAL_RING_STEP
draw_arc(center, r, 0.0, TAU, 64, COLOR_RING, 0.5, true)
var body_pos: Vector2 = _body_positions[bid]
var orbit_r: float = body_pos.x - star_center.x
if orbit_r <= 0.0:
continue
draw_arc(star_center, orbit_r, -PI * 0.7, PI * 0.7, 64, COLOR_RING, 0.5, true)
func _draw_bodies() -> void:
@@ -188,36 +200,31 @@ func _draw_bodies() -> void:
continue
var pos: Vector2 = _body_positions[bid]
var body_type: String = b.get("body_type", "")
var atmo: String = b.get("atmosphere", "none")
var inhabited: bool = bool(b.get("inhabited", false))
var color: Color
var radius: float
match body_type:
"moon":
color = COLOR_MOON
radius = 3.5
"oort_cloud":
color = COLOR_OORT
radius = 2.0
_:
if inhabited:
color = COLOR_PLANET_INHABITED
radius = 6.0
elif atmo in ["breathable", "standard"]:
color = COLOR_PLANET_HABITABLE
radius = 5.5
else:
color = COLOR_PLANET_BARE
radius = 4.5
var radius_km: float = float(b.get("body_radius_km", 0))
var radius: float = _km_to_draw_radius(radius_km, body_type)
if bid == _hovered_body:
draw_arc(pos, radius + 5.0, 0.0, TAU, 20, Color(1.0, 1.0, 1.0, 0.25), 1.0, true)
draw_arc(pos, radius + 5.0, 0.0, TAU, 20, Color(1.0, 1.0, 1.0, 0.25), 1.5, true)
draw_circle(pos, radius, color)
var tex: ImageTexture = _body_textures.get(bid)
if tex:
var draw_size := Vector2(radius * 2.0, radius * 2.0)
draw_texture_rect(tex, Rect2(pos - draw_size / 2.0, draw_size), false)
else:
var atmo: String = b.get("atmosphere", "none")
var inhabited: bool = bool(b.get("inhabited", false))
var color: Color
match body_type:
"moon": color = COLOR_MOON
"oort_cloud": color = COLOR_OORT
_:
if inhabited: color = COLOR_PLANET_INHABITED
elif atmo in ["breathable", "standard"]: color = COLOR_PLANET_HABITABLE
else: color = COLOR_PLANET_BARE
draw_circle(pos, radius, color)
var label: String = b.get("proper_name", bid) if b.get("proper_name") else bid
var show_label: bool = inhabited or bid == _hovered_body
var show_label: bool = body_type != "oort_cloud" or bid == _hovered_body
if show_label:
var lcolor: Color = COLOR_TEXT if bid == _hovered_body else COLOR_TEXT_DIM
var lsz: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9)
@@ -269,7 +276,8 @@ func _compute_body_positions() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
var mid_y: float = sz.y * 0.5
var usable_width: float = sz.x - BODY_LEFT_MARGIN - BODY_RIGHT_MARGIN
var top_bodies: Array = []
for b: Dictionary in _orbital_bodies:
@@ -280,30 +288,16 @@ func _compute_body_positions() -> void:
return int(a.get("orbit_index", 0)) < int(b.get("orbit_index", 0))
)
var by_orbit: Dictionary = {}
for b: Dictionary in top_bodies:
var idx: int = int(b.get("orbit_index", 0))
if not by_orbit.has(idx):
by_orbit[idx] = []
by_orbit[idx].append(b)
var count: int = top_bodies.size()
for i: int in range(count):
var b: Dictionary = top_bodies[i]
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var t: float = float(i) / float(maxi(count - 1, 1))
var x: float = BODY_LEFT_MARGIN + t * usable_width
_body_positions[bid] = Vector2(x, mid_y)
for orbit_idx: int in by_orbit:
var ring_bodies: Array = by_orbit[orbit_idx]
var ring_r: float = ORBITAL_RING_BASE + (orbit_idx - 1) * ORBITAL_RING_STEP
var count: int = ring_bodies.size()
for i: int in range(count):
var b: Dictionary = ring_bodies[i]
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var angle: float
if count == 1:
angle = -PI / 2.0
else:
angle = -PI / 2.0 + TAU * float(i) / float(count)
_body_positions[bid] = center + Vector2(cos(angle), sin(angle)) * ring_r
# Moons per parent count drives spacing — hard-coded divisor caused overlap on gas giants
var moons_by_parent: Dictionary = {}
for b: Dictionary in _orbital_bodies:
var parent_id: Variant = b.get("parent_body_id")
@@ -313,6 +307,10 @@ func _compute_body_positions() -> void:
if not moons_by_parent.has(key):
moons_by_parent[key] = []
moons_by_parent[key].append(b)
var body_dict_by_id: Dictionary = {}
for b: Dictionary in _orbital_bodies:
body_dict_by_id[str(b.get("body_id", ""))] = b
for parent_key: String in moons_by_parent:
if not _body_positions.has(parent_key):
continue
@@ -322,16 +320,23 @@ func _compute_body_positions() -> void:
return int(a.get("orbit_index", 0)) < int(b.get("orbit_index", 0))
)
var parent_pos: Vector2 = _body_positions[parent_key]
var count: int = siblings.size()
for i: int in range(count):
var moon: Dictionary = siblings[i]
var parent_data: Dictionary = body_dict_by_id.get(parent_key, {})
var parent_draw_r: float = _km_to_draw_radius(
float(parent_data.get("body_radius_km", 0)),
parent_data.get("body_type", "")
)
var moon_count: int = siblings.size()
for j: int in range(moon_count):
var moon: Dictionary = siblings[j]
var moon_id: String = str(moon.get("body_id", ""))
if moon_id.is_empty():
continue
var angle: float = -PI / 2.0 + TAU * float(i) / float(count)
_body_positions[moon_id] = (
parent_pos + Vector2(cos(angle), sin(angle)) * MOON_ORBIT_RADIUS
var moon_draw_r: float = _km_to_draw_radius(
float(moon.get("body_radius_km", 0)), "moon"
)
var label_space: float = LABEL_OFFSET + 12.0
var y_off: float = parent_draw_r + label_space + MOON_CLEARANCE + (moon_draw_r * 2.0 + MOON_CLEARANCE) * float(j)
_body_positions[moon_id] = parent_pos + Vector2(0.0, y_off)
for s: Dictionary in _orbital_stations:
var station_id: String = str(s.get("station_id", ""))
@@ -339,11 +344,15 @@ func _compute_body_positions() -> void:
continue
var parent_id: Variant = s.get("orbits_body_id")
var station_parent_pos: Vector2
var parent_r: float = 0.0
if parent_id != null and _body_positions.has(str(parent_id)):
station_parent_pos = _body_positions[str(parent_id)]
var pd: Dictionary = body_dict_by_id.get(str(parent_id), {})
parent_r = _km_to_draw_radius(float(pd.get("body_radius_km", 0)), pd.get("body_type", ""))
else:
station_parent_pos = center
_station_positions[station_id] = station_parent_pos + Vector2(20.0, -10.0)
station_parent_pos = Vector2(sz.x - BODY_RIGHT_MARGIN, mid_y)
var station_y: float = -(parent_r + LABEL_OFFSET + 12.0 + STATION_SIZE + MOON_CLEARANCE)
_station_positions[station_id] = station_parent_pos + Vector2(0.0, station_y)
# =============================================================================
@@ -355,12 +364,16 @@ func _gui_input(event: InputEvent) -> void:
if not _in_orbital:
return
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
_handle_orbital_click((event as InputEventMouseButton).position)
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT and mb.double_click:
_handle_orbital_double_click(mb.position)
elif mb.button_index == MOUSE_BUTTON_LEFT:
_handle_orbital_click(mb.position)
elif event is InputEventMouseMotion:
_handle_orbital_hover((event as InputEventMouseMotion).position)
func _handle_orbital_click(pos: Vector2) -> void:
func _handle_orbital_double_click(pos: Vector2) -> void:
var bid: String = _find_nearest_body(pos)
if not bid.is_empty():
for b: Dictionary in _orbital_bodies:
@@ -368,22 +381,65 @@ func _handle_orbital_click(pos: Vector2) -> void:
body_selected.emit(b)
return
func _handle_orbital_click(pos: Vector2) -> void:
var bid: String = _find_nearest_body(pos)
if not bid.is_empty():
for b: Dictionary in _orbital_bodies:
if str(b.get("body_id", "")) == bid:
_selected_body = b
_selected_station = {}
if _station_panel:
_station_panel.visible = false
_rebuild_body_panel()
if _body_panel:
_body_panel.visible = true
_dirty = true
return
var sid: String = _find_nearest_station(pos)
if not sid.is_empty():
for s: Dictionary in _orbital_stations:
if str(s.get("station_id", "")) == sid:
_selected_station = s
_selected_body = {}
if _body_panel:
_body_panel.visible = false
_rebuild_station_panel()
if _station_panel:
_station_panel.visible = true
return
_close_detail_panels()
_dirty = true
func _close_detail_panels() -> void:
_selected_body = {}
_selected_station = {}
if _body_panel:
_body_panel.visible = false
if _station_panel:
_station_panel.visible = false
func has_body_panel_open() -> bool:
return not _selected_body.is_empty()
func has_station_panel_open() -> bool:
return not _selected_station.is_empty()
func close_panels() -> void:
_close_detail_panels()
_dirty = true
func get_selected_body() -> Dictionary:
return _selected_body
func _handle_orbital_hover(pos: Vector2) -> void:
var new_body: String = _find_nearest_body(pos)
var new_station: String = "" if not new_body.is_empty() else _find_nearest_station(pos)
@@ -415,6 +471,53 @@ func _find_nearest_station(pos: Vector2) -> String:
return best_id
# =============================================================================
# Globe textures
# =============================================================================
func _load_body_textures(system_id: String) -> void:
_body_textures.clear()
var sys_dir: String = system_id.replace(" ", "-")
var wiki_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir() + "/wiki/star-systems"
for b: Dictionary in _orbital_bodies:
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var bt: String = b.get("body_type", "")
var globe_path: String
if bt in ["oort_cloud", "asteroid_belt"]:
globe_path = wiki_root + "/_generics/" + bt + "/globe.png"
else:
globe_path = wiki_root + "/" + sys_dir + "/bodies/" + bid + "/globe.png"
if not FileAccess.file_exists(globe_path):
continue
var img := Image.load_from_file(globe_path)
if img == null:
continue
var tex := ImageTexture.create_from_image(img)
_body_textures[bid] = tex
# =============================================================================
# Sizing
# =============================================================================
const DRAW_RADIUS_MIN: float = 4.0
const DRAW_RADIUS_MAX: float = 48.0
const DRAW_RADIUS_REF_KM: float = 6371.0
const DRAW_RADIUS_REF_PX: float = 24.0
static func _km_to_draw_radius(radius_km: float, _body_type: String) -> float:
if radius_km <= 0.0:
return DRAW_RADIUS_MIN
var ratio: float = radius_km / DRAW_RADIUS_REF_KM
var px: float = DRAW_RADIUS_REF_PX * pow(ratio, 0.5)
return maxf(px, DRAW_RADIUS_MIN)
# =============================================================================
# Panels
# =============================================================================
@@ -471,7 +574,6 @@ func _build_station_panel(implant_theme) -> void:
_station_panel.theme_resource = implant_theme
_station_panel.custom_minimum_size.x = PANEL_WIDTH
_station_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_station_panel.position = Vector2(PANEL_MARGIN, 60.0)
_station_panel.visible = false
add_child(_station_panel)
@@ -484,6 +586,9 @@ func _rebuild_station_panel() -> void:
if _selected_station.is_empty():
return
var sz: Vector2 = get_rect().size
_station_panel.position = Vector2(sz.x - PANEL_WIDTH - 60.0, 60.0)
var s: Dictionary = _selected_station
var s_name: String = (
s.get("proper_name", "") if s.get("proper_name") else s.get("station_id", "—")
@@ -507,3 +612,91 @@ func _rebuild_station_panel() -> void:
_station_panel.add_component(ImplantSeparator.new())
_station_panel.add_component(ImplantTextBlock.new("station atlas deferred"))
_station_panel.add_component(ImplantTextBlock.new("esc back"))
func _build_body_panel(implant_theme) -> void:
_body_panel = ImplantPanel.new()
_body_panel.name = "BodyPanel"
_body_panel.theme_resource = implant_theme
_body_panel.custom_minimum_size.x = PANEL_WIDTH
_body_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_body_panel.visible = false
add_child(_body_panel)
func _rebuild_body_panel() -> void:
if not _body_panel:
return
_body_panel.clear()
if _selected_body.is_empty():
return
var sz: Vector2 = get_rect().size
_body_panel.position = Vector2(sz.x - PANEL_WIDTH - 60.0, 60.0)
var b: Dictionary = _selected_body
var bid: String = b.get("body_id", "")
var name_str: String = b.get("proper_name", "") if b.get("proper_name") else bid
var body_type: String = b.get("body_type", "").replace("_", " ").to_upper()
var mass_class: String = b.get("mass_class", "") if b.get("mass_class") else ""
var atmo: String = b.get("atmosphere", "none") if b.get("atmosphere") else "none"
var inhabited: bool = bool(b.get("inhabited", false))
var pop: int = int(b.get("population", 0))
var radius_km: float = float(b.get("body_radius_km", 0))
var sys: Dictionary = current_system()
var sys_name: String = sys.get("proper_name", sys.get("system_id", "—"))
# Globe artwork at top
var tex: ImageTexture = _body_textures.get(bid)
if tex:
var globe_rect := TextureRect.new()
globe_rect.texture = tex
globe_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
globe_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
globe_rect.custom_minimum_size = Vector2(PANEL_WIDTH, PANEL_WIDTH)
_body_panel.add_component(globe_rect)
_body_panel.add_component(ImplantHeader.new(name_str, sys_name + " system"))
_body_panel.add_component(ImplantSeparator.new())
var type_line: String = body_type
if not mass_class.is_empty():
type_line += " · " + mass_class.replace("_", " ").to_upper()
_body_panel.add_component(ImplantDataRow.new(type_line))
_body_panel.add_component(ImplantDataRow.new("atmosphere " + atmo))
if radius_km > 0:
_body_panel.add_component(ImplantDataRow.new("radius " + _format_radius(radius_km)))
if inhabited:
_body_panel.add_component(ImplantSeparator.new())
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
_body_panel.add_component(ImplantSeparator.new())
var has_heightmap: bool = b.get("terrain_reference") != null
if has_heightmap:
_body_panel.add_component(ImplantTextBlock.new("enter view heightmap atlas"))
_body_panel.add_component(ImplantTextBlock.new("esc close"))
_dirty = true
static func _format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
static func _format_radius(km: float) -> String:
if km >= 10000.0:
return "%s km" % _format_pop(int(km))
return "%.0f km" % km
@@ -1,4 +1,4 @@
extends MetaScreen
extends "res://ui/meta/meta_screen.gd"
## #258: Main menu — New Game / Continue / Load Game / Quit.
## New Game: opens character creation screen, then starts game.
## Continue: loads most recent save directory.
+13 -40
View File
@@ -9,7 +9,6 @@ extends Control
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
#
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
const MAX_VISIBLE: int = 3
@@ -20,29 +19,16 @@ const FADE_IN_SEC: float = 0.3
const FADE_OUT_SEC: float = 0.5
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
# standard opacity = 0.85, urgent opacity = 1.0.
# Monologue colour palette — standard opacity = 0.85, urgent opacity = 1.0.
# Source: Tyre architecture review, Sprint 14.
const _LATTICE_COLORS: Dictionary = {
"lattice_augmented":
{ # detective
"standard": Color("#d0d4e0"),
"urgent": Color("#e0e8f8"),
},
"lattice_baseline":
{ # smuggler
"standard": Color("#d8d0c4"),
"urgent": Color("#f0e4d4"),
},
}
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
const _STANDARD_COLOR: Color = Color("#c8d0e0")
const _URGENT_COLOR: Color = Color("#e0e8f8")
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
const _NOTIFICATION_DURATION: float = 2.5
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
var _visible: Array[Dictionary] = []
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
# Queue entry: {text, duration, priority, is_urgent}
var _queue: Array[Dictionary] = []
# Msec timestamp when the next fade-in may begin (stagger enforcement)
var _next_fade_in_msec: float = 0.0
@@ -69,19 +55,15 @@ func _process(delta: float) -> void:
if next.get("is_notification", false):
_show_notification_line(next.text)
else:
_show_line(
next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile
)
_show_line(next.text, next.duration, next.priority, next.is_urgent)
# Display a monologue line.
# priority: higher number = more important (default 2; urgent beats normal).
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
# Empty text is silently ignored — no slot created, no queue entry.
# lattice_profile is read from GameState here and passed down — renderer stays
# decoupled from the autoload (D-020 renderer contract).
# #554: Show a brief system notification (save/load result, connection status).
# Uses neutral color, short duration, bypasses lattice_profile styling.
# Uses neutral color, short duration.
func show_notification(text: String) -> void:
if text.is_empty():
return
@@ -94,7 +76,6 @@ func show_notification(text: String) -> void:
duration = _NOTIFICATION_DURATION,
priority = 1,
is_urgent = false,
lattice_profile = "",
is_notification = true
}
if _queue.size() < MAX_QUEUE:
@@ -116,12 +97,11 @@ func show_monologue(
) -> void:
if text.is_empty():
return
var profile := GameState.lattice_profile
var now := float(Time.get_ticks_msec())
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
_show_line(text, duration, priority, is_urgent, profile)
_show_line(text, duration, priority, is_urgent)
else:
_enqueue(text, duration, priority, is_urgent, profile)
_enqueue(text, duration, priority, is_urgent)
# ---------------------------------------------------------------------------
@@ -159,9 +139,9 @@ func _show_notification_line(text: String) -> void:
func _show_line(
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
text: String, duration: float, priority: int, is_urgent: bool
) -> void:
var line_node := _build_line_node(text, is_urgent, lattice_profile)
var line_node := _build_line_node(text, is_urgent)
_vbox.add_child(line_node)
var slot := {
@@ -192,7 +172,7 @@ func _retire_slot(slot: Dictionary) -> void:
func _enqueue(
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
text: String, duration: float, priority: int, is_urgent: bool
) -> void:
if _queue.size() < MAX_QUEUE:
_queue.append(
@@ -201,7 +181,6 @@ func _enqueue(
duration = duration,
priority = priority,
is_urgent = is_urgent,
lattice_profile = lattice_profile
}
)
_queue.sort_custom(
@@ -216,7 +195,6 @@ func _enqueue(
duration = duration,
priority = priority,
is_urgent = is_urgent,
lattice_profile = lattice_profile
}
_queue.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
@@ -232,13 +210,8 @@ func _lowest_priority_idx() -> int:
return idx
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
var color: Color = (
palette.get("urgent", _FALLBACK_URGENT)
if is_urgent
else palette.get("standard", _FALLBACK_STANDARD)
)
func _build_line_node(text: String, is_urgent: bool) -> Control:
var color: Color = _URGENT_COLOR if is_urgent else _STANDARD_COLOR
var container := MarginContainer.new()
container.add_theme_constant_override("margin_left", 4)
+21 -8
View File
@@ -16,19 +16,31 @@ CREATE TABLE IF NOT EXISTS tickets (
assigned_to TEXT,
team TEXT, -- comma-separated team names, e.g. 'server', 'client,server'
decision_ref TEXT, -- e.g. 'D-010' or 'Q-001'
sprint_id INTEGER REFERENCES sprints(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sprints (
CREATE TABLE IF NOT EXISTS milestones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
goal TEXT,
start_date TEXT,
end_date TEXT,
status TEXT NOT NULL DEFAULT 'planning' CHECK(status IN ('planning', 'active', 'completed', 'cancelled')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
description TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'completed', 'cancelled')),
cascade_phase INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS ticket_milestones (
ticket_id INTEGER NOT NULL REFERENCES tickets(id),
milestone_id INTEGER NOT NULL REFERENCES milestones(id),
PRIMARY KEY (ticket_id, milestone_id)
);
CREATE TABLE IF NOT EXISTS milestone_deps (
blocker_id INTEGER NOT NULL REFERENCES milestones(id),
blocked_id INTEGER NOT NULL REFERENCES milestones(id),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE TABLE IF NOT EXISTS ticket_deps (
@@ -56,11 +68,12 @@ CREATE TABLE IF NOT EXISTS ticket_labels (
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_id);
CREATE INDEX IF NOT EXISTS idx_tickets_sprint ON tickets(sprint_id);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_decision ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_history_ticket ON ticket_history(ticket_id);
CREATE INDEX IF NOT EXISTS idx_ticket_milestones_ticket ON ticket_milestones(ticket_id);
CREATE INDEX IF NOT EXISTS idx_ticket_milestones_milestone ON ticket_milestones(milestone_id);
-- ---------------------------------------------------------------------------
-- Decision Sync Tables
+2 -2
View File
@@ -10,12 +10,12 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
| File | Domain | Decisions |
|------|--------|-----------|
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152, D-191 |
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152, D-188, D-191, D-192, D-194–D-218, D-220 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092, D-093, D-095, D-098, D-104, D-105, D-107, D-121, D-122, D-123, D-124, D-125, D-126, D-127, D-128, D-129, D-130, D-131, D-132, D-138, D-139, D-140, D-142, D-147 |
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065, D-087, D-089, D-091, D-114, D-115, D-116, D-117, D-118, D-119, D-120, D-145, D-146, D-153, D-154, D-155, D-156, D-157 |
| [economics.md](economics.md) | Economics layer, currencies, corporations, simulation | D-171, D-172, D-173, D-174, D-175, D-176, D-177, D-178, D-179, D-180, D-181, D-182, D-183, D-184, D-185, D-186, D-187, D-189, D-190 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040, D-221 |
| [questions.md](questions.md) | Open questions (index) | Q-001 through Q-094 |
| [questions-architecture.md](questions-architecture.md) | Technical questions | Q-001, Q-006, Q-009, Q-018–Q-023, Q-029, Q-030, Q-046, Q-059, Q-060, Q-063–Q-094 |
| [questions-perception.md](questions-perception.md) | Observation questions | Q-003, Q-014, Q-016, Q-024–Q-026, Q-051, Q-053, Q-054 |
+727 -7
View File
@@ -171,6 +171,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Sprites: 2x2 sim tile footprint.** Entity sprites render across 2x2 sim tiles so they feel proportional to the 1m visual grid. Tween interpolation ([D-054](#d-054-tile-based-movement-with-same-tile-occupancy)) hides half-visual-tile movement increments.
- **Mental model:** "Objects are where they look. I can position myself precisely within open space." The player reads cover and walls at visual scale (always correct). Fine movement granularity is felt, not counted.
- **What the simulation does NOT know:** Visual tiles. The retina factor is purely a client rendering convention. The server operates exclusively on 0.5m sim tiles.
- **Amendment (2026-05-22, [D-222](#d-222)):** canonical vocabulary. This 0.5m "sim tile" is now the **Subtile** — the granularity the server simulation, entity positioning, and render detail run on. The **Tile = 1m** (2×2 subtiles) is the unit sizes are quoted in and the one the world-generation cascade, pathfinding, and grids operate on. The dual-scale model and the 0.5m simulation granularity are unchanged; only the naming primacy flips. The Chunk/Block/Quarter/District ladder built on the Tile is canonical in D-222.
- **Fog shader ([D-059](perception.md#d-059-fog--shader-based-five-layers-knowledge-graph-driven)):** Unaffected — fog is screen-space, driven by PointLight2D vision cone and LOS mask from sim-resolution shadowcasting. Gradient edge "3-4 tiles" is retuned to 6-8 sim tiles (= 3-4 visual tiles) to preserve the intended softness.
- **Cursor/interaction:** No change — cursor already resolves to sim tile from pixel position. Interaction range of ~2 sim tiles = 1m (arm's length).
- **Map authoring:** Author at 1m visual scale. Subdivision tool expands each visual tile to 4 sim tiles (2x2). Validation enforces 2x2 minimum on all world geometry layers.
@@ -239,6 +240,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
### D-094: District Spatial Hierarchy — Chunk, Block, District Naming and Sizes
- **Date:** 2026-02-25
- **Decision:** The spatial hierarchy for map generation and streaming is defined as follows. **Chunk** = 64×64 sim tiles (32×32 visual tiles, 32m) — the streaming and serialization unit. **Block** = 128×128 sim tiles (64×64 visual tiles, 64m) — the generator planning unit, composed of 4 chunks arranged in a 2×2 grid. Each block contains 4 chunks; chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes), or form L-shaped buildings across chunk boundaries. **District** = 4×4 blocks = 512×512 sim tiles (256×256 visual tiles, 256m) per z-level, containing 16 blocks and 64 chunks. Large civic structures (gate terminals, horizon station installations, stadiums, parks) span multiple blocks. Three z-levels for the Transit District = ~1.35MB (trivial). This decision amends D-012 and overrides the ~150×150 visual estimate in D-014.
- **Amendment (2026-05-22, [D-222](#d-222)):** naming + scale superseded. The unit this record calls a **District** (4×4 blocks) is renamed the **Quarter** (now 512m at Tile = 1m); **District** is promoted to a new 2048m tier (4×4 quarters). Tier *counts* and nesting structure here are unchanged — only the names and the metre values (per the Tile = 1m / Subtile = 0.5m scale of D-220). See D-222 for the canonical ladder and the lore-vs-code rule.
- **Rationale:** Chunk size of 32×32 visual (64×64 sim) gives a 32m streaming cell — large enough to hold a meaningful space, small enough for efficient streaming. The 2×2-chunk block provides a generator planning unit with enough granularity for per-chunk variation. The 4×4 block district (256×256 visual) gives a full district footprint generalisable as a template for the Q-036 generator. The chunk-based fill system within blocks allows the generator to place buildings of varying scale without hard-coding building dimensions.
- **Raised by:** Tyre (chunk/block spec and memory confirmation), confirmed by team. Lead ratified district = 4×4 blocks.
- **Dissent:** Araminta preferred 32×32 visual chunk size (effectively halving the chunk to a 16m cell). Overruled by lead and team majority — 32m chunk is the minimum viable streaming cell for the simulation architecture.
@@ -588,10 +590,11 @@ Technical foundation decisions that constrain implementation: engine, client-ser
| 1 | Wiki content complete — all planets, moons, stations, heightmaps, artwork | Implant-ready Godot map with click-throughs + wiki/GTTR popups |
| 2 | Economics layer — supply/demand, transport, corporations, supply chains | Economics spreadsheets/graphs with runtime-tweakable simulation |
| 3 | Planetary/moon maps & station layouts — cities, rivers, mountains, roads, biomes | Atlas of the Reach (implant app) |
| 4 | Player control scheme — 2-floor test map, character rendering, walls/stairs/doors, lighting | Player viewport with final-version assets |
| 5 | World generation (tile/chunk/block) — walkable world, parallel asset pipeline | Walkable generated world + asset catalog |
| 4 | World generation (tile/chunk/block) — deterministic multilayer seed→tile cascade; each layer viewed as a map in the implant Atlas (no in-world rendering) | Deterministic walkable-world data + per-layer Atlas maps + asset catalog |
| 5 | Player control & in-world rendering — character, walls/stairs/doors, lighting, drawn on generated tiles (no test map) | Player viewport with final-version assets on the generated world |
| 6 | Detail coloring — room-level content, cultural architecture | Only when the world is walkable |
- **Amendment (2026-05-22):** Phases 4 and 5 swapped — **world generation now precedes player control**. The rule: no player-control or in-world rendering work begins until the generator can deterministically seed-generate every tile of every world via the full multilayer cascade. The original Phase 4 "2-floor test map" is **dropped** — test layers are produced by the generator itself once layer-drawing begins; we start drawing the world only when generation knows what to draw. Generation progress is viewed as **per-layer maps in the implant Atlas** (the Phase 3 deliverable, already built), not via an in-world renderer. The existing in-world rendering code is **left as-is until Phase 5** — neither built upon nor removed before then. Rationale: building player systems against a throwaway test substrate means rebuilding them against real generated tiles later; gating player work on deterministic generation avoids that waste. Epics #749 (now Phase 5) and #750 (now Phase 4) and the CLAUDE.md cascade table are updated to match.
- **Rationale:** The pattern of negotiating pragmatic v0.2 cuts while discussing room-level detail repeatedly produced superseded decisions, confused agents, and distracted from building the actual product. The cascade enforces a first-things-first discipline: each layer of the game is grounded in the layer below it before detail is added.
- **Raised by:** Jeroen, established 2026-03-24 during world generation workshop.
- **Dissent:** None.
@@ -700,16 +703,18 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- 3 navigation levels: system picker → orbital diagram → body atlas (heightmap + overlays)
- Heightmap displayed as `Texture2D` with pan/zoom
- Marker overlay renders cities, roads, rail, POIs, named features on top of heightmap
- Click city → City Data Panel (name, population, currency zone, Commission presence, shadow economy zone, gate distance, economics panel link)
- Click city → City Data Panel (name, population, currency zone, Commission presence, gate distance, economics panel link) — shadow economy zone dropped per §7 amendment (2026-05-22)
- Economics panel link: click-through to Phase 2 economics panel pre-filtered to that node — primary Phase 2/3 integration point
**7. Overlay System — 9 MVP Overlays**
**7. Overlay System — 8 MVP Overlays**
- **Amendment (2026-05-22):** dropped the `shadow economy` toggleable overlay (9 → 8 overlays). Shadow economy is an *underwater simulation modifier* — `shadow_economy_intensity` (D-174) feeds derived signals such as `collection_efficiency` and signal 7 `official_coverage_ratio` (D-181), but it is not a user-navigable data point, so it does not warrant a player-facing atlas overlay or City Data Panel field. The simulation layer (D-174) is unaffected. Also removed from §6 (city data panel) and §10 (completion criteria) for consistency.
- 5 always-on: terrain, infrastructure, named features, gate/spaceport POIs, political zones
- 4 toggleable: population density, production zones, shadow economy zones (broad bands), corporate presence (Tier 1 only)
- 3 toggleable: population density, production zones, corporate presence (Tier 1 only)
- Deferred overlays visible in toggle bar but locked with unlock requirements shown on hover (creates pull toward Phase 4+ systems)
- Maps to D-181 signal visibility ladder
**8. Settlement Data Model**
- **Amendment (2026-05-22, [D-223](#d-223)):** superseded — markers.json is reduced to **names only** (a flavored name pool); it no longer carries position arrays or topographic-feature geometry. River/mountain positions derive from the heightmap + drainage (D-208); settlement positions from the economic sim + placement (D-211). The 6 hand-authored templates and their `reserved` pinning are removed (bodies stay as ordinary named bodies). The pixel-space schema below is retained as historical reference only.
- **Amendment (2026-04-15):** The original §8 (below) described marker positions as lat/lon objects and city records keyed by `population_tier`/`primary_function`/`gate_terminal`/`continent_id`. That shape was aspirational — neither the generator nor the hand-authored templates ever emitted it. Both ended up writing pixel-space row/col arrays against a `512 × 256` storage grid, and PR #129 canonizes that shape so the code and the decision stop drifting. The original prose is preserved immediately below; the current shape follows.
- **Original (2026-04-10, superseded):** markers.json schema per body: `cities` (name, lat/lon, population_tier, primary_function, gate_terminal, continent_id), `roads` (path polylines, connects), `railroads` (path polylines, connects), `pois` (name, kind, position), plus existing rivers/oceans/mountains with names filled. Population tier → city count: `floor(log10(pop/1M))`, modified by `settlement_pattern`. Gate terminal POI at largest population center, sometimes scattered to a smaller one. Moons: same depth as planets, scale with population.
- **Current canonical format:** markers.json is stored in heightmap pixel space. Every marker file declares a `grid: { w, h }` header — the generator, the 6 hand-authored templates (Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade), and all 2394 procedural seed files ship `{"w": 512, "h": 256}`. Every position is a two-element **array** `[row, col]` of integer pixels into that grid, where `row ∈ [0, h)` and `col ∈ [0, w)` (row is the first axis to match NumPy convention and the flood-fill / A* / cost-grid code that `tooling/planet-gen/` already runs in). Polyline geometry (`roads[*].path`, `railroads[*].path`, `rivers[*].path`) is `[[row, col], [row, col], ...]`.
@@ -740,7 +745,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
3. Population-scaled depth (core systems rich, frontier sparse)
4. City data panel works (click any city, see data + economics link)
5. Economics panel integration works (link opens Phase 2 panel filtered to node)
6. All 9 MVP overlays present and functional
6. All 8 MVP overlays present and functional
7. Atlas is read-only (no verbs execute from map)
8. Stations show at system view with mini data panel (no drill-down)
@@ -761,4 +766,719 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*54 decisions. Last updated: 2026-04-21 (D-192 — drop PROTOCOL_VERSION lockstep handshake, sprint 36 client triage)*
### D-194: Three-Component District Mix Algorithm for City District Type Distribution
- **Date:** 2026-05-01
- **Decision:** District type distribution for a generated city is computed from three components combined at generation time:
1. **Population tier guarantees** — minimum district counts enforced by city size. Population tier is `floor(log10(pop / 1_000_000))`, capped at 5. Larger populations guarantee minimum counts of Transit, Commercial, and Residential districts.
2. **10×9 economic multiplier table** — rows are 10 `economic_role` values (manufacturing, financial, agricultural, extraction, service_mixed, institutional, transit_hub, research, military, residential); columns are 9 `DistrictType` variants. Each cell is a weight multiplier (0.0–3.0) applied to that district type's base probability for cities of that economic role.
3. **Political archetype modifiers** — `PoliticalArchetype` shifts weights for Institutional, Restricted-access, and Civic district types. Corporate archetype boosts Commercial + Restricted. Commission archetype boosts Institutional + Administrative. Pioneer archetype boosts Mixed-use + Organic residential.
- **Founding age character** is applied as a post-mix adjustment to `BlockIrregularity` (see D-216), not to the district type distribution itself.
- The mix is self-contained per city: two cities with the same economic role, population tier, and political archetype produce the same district type distribution (modulo seed-driven noise). No city-to-city state dependency.
- Integer weights throughout — no f32 for D-010 determinism.
- **Rationale:** Economic role should visibly shape a city's physical form. A financial hub looks different from a mining hub. Population tier prevents cities from being too small to sustain their economic function. Political archetype encodes power structure in spatial form — Corporate settlements are commercially dense, Commission settlements are institutionally heavy. The three-component model is the minimum set to produce legible variety; adding more inputs risks over-constraining the generator.
- **Ticket:** #920
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas pipeline), D-213 (DistrictType enum), D-214 (PoliticalArchetype), D-216 (BlockIrregularity)
### D-195: Attractor-Matching Compatibility Matrix for Generative City Placement
- **Date:** 2026-05-01
- **Decision:** City placement on a planetary surface uses an attractor-matching model. A `GeographicAttractor` is a terrain feature that increases city placement score at nearby positions. Seven `AttractorType` variants: `RiverMouth`, `CoastalAccess`, `RiverCrossing`, `ValleyFloor`, `PassEntrance`, `LakeShore`, `PlainCenter`. A `CompatibilityMatrix` is a 10×7 scoring table (10 `economic_role` values × 7 attractor types) whose cells are float weights (0.0–3.0) representing how strongly that economic role favors that terrain feature. Examples: manufacturing → RiverMouth 2.8, ValleyFloor 2.1; financial → CoastalAccess 2.5, PlainCenter 1.8; agricultural → ValleyFloor 3.0, PlainCenter 2.5. The matrix is authored data (not computed at runtime). Attractor extraction from heightmaps is defined in D-209. Matching algorithm is defined in D-211.
- **Rationale:** Terrain-naive city placement produces spatially incoherent worlds. The compatibility matrix gives different city types different terrain affinities, so financial hubs appear on coasts and agricultural cities appear in fertile valleys — without hard-coding placement rules per city type. The float weight matrix gives graduated preference, not binary requirement.
- **Ticket:** #919, #925
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas pipeline), D-208 (D8 drainage — attractor extraction), D-209 (feature tag extraction), D-211 (attractor-matching pipeline)
### D-196: SettlementClass Enum and Latent Settlement Active/Ghost Logic
- **Date:** 2026-05-01
- **Decision:** Every settlement (city marker in `atlas_city_names`) has a `SettlementClass` that determines how it enters and exits active simulation:
```rust
enum SettlementClass {
NameLocked, // Named in wiki; always active regardless of population
PopulationBudget, // Active if pop > threshold; ghost if below
EconomicTriggered, // Active only while economic role condition is met
OrganicGrowth, // Emergent; generated by simulation, no prior wiki record
}
```
- **Active threshold** (applies to `PopulationBudget`): population ≥ 50,000 for a city to receive full Phase 1 district skeleton generation. Below threshold: 1-district stub with Minimal ComplexityTier.
- **Ghost threshold** (applies to `PopulationBudget`): population < 5,000. Settlement is present in atlas data but receives no NPC population; structures are generated as abandoned (Worn/Derelict condition baseline).
- `NameLocked` settlements bypass both thresholds — they are always simulated regardless of population (handles narrative-significant small towns).
- `EconomicTriggered` settlements collapse to ghost state when their triggering economic condition lapses (e.g., a mining outpost depopulates when the mine is exhausted).
- `OrganicGrowth` settlements are not in `atlas_city_names` at generation time; they are written to the table during simulation when a settlement emerges organically.
- **Rationale:** Not every named location needs full generation, and not every simulated location is named. The classification separates authorial intent (NameLocked) from economic reality (PopulationBudget, EconomicTriggered) and simulation emergence (OrganicGrowth). Ghost settlements are important for world texture — abandoned mining towns and depopulated frontier outposts are as legible as thriving hubs.
- **Ticket:** #913
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas pipeline), D-200 (CityGenerationContext), D-203 (BodyWorldState), D-207 (atlas_city_names)
### D-197: prosperity_baseline Derivation Formula with Topographic Gradient
- **Date:** 2026-05-01
- **Decision:** Each city's `prosperity_baseline` (f32, 0.0–1.0, used as economic pressure state seed) is derived at generation time from four components:
1. **Economic role base** — lookup per `economic_role` value: manufacturing=0.55, financial=0.70, agricultural=0.50, extraction=0.45, service_mixed=0.60, institutional=0.65, transit_hub=0.60, research=0.65, military=0.55, residential=0.50.
2. **Population log-scale bonus** — `0.04 × floor(log10(pop / 1_000_000 + 1))`, capped at +0.12. Larger cities are generally more prosperous.
3. **Topographic gradient bonus** — terrain features that historically correlate with prosperity add to the baseline: river mouth +0.08, coastal access +0.06, valley floor +0.04, pass entrance +0.03. At most one terrain bonus applies (the highest-scoring attractor at the city's position).
4. **Seed noise** — ±0.05 uniform noise applied last (integer-seeded per city, D-010 determinism).
- Formula: `base + pop_bonus + terrain_bonus + noise`, clamped to [0.1, 0.95].
- `prosperity_baseline` is not the current prosperity level — it is the simulation's starting point and decay/growth target. The live pressure simulation (D-026) drifts from this value based on trade flows, events, and faction pressure.
- **Rationale:** A flat random baseline produces economically incoherent worlds. Terrain-informed prosperity encodes real-world patterns: port cities are wealthy, river-mouth cities are strategic. The log-scale population bonus prevents megacities from dominating without eliminating small-city character. Clamping to [0.1, 0.95] prevents degenerate all-thriving or all-collapsing starting states.
- **Ticket:** #920 (consumer of prosperity_baseline)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-194 (district mix — consumes prosperity_baseline), D-195 (attractor types that produce terrain bonus)
### D-198: Economic Simulation Independence from Layer 1–2 Spatial Data
- **Date:** 2026-05-01
- **Decision:** The economics simulation (Phase 2, D-026 background tier) runs independently of Layer 1 (galaxy graph) and Layer 2 (location profiles / planetary topography). Economic state is seeded at game start from `systems.db` data (economic roles, trade flows, corporate presence) and then drifts via the pressure simulation. The generator (Layer 7 district skeleton) reads economic pressure state as an input but does not feed back into the simulation model. The two layers communicate one-way: simulation → generator (pressure state used to set district condition and density), never generator → simulation.
- **Prohibited:** Generator code must not modify `PressureState`. Generator code must not query live simulation state during async background generation tasks (race condition risk). Generator reads a snapshot of pressure state taken at generation dispatch time.
- **Allowed:** The generator reads `economic_health`, `prosperity_baseline`, `industries`, and `faction_influence` from the snapshot. These are read-only inputs to Phase 1 skeleton classification and Phase 2 condition application.
- **Rationale:** Bidirectional coupling between generator and simulation creates initialization order dependencies and potential circular references. The one-way data flow (simulation → generator snapshot → generator) keeps both systems independently testable and avoids race conditions in the Rayon thread pool (D-206). The generator is a consumer of economic state, not a participant in economic evolution.
- **Ticket:** #915 (CityGenerationContext reads economic snapshot)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-026 (simulation tiers), D-200 (CityGenerationContext), D-206 (background generation queue)
### D-199: 6-Field Minimum Economic Read Set for City Generation Context
- **Date:** 2026-05-01
- **Decision:** When building a `CityGenerationContext` (D-200), the generator reads exactly 6 fields from the economic pressure snapshot per city. Reading more fields is permitted but these 6 are the minimum required for correct Phase 1 skeleton classification:
1. `economic_role` — primary function of the city (determines DistrictType distribution via D-194)
2. `prosperity_baseline` — starting economic health (0.0–1.0, see D-197)
3. `population` — city population (determines ComplexityTier ceiling, BlockSkeleton density)
4. `dominant_faction` — faction with highest `faction_influence` at this location (affects Institutional and Restricted district bias)
5. `founding_age_years` — years since settlement founding (drives BlockIrregularity via D-216, era distribution)
6. `settlement_class` — `SettlementClass` enum value (D-196, determines whether to generate at all)
- Fields 1–5 are read from `systems.db` (bodies table + economics tables). Field 6 is derived at generator dispatch time.
- All 6 fields must be present before a generation task is dispatched. Missing fields abort the task with a logged error; generation does not proceed with partial context.
- **Rationale:** A fixed minimum read set prevents generators from accumulating unbounded dependencies on simulation state. The 6 fields cover the minimum information needed to produce a correctly-classified skeleton. The abort-on-missing-fields rule ensures generator output is always deterministic from a complete context, never silently degraded from a partial one.
- **Ticket:** #915 (CityGenerationContext implementation)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-196 (SettlementClass), D-197 (prosperity_baseline), D-198 (economic simulation independence), D-200 (CityGenerationContext struct)
### D-200: Three-Tier Execution Model (Build-Time / Runtime-Background / Runtime-On-Demand)
- **Date:** 2026-05-01
- **Decision:** The generation pipeline operates at three distinct execution tiers with no cross-tier mutation:
1. **Build-time (Python pipeline):** Runs `make regen-db`. Produces `systems.db` tables including `atlas_city_names`, `atlas_province_boundaries`, `body_radius_km`. Output is a static artifact committed to the repo. Never runs during gameplay. *(Amended #963, D-202: `atlas_body_heightmaps` is no longer produced — elevation is now a per-body 16-bit `heightmap.png` file, not a DB table.)*
2. **Runtime-background (Rayon thread pool, D-206):** Triggered by content-spidering events (player approaches a system, NPC names a location, news ticker references a place). Runs D8 drainage analysis (D-208), attractor extraction (D-209), settlement placement, and Phase 1 district skeleton generation. Output goes into `BodyWorldState` cache (D-203). Transparent to main tick thread.
3. **Runtime-on-demand (main tick thread):** Triggered when the player crosses a chunk boundary. Runs Phase 2 chunk fill for the approaching chunk. Must complete within 5ms. Reads from `BodyWorldState` cache (always populated before this tier runs).
- **Tier boundary rules:**
- Build-time outputs are read-only at runtime.
- Runtime-background tasks read from `systems.db` and write to `BodyWorldState` only.
- Runtime-on-demand reads from `BodyWorldState` and writes to the active ECS world (chunk tile data, NPC spawns).
- No tier may write to a higher tier's outputs. No circular dependencies.
- `CityGenerationContext` struct (see below) is the data contract between tiers 1→2.
```rust
struct CityGenerationContext {
city_id: u64,
political_archetype: PoliticalArchetype,
prosperity_baseline: f32,
surrounding_biome: SettingType,
road_entry_directions: Vec<u8>, // compass octants (0–7)
footprint_radius_km: f32,
founding_orientation: FoundingOrientation,
world_tier: WorldTier,
}
```
- **Rationale:** Three tiers with explicit boundaries eliminates the "where does this code run?" question. Build-time is deterministic and committable. Runtime-background is parallelizable. Runtime-on-demand has strict latency budgets. Cross-tier mutation would create race conditions between the Rayon thread pool and the main tick thread.
- **Ticket:** #915
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-026 (simulation tiers), D-194 (district mix), D-203 (BodyWorldState), D-206 (background generation queue), tyre-sw1r3.md Layer 5/6 architecture
### D-201: Spatial Hierarchy — Eight Tiers with Locked Dimensions
- **Date:** 2026-05-01
- **Decision:** The generation pipeline has eight spatial tiers from galaxy to tile. Dimensions are locked and cannot be changed without amending this decision:
| Tier | Name | Dimensions | Purpose |
|------|------|------------|---------|
| 1 | Galaxy | 300 systems | Galaxy graph, gate topology, cultural corridors |
| 2 | System | — | Orbital mechanics, body catalog |
| 3 | Body | ~512×256 pixels (equirectangular heightmap) | Planetary topography, climate zones |
| 4 | Region | ~50–500km | Province boundaries (watershed-derived, D-205), biome zones |
| 5 | Settlement | ~1–30km radius | City footprint, district layout |
| 6 | District | 2048×2048 tiles (2048m, 4.19 km²) | 4×4 quarters — large urban division (D-222) |
| 7 | Quarter | 512×512 tiles (512m, 0.262 km²) | 4×4 blocks — settlement footprint cell, Phase 1 skeleton grid (D-222) |
| 8 | Block | 128×128 tiles (128m) | Generator planning unit, 2×2 chunks (D-222) |
| 9 | Chunk | 64×64 tiles (64m) | Streaming/serialization unit (D-222) |
- Tiers 6–9 (District → Chunk) are the sub-settlement spatial hierarchy, canonical in [D-222](#d-222) — renamed/resized from the original D-094 ladder (the old 512m "District" is now the Quarter; District is now 2048m), at the Tile = 1m / Subtile = 0.5m scale of D-220. This decision formalizes Tiers 1–5 with equivalent lock status.
- Tier 3 heightmap resolution (512×256 equirectangular working grid; 1024×512 PNG) is the canonical format. Deviation requires amending D-191. **Amended (#963, D-202):** the canonical *stored* heightmap is now a per-body 16-bit grayscale `heightmap.png` at **1024×512** carrying native elevation (the prior PNG was a 1024×512 RGB *relief*, now renamed `reliefmap.png`). PNG dimensions are unchanged (1024×512); Layer 1 downsamples to the 512×256 working grid. This amendment is the explicit deviation gate being satisfied — format/content changed, resolution preserved.
- Tier 4 province boundaries are pre-computed at build-time and stored in `atlas_province_boundaries` (D-205). They are not re-computed at runtime.
- The `SettingType` enum on `DistrictSkeleton` is the interface between Tier 5 (settlement planning) and the skeleton cell (the 512m Quarter, Tier 7 — `DistrictSkeleton` is pending rename to match D-222).
- **Rationale:** Locking spatial dimensions prevents the generative layers from drifting in incompatible directions. The heightmap pipeline, atlas pipeline, and district generator all assume these dimensions and would need coordinated migration if they changed. Formalization prevents silent per-system variation.
- **Ticket:** #912 (WorldTier enum), #913 (SettlementClass)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-094 (district hierarchy — Tiers 6–8), D-191 (atlas pipeline — Tier 3), D-205 (province boundaries — Tier 4), D-208 (D8 drainage — Tier 3 analysis)
### D-202: Heightmap BLOB Storage Schema (atlas_body_heightmaps)
- **Date:** 2026-05-01
- **Decision:** Heightmap elevation data is stored in `systems.db` as a BLOB in the `atlas_body_heightmaps` table. Schema:
```sql
CREATE TABLE atlas_body_heightmaps (
body_id INTEGER PRIMARY KEY REFERENCES bodies(id),
width INTEGER NOT NULL, -- pixel columns (canonical: 512)
height INTEGER NOT NULL, -- pixel rows (canonical: 256)
data BLOB NOT NULL, -- float32 little-endian, row-major, width×height floats
sea_level REAL NOT NULL DEFAULT 0.0,
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
- `data` is a `float32` little-endian BLOB. Size: `width × height × 4` bytes. Canonical: 512×256×4 = ~512KB per body.
- Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction below which terrain is underwater (default 0.0 = no ocean, overridden per body).
- The Rust loader reads the BLOB via `bytemuck::cast_slice::<u8, f32>()` after fetching from SQLite. No endian conversion needed on LE-native systems; the pipeline stores LE explicitly.
- Only inhabited bodies receive heightmap rows at build-time. Uninhabited bodies are generated on-demand (runtime-background tier, D-200).
- This table is populated by the `import_heightmaps` build-time step in the asset pipeline (D-191 §9 pipeline order). It is read-only at runtime.
- **Amendment (2026-05-23, [#963](#)):** the DB-BLOB store is **superseded** by a per-body **file-based 16-bit grayscale `heightmap.png`** stored next to the body's other assets (path from `bodies.terrain_reference`). Rationale for the change: raw float grids committed inside a binary `systems.db` are the exact binary-merge-conflict trap the asset-pipeline rule warns against, and ~512KB×N bloats the DB; a per-body file matches D-203's on-demand model and keeps `systems.db` lean. Concretely:
- **Naming fix:** the existing color hypsometric render (today's `heightmap.png`, 1024×512 RGB) is renamed **`reliefmap.png`** — it is a relief visualization, not elevation. Display-only.
- **Canonical elevation:** a new **`heightmap.png`** = 16-bit grayscale (luminance = normalized elevation), **1024×512** (2× per axis / 4× the cells of the old 512×256 sim grid — the PNG dimensions are unchanged from the prior canonical 1024×512 in D-201; only the *content* changed from an RGB relief to native 16-bit elevation). 1024×512 bounds install size (~190 MB across 267 inhabited bodies) while sub-pixel detail is synthesized by the lower cascade layers. Single source of truth — the reliefmap and all computed geography derive from it, so it is bit-identical/deterministic by construction.
- **Multi-resolution:** the stored heightmap is high-res for the lower layers (region/block/tile sample local detail); **Layer 1** (continental drainage/basins/mountain-ranges) calls `BodyHeightmap::downsample` to the `GRID_W×GRID_H = 512×256` working resolution first, decoupling continental compute cost (~45ms) from stored resolution.
- **Rust loader:** `heightmap.rs::load_heightmap_png` reads the 16-bit grayscale PNG (via the `png` crate), normalizes to f32 [0,1]; rejects RGB (a reliefmap can't be misread as elevation). `sea_level` is stored **in the PNG** as a `tEXt` chunk (the heightmap is self-describing), with a caller-supplied default as fallback.
- **`atlas_body_heightmaps` is dropped**; `import_heightmaps.py` writes the PNG file instead of a DB row. The bake runs in the content pipeline (numpy/scipy) once; the runtime cascade is pure Rust loading the file.
- **Implementation status (#963):** Consumer done — `heightmap.rs::load_heightmap_png` reads the 16-bit grayscale PNG + `sea_level` tEXt chunk, rejects RGB, downsamples for Layer 1. Producer done — `import_heightmaps.py` is the bake (rename legacy `heightmap.png`→`reliefmap.png` for all bodies incl. Sol; for non-Sol inhabited bodies write a fresh clean `reliefmap.png` + 16-bit `heightmap.png` from `simulate()` at the bumped 1024×512 grid). `atlas_body_heightmaps` dropped via MIGRATION_SQL + removed from `systems-schema.sql`. Godot client (`atlas_viewer.gd`) loads `reliefmap.png` for display. Sim determinism guarded by `test_sim_determinism.py`.
- **Rationale:** Storing heightmaps in `systems.db` keeps the DB as the single source of truth for all generation inputs, avoids a separate file-fetching path in the Rust server, and allows the pre-push hook (asset pipeline rules) to detect stale heightmap data. The float32 LE layout matches what NumPy and PIL produce natively, minimizing conversion overhead in the Python pipeline. *(Superseded by the #963 amendment above — the file-based model won out because the DB-as-single-source goal conflicts with binary-merge-conflict avoidance and DB size; a per-body committed PNG is itself a queryable, diffable-by-render asset.)*
- **Ticket:** #901 (schema), #906 (import), #916 (Rust loader)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas pipeline — heightmap source), D-203 (BodyWorldState — consumer), D-208 (D8 drainage — reads this table)
### D-203: BodyWorldState Bevy Resource with LRU Cache
- **Date:** 2026-05-01
- **Decision:** `BodyWorldState` is a Bevy `Resource` holding the Layer 1–2 output for each recently-accessed planetary body. It functions as an LRU (Least Recently Used) cache:
- **Cache capacity:** 50 bodies.
- **Memory budget:** ~5MB total (50 bodies × ~100KB per entry average). A single body's Layer 1–2 data includes: processed heightmap (float32 grid, ~512KB pre-downsampled to ~8KB working resolution), river network (`RiverNetwork` struct: river cells, confluences, mouths), drainage basin polygons, attractor list, province boundary references.
- **Eviction policy:** On cache overflow, evict the body with the oldest `last_accessed` timestamp. Bodies that are the current player location or adjacent-system neighbors are pinned (not evicted).
- **Population:** The runtime-background tier (D-200) populates cache entries via Rayon tasks. Main thread reads are always from the cache; main thread code must never perform blocking DB reads for heightmap data.
- **Struct:**
```rust
struct BodyWorldState {
body_id: u64,
heightmap: Vec<f32>, // downsampled working grid
river_network: RiverNetwork, // D-208 output
drainage_basins: Vec<DrainageBasin>,
attractors: Vec<GeographicAttractor>, // D-195 types
last_accessed: SimTick,
}
```
- The resource is initialized empty and populated on demand. Accessing a body not in the cache triggers a background generation task (D-206).
- **Rationale:** The D8 drainage analysis (D-208) and attractor extraction (D-209) are expensive (target: ~50ms/body). Running them on the main tick thread would cause frame drops. The LRU cache ensures the main thread only reads pre-computed data. 50-body capacity covers the typical gameplay scenario (player in one system, neighboring system pre-cached) with margin.
- **Ticket:** #917
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-200 (three-tier execution model), D-202 (heightmap BLOB — input), D-206 (background generation queue — populates cache), D-208 (D8 drainage — produces river network)
### D-204: body_radius_km Column on bodies Table
- **Date:** 2026-05-01
- **Decision:** A `body_radius_km REAL` column is added to the `bodies` table in `systems.db`. This value is the mean radius of the planetary body in kilometers, used to:
- Compute `area_count` (number of districts a settlement can contain, scales with surface area)
- Convert province boundary pixel coordinates to real-world km distances
- Derive the `footprint_radius_km` field on `CityGenerationContext` (D-200)
- Schema change: `ALTER TABLE bodies ADD COLUMN body_radius_km REAL` (nullable, populated by import step)
- **Fallback derivation** (applied when `body_radius_km IS NULL`): `planet_class` lookup table with canonical radii:
- `super_earth`: 8,000 km
- `earth_like`: 6,371 km
- `sub_earth`: 4,500 km
- `ocean_world`: 6,500 km
- `arid`: 5,800 km
- `ice_world`: 3,000 km
- `gas_giant`: 50,000 km (no settlements)
- `moon`: 1,737 km
- `other` / unknown: 6,371 km (Earth default)
- Fallback is applied at query time, not stored back. The column remains NULL until authoritative data is available.
- **Rationale:** Surface area scales with radius squared; a body twice Earth's radius has four times the potential settlement density. Without this field the generator must use a flat default for all planets, producing physically implausible city counts on super-earths and moons alike. The fallback ensures the generator works before all bodies have explicit radius data.
- **Ticket:** #905 (schema), #910 (populate from planet_class fallback)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas pipeline — body catalog), D-200 (CityGenerationContext — footprint_radius_km)
### D-205: Province Boundary Pre-Computation (atlas_province_boundaries)
- **Date:** 2026-05-01
- **Decision:** Province boundaries (drainage basin divides) are pre-computed at build time from the D8 drainage analysis (D-208) and stored in `atlas_province_boundaries`:
```sql
CREATE TABLE atlas_province_boundaries (
body_id INTEGER NOT NULL REFERENCES bodies(id),
basin_id INTEGER NOT NULL,
path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline
area_pct REAL NOT NULL, -- fraction of body surface area in this basin
PRIMARY KEY (body_id, basin_id)
);
```
- Boundaries are stored as pixel-space polylines in the same `[row, col]` convention as `markers.json` (D-191 §8 canonical format).
- `area_pct` is the fraction of the body's total surface area contained within this drainage basin.
- Province boundaries are the basis for district-level political zoning and cultural corridor assignment at Tier 4 (Region) in D-201.
- **Province count target:** 4–12 provinces per inhabited body, derived naturally from watershed analysis. Bodies with less topographic relief (plains worlds, ocean worlds) produce fewer, larger provinces.
- **At runtime:** Province boundaries are read from `atlas_province_boundaries` at generation dispatch time and cached in `BodyWorldState` as `drainage_basins` (D-203). They are not re-computed at runtime.
- **Rationale:** Province boundaries define the cultural geography of a world — the mountain ranges and river systems that separated civilizations and produced distinct regional identities. Pre-computing them at build time keeps the runtime-background tier focused on city placement and district generation rather than watershed analysis. Storing as polylines (not rasterized masks) keeps the table compact and human-readable.
- **Ticket:** #904 (schema), #907 (populate from watershed analysis)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas pipeline), D-201 (spatial hierarchy — Tier 4 Region), D-203 (BodyWorldState — caches province data), D-208 (D8 drainage — source of basin divides)
### D-206: Background Generation Priority Queue and Rayon Thread Infrastructure
- **Date:** 2026-05-01
- **Decision:** All non-urgent generator work runs through a prioritized Rayon thread pool:
- **Thread count:** `available_parallelism - 2`, minimum 1. Reserves 2 cores for the main tick thread and Bevy scheduler.
- **Priority queue:** Four levels: `Immediate` (player will arrive within 1 game-minute), `High` (player will arrive within 5 minutes), `Medium` (player is in the same system), `Low` (player has seen or heard of this location via NPC or news). Work items at higher priority pre-empt lower-priority items.
- **Work item types:** `AnalyzeBody(body_id)` (D8 drainage + attractor extraction), `GenerateSkeleton(city_id, context)` (Phase 1 DistrictSkeleton), `FillChunk(district_id, block_pos)` (Phase 2 chunk fill for pre-loading).
- **Event-driven pre-generation:** A `SystemNameIndex` (Aho-Corasick automaton over all body/system names from `systems.db`) scans NPC dialogue output and news ticker text. When a scan match hits, the referenced body is queued at `Low` priority if not already cached. This is the mechanism by which "NPC mentions a place → player travels there → world is already generated on arrival."
- **Completion notification:** Completed tasks send a `GenerationComplete` event to the main tick thread via a `crossbeam` channel. The main thread drains this channel once per tick.
- **Rationale:** The Rayon thread pool handles the D-200 runtime-background tier. The priority queue prevents low-priority speculation from blocking urgent work (player approaching). The Aho-Corasick name index enables cheap always-on scanning — NPC dialogue is low-bandwidth enough that scanning every output line has negligible cost. Pre-generation triggered by narrative content (NPC mentions a place) is the mechanism for making the world feel pre-existing rather than loading-on-demand.
- **Ticket:** #924 (background queue), #926 (SystemNameIndex)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-200 (three-tier execution model — runtime-background tier), D-203 (BodyWorldState — output of background tasks), D-208 (D8 drainage — enqueued as AnalyzeBody)
### D-207: Fully Generative Placement — markers.json Stripped to Topographic Features
- **Date:** 2026-05-01
- **Amendment (2026-05-22, [D-223](#d-223)):** superseded in part. markers.json no longer retains topographic-feature *geometry* — it is reduced to **names only** (a flavored name pool). River/mountain positions are derived from the heightmap + drainage (D-208); settlement positions come from the economic sim + placement (D-211). The `reserved=1` pinning and the 6 hand-authored templates (Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade) are removed — their bodies remain as ordinary named bodies, only the authored machinery is gone. See D-223.
- **Decision:** The `atlas_city_names` table replaces the authored city positions in `markers.json`. Going forward, `markers.json` files contain only topographic features (rivers, oceans, mountain ranges — per D-191 §8 canonical format). City positions, road networks, and rail networks are NOT authored in `markers.json`; they are generated from the terrain data and stored in `atlas_city_names` and derived tables.
```sql
CREATE TABLE atlas_city_names (
id INTEGER PRIMARY KEY,
body_id INTEGER NOT NULL REFERENCES bodies(id),
name TEXT NOT NULL,
economic_role TEXT NOT NULL,
population INTEGER NOT NULL,
corp_id INTEGER REFERENCES corporations(id), -- nullable, corp HQ if applicable
reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use
kind TEXT NOT NULL DEFAULT 'city' -- 'capital' | 'city'
);
```
- `name` and position in the markers.json come from different sources: position is generated by the city placement algorithm; name is either authored (wiki), LLM-generated (Gemma 2 naming pipeline), or reserved (corp HQ name). The split allows position generation and naming to run independently.
- `corp_id` links to the `corporations` table when a city is a corporation's headquarters or major hub city.
- `reserved = 1` rows are scenario-specific cities that must not be relocated by the generation algorithm; the generator places other cities around them.
- **`markers.json` authored city data** (hand-written city center positions in the 6 hand-authored templates: Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade) is migrated to `atlas_city_names` and treated as `reserved = 1` rows. The markers.json files for these templates then have their city arrays cleared.
- **Rationale:** Authored city positions in markers.json created a split between hand-authored content and procedurally generated content that was impossible to query, diff, or validate consistently. Moving city identity to a table allows: SQL joins against economic data, corp HQ cross-references, scenario reservations, and attractor-matching validation. The topographic features (rivers, mountains) remain in JSON because they are polygon/polyline geometry better suited to JSON than relational rows.
- **Ticket:** #902 (schema), #908 (populate from wiki), #909 (corp HQ cross-reference)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas pipeline — markers.json canonical format), D-196 (SettlementClass — column in atlas_city_names), D-200 (CityGenerationContext — reads from this table)
### D-208: D8 Priority-Flood Drainage Routing — Layer 1 Empty World
- **Date:** 2026-05-01
- **Decision:** Drainage routing (the computation of flow direction, flow accumulation, and river network extraction) uses the **D8 priority-flood** algorithm on the body's float32 heightmap. This is the first Layer 1 computation run on a body before any city placement or attractor extraction.
- **Algorithm:** D8 assigns each cell's flow direction to one of 8 neighbors based on the steepest descent. Priority-flood fills depression cells before routing to avoid spurious sinks. Flow accumulation is the count of upstream cells draining through each cell.
- **River threshold:** A cell is classified as a river cell when `flow_accumulation > 200`. This threshold produces river networks of realistic density on the 512×256 Layer-1 working grid (D8 runs at 512×256, downsampled from the 1024×512 stored heightmap per D-202 amended #963).
- **Outputs** stored in `BodyWorldState.river_network`:
- `river_cells: Vec<(u16, u16)>` — pixel positions of all river cells
- `confluences: Vec<(u16, u16)>` — positions where two or more rivers merge
- `mouths: Vec<(u16, u16)>` — positions where rivers reach sea level or the heightmap edge
- **Province/basin output:** Cells that divide adjacent drainage basins become province boundary candidates (D-205). Boundaries are traced as polylines after flow accumulation is complete.
- **Performance target:** ~50ms per body on a single Rayon thread for canonical 512×256 resolution.
- **Determinism:** Integer-only arithmetic throughout. No f32 in the priority-flood comparisons (use integer-scaled elevation). D-010 compliant.
- **Rationale:** D8 is the standard GIS drainage routing algorithm and produces the river networks that drive attractor scoring (river mouths, confluences = high-value `RiverMouth` attractors). The flow accumulation threshold of 200 was chosen empirically against the Lendel heightmap to produce ~8–15 named rivers per inhabited body — enough for cultural geography without over-fragmenting the landscape.
- **Ticket:** #918
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-195 (attractor types — river mouth is highest-scoring), D-203 (BodyWorldState — output stored here), D-205 (province boundaries — derived from drainage divides), D-209 (feature tag extraction — reads river network)
### D-209: Geographic Feature Tag Extraction (7 Settlement Attractor Tags)
- **Date:** 2026-05-01
- **Decision:** After D8 drainage analysis, 7 `AttractorType` tags are extracted from the heightmap + river network and stored as `Vec<GeographicAttractor>` in `BodyWorldState`. Each attractor has a position `[row, col]` and a `strength: f32` (0.0–1.0) derived from local terrain quality.
- **Extraction rules per type:**
- `RiverMouth`: cells in `river_network.mouths`. Strength = `flow_accumulation[cell] / max_flow_accumulation` (normalized). Always high-value.
- `CoastalAccess`: cells within 3 pixels of a sea/ocean polygon (from `oceans[]` in markers.json), not already `RiverMouth`. Strength = 0.6 baseline + coast length bonus.
- `RiverCrossing`: cells at confluences or where a river crosses a topographic saddle. Strength = `flow_accumulation / max_flow_accumulation × 0.7`.
- `ValleyFloor`: local elevation minima in non-river cells with positive habitability score (slope < 5°, elevation 10–60% of range). Strength = habitability score.
- `PassEntrance`: local saddle points between adjacent drainage basins. Strength = inverse of elevation percentile (lower passes score higher).
- `LakeShore`: cells adjacent to `lake` polygons in markers.json. Strength = 0.5 baseline.
- `PlainCenter`: cells in flat terrain (slope < 2°) away from all other attractors. Strength = habitability score × 0.4.
- Sub-biome classification (vegetation, aridity, temperature zones) is derived in parallel and stored as `SubBiomeVariant` on the attractor for use by the ZonePalette modifier system (D-101).
- **Rationale:** The 7 attractor types cover the terrain features that historically determine city placement. Their extraction from the heightmap is deterministic and cheap given the D8 analysis is already complete. The strength normalization ensures attractor scores are comparable across bodies with different elevation ranges.
- **Ticket:** #925 (types), #919 (matching pipeline that consumes these)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-195 (CompatibilityMatrix — attractor types), D-203 (BodyWorldState — storage), D-208 (D8 drainage — prerequisite), D-211 (attractor-matching pipeline — consumer)
### D-210: Sub-Biome Variant Classification and terrain_modification_cost
- **Date:** 2026-05-01
- **Decision:** Each `GeographicAttractor` (D-209) carries a `sub_biome: SubBiomeVariant` tag that classifies the local terrain more finely than the top-level `SettingType`. This drives two systems: ZonePalette modifier selection (which visual variant to use) and `terrain_modification_cost` (how expensive it is to build infrastructure at this location).
- `SubBiomeVariant` values: `TropicalWet`, `TemperateForest`, `TemperateGrassland`, `BorealForest`, `Tundra`, `Desert`, `Savanna`, `Alpine`, `Wetland`, `CoastalLowland`, `Volcanic`.
- `terrain_modification_cost: f32` (1.0 = baseline, higher = more expensive): derived from sub-biome + local slope. Flat grassland = 1.0. Volcanic = 4.5. Wetland = 3.2. Alpine = 3.8. Coastal lowland = 1.4. Used by the attractor-matching pipeline (D-211) to penalize high-cost terrain for economically marginal cities.
- Sub-biome classification uses: elevation percentile (of body total), local slope, moisture proxy (distance to nearest river mouth or coast), and temperature proxy (latitude of the equirectangular pixel).
- Sub-biome data is stored in `BodyWorldState` alongside the attractors; it is not a separate DB table.
- **Rationale:** Two cities on coastal terrain feel different when one is a tropical lowland port and the other is a cold Nordic fjord. Sub-biome tags enable the ZonePalette to select the correct visual register (T6 beach/coastal with tropical modifier vs T7 mountain/high with coastal modifier). The `terrain_modification_cost` gives the generator a principled reason to prefer some attractor positions over others for lower-prosperity cities.
- **Ticket:** #919 (attractor matching — uses terrain_modification_cost)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-101 (ZonePalette modifier system — consumes sub_biome), D-195 (attractor types), D-209 (feature tag extraction — assigns sub_biome)
### D-211: Attractor-Matching Five-Phase Pipeline for Settlement Placement
- **Date:** 2026-05-01
- **Decision:** Given a body's `Vec<GeographicAttractor>` and a set of cities from `atlas_city_names`, settlement placement runs a five-phase matching pipeline:
1. **Score matrix build:** Compute a `city_count × attractor_count` score matrix. Each cell = `CompatibilityMatrix[economic_role][attractor_type] × attractor.strength × (1.0 / terrain_modification_cost)`.
2. **Tier A greedy assignment:** For each city with `SettlementClass::NameLocked` or population ≥ 1,000,000, assign the highest-scoring unoccupied attractor using greedy selection. These cities must be placed first to anchor the spatial layout.
3. **Hungarian algorithm for Tier B+C:** Apply the Hungarian algorithm to the remaining cities (population 50,000–999,999) and remaining attractors. Produces optimal global assignment maximizing total score.
4. **Synthetic attractor overflow:** Cities that cannot be matched to a real attractor (attractor pool exhausted) receive a synthetic `PlainCenter` attractor generated at a position that respects minimum city spacing (15 pixels minimum on 512×256 grid = ~50km minimum separation).
5. **Name fulfillment check:** After placement, verify that all `atlas_city_names` entries for this body have been assigned a position. Log a warning for any unplaced city.
- **Two-tier mismatch flagging:** If a matched city-attractor pair has score < 0.35, log a `WARNING` (below expected quality). If score < 0.15, log an `ERROR` and flag for manual review. Generation proceeds in both cases; the flags are for content auditing, not hard blockers.
- **Output:** `Vec<CityPlacement { city_id, position: [row, col], attractor: AttractorType, score: f32 }>` written to `atlas_city_positions` at build time.
- **Rationale:** Greedy-first for large/locked cities ensures anchor cities (capitals, corp HQs, wiki-named cities) are placed at terrain features that match their lore role. Hungarian for medium cities finds the globally optimal assignment, not just locally optimal. Synthetic overflow prevents the algorithm from failing on bodies where city count exceeds natural attractor count (dense, flat worlds). The two-tier warning system enables content QA without blocking generation.
- **Ticket:** #919, #925
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-195 (CompatibilityMatrix), D-207 (atlas_city_names), D-209 (GeographicAttractor — input), D-210 (terrain_modification_cost — input)
### D-212: TerritorialStatus Priority-Ordered Derivation Algorithm
- **Date:** 2026-05-01
- **Decision:** Each `Province` (watershed-derived drainage basin, D-205) receives a `TerritorialStatus` value derived by priority-ordered classification at generation time:
```rust
enum TerritorialStatus {
CommissionControlled, // Commission faction_influence ≥ 0.6 in this province
CorpTerritory, // Single corporation faction_influence ≥ 0.5
ContestedZone, // Two or more factions each ≥ 0.3, no dominant faction
FrontierUnclaimed, // No faction with influence ≥ 0.2
IndigenousHeld, // Cultural corridor has indigenous autonomy flag
Derelict, // population_density < 0.01 AND no faction ≥ 0.1
}
```
- Classification applies checks in priority order: `CommissionControlled` checked first, `Derelict` last. The first condition that is true sets the status.
- `placed_at_generation: bool` flag on `Province` distinguishes classification at build time (true) from runtime re-classification during simulation (false). Build-time status is the starting state; simulation can change it, and the flag ensures the original classification is recoverable for reset/new-game scenarios.
- Faction influence values are read from `systems.db` (economics tables) at build time using the same D-199 economic read pattern.
- **Rationale:** Territory status is a high-level descriptor visible to the player on the Atlas overlay (D-191 §7, political zones overlay). It must be derivable from the generation inputs without runtime simulation state. The priority-ordered algorithm ensures clear, predictable classification — no ambiguous provinces. The `placed_at_generation` flag enables the game to show "how this province was at settlement time" vs. "how it is now."
- **Ticket:** #921
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas overlay — political zones), D-199 (economic read set), D-205 (Province — this status is a field on it)
### D-213: FoundingOrientation Enum and Spatial Grid Rotation
- **Date:** 2026-05-01
- **Decision:** `FoundingOrientation` describes the primary spatial axis of a city's original street grid, derived from the terrain feature that anchored the founding settlement. It controls the rotation of the district grid skeleton.
```rust
enum FoundingOrientation {
Coastal { facing_degrees: u16 }, // street grid perpendicular to coastline
RiverAligned { bearing_degrees: u16 }, // street grid parallel to founding river
TerrainFollowing, // grid rotated to follow local contours
Cardinal, // grid aligned to N/S/E/W (commission-planned)
Free { bearing_degrees: u16 }, // arbitrary bearing (pioneer settlements)
}
```
- `facing_degrees` and `bearing_degrees` are integer degrees 0–359 (0 = North, clockwise). Integer to preserve D-010 determinism.
- The founding orientation is derived from the matched attractor type (D-211): `RiverMouth` → `Coastal`; `RiverAligned`; `CoastalAccess` → `Coastal`; `ValleyFloor` → `TerrainFollowing`; `PlainCenter` + Commission-controlled province → `Cardinal`; `PlainCenter` + other → `Free`.
- The district skeleton generator (Phase 1) applies `FoundingOrientation` as the base rotation for the outermost district ring. Interior districts inherit the orientation unless overridden by a `PoliticalArchetype` modifier.
- **Hard constraint:** Maximum ±45° deviation from the parent orientation per district (same limit as D-096 `BlockPlacement.rotation_steps`). Beyond ±45°, tile-based pathfinding produces movement artifacts.
- **Rationale:** Street grids reflect the terrain and founding logic of the original settlement. Roman camps faced cardinal directions. River towns align with the river. Coastal cities face the water. Encoding this as a named enum rather than a raw angle makes the orientation legible in the data model and debuggable during generation.
- **Ticket:** #914
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-096 (DistrictLayoutMode — inherits orientation), D-211 (attractor-matching — derives orientation), D-214 (PoliticalArchetype — may override orientation), D-215 (spatial arrangement patterns — uses orientation)
### D-214: PoliticalArchetype Enum and Settlement Spatial Character
- **Date:** 2026-05-01
- **Decision:** `PoliticalArchetype` classifies a settlement's dominant power structure and its physical expression in district layout:
```rust
enum PoliticalArchetype {
Commission, // Top-down Commission planning; rectilinear, institutional core
Corporate, // Corp-dominated; commercial density, restricted zones, campus blocks
Pioneer, // Self-organized; organic growth, mixed use, ad-hoc infrastructure
Military, // Garrison or fortification origin; defensible geometry, restricted perimeter
Academic, // University or research origin; campus-quad structure, green space
Industrial, // Factory-first; large-footprint industrial blocks, worker residential rings
}
```
- `PoliticalArchetype` is derived at generation time from `TerritorialStatus` (D-212) + `economic_role`: `CommissionControlled` province → `Commission`; `CorpTerritory` → `Corporate`; `FrontierUnclaimed` → `Pioneer`; military economic role → `Military`; research economic role → `Academic`; manufacturing + extraction → `Industrial`.
- When multiple signals conflict (e.g., Commission-controlled manufacturing hub), `TerritorialStatus` takes precedence over `economic_role` for archetype derivation.
- **Spatial effect on district mix:** See D-194. Each archetype applies weight multipliers to district type selection.
- **`AttractorAssignment` disambiguation:** `OrganicGrowth` (a `DistrictType` value and also an `EraCause` value) is always unambiguous in context. On `DistrictType`, it means the district grew without a planning mandate. As `EraCause`, it means the era tag was acquired through organic settlement expansion rather than a discrete historical event. Both usages are permitted; the type system distinguishes them.
- **Rationale:** Power structure should be legible in a city's spatial form without the player reading a wiki entry. Commission cities look different from Corporate cities look different from Pioneer cities — not just in palette, but in street geometry, district type distribution, and building scale. Encoding this as a named enum ensures the distinction is consistent across all generation code.
- **Ticket:** #914
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-194 (district mix — archetype modifiers), D-212 (TerritorialStatus — primary input), D-213 (FoundingOrientation — archetype may override), D-215 (spatial arrangement patterns)
### D-215: Five Explicit Political Archetype Spatial Arrangement Patterns
- **Date:** 2026-05-01
- **Decision:** Each `PoliticalArchetype` maps to one of five spatial arrangement patterns that govern district adjacency and the placement of landmark multi-block reservations:
1. **Radial core** (Commission, Academic): Central landmark (civic square, institutional plaza, or university quad) surrounded by mixed-use rings. Transit spokes radiate outward. Districts are denser near center.
2. **Campus grid** (Corporate): Restricted campus block occupies 2–4 blocks in the district interior. Commercial districts ring the exterior. Worker residential on periphery.
3. **Ribbon development** (Pioneer, Industrial): Districts string along a linear feature (river, road, industrial rail). No dominant center. Mixed adjacency at every edge.
4. **Fortified perimeter** (Military): Restricted and Secured districts at the edge of the footprint. Open access in the interior core. Single controlled access point per district edge.
5. **Hub-and-spoke** (transit_hub economic role, any archetype): Transit district at center, all other district types accessible via direct corridors. Maximum 2-district travel between any two districts.
- The arrangement pattern constrains block adjacency during Phase 1 skeleton generation. Specifically: the first 2–3 districts placed in a settlement follow the pattern. Later districts are constrained only by the road network, not by the pattern.
- Arrangement patterns must **vary in angular orientation** per seed (not just position) — the same archetype's radial core must not always face the same direction across seeds.
- **Rationale:** The 14 D-ready items from the generator-architecture workshop established that spatial arrangement should encode power structure. These five patterns are the minimal set to cover the 6 archetypes (Pioneer and Industrial share ribbon development; hub-and-spoke is a cross-archetype pattern for transit-primary cities). Pattern variation in angular orientation prevents players from pattern-matching settlement layout after the first playthrough.
- **Ticket:** #914 (types), #899 (implementation — Phase 1 skeleton generator)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-094 (spatial hierarchy — district sizes), D-194 (district mix — archetype modifiers), D-214 (PoliticalArchetype — pattern assignment)
### D-216: BlockIrregularity from founding_age — Layout Age Character
- **Date:** 2026-05-01
- **Decision:** `block_irregularity: f32` is a derived value on each block (range 0.0–1.0) that controls how much a block deviates from the district's canonical grid. It is computed from `founding_age_years` and `PoliticalArchetype`. The formula:
```
base_irregularity = (founding_age_years / 1000.0).min(1.0)
archetype_step = match archetype {
Commission | Military => -0.3, // suppresses organic deviation
Corporate | Academic => -0.1,
Industrial => 0.0,
Pioneer => +0.3,
}
block_irregularity = (base_irregularity + archetype_step).max(0.05).min(1.0)
```
- Minimum 0.05 is enforced — no block is perfectly regular, even new Commission-planned settlements.
- `block_irregularity` feeds the `BlockPlacement.offset` magnitude in `DistrictLayoutMode::Organic`: `max_offset_sim_tiles = (block_irregularity × 16.0) as i16`.
- An old Pioneer settlement (age 800+ years) can have `block_irregularity ≈ 1.0`, producing maximum ±16 sim tile offsets and ±45° rotations. A new Commission district (age < 50 years) will have `block_irregularity ≈ 0.05`.
- All arithmetic uses integer-scaled intermediates wherever possible (age is integer years; archetype_step is stored as integer basis points internally). The f32 in the formula above is for documentation clarity only.
- **Rationale:** Age is the single most reliable predictor of urban irregularity in the real world. Old cities that grew organically have crooked streets; new planned cities have grids. Encoding this as a formula rather than a lookup table allows continuous variation along the age axis while preserving the political meaning of the archetype modifier.
- **Ticket:** #922
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-096 (DistrictLayoutMode::Organic — consumes block_irregularity), D-194 (district mix — founding_age is an input), D-214 (PoliticalArchetype — archetype_step source)
### D-217: Tile Condition Thresholds (0.63 / 0.43 / 0.23)
- **Date:** 2026-05-01
- **Decision:** A tile's visual condition is derived from the district's `prosperity_score` (live pressure simulation value, 0.0–1.0) using four threshold bands:
| Band | Condition | prosperity_score range | Tile visual state |
|------|-----------|----------------------|-------------------|
| 1 | Intact | > 0.63 | Clean, undamaged, well-maintained |
| 2 | Worn | 0.43 – 0.63 | Scuff marks, minor discoloration, partial repairs |
| 3 | Cracked | 0.23 – 0.43 | Visible damage, incomplete repair, graffiti |
| 4 | Broken | < 0.23 | Structural damage, debris, derelict appearance |
- **Cache invalidation:** A tile's condition only changes when `prosperity_score` crosses a threshold boundary (from band N to band N±1). This avoids per-tick visual updates. The simulation checks threshold crossings once per game-minute (D-031 day-phase tick rate).
- **Baseline floor:** The block's `EraCause` sets a minimum condition floor:
- `Decay` era: minimum Cracked (no tile in a Decay-era block is ever Intact or Worn without an active renovation event)
- `EmergencyExtension` era: minimum Worn
- All other eras: no floor (condition follows prosperity_score freely)
- **Phase 2 application:** Chunk fill applies the baseline condition at fill time. Subsequent condition updates from simulation crossing thresholds are applied as `ChunkMutations.tile_overrides`.
- Condition thresholds are authored constants, not computed. Any change to the thresholds (0.63 / 0.43 / 0.23) requires amending this D-record.
- **Rationale:** Threshold-crossing invalidation is a standard visual LOD technique that avoids expensive per-frame recalculation. The four bands (Intact/Worn/Cracked/Broken) match the visual fidelity budget for the current art direction — more bands require more tile variants per palette. The era-based floor ensures that historical context is always visible: a Decay-era block cannot spontaneously look pristine from a prosperity spike alone.
- **Ticket:** #923
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-100 (DamageOverlay — post-condition modification), D-194 (district mix — prosperity_baseline is the seed for prosperity_score)
### D-218: WorldTier Enum Canonical Values (Epicenter/Regional/Backwater/Passage/Waypoint)
- **Date:** 2026-05-01
- **Decision:** The canonical `WorldTier` enum values are:
```rust
enum WorldTier {
Epicenter, // Hub system. Full simulation. High faction pressure. Multi-district cities.
Regional, // Regional hub. 1–4 districts per city. Partial full-budget districts.
Backwater, // Small community. Dense isolated settlement. Full sim budget — NOT capped.
Passage, // Transit stop. Pass-through. ComplexityTier ceiling: Moderate.
Waypoint, // Not simulated until player approaches. ComplexityTier ceiling: Minimal.
}
```
- The values `Peripheral`, `Connected`, and `Core` used in generator.rs prior to Sprint 38 are **incorrect** — they were stubbed values that do not match the workshop design (workshop-outcomes.md §WorldTier and ComplexityTier). They must be replaced with the five canonical values above.
- **ComplexityTier ceiling per WorldTier:**
- `Epicenter` → Full
- `Regional` → Full
- `Backwater` → Full (critical: `Backwater` is network-insignificant, NOT budget-capped; isolated communities can be socially complex)
- `Passage` → Moderate
- `Waypoint` → Minimal
- **Source of truth:** workshop-outcomes.md §WorldTier and ComplexityTier table (generator-architecture workshop, lead decision L-3).
- All code referencing `WorldTier::Peripheral`, `WorldTier::Connected`, or `WorldTier::Core` must be updated to the canonical values. This includes generator.rs, any tests, and any serialized data that references these variants.
- **Rationale:** The three-value stub (Peripheral/Connected/Core) was authored before the generator architecture workshop established the five-value canonical model. The mismatch between the code and the design means any generator code built against the stub types would need rewriting anyway. Correcting it now before the Phase 1 implementation work begins eliminates that rework. The `Backwater` full-budget exception is architecturally significant: dense isolated communities (mining towns, research outposts) should be as socially rich as regional hubs — their isolation is their drama, not their limitation.
- **Ticket:** #900 (bug fix), #912 (full enum implementation)
- **Raised by:** Generation cascade workshop (#897). Original workshop-outcomes.md §WorldTier (generator-architecture workshop, lead decision L-3).
- **Cross-reference:** D-096 (DistrictLayoutMode — WorldTier is a DistrictSkeleton field), D-097 (GuaranteeAuditResult — tier-conditional guarantees), D-201 (spatial hierarchy — WorldTier assigned at Tier 2 System level)
---
### D-220: Settlement Density Model — Tile Scale, Density Classes, and Vertical Pressure
- **Date:** 2026-05-03
- **Decision:** Settlement footprint, building height, and basement depth are all driven by a single **density scalar** on each settlement, derived from population, available land, terrain constraints, and economic pressure. This decision establishes the tile scale, the density classification system, and the rules for how density shapes the built environment.
**Tile scale (amends D-201 Tier 0):**
| Unit | Dimensions | Real-world size | Notes |
|------|-----------|-----------------|-------|
| Subtile | 1×1 | 0.5m × 0.5m | Rendering unit — character positioning, furniture placement, visual detail |
| Tile | 1×1 | 1m × 1m | Spatial unit — generation pipeline, pathfinding, district grids |
| Chunk | 64×64 tiles | 64m × 64m | Streaming unit (was 32m at 0.5m/tile; now 64m at 1m/tile) |
| Block | 128×128 tiles | 128m × 128m | 2×2 chunks |
| District | 512×512 tiles | 512m × 512m = 0.262 km² | 4×4 blocks |
Each tile contains 2×2 subtiles. The generation cascade operates in tiles. The renderer subdivides to subtiles for visual fidelity. All higher tiers (Province, Area, Body) remain as defined in D-201.
- **Amendment (2026-05-22, [D-222](#d-222)):** the 512m unit this table calls **District** is renamed the **Quarter**; **District** is promoted to a new 2048m tier (4×4 quarters). The Tile/Subtile scale above is unchanged. Throughout this record — including the footprint formula (`district_count = footprint / 0.262`) and the density examples — "district" denotes the **0.262 km² Quarter cell**, not the new 2048m District. See D-222 for the canonical ladder and the lore-vs-code rule.
**Density classes:**
Density is measured in people per km² of settlement footprint. It is the primary driver of settlement character — not population alone. The same population at different densities produces completely different settlements.
| Class | Density (ppl/km²) | Horizontal character | Vertical character | Infrastructure cost | Who builds at this density |
|-------|-------------------|---------------------|-------------------|--------------------|-----------------------------|
| Frontier | ~400 | Large gaps between structures, open ground dominant, private roads | 1 floor, no basement | Very high per-capita (long utility runs, private maintenance) | **The very rich** (villa estates, corp executive compounds, prestige vanity builds) OR **the very poor** (pre-utility squatter settlements, homesteads with no services). Frontier density is a luxury or an absence, never the default. |
| Settled | ~1,500 | Planned grid, moderate spacing, shared streets, visible utility corridors | 1-2 floors, optional basement | Efficient — the utility-cost-optimized default | **Default for most Reach colonies.** Every meter of sewer, power conduit, and road is new capital expenditure on a virgin world. Settled density is what you get when infrastructure cost constrains the plan. Kiruna is the archetype. |
| Established | ~3,000 | Mixed residential, shared walls appearing, streets narrowing, green space intentional not incidental | 2-3 floors, basement common | Amortized — mature infrastructure absorbing incremental growth | **Mature settlements.** Second-generation growth over an existing utility grid. The settlement has existed long enough for the infrastructure investment to pay off and densification to begin. Tiel, Périgueux. |
| Dense | ~6,000 | Shared walls dominant, narrow streets, minimal private outdoor space, commercial ground floors | 3-6 floors, 1-2 basement levels (parking, storage, utilities) | High per-area but efficient per-capita | **Economic hubs.** Transit nodes, extraction chokepoints, terrain-constrained sites where the jobs pull people in faster than the settlement can spread. Kanazawa, Uzès. Density is a signal that economic gravity is compressing the settlement. |
| Compressed | ~15,000 | Vertical dominant, street canyons, multi-use buildings, public space carved from built volume | 6-12+ floors, 2-3 basement levels (metro, parking, utilities, subsurface logistics) | Very high per-area, requires vertical infrastructure (elevators, pressurized systems, multi-level utilities) | **Extreme economic pull.** Major corp HQs, gate-adjacent trade hubs, capital worlds. Rare in the Reach. The land itself is too valuable for anything but vertical. Paris, Amsterdam inner ring. |
**What drives density — the four pressures:**
1. **Utility cost (compressing force).** The dominant pressure on new colonies. Infrastructure is expensive per meter. This pushes settlements toward Settled as the floor, not Frontier. Every colony pays this tax unless someone absorbs the cost deliberately.
2. **Economic pull (compressing force).** Jobs, trade, resources, transit — anything that draws people to a specific location faster than the settlement can grow outward. The stronger the pull, the higher the density. Corp HQ worlds, gate-adjacent systems, extraction sites all have high economic pull. This is the force that pushes settlements from Settled through Dense to Compressed.
3. **Terrain constraint (compressing or dispersing force).** Coastal strips, canyon floors, mountain passes compress settlements along one axis (Bisbee: 2.5km × 300m). Open plains allow radial spread. River valleys elongate. The terrain doesn't set the density class directly — it shapes the footprint geometry within that class.
4. **Wealth/status (dispersing force — at a premium).** The only force that pushes density DOWN below the utility-cost floor. Low density in an otherwise dense settlement is conspicuous consumption of space — private utility runs, private road maintenance, private security perimeters. Villa estates, executive compounds, prestige campuses. This is Frontier density as luxury, not as poverty.
**The U-shaped wealth curve:**
Density has a U-shaped relationship with wealth. The very rich and the very poor both live at low density — but for opposite reasons. The rich can afford the infrastructure cost of space. The poor live where no infrastructure exists. Everyone in between clusters at Settled-to-Dense because that's what utility economics demands.
The player reads this instantly: low-density district on a dense world = money. Low-density district on a frontier world = nothing built yet. Same tile spacing, completely different story — and `prosperity_baseline` (D-197) disambiguates. High prosperity + low density = wealth. Low prosperity + low density = absence.
**Density → vertical pressure mapping:**
Density drives building height and basement depth via simple thresholds. These are tendencies, not hard limits — individual buildings vary, but the district-level character follows the pattern.
| Density class | Typical above-ground floors | Typical basement levels | Vertical pressure description |
|---------------|---------------------------|------------------------|------------------------------|
| Frontier | 1 | 0 | Single-story. Horizontal only. |
| Settled | 1-2 | 0-1 | Mostly single-story, occasional two-story. Basement where terrain allows. |
| Established | 2-3 | 1 | Two-story default. Three-story at intersections and commercial streets. Basements standard. |
| Dense | 3-6 | 1-2 | Multi-story default. Ground-floor commercial. Structured parking appears underground. |
| Compressed | 6-12+ | 2-3 | Highrises. Street canyons. Multi-level subsurface: parking, metro, utilities, logistics. |
The transition from "buildings grow out" to "buildings grow up" happens at Dense. Below Dense, the settlement solves population pressure by adding more districts horizontally. At Dense and above, horizontal expansion is constrained (by terrain, by utility cost, by land value) and vertical growth begins.
The transition from "no basement" to "buildings grow down" happens at Established. Below Established, basements are optional amenities. At Established and above, basements become structural necessities — storage, utilities, parking that can't fit at grade.
**Density derivation:**
The density scalar for a settlement is derived from existing systems.db data:
```
base_density = SETTLED_DEFAULT // 1,500/km² — the utility-cost floor
// Economic pull increases density
if economic_role in [transit, commercial, manufacturing]:
base_density *= 1.5–2.0
if is_corp_hq_world:
base_density *= 1.5
if gate_adjacent:
base_density *= 1.3
// Terrain constraint modulates
if terrain_constraint_factor > threshold: // coastal strip, canyon, etc.
base_density *= 1.2–1.8
// Wealth disperses (for specific districts within a settlement)
if district.prosperity_baseline > 0.8:
effective_density *= 0.4–0.6 // villa district within a dense city
// Clamp to class boundaries
density_class = classify(effective_density)
```
The exact multipliers are tunable. The structure — base at Settled, economic pull compresses, wealth disperses — is the locked architectural decision.
**Settlement footprint formula:**
```
footprint_km2 = population / effective_density
district_count = ceil(footprint_km2 / 0.262)
grid_side = ceil(sqrt(district_count))
```
Not all districts within the grid are built. A Settled-density village of 2,000 people covers ~1.3 km² = 5 districts in a 3×3 grid, but only 5 of 9 grid cells contain structures. The remaining 4 are open ground, agricultural land, or wilderness. The fill ratio increases with density class:
| Density class | Typical grid fill ratio | What fills the empty cells |
|---------------|------------------------|---------------------------|
| Frontier | 20-40% | Wilderness, private grounds, no development |
| Settled | 50-70% | Agricultural plots, utility corridors, planned expansion reserves |
| Established | 70-90% | Parks, low-density transition zones, institutional grounds |
| Dense | 90-100% | Nearly full — remaining gaps are intentional public space |
| Compressed | 100% | Full. No gaps. Every cell is built. |
**Reference calibration (real-world sanity check):**
| Real settlement | Pop | Real area | Real density | Model class | Model area | Match quality |
|----------------|-----|-----------|-------------|-------------|------------|---------------|
| Wadenoijen, NL | 695 | 0.10 km² | 6,950/km² | Dense | 0.12 km² | Close |
| Kerk-Avezaath, NL | 1,250 | 0.40 km² | 3,125/km² | Established | 0.42 km² | Close |
| Bisbee, AZ | 5,000 | 1.50 km² | 3,333/km² | Established | 1.67 km² | Close |
| Kiruna, SE | 17,000 | 11.40 km² | 1,491/km² | Settled | 11.33 km² | Near-exact |
| Périgueux, FR | 30,000 | 9.82 km² | 3,055/km² | Established | 10.00 km² | Near-exact |
| Tiel, NL | 39,000 | 13.00 km² | 3,000/km² | Established | 13.00 km² | Exact |
| Kanazawa DID, JP | 466,000 | 59.00 km² | 7,898/km² | Dense | 77.67 km² | Reasonable |
| Paris proper, FR | 2,040,000 | 105.00 km² | 19,429/km² | Compressed | 136.00 km² | Reasonable |
The model is calibrated to real-world settlements. Discrepancies come from the square-grid assumption (real settlements elongate along rivers, coasts, and roads) and from the discrete density class boundaries (continuous real densities mapped to five bins).
**Interaction with other D-records:**
- **D-194 (district mix):** District *types* are still determined by the three-component mix. Density determines how much *space* those districts occupy and how they're spaced.
- **D-197 (prosperity_baseline):** Disambiguates the wealth curve. Same low density reads as luxury (high prosperity) or absence (low prosperity).
- **D-201 (spatial hierarchy):** Tile scale amended from 0.5m to 1m with 0.5m subtile. Chunk real-world size doubles from 32m to 64m. All higher tiers unchanged.
- **D-215 (spatial arrangement patterns):** The five archetype patterns operate within the footprint that density determines. CompanyTown spine is longer at Settled density, compressed vertically at Dense density.
- **D-216 (BlockIrregularity):** Irregularity affects street grid geometry within a district. Density affects building height and spacing within that geometry. Orthogonal — a Dense district can be Organic (irregular streets, tall buildings) or Grid (regular streets, tall buildings).
- **Rationale:** Population alone cannot determine settlement character. A village of 2,000 at medieval European density (Kerk-Avezaath, 3,200/km²) and a homestead spread of 2,000 at frontier density (400/km²) produce a 10× footprint difference. In the Reach, utility cost on virgin worlds is the dominant constraint, making Settled (~1,500/km²) the natural floor. The density scalar unifies footprint calculation, vertical pressure, and inter-building spacing into a single derivable parameter. The tile/subtile split preserves 0.5m visual fidelity for character movement and furniture placement without inflating the generation pipeline's spatial grid.
- **Ticket:** (pending — implementation tickets to be created when cascade Tier 3/4 work begins)
- **Raised by:** Jeroen + Claude, design session 2026-05-03. Grounded against real-world settlement data (Wadenoijen, Kerk-Avezaath, Uzès, Tiel, Périgueux, Kiruna, Kanazawa, Paris, Bisbee, Alice Springs, Brasília).
- **Cross-reference:** D-194 (district mix), D-197 (prosperity_baseline), D-201 (spatial hierarchy — tile scale amendment), D-215 (spatial arrangement patterns), D-216 (BlockIrregularity)
- **Dissent:** None
---
### D-222: Spatial hierarchy and naming — Subtile to District, lore vs code
- **Date:** 2026-05-22
- **Decision:** Canonical naming and sizes for the sub-settlement spatial hierarchy. This record is the single source of truth for the ladder; it supersedes the dimensions/naming scattered across D-094 (hierarchy), D-201 (tier table), D-220 (tile scale), and D-066 (dual-scale grid), which are amended to point here.
**The ladder (each tier nests cleanly in the one above):**
| Unit | Side | Composition | Real-world analogue | Used for |
|------|------|-------------|---------------------|----------|
| Subtile | 0.5m | — | a footstep | server simulation, entity positioning, render detail |
| Tile | 1m | 2×2 subtiles | a doorway | **the unit all sizes are quoted in**; generation cascade, pathfinding, grids |
| Chunk | 64m | 64×64 tiles | half a block | streaming / serialization unit (technical) |
| Block | 128m | 2×2 chunks | a city block (~100m real) | generator planning unit |
| Quarter | 512m | 4×4 blocks (16) | a superblock (≈ Barcelona *superilla*, 0.262 km²) | the cell a settlement footprint is tiled into |
| District | 2048m | 4×4 quarters (256 blocks) | a real urban district (4.19 km²) | large urban division |
| *(above)* | *fluid* | — | borough / sector / city | **not a fixed tier** — settlements vary too much; groupings above District are settlement-specific |
Nesting rhythm: Chunk→Block is 2×2 (Chunk is a technical sub-block streaming unit); Block→Quarter→District is a uniform 4×4 (the legible "human" ladder).
**Lore ≠ code (load-bearing):** the tier names above are **code/generation units** — fixed grid cells. The *same words* used in lore, narrative, and UI ("Sova Transit District", "the market quarter") are **free-form region labels** with no obligation to map to a code tier. A narrative "district" may span several code Quarters, sit inside one, or ignore the grid entirely. **Reviewers and the clerk must not reconcile a lore name to a code tier** — they are different registers that happen to share vocabulary.
**Rename from prior records:** the 512m cell that D-094 / D-201 / D-220 called a **District** is now the **Quarter**. "District" is promoted to the 2048m tier (4×4 quarters), matching the real-world scale of a named urban district. The tile scale (Tile = 1m, Subtile = 0.5m) and the doubled footprints established by D-220 are unchanged. Code that uses "District" for the 512m skeleton unit (e.g. `DistrictSkeleton`) is now misnamed → see follow-up ticket.
- **Rationale:** The old hierarchy named only Chunk/Block/District and jumped from a 512m "District" straight to the 1–30km Settlement, leaving the 512m cell wearing the name of a unit two rungs too large — a real district is kilometres and many neighborhoods, not 16 blocks. Grounding against real sizes (city block ~100m; Barcelona superblock 400m / 9 blocks; real district several km) puts Block at 128m (correct), the 512m cell at Quarter/superblock, and a true District at 2048m (4.19 km²). Leaving everything above District fluid matches reality — boroughs/sectors/cities vary too much to force a fixed generation tier, and the Reach's settlements range from frontier homesteads to compressed capitals. Separating the lore register from the code register lets narrative use "district" naturally without dragging the generator's grid into it.
- **Raised by:** Jeroen + Claude, design session 2026-05-22. Grounded against real-world block/superblock/district scales.
- **Cross-reference:** [D-094](#d-094) (spatial hierarchy — renamed/superseded), [D-201](#d-201) (tier table — superseded for the sub-settlement tiers), [D-220](#d-220) (tile scale — Tile/Subtile retained; its "district" footprint cell is the Quarter), [D-066](#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor) (dual-scale — Subtile is the 0.5m grid), [D-093](content.md#d-093-sova-transit-district--spatial-layout-and-district-topology) (Sova Transit District — a *lore* district, not bound to the code tier)
- **Dissent:** None
---
### D-223: Authored content as a flavored name pool — markers names-only, hand-authored templates removed
- **Date:** 2026-05-22
- **Decision:** Per-planet authored content feeds the deterministic generator as a **flavored name pool only** — never as authored geometry or pinned positions. This supersedes the topographic-geometry retention and the `reserved=1` pinning of [D-207](#d-207-fully-generative-placement--markersjson-stripped-to-topographic-features), and the hand-authored-template references in [D-191](#d-191) §8.
**markers.json → names only.** A body's `markers.json` carries a flavored pool of names (river names, mountain names, settlement names) and **no positions or geometry**. Positions are not authored anywhere; they are derived or generated:
- **Rivers** — courses come from the deterministic D8 drainage (D-208) on the fixed heightmap; the **largest rivers take names from the pool**.
- **Mountains** — positions are a given from the **fixed heightmap**; they take names from the pool.
- **Settlements** — the economic sim runs, the **largest population areas** are found, the capital and cities are picked by the placement pipeline (D-211), and they **take names from the pool**.
**The 6 hand-authored templates are removed.** Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade were a superseded hand-authored-showcase direction that kept resurfacing and complicated the landscape. Their hand-authored marker positioning, the `reserved=1` pinning, and the "6 templates" framing/special-casing are all removed. **The bodies themselves remain real places** — their wiki pages, lore, corporations (e.g. comptoir-lendel), and names stay and feed the name pool; deleting them would only force regenerating equivalent content. Only the authored *machinery* is tossed. **After this there is no distinguishable category of "template" left** — the six are processed by the exact same pipeline and data model (fixed heightmap + names-only markers) as every other body, with no special code path, schema flag, or pinning. They differ only in *content richness* (more developed lore/names/economic profile), which is a difference of degree shared by any notable body, not a difference of kind. The concept dissolves; that is why it can be removed wholesale rather than carefully migrated.
**No pinned positions anywhere.** Determinism comes from fixed heightmaps + deterministic drainage + deterministic economic sim + seeded placement — not from authored anchors. The lore-vs-code split (D-222) governs how pool names attach to generated features: names are free-form labels on generated geometry, not bound to grid cells.
Sub-city authored set-pieces (e.g. D-093 Sova Transit District, station interiors) follow the same principle when Phase 4 reaches that scale — generated geometry, lore names/roles attached, nothing pinned — to be applied (and prior authored layouts superseded) at that point.
- **Rationale:** Authored positions created a hand/procedural split that (per D-207's own rationale) was impossible to query, diff, or validate. Reducing authored content to a name pool removes that split entirely while preserving the flavor that makes places feel hand-made — names follow culture and region, geometry follows terrain and economics. The templates were leftover scaffolding from before fully-generative placement; removing them simplifies the pipeline without losing any canonical place.
- **Implementation:** tracked under Phase 4 (epic #750) — strip markers to names, remove template machinery + `reserved` pinning, update the atlas pipeline/schema, regen. Historical archives (sprints, workshops, audits, CHANGELOG, atlas proposals) are left untouched.
- **Implementation status (#951, 2026-05-22): done.** All 2,398 `markers.json` reduced to a names-only pool (25,264 names; 349 city names across 271 bodies). The Python atlas **geometry generator** (`generate_atlas.py`) and the **LLM naming cluster** (`gemma_naming.py`, `naming_core.py`, `apply_name_fixes.py`, their tests/QA, the `fix_fewshot_bleed`/`prune_atlas_features` geometry tools, the redundant `import_city_names.py`, and the `run-atlas-naming.sh` runner) were retired — the procedural server cascade (Phase 4) supersedes them. The Gemma prompting methodology is preserved in [docs/gemma-naming-methodology.md](../docs/gemma-naming-methodology.md). Shared atlas-DB utilities moved to `tooling/planet-gen/atlas_common.py`; **`import_economics.py` is now the sole regen-db generator that owns the atlas index** — it loads the names pool into `atlas_city_names` and empties the 8 geometry tables (`atlas_cities/roads/railroads/pois/rivers/oceans/mountain_ranges/body_grids`), which the cascade fills. `population`/`kind`/`settlement_class` on `atlas_city_names` are **deferred to placement (#955)** — `attractor_matching` reads them at 0/default until then; that empty state is the gap the cascade closes. The `reserved=1` corp-HQ cross-reference stays (corp HQ names are real places). A latent duplicate-accumulation bug in name population (no clear + no unique constraint) was fixed with a deterministic rebuild. **Sol (system `GJ 0`) is permanently exempt from the normal generators:** it uses real Earth/Mars/Luna geography via the offline `sol_import.py` (left in place for future scripted integration), so its bodies keep geometry-bearing `markers.json` as preserved positional config and are skipped by the names-pool importer — Sol names will come from its own integration, not the cascade. `make regen-db` green.
- **Raised by:** Jeroen, 2026-05-22 — resolving the open question on merging preconfigured content into the deterministic cascade.
- **Cross-reference:** [D-207](#d-207-fully-generative-placement--markersjson-stripped-to-topographic-features) (superseded — names-only, no reserved pinning), [D-191](#d-191) §8 (markers format — names-only), [D-208](#d-208) (drainage → river courses), [D-211](#d-211) (settlement placement), [D-199](#d-199) (economic read set), [D-222](#d-222) (lore≠code names on generated geometry)
- **Dissent:** None
---
### D-224: SeedChain — deterministic seed-derivation contract
- **Date:** 2026-05-23
- **Decision:** All deterministic generation — the Phase-4 world cascade now, and NPC / storyteller / economic generation later — descends from a single master world seed through one shared type, **`SeedChain`**, living at **`server/src/seed.rs`** (top-level, because seeds are broader than the atlas). It is the **only** sanctioned way to derive a child seed; the ad-hoc pre-mixing (`seed.wrapping_add(C)`) that was in `skeleton_gen.rs` / `district_mix.rs` is removed, and the doc-comment references to a "SeedChain" in those files become real.
**Mixing primitive.** `splitmix64` — already used by `EntityRng::from_seed_and_id` (`simulation/rng.rs`), chosen there specifically to avoid the `wrapping_add` collision class where `(seed=0,id=N) == (seed=1,id=N-1)` — is promoted to a shared `pub(crate)` function in `server/src/seed.rs` and reused. One canonical *mixer* for the whole codebase. (`EntityRng` keeps its own domainless combine — `splitmix64(world_seed) ^ splitmix64(stable_id)` — for now; re-expressing it as `derive(Npc, stable_id)` would be a deliberate, **stream-changing** migration, not a free cleanup, so it is left as a follow-up.)
**Contract:**
```rust
pub struct SeedChain(u64); // Copy
pub enum SeedDomain { Body, Layer1Topography, Layer3Settlement, Layer4Quarter, Block, Npc /* … */ } // u64 tags
impl SeedChain {
pub fn root(world_seed: u64) -> Self; // top of the chain
pub fn for_body(world_seed: u64, body_id: &str) -> Self; // root + derive(Body, fnv1a_64(id))
pub fn derive(self, domain: SeedDomain, id: u64) -> Self;
pub fn atlas_rng(self) -> AtlasRng; // integer-only LCG stream (D-010)
pub fn seed(self) -> u64; // raw — for SimRng/ChaCha or further derive()
}
```
**Derivation (load-bearing — pinned, because changing it changes every generated world):**
`derive(domain, id) = splitmix64(self.0 ^ splitmix64(domain as u64)) ^ splitmix64(id)`.
Properties: deterministic; domain-separated (distinct `SeedDomain` tags never share a stream); full avalanche (splitmix64 on each input); integer-only (D-010 #4); chainable (`root → Body → Layer3Settlement → Quarter → Block`). The output is well-distributed, so `AtlasRng::new` is fed the derived seed directly — no `| 1` or golden-ratio pre-mix guard.
**Body identity (the `Body` domain id).** Bodies are identified by a *string* `body_id`, not a numeric StableId, so `SeedDomain::Body` is keyed by **FNV-1a (64-bit) of `body_id`** — the repo's standard deterministic `&str → u64` convention (matching `TemplateId`/`TriangleId`). `SeedChain::for_body(world_seed, body_id)` (`root(world_seed).derive(Body, fnv1a_64(body_id))`) is the single sanctioned path; callers must use it rather than inventing their own string→u64 hash, or they would silently derive divergent worlds from the same seed — the very class of nondeterminism this record exists to kill, one level up.
**Stability guards.** `SeedDomain` carries explicit `#[repr(u64)]` discriminants and is append-only; the unit test `seed_domain_discriminants_are_pinned` fails CI if any is renumbered (which would re-roll every world). `AttractorType` likewise carries explicit `#[repr(u8)]` discriminants because it is cast `as u8` as a sort key (`features.rs`); reordering it would change attractor ordering and flip the cascade golden.
**Scope of effect (verified 2026-05-23):** SeedChain changes only the RNG-*using* layers — the existing `skeleton_gen.rs` (Layer 4 block placement) and the future Layer-3 settlement placement (#955). It does **not** affect Layer 0 heightmaps (produced by the Python `planet_simulation` pipeline, seeded separately via `--seed`, committed as `heightmap.png` files) nor Layer 1 (`drainage`/`features`/`subbiome` are RNG-free — pure functions of the heightmap). The #952 Layer 0→1 golden fixtures are therefore SeedChain-independent and can be captured in any order relative to the SeedChain work.
- **Rationale:** Three seeding paths had drifted apart — `AtlasRng` (LCG, "callers pre-mix"), `EntityRng` (correct splitmix64 mixing), and ad-hoc `wrapping_add` in atlas callers — while the code already *named* a SeedChain that didn't exist. A single typed derivation chain with domain separation makes every sub-stream reproducible from one world seed, eliminates the `(seed,id)` collision class `wrapping_add` invites, and gives the determinism harness (#952) a stable contract to verify against. Promoting one mixer prevents two divergent implementations.
- **Implementation:** #952 (Phase 4, epic #750) — `server/src/seed.rs` (incl. `for_body`/`fnv1a_64`), `SeedChain` threaded through the atlas RNG callers, an extensible cascade harness (`run_cascade`/`CascadeSnapshot`/`CascadeLayer`), and a golden-seed regression test (SHA-256 of `heightmap.png` + JSON-serialized `Layer1Output`, run at 256×128 so the river network is non-empty — JSON not msgpack, to match the diffable `golden_suite.rs` convention). Pre-Phase-5: no savegames exist, so the seed-stream change needs no migration; D-202's `schema_version` lineage covers future changes once saves exist.
- **Raised by:** Jeroen + Claude, `/whats-next` refinement of #952, 2026-05-23.
- **Cross-reference:** [D-010](#d-010) (determinism — integer-only, seed→identical output), [D-200](#d-200) (three-tier execution model), [D-208](#d-208) (RNG-free drainage), [D-223](#d-223) (names-only pool — placement uses seeded RNG), `simulation/rng.rs` (EntityRng / splitmix64 precedent)
- **Dissent:** None
---
*84 decisions (D-001 through D-224, excluding gaps). Last updated: 2026-05-23 (D-224 — SeedChain seed-derivation contract)*
+24 -1
View File
@@ -64,4 +64,27 @@ How the team works: composition, naming, workflow.
---
*4 decisions. Last updated: 2026-02-11*
### D-221: Replace sprint workflow with kanban + milestones
- **Date:** 2026-05-03
- **Decision:** Sprint-based workflow (38 sprints, Sprint 1–38) replaced by kanban + milestones. Sprint infrastructure deleted from the repo — git history serves as the archive. No rollback provision maintained in the codebase.
- **What stays:** Cascade phases (macro direction), agent personalities (divergence engines), D-records, tickets (descriptive not prescriptive), workshops, rules/memory system.
- **What changes:**
- Sprint boundaries → milestones tied to concrete deliverables (cascade phase, feature gate)
- Sprint briefings → live ticket queries (`ticket list --status in_progress` + dependency graph)
- Team branches (`sprint-N/team`) → topic branches per logical work unit
- File-scope restrictions per team → removed; any agent can touch any file
- Version `v0.1.{sprint_number}` → `v0.{phase}.{n}` tied to cascade phases
- Si (project manager) → Si (refinement manager) + new clerk agent (institutional guardrail)
- `/sprint-start` → `/whats-next` (dependency-driven batch selection with parallel Si refinement review)
- `/pr-push` → `/pr-process` (author-side review cycle: commit, push, pick up reviewer comments, dispatch fixes)
- Milestones are many-to-many with tickets, can run in parallel, can block other milestones
- **What dies:** Sprint CLI (`tooling/db/sprint`), sprint skills (`/sprint-start`, `/sprint-plan`, `/sprint-status`), sprint briefing files, sprint close/kickoff ceremonies, team-scoped file restrictions.
- **Rationale:** Structural failure modes across Sprints 37–38: ticket-as-contract (agents execute the letter instead of reading the code), artificial delay (ready work parked for "next sprint"), role theater (file-scope restrictions blocking one-liner fixes), order rigidity (stale briefings), locking on bad design (ticket authority overriding code reality), self-review collapse (coding + reviewing in same context window). See Q-096 for full analysis.
- **Test plan:** First milestone is Tier 0+1 planet-down-cascade work (6 schema tables + 6 Python importers). Measurement: did work flow or stall? Did removing file-scope restrictions cause chaos? Did tickets-as-description work?
- **Source:** Q-096 (raised 2026-04-20, expanded 2026-05-01)
- **Raised by:** Jeroen
- **Dissent:** None
---
*5 decisions. Last updated: 2026-05-03*
+9 -1
View File
@@ -330,4 +330,12 @@ Technical foundation questions: engine, protocols, data structures, performance,
---
*46 questions (8 resolved, 1 partially resolved, 37 open). Last updated: 2026-04-05.*
### Q-098: Persistence of generated river/city mapping outputs
- **Status:** Open — to resolve during Phase 4 execution-model work (#952)
- **Question:** The deterministic cascade can recompute river courses (D8 drainage, D-208) and city placements (economic sim + attractor matching, D-211) from seed at any time, so persisting them is a *cost* optimization, not a correctness need. But the compute is expensive — recomputing per session or per atlas view is waste. How are these mapping outputs persisted so they are computed **once per body and kept**? D-203's BodyWorldState cache is an LRU — it *evicts* (volatile). The fork: **(a)** build-time bake into `systems.db` (D-200 build-time tier — precompute all, ship); **(b)** lazy compute + persist at runtime (cache DB / savegame — compute on first visit, keep); **(c)** hybrid. Whatever the answer, the in-memory LRU should sit over a *durable* store so eviction triggers a cheap reload, not a recompute.
- **Context:** Raised 2026-05-22 looking ahead from the Phase 4 markers strip (#951). Refines D-200 (three-tier execution) and D-203 (LRU cache). Gates the Atlas layer viewer (#960), which needs persisted mapping to render without recomputing. Determinism (#952) guarantees recompute is always a valid fallback.
- **Cross-reference:** [D-200](architecture.md#d-200), [D-203](architecture.md#d-203), [D-208](architecture.md#d-208), [D-211](architecture.md#d-211), #952 (determinism harness), #960 (atlas viewer)
---
*47 questions (8 resolved, 1 partially resolved, 38 open). Last updated: 2026-05-22.*
+12 -1
View File
@@ -238,4 +238,15 @@ Narrative, NPCs, dialogue, templates, setting, worldbuilding, and storyteller me
---
*25 questions (11 resolved, 2 partially resolved, 12 open). Last updated: 2026-04-21 (Q-095 resolved — D-193 Lattice Commission)*
---
### Q-097: Strip "What They Don't Talk About" from corporation pages
- **Status:** Open
- **Question:** Should we remove "What They Don't Talk About" sections from corporation wiki pages? Currently 116 of 156 corp pages have this section. The argument: cultural silences are a population/system-level phenomenon — people carry them because of where they live, not who employs them. A mining company doesn't develop its own cultural ethos; its workers inherit the system's silences. Corporate secrecy (trade secrets, undisclosed contracts) is just business, not anthropology. Gate Corporation may be an exception as a civilization-scale institution, but Arbour Aggregates and Rush Mining are regional businesses whose people are system-people first.
- **Context:** Pattern originated from Gate Corporation (which plausibly operates at civilization scale) and was applied uniformly to all corp pages during bulk authoring. Now reinforcing itself — reviewers flag its *absence* as a defect (Sprint 38 PR #140 round 1). If left in place, every new corp page will copy the pattern. Counter-argument: some corporate silences *are* distinct from system silences (e.g., a pharmaceutical company's certification history vs. the system's general cultural memory). The question is whether that justifies a dedicated section or whether it belongs inline in Operations/Market Position.
- **Affects:** 116 wiki/corporations/*.md files, corp page template, reviewer expectations
- **Source:** Sprint 38 PR #140 review discussion (2026-05-02)
---
*26 questions (11 resolved, 2 partially resolved, 13 open). Last updated: 2026-05-02 (Q-097 corp silences)*
+184
View File
@@ -0,0 +1,184 @@
# Open Questions — Process
How the team works: workflow mechanics, cadence, ceremony, release rhythm.
---
### Q-096: Replace sprint workflow with kanban + milestones
- **Status:** Resolved — see D-221
- **Date raised:** 2026-04-20
- **Last updated:** 2026-05-01 (expanded from "should we?" to concrete proposal with test plan)
- **Raised by:** Jeroen
- **Assigned to:** Jeroen (final call)
---
#### Problem Statement
Sprints were introduced when the workflow was framed as Scrum-adjacent. Across 38 sprints, the failure modes have sharpened beyond the original "ceremony overhead" concern into structural problems with how agents interpret sprint-shaped work.
**Original concerns (Sprint 36):**
| Mechanism | Compensates for | Applies here? |
|---|---|---|
| Time-box (1-2 weeks) | Humans need deadline pressure to scope | No — Claude scopes on demand |
| Retro | Team learning ritual, morale | No — adjustments land as rule/memory edits immediately |
| Burndown / velocity | Estimation & planning for multi-person roadmaps | No — solo dev, no external stakeholders |
| Demoable increments | External stakeholder cadence | No external stakeholders |
| Sprint-kickoff ceremony | Re-energize after context loss | Context loss is per-session, not per-sprint |
| Version bump at sprint close | Predictable shippable cadence | `v0.1.{sprint_number}` doesn't reflect feature progress |
**New failure modes observed (Sprint 37-38):**
1. **Ticket-as-contract.** Agents execute the letter of the ticket instead of reading the code and doing what's actually right. If the ticket says "implement X using approach Y," the agent implements Y even when the codebase clearly suggests Z. The ticket becomes a shield against thinking.
2. **Artificial delay.** Work that's ready gets parked because it's "next sprint." Work that's blocked gets thrashed because it's "this sprint." The sprint boundary creates urgency and delay simultaneously.
3. **Role theater.** Agents treat team-scope restrictions (server team can't touch client files) as hard boundaries when the fix is a one-liner. This models a human org chart, not the reality of one person and a context window.
4. **Order rigidity.** Agents unwilling to reorder work when dependencies shift, because the briefing prescribed a sequence. Sprint briefings go stale instantly (Sprint 38 server briefing was missing 26 tickets within a day of creation).
5. **Locking on bad design.** Agents commit to approaches described in tickets even when implementation reveals the design was wrong. The ticket's authority overrides what the code is telling them.
6. **Self-review collapse.** Coding agents write code, create the PR, review their own PR, approve it, and push — all in one convergent context. The adversarial review pipeline collapses because the reviewer has all the author's context and biases. PR comments aren't posted, review gates are skipped, the entire point of separate reviewer personalities is defeated. Requires constant human intervention to enforce.
**Compounding factors (May 2026):**
- Claude Code team-mode harness partially broken (v2.1.123: custom agent types missing SendMessage, shutdown doesn't work, TeamDelete doesn't kill panes). Workaround exists but fragile.
- Opus 4.7 prompt quality ("floatiness") makes the problem worse — agents are already less precise, and rigid ticket framing doesn't compensate, it amplifies.
---
#### Proposal: Kanban + Milestones + Adversarial Personalities
**What stays:**
| Element | Why it stays |
|---------|-------------|
| Cascade phases | Macro direction — what to build in what order |
| Agent personalities + briefings | Adversarial diversity — the real purpose is breaking convergence patterns by seeding different thinking via personality prompts. Not role-play, divergence engines. |
| D-records | Decisions are decisions regardless of process |
| Tickets | Track what needs doing — but descriptive, not prescriptive |
| Workshops | The format where adversarial diversity pays off. Unchanged. |
| Rules/memory | Live institutional knowledge, immediate course correction |
**What changes:**
| Old | New |
|-----|-----|
| Sprint numbers, sprint boundaries | Milestones tied to concrete deliverables (cascade phase, feature gate) |
| Sprint briefings (pre-baked, go stale) | Session startup: `ticket list --status in_progress` + blocked-by graph |
| Team branches (sprint-N/server) | Topic branches per logical chunk, any agent can touch any file |
| File-scope restrictions per agent | Removed — personalities guide perspective, not file access |
| Author and reviewer in same session | Worktree isolation enforced: reviewer spawns in separate worktree, reads diff cold |
| Version `v0.1.{sprint_number}` | `v0.{phase}.{n}` tied to cascade phases |
| Sprint-close cleanup sweep | Pre-push clerk hook (automated) + milestone-boundary audit (manual) |
| Si (project manager) + Qatux (librarian) as separate agents | Merged into "clerk" agent — institutional guardrail |
| Sprint velocity tracking | WIP limits per work-type (max N tickets in_progress) |
**What dies:**
- Sprint CLI (`tooling/db/sprint`)
- Sprint briefing files (`docs/sprints/sprint-N/`)
- Sprint-kickoff and sprint-close ceremonies
- The Si agent as a standalone personality
- Team-scoped file restrictions in CLAUDE.md
**Personalities are not teams.** Gestalt, Tyre, Paula, Burnelli-Sheldon, etc. survive as prompt-seeded divergence engines. They make pushback legible — when Jeroen sees a Burnelli-Sheldon objection he knows it's an economics concern, when he sees a Paula objection he knows it's a narrative/political concern. The personality is the signature, not a job description.
**Worktrees are context firewalls, not team boundaries.** Branches and worktrees survive — but their purpose changes from "team scope" to "context isolation." The reason a coding agent self-reviews its own PR is that it has all the context of writing the code. A reviewer spawned in a separate worktree physically cannot have that context — it reads the diff cold, which is exactly what a reviewer should do. The worktree enforces adversariality at the mechanism level, not the process level. This is an argument FOR keeping branch/worktree separation even after killing team-scoped restrictions.
---
#### Clerk Agent (Si + Qatux merge)
The team lead (interactive Claude session) optimizes for the user's current intent. This makes it structurally incapable of being the institutional guardrail — it will go along with whatever the user says in the moment, even if it contradicts a decision from three weeks ago.
The clerk is the separation of concerns between **responsiveness** (team lead) and **institutional memory** (clerk).
**Pre-push hook implementation:**
1. Pre-push hook spawns clerk agent with the diff
2. Agent checks D-record consistency, ticket drift, contradiction with active decisions
3. Agent writes findings to `.cache/pre-push-review.md`
4. Agent outputs exactly one word: `APPROVED` or `REJECTED`
5. Hook reads the word, exits 0 or 1
6. **Timeout behavior:** if agent doesn't respond within N seconds, automatic `REJECTED`. Agent keeps running in background — findings file arrives later. User reads it, decides whether to fix or force-push.
The findings file separates the agent's reasoning from its authority. Binary gate, verbose reasoning.
**What the clerk checks:**
- Changed files don't contradict active D-records
- Referenced decisions still exist
- Implementation doesn't drift from ticket intent (but ticket is descriptive, not prescriptive — drift is flagged, not blocked)
- Q-records relevant to changed files are surfaced
**What the clerk does NOT do:**
- Subjective code quality review (that's /pr-review)
- Sprint management (no sprints)
- Briefing maintenance (briefings replaced by live ticket queries)
---
#### Test Run Plan
**Scope:** The Tier 0/Tier 1 cascade work — all schema tables and Python importers from the planet-down-cascade workshop.
**Milestone definition:** "All schema tables and Python importers land on main. `make regen-db` produces a DB with heightmaps, city names, province boundaries, body_radius_km, normalized economic_role."
**Rules for the test run:**
- No sprint number assigned
- Topic branches per logical chunk (not team branches)
- Any agent personality can touch any file
- Tickets describe what needs doing, not how
- WIP limit: 3 tickets in_progress at once
- Milestone complete when all Tier 0 + Tier 1 tickets are done and verified
**What we're measuring:**
1. Did work flow or stall without sprint pressure?
2. Did removing file-scope restrictions cause chaos or remove friction?
3. Did tickets-as-description work, or did agents flail without prescriptive tickets?
4. Did anything get worse compared to Sprint 38's current state?
**Rollback trigger:** If after the test run the work feels less productive or less controlled than a sprint, Sprint 39 picks up where 38 left off. All sprint tooling remains in the codebase — dead code, not deleted code.
**Parallel work (independent of test run outcome):**
- Build the clerk pre-push hook — pure additive, helps regardless of process
- Can be tested against Sprint 38 pushes before the process switch
---
#### Risks
| Risk | Mitigation |
|------|-----------|
| No natural "stop and audit" moment | Clerk pre-push hook + milestone-boundary manual audit |
| Work sprawl without sprint focus | WIP limits enforce focus; milestones enforce shipping |
| Losing the "signature" on who did what | Personalities preserved — commits and PRs still carry agent identity |
| Process change is a distraction from actual development | Test run uses real cascade work, not meta-work. Process change is the test. |
| Can't roll back cleanly | Sprint tooling stays in repo. Reversible. |
| Clerk agent too slow / too noisy | Timeout-reject for speed. Tune signal-to-noise over first milestone. |
---
#### Decision Timeline
1. **Now:** Sprint 38 continues as-is
2. **Sprint 38 close:** Decision point — run test milestone or Sprint 39
3. **During test milestone:** Build clerk pre-push hook in parallel
4. **Test milestone complete:** Evaluate against measurement criteria
5. **If positive:** Write D-record confirming kanban + milestones. Kill sprint tooling.
6. **If negative:** Sprint 39. Revisit after more Claude Code harness fixes.
---
**Related:**
- `feedback_broken_tests_not_preexisting_shield.md` — pattern sprints may enable
- tmux teammate mode: confirmed working on Claude Code 2.1.148 (the earlier "broken" status was a `teammateMode: in-process` config issue, not a regression)
- Epic #854 — pipeline discipline automation
- D-166 — cascade phases
---
*Open count: 0.*
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned araminta`
## v0.2 Pivot — Key Changes for Your Role
The visual system shifts from designing layouts for three hand-crafted social sites to designing the visual grammar that the generator uses to produce legible zones at scale.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-04-05
Last updated: 2026-04-05
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned burnelli-sheldon`
## Your Domain
+42
View File
@@ -0,0 +1,42 @@
---
title: "Clerk - Project Briefing"
description: "Pre-push institutional guardrail — checks D-record consistency, ticket drift, decision contradictions, and Q-record surfacing before code lands"
type: briefing
status: active
agent: Clerk
updated: 2026-05-03
---
# Clerk - Project Briefing
Last updated: 2026-05-03
## Role: Pre-Push Institutional Guardrail
Review branch changes before push to catch institutional drift: decision contradictions, ticket scope creep, missing D-records for new behavior, and unresolved Q-records that should block merge.
## What to Check
1. **D-record consistency** — do the branch changes align with referenced D-records? Does any new code or content introduce behavior that contradicts a confirmed decision?
2. **Ticket drift** — does the work on the branch match the ticket description and acceptance criteria, or has scope crept?
3. **Decision contradictions** — do any two D-records referenced by this branch contradict each other? Has a D-record been amended elsewhere but not reflected in the branch?
4. **Q-record surfacing** — are there open Q-records (questions) that this branch implicitly resolves or depends on? If so, flag them for explicit resolution before merge.
## Where to Find Decisions
- **Decision index:** `decisions/README.md` — master index of all domain files
- **Domain files:** `decisions/architecture.md`, `decisions/perception.md`, `decisions/content.md`, `decisions/scope.md`, `decisions/economics.md`, `decisions/process.md`
- **Open questions:** `decisions/questions.md`, `decisions/questions-process.md`
- **Rejected proposals:** `decisions/rejected.md`
- **Decision DB table:**
```bash
tooling/db/sqlite-query "SELECT id, title, status, domain FROM decisions WHERE domain = '<domain>'"
```
## Output
Write findings to `.cache/pre-push-review.md`. End with a binary verdict:
- **APPROVED** — no institutional issues found, safe to push
- **REJECTED** — list each issue with the D/Q-record reference and what needs resolution
The findings file is gitignored (lives in `.cache/`) and is consumed by the push workflow.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned dudley`
## Status
STANDBY. This briefing will be populated when backend/engine implementation begins. Dudley activates when simulation implementation work is assigned.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned gestalt`
## Key Design Pivots
- **Tycoon bookmark (D-117):** Zero investigation content. Tycoon (small business owner) is the starting state.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned gore`
## v0.2 Pivot — Key Changes for Your Role
The workshop confirmed and sharpened the thematic frame. "Is this enough?" survives as the central question. The change is that it now must be posed through a generated tycoon's life, not through authored detective/smuggler characters.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned hoshe`
## Status
**ACTIVE.** Test infrastructure from earlier sprints is operational. v0.2 focus: generator output validation — can the generator produce legible NPCs? Are relationships readable from generator output?
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned justine`
## Status
**ACTIVE.** CI and build pipeline maintenance. Content validation infrastructure remains relevant as the AI-assisted content pipeline (D-123) produces NPC content at scale.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned mellanie`
## v0.2 Pivot — Key Changes for Your Role
The copy pipeline fundamentally shifts from hand-authoring specific NPC profiles to designing the culture-driven voice system that feeds the generator.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned miri`
## v0.2 Pivot — Key Changes for Your Role
The workshop confirmed Sova Transit District as the v0.2 setting (tycoon bookmark in Van Maanen's Star) and resolved the culture architecture gap in Miri's favour.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned nigel`
## v0.2 Pivot — Key Changes for Your Role
The replayability frame shifts from "same conspiracy, different character lens" to "same generator, different seeded world." The anti-metagaming goal survives; the mechanism changes.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned oscar`
## Status
STANDBY. This briefing will be populated when networking/multiplayer implementation begins.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned ozzie`
## v0.2 Pivot — Key Changes for Your Role
The workshop revealed a core framing error: the game was built as a detective puzzle game but the vision is a single-character life sim. The fix is not cosmetic.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned paula`
## v0.2 Pivot — Key Changes for Your Role
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned qatux`
## Decisions Relevant to Your Role
All decisions (D-001 through D-137) — maintains decision domain files in `decisions/` as the canonical record. Indexes and retrieves decision context for the team.
+49 -53
View File
@@ -1,66 +1,62 @@
---
title: "Si - Project Briefing"
description: "Sprint and project manager — owns sprint planning, ticket tracking, v0.2 critical path dependency chain, and CI pipeline awareness for definition of done"
description: "Refinement manager — assesses ticket readiness, ensures sufficient context before work begins, surfaces missing D/Q-records and unresolved dependencies"
type: briefing
status: active
agent: Si
updated: 2026-03-13
updated: 2026-05-03
---
# Si - Project Briefing
Last updated: 2026-03-13
Last updated: 2026-05-03
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned si`
## Role: Refinement Manager
Assess whether tickets have enough context to be worked. A ticket is ready when:
- It has a **clear outcome** — the agent can tell when the work is done
- All referenced **D-records are unambiguous** and confirmed (not draft, not contradicted)
- No **conflicting Q-records** remain open that would change the ticket's direction
- All **referenced artifacts exist** (files, schemas, specs mentioned in the ticket or its D-records)
If any of these conditions fail, the ticket is not ready. Flag what is missing and who owns the resolution.
## Where to Find Decisions
- **Decision index:** `decisions/README.md` — lists all domain files and how to query them
- **Domain files:** `decisions/architecture.md`, `decisions/perception.md`, `decisions/content.md`, `decisions/scope.md`, `decisions/economics.md`, `decisions/process.md`
- **Open questions:** `decisions/questions.md` (Q-NNN), `decisions/questions-process.md` (Q-NNN process)
- **Rejected proposals:** `decisions/rejected.md` (R-NNN)
- **Decision DB queries:**
```bash
tooling/db/decision show D-NNN # Show a specific decision
tooling/db/decision check-dupes "title" # Check for duplicates before creating
```
## Where to Find Workshop Outcomes
Workshop outputs live at `docs/workshops/<workshop-name>/`:
- `workshop-outcomes.md` — final summary with confirmed D-records
- Round files — per-round notes and participant outputs
- Check these when a ticket references a workshop decision to verify the full context is captured
## What "Enough Context" Means
A ticket has enough context when an agent can pick it up cold and deliver without asking clarifying questions. Concretely:
1. **Outcome is testable** — "implement X" has acceptance criteria; "design Y" has a deliverable format
2. **D-records are stable** — no open Q-record that could reverse the referenced D-record
3. **Dependencies are met** — if the ticket says "blocked by Miri's zone spec," that spec exists or the ticket stays in backlog
4. **Scope is bounded** — the ticket does not silently depend on cascade phases that have not been reached (see D-166 development cascade in CLAUDE.md)
## Key Commands
## Decisions Relevant to Your Role
All decisions across all domains. Read `decisions/README.md` for the index. Query decisions DB for sprint planning:
```bash
make decisions-coverage # Decision-to-ticket coverage by domain
make decisions-orphan # Decisions without implementing tickets
make decisions-active # List all active decisions
tooling/db/ticket list --status backlog # Candidates for refinement
tooling/db/ticket show <id> # Full ticket details
tooling/db/decision show D-NNN # Check a referenced decision
tooling/db/sqlite-query "SELECT * FROM decisions WHERE status = 'open'" # Open decisions
```
## Open Questions Assigned to You
All open questions - tracks as tickets for assignment and progress monitoring.
## Development Cascade (D-166)
Development follows a strict 6-phase cascade. Currently in Phase 1 (wiki content). Generator spike (D-119) is Phase 5.
**Prerequisites that must exist before/during the generator spike:**
1. Miri: Zone identity spec + Van Maanen's Star culture profile — blocks voice, NPC gen, AI pipeline
2. Araminta: Zone visual grammar, tile palettes — blocked by Miri zone identity spec
3. Tyre: NpcBlueprint struct design — generator output format
4. Mellanie: Culture-primary voice cards — blocked by Miri culture profile
**Dependency chain:**
```
Miri: Zone Identity Spec + Van Maanen's Star Culture Profile
|-- Araminta: zone visual grammar, tile palettes
|-- Tyre: generator zone template parameters, NpcBlueprint
|-- Mellanie: culture-primary voice cards
v
Tyre: Generator Spike (Sprint 25)
|-- Produces: auto-generated location + NPCs
v
Test: Can the player read NPC relationships from generator output?
```
**Estimated timeline (Tyre):** 7 sprints to proof-of-life playtest (generated location + legible characters + tycoon bookmark from creation to Day 3).
## Current Priorities
Consult `docs/sprints/` for current sprint briefing. Break v0.2 decisions (D-114 through D-137) into tickets if not already covered. Track dependencies above.
## Development Workflow
See `docs/DEVOPS.md` for full procedures. Key awareness for sprint planning:
- `make ci` must pass before any PR merges — definition of done includes CI green
- `make ci-server` = lint-server → build-server → test-server
- `make ci-client` = lint-client → build-client → test-client
- Server work lives in `server/`, client work in `client/`, cross-boundary tests in `tests/`
- Hoshe's test plans target these directories per D-030 testing architecture
## Key Documents
- decisions/ - domain-split decision files (see decisions/README.md for index)
- docs/DEVOPS.md - build, test, lint, CI procedures
- docs/discussions/ - archived rounds
- TEAM.md - team roster
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned stig`
## Status
**ACTIVE.** v0.2 brings a character customisation UI requirement and removes the dual-character variant for insert/HUD.

Some files were not shown because too many files have changed in this diff Show More