Compare commits

...
211 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.8 a4727bf73e fix(economics): address PR #148 review — dry-run guard + pin validation
Review fixes (Hoshe/Tyre, both APPROVE):

- populate_trait_templates / populate_atlas_body_trait_bias: the table
  DELETEs ran unconditionally (safe only via transaction rollback on
  dry-run, and divergent from every other populate_* function). Restructured
  so validation + guardrails always run (dry-run now actually surfaces the
  would-bake counts and catches errors) but mutations happen only under
  `if not dry_run:`. Verified: --dry-run reports 28 templates, writes nothing.
- atlas_body_trait_bias: reject `pin` entries that carry a
  weight_multiplier_bps (pin is mandatory, no multiplier) — closes a silent-
  accept gap before #1017 authors ~30-40 real pins.

Deferred (noted on tickets): visual_bundle fallback-map completeness
(Phase-5/Araminta), pin-count-vs-K bake check (#1017 acceptance),
geographic_sector pool-narrowing semantics (#977).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:36:37 +02:00
jpmschweitzerandClaude Opus 4.8 298c37b1ee chore(meta): update changelog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:05:26 +02:00
jpmschweitzerandClaude Opus 4.8 375d04a29d data(content): core architecture-flavor trait-template catalog (#1005)
The D-232 core hand-curated catalog (28 templates) the architecture-flavor
draw reads from, baked into trait_templates (#993):

- Cross-corridor pool (10): economic-function templates gated by BulkClass
  (extraction/industrial/cold-chain/precision/information/civic) + universal
  baselines + the foreign-import swerve. Guarantees the CI floor.
- Per-corridor baseline (8): each corridor's default cohesive look — core
  cosmopolitan, north anglo-frontier, south lusophone, west germanic +
  compact-cooperative, east dense-utilitarian, deep-frontier surname +
  hardscrabble.
- Heritage sub-pools (10): deep-history callbacks tied to the D-237 heritage
  taxonomy (scottish highland, west-african compound, iberian hacienda,
  atlantic creole, nordic timber, central-european blok, east-asian temple,
  vietnamese water village, afrikaans kraal, arab oasis).

Each template = holistic bundle: two-tier eligibility (hard gates
bulk_class/ubiquity/prosperity_bps + soft weight mods), zone_affinity over
the real DistrictType enum, allow/block tags from a shared ObjectTag
palette, and a D-235 visual_bundle with generic fallback parents. All
numerics integer basis-points (D-010).

Adds the D-232 CI guardrails to the baker (V-TT-01: >=5 templates eligible
per BulkClass; V-TT-02: no template >60% of pool weight) — both pass with
margin (23-24 eligible/class, 5% max share). Catalog can grow via the
bounded Gemma pass (#992); hero pins are #1017.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:04:43 +02:00
jpmschweitzerandClaude Opus 4.8 a133b6416e feat(schema): trait_templates + atlas_body_trait_bias tables + bake (#993)
D-232 architecture-flavor storage mechanism (content is #1005/#1017):

- trait_templates: shared catalog of holistic template bundles — two-tier
  eligibility (hard gates bulk_class/prosperity_bps/ubiquity + soft weight
  mods), zone_affinity, allow/block tags, visual_bundle. List/map fields
  are JSON; all numerics integer basis-points (D-010).
- atlas_body_trait_bias: sparse per-body hero pins — pin/boost/suppress
  with basis-point multiplier ranges (boost ≤3×, suppress ≥0.33× never 0).
- Baked at import (steps 14/15) from wiki/economics/architecture_trait_
  catalog.toml + architecture_trait_bias.toml. FK-validated (body_id →
  bodies, template_tag → trait_templates), enum + multiplier-range checks,
  graceful on absent source. Source location provisional pending Q-107;
  the baked tables are invariant per D-232. Retires the round-2
  atlas_body_culture tables.
- Catalog ships a 3-template bootstrap seed to prove the bake end-to-end;
  the full ~35-template catalog is #1005, hero pins #1017.
- Stamp sources updated (IMPORT_ECONOMICS_SOURCES + GENERATOR_SOURCES).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:47:35 +02:00
jpmschweitzerandClaude Opus 4.8 00c8574e11 data(economics): address PR #147 review — fused/activity pin fixes
Review fixes for the #1016 cultural pass (Hoshe/Paula/Miri):

- Kruger 60 (GJ 860A) xhosa -> cosmopolitan (Miri): GTTR describes an
  Afrikaner/Xhosa/Zulu founding "without mapping cleanly onto any of
  them" — the textbook genuinely-fused case, which taxonomy rule 4
  resolves to cosmopolitan rather than one of the three. Commented.
- Altmark (GJ 357) financial_technocratic -> cosmopolitan (Paula):
  reverts to the original shipped value; the GTTR reads as commercial
  non-alignment, not credential-based technocracy.
- Kampala Gate (GJ 667B) + Moyale (GJ 1292) keep swahili but add inline
  comments documenting that these are East-African-INTERIOR founders
  (Baganda; Kenyan/Ethiopian Borana-Oromo) for which the taxonomy has no
  dedicated token — swahili is the nearest available, NULL would fall to
  the wrong corridor default. Gap logged for a follow-up taxonomy value.
  Mwangaza (693) swahili is literally correct (coastal) — untouched.
- heritage-taxonomy-draft.md: stale "(38)" subheadings -> "(47)" to match
  the actual canonical count (Hoshe/Miri doc typo).

Retracted: Pedra Seca (GJ 421B) xhosa — reviewer thought it lacked
authored backing, but its gttr.md explicitly states "Afrikaans and Xhosa
heritage"; pin kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:23:42 +02:00
jpmschweitzerandClaude Opus 4.8 9e791240c5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:06:56 +02:00
jpmschweitzerandClaude Opus 4.8 21793c1a94 data(economics): #1016 cultural-write pass + canonical 47-value heritage migration
Completes the deferred cultural half of #1016 and migrates the heritage
vocabulary to the canonical 47-value taxonomy (D-237).

Migration (mechanical):
- _CULTURAL_HERITAGE replaced with the canonical 47-value set
  (docs/.../heritage-taxonomy-draft.md). Renamed the 4 shipped hero
  values to canonical: afrikaans_cape->afrikaans, french_provencal->
  french, italian_northern->italian, norse_compact->nordic.

Content pass (authored from GTTR/name heritage triage, 6-sector fan-out):
- +139 new cultural_specialization pins and 1 change (Altmark
  cosmopolitan->financial_technocratic), taking coverage 32 -> 171
  across 54 distinct values. Each pin is grounded in an explicit GTTR
  founding-community statement or system/feature name etymology, applied
  only where founding heritage DIVERGES from the corridor baseline
  (D-167/D-232); corridor-typical systems left NULL.

Distribution is now balanced and diverse — portuguese 15, german 12,
vietnamese 10, afrikaans 10 (no longer dominant post-#1019), then a long
tail filling the previously-missing slots (welsh, herero, czech, akan,
hungarian, konkan, afro_brazilian, cape_verdean, shona, arab/persian/
turkic, etc.).

Judgment calls (flagged for review):
- Kruger 60 + Pedra Seca -> xhosa (mixed SA founding; adds diversity).
  Pedra Seca has a Portuguese name but Afrikaans/Xhosa GTTR founding —
  name/heritage mismatch noted for a future content fix.
- Kampala Gate / Moyale / Mwangaza -> swahili as the nearest token for
  East-African-interior heritage; taxonomy may want a dedicated value.
- Kept hero pins Kensho=scholarly, Keid=scholarly, Nyrheim=nordic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:06:23 +02:00
jpmschweitzerandClaude Opus 4.8 e1e1185cc3 fix(content): correct Golestan topology wording to match 2-aperture data
GJ 680 has aperture_count=2 / gate_connections=2, linking two distinct
systems (Athabasca GJ 1289 and Enugu GJ 75) — but the prose, inherited
verbatim from the original Carnarvon page, described "one gate in, one
gate out, both the same gate" and "one aperture connecting to Enugu and
Athabasca" (self-contradictory). Reword the gttr and index to two gates
while keeping the system's low-through-traffic spur character (the
spur_end topology label and "nobody routes through here" identity are
unchanged). Resolves the deferred PR #146 review finding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:27:30 +02:00
jpmschweitzerandClaude Opus 4.8 8dc6c0ed42 data(content): address PR #146 review — Ghawr rename, faction pins, catalog
Review fixes for the #1019 heritage rebalance:

- Marib's farm world Ghouta -> Ghawr (Miri): "Ghouta" carries strong
  modern associations (2013 Damascus attack); Ghawr (the Jordan Rift
  Valley, an irrigated lowland farming region) keeps the water-fed-
  drylands sense without the resonance.
- Add dominant_faction = "independent" to GJ 914A / 508A / 1245B (Hoshe)
  — all five rebalanced systems are self-governing independent
  communities; uniform with siblings GJ 722 / 680 and clears V-FAC-01.
- catalog.md: fill GJ 914A -> Marib and GJ 722 -> Sığınak (Hoshe) — the
  two anchor systems still showed em-dash placeholders.
- Marib gttr: name the second aperture neighbour Sığınak instead of the
  bare "GJ 722" (Miri).

Pre-existing Golestan aperture/spur wording (reviewers split on whether
it is even wrong) is left for a separate content pass — not introduced
by this rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:17:07 +02:00
jpmschweitzerandClaude Opus 4.8 36ae931997 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:12:00 +02:00
jpmschweitzerandClaude Opus 4.8 03188de16d docs(workshops): record #1019 heritage rebalance batch
The pre-execution conversion plan and shipped-state addendum for the
South-African heritage rebalance: per-system field maps, the worked
gttr rewrites, the cross-reference cleanup list, and the rationale for
keeping load-bearing mystery systems out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:11:20 +02:00
jpmschweitzerandClaude Opus 4.8 8844bef139 data(content): rebalance 5 thin afrikaans systems to MENA heritage (#1019)
The corridor over-represented southern-African heritage (afrikaans on 15
systems; arab=2, persian/turkic=0) as an artifact of the earlier
name-balancing overcorrecting. Convert five genuinely-thin afrikaans
systems — whose distinctive threads are heritage-agnostic — into
Arab/Persian/Turkic founders so the cultural cascade is built on a
balanced set. Load-bearing mystery systems (Eerste Wacht / Helderoog /
Brandpunt etc.) are left untouched.

  Droëland  (GJ 914A)  -> Marib    (arab)
  Stilwater (GJ 508A)  -> Sawad    (arab)
  Koeberg   (GJ 1245B) -> Akhgar   (persian)
  Skuilplek (GJ 722)   -> Siginak  (turkic)
  Carnarvon (GJ 680)   -> Golestan (persian; Mostert dynasty -> Farahani)

Result: afrikaans 15->10; arab 0->2, persian 0->2, turkic 0->1.

Full narrative retouch (not just names): gttr.md + index.md prose,
body/station proper_names, cultural_specialization, gttr_hook,
atlas_city_names, and the four brand-corps named after the converted
systems (corp IDs kept stable; only display names + products renamed).
Cross-references in neighbour systems, the catalog, the aggregate
drifter guides, and the atlas proposals are updated to match.

arab/persian/turkic added to _CULTURAL_HERITAGE so the values validate;
the full canonical 47-value migration remains #1016.

Also fixes a pre-existing canon bug: Sawad (GJ 508A) no longer claims
GJ 914A is "unsettled" — it is Marib, a three-century settlement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:11:10 +02:00
jpmschweitzerandClaude Opus 4.8 01ba14ada9 docs(workshops): canonical D-237 heritage taxonomy (47 values, approved)
Reconciles the ~30 inconsistent heritage proposals from the 6-sector #1016
content pass into one canonical cultural_specialization heritage vocabulary
(47 values). Key reconciliations: norse_compact->nordic (un-fuses people from
faction), spanish_iberian+spanish_hispanic->spanish, brazilian_portuguese+
lusophone_frontier->brazilian, italian_northern->italian, afrikaans_cape->
afrikaans, french_provencal+french_canadian->french. No compound values; one
token per system (blends take dominant heritage or the cosmopolitan activity
value).

Maintainer decisions (2026-06-01): keep ALL values distinct (no lumping —
welsh/afro_brazilian/cape_malay/xhosa/luxembourgish/konkan all retained);
keep empty-but-complete slots (yoruba/hausa/hungarian/persian/turkic). Fills
the Middle-East gap the pass exposed: arab/persian/turkic added even at near-
zero current usage so the canon is complete.

Logs the southern-African overrepresentation (~25/300, afrikaans=15) as a DLC
content-rewrite rebalancing task (#1019) rather than lumping the taxonomy.

Migration note included: 4 already-shipped hero cultural values use old suffix
forms (afrikaans_cape, french_provencal, italian_northern, norse_compact) and
will be renamed in the culture-write pass when _CULTURAL_HERITAGE is replaced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:49:00 +02:00
jpmschweitzerandClaude Opus 4.8 8addf04835 data(economics): #1016 econ+faction content pass (143 systems, cultural deferred)
The 6-sector content pass (core pilot + deep_frontier/north/east/south/west,
~260 systems read from both index.md and gttr.md) authored economic_
specialization + dominant_faction for the systems whose lore makes those
identities distinct. Conservative pin bar: corridor-typical systems left NULL
for the heuristic/derivation fallback.

Totals now: economic 170 (27 hero + 143), faction 76 (27 hero + 49),
cultural still 27 (DEFERRED — the culture pass needs a canonical heritage
vocabulary first; the agents surfaced ~30 real heritages from the names plus a
genuine Middle-East/Arabic gap, to be reconciled into one taxonomy before
writing cultural_specialization).

regen-db green, stamp fresh, all CI guardrails pass. Soft warnings are the
expected D-177 MonopolySource review list, the known Arbour estate/breadbasket
question, and sparse-corp_presence W-FAC-03 advisories — none blocking.

Notable pins: Haodu financial_hub/syndic_dominant, Matamba legal_archive,
Inhambane/Sonnwend/etc breadbasket, the terroir cluster, the west_reach
Compact zone, Aurelius governance_center/veil_institute. FLAGs left NULL
pending human calls (ISPI=veil_institute?, Gate-Corp-as-faction).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:27:24 +02:00
jpmschweitzerandClaude Opus 4.8 0251c65b96 feat(wiki): surface D-237 specialization fields in the system infobox
generate_infobox() now renders economic_specialization (Specialization) and
cultural_specialization (Cultural Register) as read-only infobox rows when
authored (NULL = omitted, falls to heuristic). dominant_faction already
showed via Governance. Makes the authored generator-input values visible on
the wiki face so the #1016 content pass output renders. Verified: Ran shows
'breadbasket'/'agrarian'; unauthored systems omit the rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:29:37 +02:00
jpmschweitzerandClaude Opus 4.8 e0d796357f docs(decisions): document economic_base_primary vs economic_specialization seam (D-237)
Records that the two are a deliberate parallel representation, not a
duplicate: economic_base_primary is uncomputable GTTR prose flavor rendered
to the wiki infobox; economic_specialization is the higher-fidelity enum the
D-233 generator consumes. Notes they must stay mutually consistent (#1016
authors the enum from the same lore; W-SES-03 flags divergence), that
unification was considered and rejected (each carries info the other can't),
and clarifies the DB-canonical / index.md-projection data flow (only the
prose sections are authored in-place). Surfaced when the content-pass review
questioned the redundancy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:42:38 +02:00
jpmschweitzerandClaude Opus 4.8 c6abff0f44 chore(db): regen systems.db — D-237 specialization layer populated
make regen-db with the D-237 authored layer live: 27-row
specialization_vocabulary, economic_specialization + cultural_specialization
on 27 hero systems, dominant_faction on the authored set. Meta stamp fresh
(check-systems-db OK). Unauthored systems remain NULL pending the #1014
fallback and #1016 content pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:09:56 +02:00
jpmschweitzerandClaude Opus 4.8 9fe4ddfb04 fix(economics): restore dropped vocab_rows.append in import_system_specialization
The #1015 V-SES-03 edit replaced the vocabulary validation loop body and
dropped the trailing vocab_rows.append(), so specialization_vocabulary was
inserted with 0 rows even though per-system UPSERTs (which read the dict
directly via valid_spec_ids) still succeeded. regen-db reported
'vocab 0 | economic 27 | ...'. Restored the append at the end of the loop.

Verified: regen-db now writes 27 vocab rows + 27/27 economic/cultural;
spot checks financial_hub=NonPhysical/Specialist, Ran=breadbasket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:09:49 +02:00
jpmschweitzerandClaude Opus 4.8 2f994c1795 feat(economics): D-237 specialization CI guardrails + coverage report (#1015)
Adds the V-SES / V-FAC guardrail suite to import_system_specialization():

Always-on hard errors (abort the import transaction): V-SES-01 economic
value in vocabulary; V-SES-03 production_ubiquity_override >= commodity
catalog concentration (HUB values with empty override exempt — they
intentionally project lower); V-SES-04 cultural value in the combined
activity+heritage vocabulary; V-SES-05 faction in 8-value vocab; V-SES-06
vocab commodity FK; plus unknown system_id.

Completeness gates — V-SES-02 (every inhabited system resolves a non-null
economic value) and V-FAC-01 (every inhabited named system has authored
faction) — are HARD only under --strict-specialization (default off). Their
preconditions are the #1014 heuristic fallback (blocked by #982) and the
#1016 content pass; until those land they print as warnings so regen-db
stays green.

Soft warnings (always print): W-SES-01 MonopolySource D-177 review list,
W-SES-02 single-value concentration, W-SES-08 estate_farming + large pop,
W-FAC-01 compact + TRACTUS currency, W-FAC-02 compact + MonopolySource
(terroir/marble exempt), W-FAC-03 syndic_dominant w/o corp HQ, W-FAC-04
compact_sympathetic + MARK currency. Coverage report always prints
economic/cultural/faction counts + BulkClass/ProductionUbiquity dists.

Cultural vocabulary is _CULTURAL_ACTIVITY | _CULTURAL_HERITAGE module
constants — #1016 adds heritage values there in one place.

Validated on a copy of the live DB: default exits 0 (gates as warnings, real
W-SES-08 hit on Arbour); strict exits 1 on V-SES-02 (Sol, Struve) and rolls
back cleanly; dry-run exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:44:03 +02:00
jpmschweitzerandClaude Opus 4.8 883c1ab0b1 feat(economics): import_system_specialization() — D-237 authored layer (#1013)
Wires the D-237 authored specialization layer into import_economics.py:

- import_system_specialization(): reads specialization_vocabulary.toml
  (FK-validated against commodities; repopulates specialization_vocabulary)
  and system_specialization.toml (UPSERTs economic_specialization +
  cultural_specialization onto system_economy, dominant_faction onto
  system_factions for authored systems only). Hard errors on bad commodity
  FK / projected enum / unknown system_id abort the transaction; prints a
  coverage report + missing-row warnings.
- Called as step 4b in main() (after commodities so the FK resolves, before
  currency zones). economic/cultural columns are importer-owned and cleared
  to NULL first for idempotency; dominant_faction only overwritten for
  authored systems (never globally cleared — shared with other derivation).
- specialization_vocabulary added to MIGRATION_SQL (after commodities for FK)
  so the migration path on existing DBs creates the table, not just fresh
  systems-schema.sql builds — this is what #1011 missed. Also cleared before
  commodities in the FK-safe clear block.
- Both source TOMLs added to IMPORT_ECONOMICS_SOURCES and mirrored in
  check-systems-db-stamp GENERATOR_SOURCES so editing them flips the stamp.

Validated: full non-dry-run import on a copy of the live systems.db exits 0;
27 vocab rows, 27/27/27 economic/cultural/faction set, no missing rows;
spot checks Ran=breadbasket/agrarian, Vuurkloof=independent,
financial_hub=NonPhysical/Specialist. Dry-run also exits 0.

V-SES CI guardrail suite remains #1015; code note flags that V-SES-03
(equal-or-higher) must not hard-fail HUB specializations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:09:06 +02:00
jpmschweitzerandClaude Opus 4.8 4f53793755 feat(schema): D-237 specialization columns + specialization_vocabulary table
Ticket #1011. system_economy gains economic_specialization +
cultural_specialization (TEXT, NULL = heuristic fallback); new
specialization_vocabulary table (specialization_id PK, commodity_id
FK->commodities, production_ubiquity_override, projected BulkClass +
ProductionUbiquity for D-233, description) + commodity index. Columns
mirrored into COLUMN_MIGRATIONS for idempotent migration; table rides
systems-schema.sql. dominant_faction already exists on system_factions
(populate-only). Validated: importer parses; schema applies to scratch DB;
idempotent migration ran 2x on a copy of live systems.db, 301 rows preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:04:00 +02:00
jpmschweitzerandClaude Opus 4.8 a4541bde18 chore(db): raise ticket WIP limit 3 → 5
Per maintainer call — 3 was too tight for the parallel Phase 4 work in
flight; 5 better matches how many tickets are genuinely active at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:33:42 +02:00
jpmschweitzerandClaude Opus 4.8 2e9622d7ed docs(decisions): amend D-199/D-214/D-232 for D-237 specialization layer
Completes ticket #1010. The D-237 record and D-233 re-amendment landed
earlier; this adds the three downstream amendments:

- D-199: read-set gains economic_specialization + cultural_specialization;
  dominant_faction now authored-sourced where present.
- D-214: dominant_faction is an authored 8-value vocabulary for named
  systems (heuristic fallback otherwise); lattice_commission is not a
  faction value (Commission is a regulator). Archetype mapping unchanged
  but now reads a trustworthy faction for the systems the heuristic got
  wrong (e.g. Groombridge → Syndic/Corporate, not Commission).
- D-232: template draw reads authored cultural_specialization to select/
  bias the pool when founding heritage diverges from the corridor baseline
  (Phase-4 correctness, not flavor); NULL = existing corridor algorithm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:33:40 +02:00
jpmschweitzerandClaude Opus 4.8 1521182c72 docs(decisions): open Q-108 — subterranean/enclosed settlement morphology
The D-233/D-237 built-world fill model is implicitly a surface model
(roofed-coverage fraction, operations surface, scatter-vs-concentrate on
open ground), but bodies.settlement_pattern already carries enclosed
morphologies (underground_concentrated, cave, domed, underground_complex)
that nothing in the chain reads — not in the D-199 read-set, not in
city_context_reader, not in skeleton_gen. Engine z-level primitives exist
(D-110, D-106, UndergroundComplex) but no morphology switch connects them
at the built-world layer, so a body like Vuurkloof (GJ 35) would generate
as a surface town against its underground-concentrated lore.

Q-108 schedules the surface-vs-enclosed morphology debate before the
Phase 4 content pass authors built form for enclosed-habitat bodies.
Tracked by ticket #1018. Surfaced via Vuurkloof during the
system-economic-specialization workshop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:09:17 +02:00
jpmschweitzerandClaude Opus 4.8 95121fa507 chore(meta): update changelog
Add the D-237 authored specialization layer (and D-233 re-amendment) under
[Unreleased].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:41:22 +02:00
jpmschweitzerandClaude Opus 4.8 b9b39e84d0 chore(skills): log workshop-start improvements from this run
Running notes for a later workshop-start pass: read-only participant agents
can't self-write output files (lead-as-scribe relay needed); SI has no
round-1 task; create+assign tasks before spawning; TaskCreate is one-per-call;
decision claim is not idempotent (caused a stray duplicate ID); the optional
scout-critic idea and its guardrails; and background always-present teammates
can be slow to consume shutdown_request at wrap-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:40:15 +02:00
jpmschweitzerandClaude Opus 4.8 33c083f0ef docs(workshops): system-economic-specialization rounds + outcomes
Three-round design workshop (Miri, Burnelli-Sheldon, Paula; documented by
Qatux, ticketed by SI) that produced D-237. Includes per-agent round
outputs, round notes, the independent scout critique, and the final
workshop-outcomes.md with the consolidated schema, 27-value economic
vocabulary, cultural + faction vocabularies, deterministic-varied fallback
spec, must-pin table, and CI guardrails.

Clerk-Skip: workshop discussion docs, no D-record surface (D-237 filed separately)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:40:08 +02:00
jpmschweitzerandClaude Opus 4.8 f13216f5af docs(decisions): file D-237 authored specialization layer + re-amend D-233
Add D-237: each inhabited system carries three authored, static (t=0),
deterministic per-system identity fields sourced from
wiki/economics/system_specialization.toml — economic_specialization
(27-value vocabulary, scale encoded in the value), cultural_specialization
(unified activity + heritage vocabulary), and dominant_faction (populate
the existing system_factions column, 8 values, lattice_commission dropped).
Authored lore wins; a deterministic-varied seed-hashed weighted draw is the
fallback for unnamed systems so they don't read identically.

Re-amend D-233 to source dominant_bulk_class x dominant_production_ubiquity
from this authored layer (heuristic becomes fallback). Groombridge encodes
as a financial_hub Specialist district with the GSH clearing house as a
D-222/D-232 landmark rather than a composite vocabulary value.

Add the specialization-layer d2 diagram (3 fields -> D-233/D-232/D-214
consumers). Produced by the system-economic-specialization workshop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:39:57 +02:00
jpmschweitzerandClaude Opus 4.8 edc28d5cdb chore(config): allow read-only file access for teammates + system-economic-specialization workshop brief
- settings.json: add Read(repo/**), Read(/tmp/**), Grep, Glob to the
  allowlist. Teammates inherit settings.json (not settings.local.json),
  which had no Read rule — so every teammate file read escalated to the
  lead as a per-file prompt. This closes that gap.
- Add the system-economic-specialization workshop brief (lore-authored
  per-system dominant-commodity field + within-system weighted variation;
  Q5 covers adding a small identity field set in the same 300-system pass).

Note: agent memory-strip (6c93b698f) + this read-allow reach teammates
only after a Claude Code restart (settings/agent-def snapshot is taken at
lead startup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:52:48 +02:00
jpmschweitzer 06d9708dbb Merge PR #145: wire D-199 economic read-set into generation context (#954)
Layer 2 clean half. Prosperity in integer basis points (D-010).
Reviewed-by: Hoshe (code quality), Tyre (architecture) — both APPROVE.
2026-05-31 14:34:57 +02:00
jpmschweitzerandClaude Opus 4.8 d6e37c0c0e fix(simulation): prosperity in basis points + shared log10_floor helper (#954)
Address PR #145 review. Convert prosperity from f32 to integer basis
points (matching the existing prosperity_bps, D-010 integer-only),
removing both float-determinism bugs by construction:

- New server/src/bps.rs: log10_floor (integer order-of-magnitude) +
  bps_to_f32 edge helper, with boundary tests.
- city_context_reader: prosperity_baseline_bps (u32, 0-10000). role base
  + pop bonus (400*log10_floor, cap 1200) + symmetric noise via integer
  modulo (fixes the always-negative bug) + clamp [1000,9500]. No floats.
- CityGenerationContext.prosperity_baseline -> prosperity_baseline_bps;
  updated the two test context builders. Not serialized — no wire break.
- district_mix population_tier now uses log10_floor (same determinism
  bug class as the comment claimed to avoid).
- Tests in bps + assert positive noise is achievable (the case the old
  test hid).

cargo check/clippy --all-targets -D warnings clean; 1291 lib tests pass;
fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:31:27 +02:00
jpmschweitzerandClaude Opus 4.8 6c93b698f5 chore(agents): strip memory: project from one-shot agents
Only Qatux (the librarian — cross-session recall is its job) keeps
project memory. Removed memory: project from the 20 ephemeral
implementer/reviewer/design agents that are spawned per-task and die:
they have no curation discipline and already use decisions/ + briefings/
as shared memory, so an uncurated per-agent store only invited rot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:54:33 +02:00
jpmschweitzerandClaude Opus 4.8 a854b89aa0 feat(simulation): wire D-199 economic read-set into generation context (#954)
Layer 2 clean half. GenerateSkeleton now builds a CityGenerationContext
from a body's real economic read-set instead of stub defaults.

- New CityContextReader (server/src/atlas/city_context_reader.rs): reads
  the 6-field D-199 set (economic_role, prosperity_baseline, population,
  dominant_faction, founding_age_years, settlement_class) via one JOIN;
  prosperity_baseline derived per D-197.
- GenWorkItem::GenerateSkeleton carries the resolved context; run_work_item
  builds the real context instead of DistrictWorldState::default().
- Left at defaults (deferred, documented): dominant_bulk_class /
  dominant_production_ubiquity (#982, design-blocked), trait_selection
  (#1005), morphology_zone + Layer-3 fields (attractor placement / D-204 /
  D-214).

16 new tests; cargo check/clippy --all-targets -D warnings clean; 1279
lib tests pass; fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:46:24 +02:00
jpmschweitzerandClaude Opus 4.8 e98a55de5f docs(skills): default post-merge worktree/branch cleanup
- pr-review §7a: after a PR merges, recommend tearing down the worktree
  and deleting the merged topic branch (local + origin) by default —
  state the action, let the user decline; never touch protected branches;
  git branch -d as the unmerged-work safety net.
- whats-next §3c: document the worktree lifecycle — created at activation,
  removed by pr-review §7a at merge; prune stale worktrees before creating.

Closes the gap that left empty worktrees parked at main's HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:56:13 +02:00
jpmschweitzer 273db192b9 Merge PR #144: backfill founding_age_years for inhabited bodies (#1000)
Reviewed-by: Hoshe (data QA), Tyre (pipeline) — both APPROVE.
2026-05-30 22:50:17 +02:00
jpmschweitzer dfd91923e7 Merge PR #143: foundation shared types + struct plumbing (#1006)
Reviewed-by: Hoshe (code quality), Tyre (architecture) — both APPROVE.
2026-05-30 22:49:55 +02:00
jpmschweitzerandClaude Opus 4.8 9f4b1bf02c fix(db): address PR #144 review (#1000)
- Update D-216 amendment text to match the implemented decision:
  events-first uses event_type='colonial_charter' (9 systems, 11 bodies,
  5 diverging from wave) — not the stale founding/first_settlement/
  colonization scaffolding vocabulary.
- Add ORDER BY sort_order ASC to the colonial_charter subquery for a
  deterministic pick if a system ever has two charter rows.
- Add explicit ELSE NULL to the wave-fallback CASE so an unexpected
  settlement_wave leaves a trace instead of silently nulling.

Data unchanged: 269/273 set, 4 Sol NULL; stamp fresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:07:06 +02:00
jpmschweitzerandClaude Opus 4.8 99da796cde fix(simulation): address PR #143 review (#1006)
- Add D-230 skeleton: DistrictSkeleton field to DistrictWorldState;
  cascade Default to DistrictSkeleton + contained enums/structs. Box the
  GenCompletion::SkeletonGenerated state to avoid large_enum_variant.
- Derive PartialEq on FloorExtent/FloorHeightProfile/DistrictWorldState/
  CityGenerationContext (+ minimal cascade) for downstream assert_eq tests.
- Add 4 unit tests for floor_at_voxel_z / voxel_range_for_floor (uniform,
  basement, variable heights, boundary) — the Q-104 deliverable.
- Drop unused smallvec direct dep (stays transitive via bevy_ecs).
- Key districts insert by skeleton.district_id, sharpen TODO(#957).

clippy --all-targets -D warnings clean; 1263 lib tests pass; fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:06:57 +02:00
jpmschweitzerandClaude Opus 4.8 899f447eed fix(simulation): review follow-ups on foundation types (#1006)
- Derive MorphologyZone Default (#[default] AlluvialPlain), drop manual
  impl — clears the clippy --all-targets warning (pre-push gate).
- Fix floor_at_voxel_z doc (described the inverse function).
- Document floor_at_voxel_z / voxel_range_for_floor voxel-z as
  building-relative (base_floor = z:0) for #982-985 callers.
- TODO(#957): districts keyed by city_id is a stub; use district_id.

clippy --all-targets clean; 1259 lib tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:16:03 +02:00
jpmschweitzerandClaude Opus 4.8 4319e40a63 chore(db): backfill founding_age_years for inhabited bodies (#1000)
Add idempotent MIGRATION_SQL UPDATE to import_economics.py deriving
bodies.founding_age_years via COALESCE(colonial_charter event age,
settlement_wave fallback) per the D-216 amendment.

- Events-first: colonial_charter age_years (authored; 11 bodies).
- Wave fallback: wave_1=600/wave_2=500/wave_3=300/wave_4=100/wave_5=40
  (258 bodies).
- Exclude settlement_wave in (origin, unsettled) -> stays NULL: 4 Sol
  bodies remain NULL (out of player scope, D-236).

Source-canonical via regen-db; 269/273 inhabited bodies now populated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:07:47 +02:00
jpmschweitzerandClaude Opus 4.8 46b8688fa3 feat(simulation): foundation shared types + struct plumbing (#1006)
Define the type-definition layer the Phase-4 fill-seam tickets depend on
(D-229/D-230/D-231/D-232/D-233), compiling with stubs/defaults; behavior
logic lands in #982-985/#998.

- New types in generator.rs: BuildingPropertyTag, FloorExtent +
  FloorHeightProfile (floor_at_voxel_z/voxel_range_for_floor, resolves
  Q-104), BuildingEntryClass, ConstructionEra, ZoneTypeId, MorphologyZone,
  BulkClass(5), ProductionUbiquity, DoorSpec, InteriorDescriptor,
  DistrictWorldState.
- Rename spatial AccessTier -> ZoneAccessTier to free the name for the new
  per-building BuildingEntryClass.
- CityGenerationContext: +morphology_zone, +trait_selection,
  +dominant_bulk_class, +dominant_production_ubiquity.
- BodyWorldState: +districts (DistrictWorldState w/ block_tags).
- GenCompletion::SkeletonGenerated carries body_id + DistrictWorldState;
  plugin handler inserts into BodyWorldState.districts.
- Add smallvec as a direct dep (DoorSpec list stays Vec for now, TODO).

cargo check --all-targets / clippy clean; 1259 lib tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:07:33 +02:00
jpmschweitzerandClaude Opus 4.7 6e7f6356f0 docs(decisions): D-216 amendment — canonical wave-to-age fallback for founding_age backfill (#1000)
Anchors the events-first / wave-fallback COALESCE that #1000 will write into import_economics.py MIGRATION_SQL. Founding-edge values from systems-framework-final-miri.md: wave_1=600, wave_2=500, wave_3=300, wave_4=100, wave_5=40, origin=NULL (Sol excluded per D-236). historical_events is currently empty so events-first is forward-compatible scaffolding; wave-fallback is the practical default for all 273 inhabited bodies today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:08:51 +02:00
jpmschweitzerandClaude Opus 4.7 fc58e90fcf docs(decisions): D-236 Sol player-access scope — Atlas highest-level only, deeper Sol = future DLC
Formalises what was previously implicit in D-223's naming exemption and GJ-0's wiki frontmatter into a single canonical scope rule. (a) Phase-4+ generation pipelines exclude Sol bodies (settlement_wave='origin' gate); (b) Atlas renders Sol at system level only -- no zoom; (c) deeper Sol treatment is reserved for a future DLC. Cross-refs D-145 (DLC precedent), D-191 (Atlas Phase-3), D-223.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:52:59 +02:00
jpmschweitzerandClaude Opus 4.7 6ba4b2afc1 docs(decisions): refinement-pass resolutions — audit tier-gate, bulk_class projection, two-layer streets
From the Si refinement of the Phase-4 fill-seam tickets. D-097: the mandatory-check gate keys on ComplexityTier, not WorldTier (wording fix). D-233: bulk_class is the 8->5 built-form projection (the 8 cargo-types stay on commodities); a settlement's dominant commodity is derived from its production chains, not an authored role->commodity table. D-234: streets are two-layer — MST arterials (Civ6-style, the guarantee-audit graph) + +/-45deg grid local streets (Civ4-style); Voronoi rejected (breaks +/-45 + the axis-aligned footprint fast-path). Ticket-level refinements (foundational types task, era-gating fix, catalog resequence) live in the ticket DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:17:25 +02:00
jpmschweitzerandClaude Opus 4.7 1a23739742 docs(workshops): economic-built-world brief + round-1 coverage map
The Stage-2 workshop brief and the consolidated three-scan coverage map (per-step decided-vs-open across the 4-step built-world pipeline) that became round 2's foundation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:37:54 +02:00
jpmschweitzerandClaude Opus 4.7 335ee9a242 docs(decisions): economic-built-world workshop — fill-seam records + trait-template flavor model
Rounds 2-3 of the economic & built-world workshop (Phase 4 fill seam). New records: D-229 building-property-tag schema (resolves Q-104 via FloorExtent), D-230 FillChunk two-phase execution, D-231 DoorSpec/InteriorDescriptor (the Phase-6 interior seed), D-232 architecture-flavor as an economically-gated / seed-drawn / wiki-biased trait-template catalog, D-233 economic block-fill (BulkClass x ProductionUbiquity), D-234 morphology->street/footprint constraints, D-235 exterior visual grammar + asset fallback hierarchy. Amended D-097/D-198/D-199/D-184; superseded in part D-101-A/D-104/D-105/D-107. Q-104 + Q-106 resolved; Q-107 (wiki->Atlas content consolidation) opened.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:37:46 +02:00
jpmschweitzerandClaude Opus 4.7 f1c1d2c8f0 docs(decisions): atlas-to-tile derivation model — D-227/D-228 + Q-100..105
Atlas-derivation workshop output (Jeroen + Tyre / Nigel / Burnelli / Gestalt).
The foundational world-derivation model:

- D-227 deterministic-rebuild world model: world = pure function of seed+atlas,
  derived on demand + cached, only tile mutators persisted, fully volumetric
  (voxel / subvoxel), vertical extent physical not floor-count, variable-height
  floors (cave/basement/built all count).
- D-228 composite tile schema: orthogonal axes (TerrainMaterial / FloorMaterial /
  Vegetation / Water / elevation), derived shape + tactical form, region-level
  morphology zones, cohesion matrix, dynamic clock-bound region seasonal state
  (water-height / snow / weather / crop-cycle from one cheap per-region calc).
- Q-100..Q-105: biome authority, refinement contract (+ body-class river density
  + ocean mask), cohesion-matrix algorithm, mutator op schema, floor-to-voxel-z
  mapping, region seasonal/clock state.
- Workshop brief: docs/workshops/atlas-derivation-model.md.

Epic #974 + ocean-mask story #975 track implementation (Phase 4 milestone).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:55:57 +02:00
jpmschweitzerandClaude Opus 4.7 c1d0d382ac style(simulation): rustfmt trace_outer_boundary (#960)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:15:34 +02:00
jpmschweitzerandClaude Opus 4.7 89596dbb87 test(client): live atlas generation-overlay inspection scenario (#960)
atlas_gen_open opens the real atlas app via HudGroups, navigates to GJ71c's
regional screen, and enables the Layer-1 overlays — exercising the production
opener + the live request→cascade→render pipeline against a --test-mode server
(SR_LIVE=1). Capture with: tests/run-visual --screenshot atlas_gen_open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:09:48 +02:00
jpmschweitzerandClaude Opus 4.7 6134296840 fix(ui): atlas overlay scale, opener robustness, rivers match water (#960)
- Generation overlays map positions from Layer1Output.grid_w/grid_h onto the
  displayed texture (not the markers.json grid), fixing the off-by-2 scale.
- AtlasViewer loads ImplantPending by path instead of referencing the new
  class_name, so a stale global-class cache (a session that hasn't re-imported
  after the class was added) can't fail to parse AtlasViewer and break the
  atlas from opening.
- Rivers and mouths use the reliefmap's own water colour (sampled median ocean
  blue) so river lines blend into surface water instead of reading as a
  distinct-coloured line flowing onto the sea.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:03:47 +02:00
jpmschweitzerandClaude Opus 4.7 adbd7f6744 fix(simulation): Layer-1 self-describing grid dims + Moore basin trace (#960)
Two Layer-1 generation fixes surfaced by the per-layer atlas viewer:

- Layer1Output now carries grid_w/grid_h (the downsampled working-grid the
  positions live in). The client maps overlays from these, so the scale is
  correct for any source heightmap resolution rather than assuming the texture
  size — fixes overlays projecting at half scale into a corner.
- Drainage basin boundaries are traced as ordered, non-self-crossing contours
  via Moore-neighbour tracing instead of an angle-from-centroid sort. The sort
  produced star-shaped, self-crossing polygons for concave basins that rendered
  as straight chords across the map.

Golden (cascade_layer1.json) and the cross-language atlas_response_ready
fixture regenerated. 100 atlas lib tests + the new tracer test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:02:32 +02:00
jpmschweitzerandClaude Opus 4.7 c3bcf09c72 chore(client): gdformat visual_capture + visual_scenarios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:54:51 +02:00
jpmschweitzerandClaude Opus 4.7 6bb21db2cb test(client): implant_pending visual scenario (#960)
Ad-hoc capture scenario that overlays the ImplantPending "generating" indicator
on the booted scene for visual sign-off — `tests/run-visual --screenshot
implant_pending`. No golden committed; this is an inspection scenario, not a
regression gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:53:57 +02:00
jpmschweitzerandClaude Opus 4.7 9733cbc0af fix(client): visual_capture Protocol decode in -s mode
visual_capture.gd runs via `godot -s`, where the `class_name` registry isn't
populated — so the bare `Protocol.decode_snapshot()` reference failed to
compile, breaking ALL --screenshot/--movie/golden captures (not just the
replay scenarios that use it). Instantiate the script and call the static
decoder on the instance (then free), matching the file's existing -s-mode
load() workaround for VisualScenarios.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:51:51 +02:00
jpmschweitzerandClaude Opus 4.7 25ef9d2f63 feat(ui): ImplantPending — diegetic Layer-1 "generating" indicator (#960)
A reusable implant-layer busy indicator (Araminta's spec): an animated
bracket sweep `[ >>>>···· ]` with a caption, themed entirely via ImplantTheme.
The Atlas shows it centered over the canvas while the Layer-1 proxy is Pending
and hides it on Ready — so cache hits never flash it and serverless mode never
shows it.

Layer separation (per direction): ImplantPending belongs to the diegetic
implant UI only. The global, non-diegetic UI layer must use its own busy
indicator with its own visuals — sharing the sweep logic is fine, resting on
this theme/these glyphs is not. Documented in the component header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:05:39 +02:00
jpmschweitzerandClaude Opus 4.7 616f7e9f3b feat(ui): wire atlas Layer-1 request/re-poll to generation overlays (#960)
show_body() now requests the body's Layer-1 cascade output via
SimBridge.request_atlas_layers(); the response feeds set_generation_layer1().
The proxy returns Pending on a cache miss and generates in the background
(D-225), so the viewer re-requests every 0.5s (20-retry ceiling) until Ready,
guarding against stale responses by body_id. Serverless/test mode is a no-op
(overlays stay empty). Connected in _ready, torn down in _exit_tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:07:59 +02:00
jpmschweitzerandClaude Opus 4.7 69ed80c96c chore(client): gdformat pass on atlas codec scripts
Formatting-only cleanup of the #969/#960-A codec + bridge scripts to match
gdformat output (the pre-push gdformat check is advisory; these landed
un-formatted). No behavior change — 70/70 protocol tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:03:29 +02:00
jpmschweitzerandClaude Opus 4.7 33a2ed6aae feat(ui): atlas generation overlays — Layer-1 rivers/basins/attractors (#960)
Render the proxied Layer1Output (D-225) as toggleable Atlas overlays. The
viewer stores the decoded Layer1Output via set_generation_layer1() and the
marker overlay draws three new layers gated by their visibility flags:

  RVR  river_cells as dots, confluences as small circles, mouths as
       double-ring sea-terminus markers
  BAS  drainage-basin boundaries as thin closed polylines + faint fill
  ATR  geographic attractors — shape by attractor_type (Araminta's 7-shape
       vocabulary), color by sub_biome, size by strength; <0.15 culled

Layer-1 positions are [row, col] in the 512x256 working grid, which matches
the viewer's grid_to_canvas transform, so they project directly onto the
heightmap. OVERLAY_DEFS gains gen_l1_rivers/basins/attractors (toggle group)
so AtlasOverlayBar auto-exposes them as toggle buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:02:02 +02:00
jpmschweitzerandClaude Opus 4.7 03d0264eb0 feat(client): atlas request/response transport on the bridge (#960, D-225)
Client transport half of the layer-stream protocol.
- SimBridge.request_atlas_layers(body_id) sends an AtlasLayerRequest frame
  (live mode only; no-op in test mode); responses arrive via a new
  atlas_layers_received signal.
- receive_bytes now decodes each frame ONCE via Protocol.decode_inbound and
  branches by shape (snapshot vs atlas response) — avoids double-decoding the
  20 Hz snapshot path. decode_snapshot is split into decode_raw +
  _decode_snapshot_from_raw (public decode_snapshot unchanged, so the 70 protocol
  tests stay the regression guard); decode_inbound returns {kind, value}.

70/70 protocol tests pass, including the new decode_inbound classifier test.
(Pre-existing client-suite failures in server-dependent e2e/roundtrip + unrelated
audio/fog/dialogue suites are unchanged — verified identical at baseline.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:36:39 +02:00
jpmschweitzerandClaude Opus 4.7 a9ba8ba90c feat(client): atlas layer-stream codec — encode request / decode response (#960, D-225)
Client half of the layer-stream protocol (codec only; transport wiring next).
- protocol.gd: encode_atlas_layer_request (bare {body_id, up_to} map so the
  server demux routes it to the proxy, not the PlayerInput array) and
  decode_atlas_layer_response (-> {body_id, status, error, layer1}; returns null
  for non-atlas frames, e.g. a snapshot, so receive_bytes can disambiguate).
- gen_fixtures.rs: cross-language fixtures (atlas_response_ready/pending/
  not_found) from real rmp_serde output, matching the test_protocol.gd pattern.
- test_protocol.gd: 5 tests decode the fixtures + verify a snapshot is not
  mistaken for a response + the request encodes to the right shape. 68/68 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:25:41 +02:00
jpmschweitzerandClaude Opus 4.7 1bece0b201 feat(simulation): serve atlas layer requests over the bridge — completes #969 (D-225)
Closes the server-side layer-stream loop. serve_atlas_requests (PreInput) drains
the AtlasRequestBuffer and runs each through handle_atlas_request (cache hit ->
Ready; miss -> resolve via BodySourceResolver + enqueue an Immediate AnalyzeBody
-> Pending), buffering AtlasLayerResponses. send_atlas_responses (PostSnapshot)
flushes them to the client. main.rs wires BodySourceResolverResource (base root
= systems.db's 3rd ancestor; mod roots layer on later). Misses flow through the
#968 background tier and a re-request hits the now-warm cache.

Full path now live server-side: client request -> receive() demux -> serve ->
proxy -> (cache | queue+cascade) -> response -> client. The client half (send
request, decode response, render overlays) is #960.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:06:33 +02:00
jpmschweitzerandClaude Opus 4.7 dfdc578429 feat(simulation): demux inbound bridge stream — receive() + atlas routing (#969, D-225)
Replaces the fixed-type SimBridge::receive_inputs() with a tagged
receive() -> Option<Inbound>, where Inbound is Inputs(Vec<PlayerInput>) or
AtlasRequest(AtlasLayerRequest). A shared decode_inbound() demuxes a frame by
shape (msgpack array = inputs, map = atlas request) — additive, no wire change
to existing input/snapshot frames. Adds send_atlas_response() to the trait
(both TcpBridge + LocalBridge impls). receive_bridge_inputs routes inputs to
the InputQueue as before; atlas requests to a new AtlasRequestBuffer (drained
by the serve system next). Integration tests (bridge_tcp/bridge_ipc) updated to
the tagged receive(); a demux unit test covers all three branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:00:30 +02:00
jpmschweitzerandClaude Opus 4.7 edbc0f4ff1 feat(simulation): atlas layer-proxy handler — cache hit/miss -> queue (#969, D-225)
handle_atlas_request: cache hit -> serialize Layer1Output, reply Ready; miss ->
resolve the source heightmap (BodySourceResolver) + enqueue an Immediate
AnalyzeBody on the #968 background queue, reply Pending (client re-requests; the
drain system populates the cache so a later request hits); unknown/no-terrain ->
NotFound. Adds the AtlasLayerRequest/Response/Status wire types (+ Serialize on
CascadeLayer). Pure handler; the bridge routing is the proxy's other half.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:41:28 +02:00
jpmschweitzerandClaude Opus 4.7 0097fd5027 feat(simulation): mod-first BodySourceResolver (#969, D-225)
First piece of the layer-stream proxy. Resolves body_id -> source heightmap.png
read-only from systems.db (terrain_reference, mirroring CultureResolver's
pattern), searching roots mod-first over the base install. Explicit errors
(UnknownBody / NoTerrainReference / SourceMissing). v1 callers pass the base
root only; the search-order logic is proven with synthetic mod roots so the
mod-first seam is ready (Q-099 covers registering a mod's new bodies).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:36:18 +02:00
jpmschweitzerandClaude Opus 4.7 bd5355d0a5 docs(decisions): D-226 live-pause inspection harness (agent-navigable real-UI review)
A multi-layer debug/review harness that reuses the real client UI so a human and
an automated agent can inspect deterministic server-computed state by attaching
to the live (auto-pausing) server. Supersedes the offline file-dump idea from
the D-225 discussion — the review tool is the production tool, no divergence,
no stale dumps.

Stack (bottom-up): live-pause substrate (TickRate::Paused + paused-allowlist,
triggered by gameplay_occluded; freezes world phases, keeps the bridge/gen-drain
alive) -> layer-stream proxy (D-225) -> human-visual viewer (#960, additive
overlays; shape=attractor type, color=sub-biome) -> agent channel
(AtlasAgentInterface: JSON observe + named-intent act, headless) -> interactive
capture (reuses the existing tests/run-visual primitive; complements, does not
supersede, the visual-golden regression role).

Consumers: Layer-1 geography now; economics + save-state inspection adopt the
pattern in their phases. Designed with Tyre (channel/pause/headless) + Araminta
(overlay encoding + affordance UX). Rides existing seams — a naming-and-contract
exercise, not a new subsystem.

Tickets: #970 auto-pause, #969 proxy, #960 viewer, #971 agent channel,
#972 capture (build order bottom-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:24:00 +02:00
jpmschweitzerandClaude Opus 4.7 8cedf670f0 feat(simulation): activate background generation tier end-to-end (#968, D-206/D-225)
The D-206 queue (#924) and D-203 cache (#917) were built but never connected to
the running app — the whole background tier was inert. This closes the loop:
submit -> Rayon -> cascade -> completion -> drain -> cache.

- gen_queue.rs: GenWorkItem::AnalyzeBody now carries enqueuer-resolved inputs
  { body_id, heightmap_path, sea_level, body_seed: SeedChain } (D-225 boundary —
  run_work_item stays pure compute, no path/DB resolution). run_work_item runs
  the real cascade: load heightmap.png -> downsample to GRID_W×GRID_H working
  grid (D-202) -> run_layer1 -> BodyWorldState; load failure -> Failed.
  GenCompletion::BodyAnalyzed carries the computed BodyWorldState.
- cascade.rs: CascadeSnapshot::into_body_world_state() conversion.
- plugin.rs (new): GenerationPlugin registers GenerationQueue +
  BodyWorldStateCache and adds a PreInput drain system that inserts BodyAnalyzed
  states into the cache (off the Rayon workers — a cheap channel drain, never
  the ~45ms cascade). Wired into both the production and test app setups.

Tests run the real cascade on a tiny temp heightmap PNG (no committed fixture);
the plugin test proves the full submit->...->cache loop. The proxy (#969) is the
production submitter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:47:59 +02:00
jpmschweitzerandClaude Opus 4.7 53263492ab docs(decisions): D-225 atlas layer-stream proxy — resolves Q-098, adds Q-099
Resolves how per-body cascade layer data reaches the Godot Atlas viewer (#960),
on the lead's mod-first directive + Tyre's design pass:
- No bake (privileges first-party content + ~100MB install bloat). A server-side
  layer-stream proxy computes on demand from moddable source files and streams
  Layer1Output to the client.
- Transport: additive message tag on the existing IPC stream (no second socket);
  single framed MessagePack response; raster stays a disk load.
- Mod-first BodySourceResolver: mod dirs override base install; heightmap.png is
  the sole source of truth.
- Cache via the D-206 background queue on miss (never blocks the tick thread,
  D-203); eviction -> recompute (deterministic, ~45ms).
- Whole Layer1Output per response; client composites additive overlays.

Q-098 resolved against its own premise (no durable store needed — recompute on
eviction is fine for a viewer). Q-099 spun off: mods adding new bodies need
terrain_reference rows in the binary systems.db (D-189), out of scope for #960.

Implementation decomposed into #968 (activate AnalyzeBody cascade run + cache
populate — the long pole) -> #969 (proxy + bridge protocol) -> #960 (viewer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:39:41 +02:00
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
jpmschweitzer 38a1007e04 chore(meta): release v0.1.37
Sprint 37: Sweep — 25/25 original-scope tickets shipped across 4
teams (ci 7 / server 9 / client 6 / copy 3).

Highlights:
- Asset pipeline discipline: source-canonical systems.db with
  meta-stamp, pre-push hook, /pr-push auto-regen, documented rule
  (#854-#859, #723)
- D-192 PROTOCOL_VERSION lockstep removed server + client
  (#874, #875)
- D-167 HeritageRoot + D-032 CharacterArchetype stripped per
  development cascade (#877, #878)
- New Game hang fix + scene-level merge-path tests (#872, #873)
- 105 brand corp stubs authored to three-layer depth (#861)
- D-193 Lattice Commission canonical (#876)
- v0.1 Sova/Van Maanen residue purged (#865)
- 21 raw-commodity coverage gaps closed (#860)
- Atlas generator polish + determinism smoke (#853, #847)
- Bookmark save/load persistence (#863, #862)

5 in-sprint follow-ups carried to backlog (#882, #884, #885,
#892, #893) for sprint 38 planning.
2026-04-22 11:39:36 +02:00
jpmschweitzer fcd434e528 Merge remote-tracking branch 'origin/sprint-37/copy'
Resolved 8 conflicts in wiki/corporations/ between server #860 tag
updates and copy #861 three-layer authoring. Resolution: union of
server's commodity-tag additions + D-175 decision_refs, plus copy's
cross_refs populated entries.
2026-04-22 11:25:55 +02:00
jpmschweitzerandClaude Opus 4.7 75287f9534 content(wiki): address PR #138 round 2 — N1 + N2
- N1: wiki/factions/lattice-commission.md:144 — refresh footer
  Updated: timestamp to 2026-04-22. Round-1 fix touched the
  frontmatter but missed the footer block; both fields now agree.
- N2: wiki/factions/index.md — rewrite the Local Factions cross-ref
  to describe the-ring.md as it actually is on the branch:
  a canonical named faction (faction_type: criminal, status:
  canonical) documenting an informal contraband network at
  district scale, preserved as the one maintained worked example
  for corridor authors. Previous prose mischaracterized the file
  as an "archetype doc," which it is not.

lattice-commission.md has no direct link to the-ring.md, so no
change needed there — the only corridor-wide phrasing left is
"informal contraband networks" in the Faction Relationships
table, which is a generic structural observation about smuggling
as a Commission enforcement target, not a cross-ref to the-ring.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:16:30 +02:00
jpmschweitzerandClaude Opus 4.7 b6107393a1 content(wiki): address PR #138 review — errors + warnings
Blocking fixes (4):
- P1: wiki/factions/index.md — replace canonical "Local Factions /
  The Ring" table row with a prose note, since the-ring.md was
  rewritten in #865 as a generic informal-contraband-network
  archetype rather than a named local faction.
- P2: wiki/factions/lattice-commission.md — replace The Ring
  cross-refs (table row + Cross-References list) with generic
  "informal contraband networks" framing; add D-193 cross-ref.
- P3: wiki/factions/lattice-commission.md — rewrite "Gameplay
  Presence" section. Removed v0.1 smuggler/detective framing
  (THE FRIEND detective path, Commission kiosk in logistics hub,
  "detective's arrival" language) and replaced with corridor-
  wide structural description: certification inspectors,
  field investigators, compliance kiosks, audit cadence.
- P5: wiki/factions/lattice-commission.md — refresh `updated:`
  timestamp to 2026-04-22.

Warnings (9):
- H1-H4: saigon-consulting, haesungdan-records, calluna-wellness,
  leerfeld-records — expanded each above the ≥95-line DoD floor
  with missing Political Relationships sections (mellanie3's
  systematic section-skip, same pattern as the earlier cycle-2
  pad pass) plus one extra cross-reference each.
- H5: 25 files in miri2's cycle-2 slice (alphabetical range
  ondori-interactive → transit-library) used shorter "##
  Relationships" header; renamed to "## Political Relationships"
  to match the canonical section title from alcyone-tech.md.
- P4: lusaka-advisory — added documentary anchor to the concealed
  fact. The cross-engagement reference ledger (internal partner-
  only document maintained since 2052 restructuring) and the
  2058 partner-retreat memo declining to present it in dispute
  forums are the investigative surface. Addresses Paula's note
  that the original concealed fact existed entirely in partners'
  heads with no documentary trail.

Not fixed (documented as false positives — verified clean on
branch):
- M1: glossary.md line 19 NOT-list does not contain "Lattice
  Commission" (Mellanie removed it in the D-193 commit;
  reviewer likely reading a stale snapshot).
- M2-M5: Van Maanen / Station Sova references in
  mastroianni-vehicle-group, ferreira-monteiro, gate-corporation,
  stalownia-kowalski — all four files grep clean (#865 Bucket C
  rewrote them).
- M8: wiki/star-systems/GJ-35/sova/transit/index.md was deleted
  in commit 25eb8bd0 — file cannot be fixed.

Info-only comments (P6, P7, M6, M7) left as advisory notes
for future deepening passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:08:29 +02:00
jpmschweitzer b71c505c4d chore(skills): pr-review — point reviewers at team worktree, not main
Sprint 37 PR #138 review produced 6 false-positive findings because
the reviewer defaulted to Read/Grep on the main repo path instead of
the sprint-37/copy branch worktree. Every finding was a verbatim match
against main's state but irrelevant to the branch — the branch had
already cleaned the residue being flagged as "still present." False
negatives are the same drift running the other direction: bugs
introduced on the branch never get seen because the reviewer never
reads the branch.

Fix: resolve the team worktree path before spawning reviewers
($(dirname <repo>)/.sprint/sprint-N/<team>/) and pass it into the
reviewer prompt with non-negotiable language. The worktree IS the
branch; Read/Grep on paths rooted there cannot resolve to main.

Fallback (no worktree — e.g. non-sprint branch or post-teardown):
git show origin/<branch>:<path>. Explicitly flagged in the prompt
so the reviewer knows Read/Grep on any local path is wrong.
2026-04-22 11:07:51 +02:00
jpmschweitzer 57d101d1cf Merge remote-tracking branch 'origin/sprint-37/server' 2026-04-22 10:37:39 +02:00
jpmschweitzer aa1d74e851 Merge remote-tracking branch 'origin/sprint-37/client' 2026-04-22 10:37:10 +02:00
jpmschweitzerandClaude Opus 4.7 055a019277 chore(config): pre-push — fall back to origin/main on first push
When a new branch is pushed for the first time, origin/<branch> does
not yet exist, so the pre-push hook was falling through to treating
every directory as changed. On a wiki-only branch this meant running
Godot headless parse, cargo fmt + clippy, ruff, and validating all
2762 repo-wide JSON files — tens of seconds of churn against a diff
that had no client/server/tooling/JSON content.

Fix: try origin/<branch> first, fall back to origin/main before
giving up. The JSON validation block now uses the same REMOTE_REF
the directory-change detection settled on, so both code paths stay
consistent.

Scope note: this is CI/tooling infrastructure, not copy-team scope,
but surfaced as part of reviewing the slow push on sprint-37/copy.
Bundling here rather than a separate branch at Jeroen's direction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:32:51 +02:00
jpmschweitzerandClaude Opus 4.7 fdf8dd4842 content(wiki): author 105 brand corp stubs to three-layer depth (#861)
Sprint 36 PR #133 shipped 112 new corp wiki pages as ~24-line
frontmatter-only placeholders. This commit authors 105 of them to
the canonical three-layer narrative standard (≥95 lines each).

Three-layer content model per D-189:
1. Public identity — branding, market position, public face
2. Actual operation — supply chain, workforce reality, profit model,
   political relationships, regulatory dependencies
3. One concealed fact — a single discoverable secret shaped like an
   iceberg. Public commitment stays technically true; the compromise
   lives in the deliberately-drafted clause, the accelerated refresh
   cycle, the "behavioral pattern" that isn't a "guest record."

Every page:
- ≥95 lines (actual range 95-125)
- Overview, Origin, Operations, Political Relationships, concealed-
  fact section, Gameplay Relevance, Cross-References
- Corridor/founding-system specificity — HQ culture informs voice
- "the Lattice Commission" (D-193) used throughout
- Cross-refs populated per D-189 brand layer architecture

Quality bar: wiki/corporations/alcyone-tech.md — authenticated
timing side-channel vulnerability, legal non-disclosure decision,
accelerated refresh cycle disguised as "performance enhancement."
Clean public promise; deliberately-kept secret; reachable by a
player who finds the internal report.

Authored across three cycles: cycle 1 validation (~20), cycle 2
main push (75 at 25 per author), cycle 3 cleanup (9). Split by
brand category and corridor clustering to encourage natural
cross-refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:27:12 +02:00
jpmschweitzerandClaude Opus 4.7 25eb8bd096 content(wiki): purge v0.1 Sova/Van Maanen residue (#865)
Spring-cleaning accumulated v0.1 vertical-slice residue across the
wiki. Scope per Jeroen's pre-decisions on 2026-04-21:

- Delete outright: wiki/star-systems/GJ-35/sova/ subtree (5 files +
  2 dirs) — v0.1 Station Sova / Transit District content.
- Strip example blocks in authoring guides and _templates/ that
  cited dropped v0.1 NPCs (Kael Davan, Sera Venn, Nils Davan, Devra,
  Torek, Renn, Pell, Tav, Lera Sessik, Sabel). Replaced with short
  "v0.1 examples removed — D-122" notes pointing authors to the
  active NPC roster.
- Rewrite canonical-lore references file-by-file: faction pages,
  technology pages, contraband pages, concepts pages, corp pages
  that cited v0.1 names/places. Preserve institutional/structural
  content, drop specific-name citations.
- Commission name drift: replace "Concord Commission" and "Assembly
  Commission" with "the Lattice Commission" (D-193) throughout.
- Van Maanen's Star cultural references: the canonical in-game name
  is Vuurkloof. "Van Maanen's Star" is retained only as an
  astronomical designation (glossary + GJ-35 Calibration Note).
  All cultural/system references converted to Vuurkloof across
  culture-authoring-guide, cultural-generation-guide, founder-gates,
  and star-system adjacent-system descriptions.
- Strip `v0.1` from faction/triangle frontmatter tags (8 files).
- wiki/triangles/hub-power.md and wiki/factions/the-ring.md:
  rewritten using role-name exemplars (Ring Coordinator / Shift
  Supervisor / THE FRIEND) instead of named v0.1 NPCs. Triangle
  pattern preserved as a structural reference.

Also applies drive-by Commission drift fixes and minor cross-ref
updates to canonical corps (gate-corporation, mastroianni-vehicle-group,
stalownia-kowalski, prometheus-labs, ferreira-monteiro, vins-de-grand-vide,
and 13 others).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:26:45 +02:00
jpmschweitzerandClaude Opus 4.7 9c68613ad8 decide(content): resolve Q-095 as D-193 Lattice Commission
Canonical long-form of the Concord Assembly's regulatory authority is
"the Lattice Commission". Short form "the Commission" remains standard
in-prose. Drift forms "Concord Commission" and "Assembly Commission"
are deprecated. Resolves Q-095 (#876).

Rationale: naming after the regulated domain (Lattice) rather than the
parent Assembly mirrors real-world regulators (FDA, FAA, NRC) and
disambiguates from the multiple other "Concord"-prefixed references
in the wiki. The Commission's jurisdiction is Lattice-scoped, not
Concord-scoped — it has no authority in Compact member systems.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:26:16 +02:00
jpmschweitzerandClaude Opus 4.6 b86bb00a55 refactor(simulation): PR #137 review — audit override + regression tests + docs
Addresses Tyre, Hoshe, and lead review comments on PR #137:

- **Audit doc amendment** (Tyre E1 / Hoshe H1 / Lead): add "Lead override
  (2026-04-21)" section at top of docs/architecture/sprint-37-878-audit.md.
  Rewrites the conclusion to "DECISION: STRIP" with the cascade-based
  rationale. Preserves the original audit body as the pre-override record.

- **Regression tests** (Lead 2a-2b / Hoshe H2 / H3): add POSITIVE
  assertions of the new uniform behavior so silent reintroduction fails.
  - `phase2_container_verb_labels_uniform_regardless_of_player_state` —
    two trials (empty KG, POI-bearing KG) assert container verb labels
    equal Phase-1 defaults.
  - `monologue_pool_selection_uniform_no_archetype_key` — two observers
    with divergent MonologueState both draw from OBSERVE_NPC_LINES.

- **Decision record amendments** (Lead 3 / Tyre S2): D-032, D-035, and
  D-057 amended with Phase 6 deferral wording. "Retired pending Phase 6,
  not deferred with scaffolding." Reintroduction gate: a confirmed
  Phase 6 character-model design.

- **types.rs doc fixes** (Tyre S1 / Hoshe H5): StartupMessage protocol-
  flow comment updated to reflect no-version handshake (D-192).
  ObserverSnapshot version-history block grows a "Sprint 37 wire-format
  shifts" section documenting D-192 + #878 schema drops.

- **observer/tests.rs:944 comment** (Hoshe H6): rewritten to cite
  cascade rationale instead of the stale D-032-SUPERSEDED premise.

- **tests/run-atlas-determinism exit** (Hoshe H7): exit 0 when EXIT_CODE=2
  (venv/DB missing = skip, not fail). Preserves skip semantics for
  tests/run-all on machines without the Python venv.

Follow-up tickets filed:
- #895 (server, low): expand check-systems-db-stamp GENERATOR_SOURCES
  to cover gemma_naming.py + naming_core.py (Tyre S3).
- #896 (planning, low): add CLAUDE.md carveout for server wiki writes
  closing coverage gates (Tyre S4 / Hoshe H8).

H4 investigation: v01_integration_playthrough.rs was not the only E2E
handshake→tick→snapshot test; coverage preserved by bridge_ipc.rs,
bridge_tcp.rs, and game_loop.rs (the latter is pre-existing-broken
per #885). No replacement test needed.

1142/1142 lib tests pass. cargo clippy -- -D warnings clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 10:09:35 +02:00
jpmschweitzerandClaude Opus 4.7 60733738f2 fix(client): PR #135 review — T1/T3/H4-H7 blocking + nits
Code changes addressing PR #135 review (Tyre + Hoshe):

- **T3 (blocking):** test_merge_path_flows_sprint37.gd `_load_main_menu`
  and `_load_char_create` now assert the scene loaded instead of silently
  returning. Missing .tscn → red test, not falsely green.
- **T1:** sim_bridge.gd signal `handshake_complete(protocol_version: int)`
  was D-192 residue with no listeners. Drop the int parameter entirely
  and the literal-0 emit.
- **H4:** test_new_game_catalog_snapshot_resolves_loading_state now
  asserts SimBridge.state == CONNECTED terminus, not just the loading
  flag — guarantees full flow completion, not merely flag-clear.
- **H5:** test_protocol_bridge.gd file-level comment refreshed; drops
  reference to removed protocol-version check tests.
- **H6:** test_p0_regressions.gd `_make_snapshot_bytes` comment refreshed
  and version field removed from fixture dict (D-192: not required).
- **H7:** test_merge_path_flows_sprint37.gd `_make_catalog_snapshot`
  drops version field from fixture dict (D-192).

Follow-up tickets filed for reviewer suggestions:
- **T2:** #889 — revive EntityRenderer sprite constants coverage
  (D-044 ENTITY_WIDTH/HEIGHT, asserted by deleted test_sprite_integration).
- **T4:** #890 — UI timeout fallback for bookmark catalog wait in
  main_menu (systemic 'catalog never arrives' class beyond #872's
  TCP-batch race).
- **T5/T6:** #891 — scene-flow test tier docs + test-only reset
  helpers (SimBridge.reset_for_test, MetaStack.reset_for_test) +
  minimal public API on scenes so UI refactors don't break all four
  flow tests simultaneously.

Verification:
- `make lint-client` — no script errors
- `gdlint client/scripts/ client/ui/` — no problems
- `make test-client` — 2428/2488 passing. 60 remaining failures are
  pre-existing, unrelated to sprint 37 (test_dialogue_sprint20 #558
  signals, test_input_roundtrip integration-sans-server, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:06:39 +02:00
jpmschweitzer 469377caa6 Merge remote-tracking branch 'origin/main' into sprint-37/client 2026-04-22 09:47:36 +02:00
jpmschweitzerandClaude Opus 4.7 bc310a3ca5 fix(client): sprint 37 test-pass fixes — parse error + test hygiene
- character_creation.gd:1614: type-annotate dir_name to fix GDScript
  inference parse error. CARDINAL_DIRS is an untyped const Array, so
  `var dir_name := CARDINAL_DIRS[idx]` failed type inference and blocked
  test_character_creation_sprint28.gd test discovery. Runtime was lenient
  but gdUnit4's scanner is strict.
- test_merge_path_flows_sprint37.gd (_load_char_create): call add_child
  before _update_start_btn_state so @onready vars (_footer_start) are
  bound when the helper dereferences them. Unblocks 2 merge-path tests.
- test_anti_tedium.gd (test_bug_report_sends_unpause_on_close): call
  dialog.close() instead of the old dialog._close() rename casualty.
  `on_close()` is a lifecycle hook — it doesn't actually transition
  state; `close()` is what MetaScreen exposes.
- test_anti_tedium.gd (before_test): clear MetaStack._stack. Prior tests
  leave stale freed dialog refs on the stack; _any_pausing() iterates
  the stack during close() and crashes with "previously freed".

Net: sprint-37 test files (test_merge_path_flows_sprint37.gd,
test_anti_tedium.gd) now pass. Overall suite: 2428/2488 passing
(60 remaining failures are pre-existing, unrelated to sprint 37).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:06:51 +02:00
jpmschweitzerandClaude Opus 4.6 8f46048a75 style(simulation): cargo fmt
Formatting normalization caught by pre-push hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 09:04:38 +02:00
jpmschweitzer 94a687fc08 Merge remote-tracking branch 'origin/main' into sprint-37/server
# Conflicts:
#	CHANGELOG.md
#	Makefile
#	server/data/systems.db
2026-04-22 09:01:43 +02:00
jpmschweitzerandClaude Opus 4.6 f8407980db refactor(simulation): derive Default for MonologueState (clippy)
After #878 removed the `character` field, the manual Default impl is
derivable. Clippy flags this under `derivable_impls` with -D warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:58:42 +02:00
jpmschweitzerandClaude Opus 4.6 68ef5cb380 chore(meta): update changelog for sprint 37 server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:57:00 +02:00
jpmschweitzerandClaude Opus 4.6 11db2f4b41 docs(architecture): sprint 37 archetype-trace audit (#878)
Tyre's trace of every live consumer of CharacterArchetype across server
and client, with the cascade-based rationale for full removal. Captured
so future audits don't repeat the same investigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:56:21 +02:00
jpmschweitzerandClaude Opus 4.6 cae3d3ab85 refactor(simulation): strip archetype trace + HeritageRoot per cascade (#877, #878)
Sprint 37 dead-code sweep closing out two stale supersession chains:

#877 (D-167, 2026-03-24): Removes HeritageRoot type alias and
ZonePaletteModifier::Heritage variant from server/src/simulation/
generator.rs. The 7 abstract heritage roots were retired in favour of
the corridor cultural system; these two stubs were the only remaining
references.

#878 (D-032 + cascade rule): Strips the entire CharacterArchetype
(Smuggler/Detective) trace from the server. Per lead direction
2026-04-21 and the development cascade (CLAUDE.md), character/NPC/
verb-differentiation/monologue code is Phase 6 detail that should
not exist in code yet. The running archetype trace was pre-cascade
filler, not production — production is only the client's character-
creation UI and insert screens (client follow-up in #882).

Deleted:
- CharacterArchetype enum + StartupMessage.character_archetype field
- archetype_verb_label() + archetype branch of apply_phase2_verb_filter
  (D-057 character-verb differentiation — marked superseded)
- MonologueState.character partitioning
- Gauntlet archetype plumbing (setup_gauntlet no longer takes an archetype)
- server/content/schemas/drama_module.schema.yaml (zero Rust consumers)
- server/content/modules/tier1/smuggling_ring_v0_1.yaml
- server/tests/archetype_monologue.rs (regression guard for the removed system)
- server/tests/v01_integration_playthrough.rs (archetype-dependent)

Decision updates:
- decisions/content.md D-032 supersession rewritten to cite the cascade
  (v0.2 drop invalidated the prior D-117 framing).
- decisions/content.md D-035 tag taxonomy: `character` enum footnote
  updated; field noted as unused, do not reintroduce without a
  confirmed Phase 6 design.
- decisions/perception.md D-057: archetype-verb differentiation marked
  superseded.

Also bundles the types.rs version-field removal from #874 since the
file was already touched here.

Full trace audit in docs/architecture/sprint-37-878-audit.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:55:48 +02:00
jpmschweitzerandClaude Opus 4.6 c640563fc0 refactor(simulation): drop PROTOCOL_VERSION from bridge handshake (#874, D-192)
Removes the PROTOCOL_VERSION lockstep handshake across the bridge
transports. HandshakeMessage is now an empty marker indicating the
server is ready; the client sends StartupMessage next. Field-presence
and roundtrip behavioral tests are retained — tautological
version-literal assertions were deleted in Sprint 36.

After this lands, genuine schema drift surfaces as MessagePack
missing-field errors downstream, which is the intended signal per
D-192. Client ticket #875 is blocked by this commit and will ride
the same wave; server schema changes in #878 (character_archetype
removal) also depend on PROTOCOL_VERSION already being gone.

types.rs changes are bundled with the broader #877/#878 dead-code
sweep in the following commit, since they share the same file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:55:19 +02:00
jpmschweitzerandClaude Opus 4.6 bae1c5e156 data(economy): close D-175 Phase 2 coverage gate — 21 raw-commodity gaps (#860)
Adds and expands wiki/corporations/*.md entries so make economy-db
Phase 2 gate passes end-to-end without the 21 raw-commodity / system
coverage failures. Unblocks Phase 2 demand simulation.

Existing corporations (15): tag additions to baektu-mining-alliance,
bergkraft-antriebswerke, bifrost-marmor, gate-corporation,
hanyang-precision, jeju-lattice, nordmark-skog, norrland-woodcraft,
salud-alliance, shetland-wool, societe-chimique, stalownia-kowalski,
svanevann-waters, thrds, tongyeong-drive.

New corporations (6): arbour-aggregates, earth-standard-group,
rush-mining, scapa-flow-industries, sede-chemical-works,
threshold-fuel-syndicate.

systems.db regenerated — coverage validation now passes.

Cross-team scope note: wiki/ is copy-team territory per CLAUDE.md.
Writes here are accepted by lead for this sprint (maintenance sweep,
pragmatic fix); copy-team review follow-up filed as #884.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:55:05 +02:00
jpmschweitzerandClaude Opus 4.6 7fffdc572e test(assets): atlas determinism smoke test (#847)
Adds tests/run-atlas-determinism — imports generate_atlas as a module
and calls process_body() twice with seed=42 and dry_run=True, comparing
the returned markers dicts as JSON. No wiki files are written.

Guardrail against determinism regressions in terrain analysis, city
placement, A* road routing, infrastructure MST, and gate terminal
placement. GJ892f (domed, population 300, 1 city) is the smallest
well-exercised case.

Makefile target: make test-atlas-determinism.
Wired into tests/run-all alongside run-ipc-integration and run-visual.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:54:47 +02:00
jpmschweitzerandClaude Opus 4.6 531bb1df5c refactor(assets): atlas naming — dedup, compass filter, river vocab, infra names (#853)
Addresses five of seven subtasks from atlas-generator-refinement-notes:

- Cross-body dedup: corpus keyed by (corridor, feature_type) instead of
  (system_id, feature_type), seeded from existing atlas_* rows so re-runs
  don't collide with already-committed names. §1, §2.
- Empty-name fallback for mountain ranges when Gemma returns fewer names
  than needed ({body_proper} Range {i+1}). §2.
- Suffix monotony detection: flags bodies where >40% of mountain names
  share a trailing word (warning only — batch pipeline has no voice
  access for auto-fix). §3. Follow-up in #886.
- Compass-direction ban: build_batch_prompt explicitly forbids
  "Eastern/Northern/Western X" in few-shot instructions. §4.
- River vocabulary filter: is_valid_name rejects "X Flow" / "X Current"
  when feature_type="river" — these are ocean terms bleeding through. §6.
- Infrastructure naming: deterministic post-pass assigns "{CityA}–{CityB}
  {corridor_suffix}" to unnamed roads and railroads (Corridor/Road/Estrada/
  Strasse/Track by corridor). §7.

Cultural-history prompt threading (§5) remains as existing corridor_substyles
refill mechanism; explicit cultural-history blurb deferred to #886.

naming_core bumped to v0.3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:54:37 +02:00
jpmschweitzerandClaude Opus 4.6 9f755f2a62 feat(simulation): persist SelectedBookmark across save/load (#863, #862)
Adds Serialize/Deserialize to SelectedBookmark and wires it into
SaveStateV1 so a loaded game remembers which bookmark and starting
location the player picked. Replaces the TODO at bookmark/mod.rs:95
(originally deferred to Sprint 37 alongside #614).

Also refactors BookmarkPlugin to accept an injected BookmarkRegistry
via BookmarkPlugin::new(registry) (#862). The Default constructor
still wires the canonical tycoon registry — injection is for tests
and future TOML loading. Flagged in PR #132 review as a follow-up.

Updates bookmark spec §4.4 to remove the v0.2-deferred scope note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:54:21 +02:00
jpmschweitzerandClaude Opus 4.6 7c40067935 fix(simulation): lower storyteller activation_pass log to debug (#789)
The "no co-present NPC assigned to a Simmering triangle" branch fires
as WARN every few seconds during early gameplay when no NPC relationships
have escalated. This is normal state, not an error condition — downgrade
to debug so the warning channel reflects actual problems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:54:09 +02:00
jpmschweitzer 5b68c4ce80 Merge remote-tracking branch 'origin/sprint-37/ci' 2026-04-22 08:47:27 +02:00
jpmschweitzerandClaude Opus 4.6 06838043fd chore(meta): update changelog for review-fix pass (PR #136)
Corrects the "three generators" wording after the brand pipeline merge.

Refs: PR #136

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:39:03 +02:00
jpmschweitzerandClaude Opus 4.6 3d9dd7d909 chore(ci): merge brand pipeline into import_economics + harden review findings
Addresses all blocking + minor items from PR #136 review.

Architectural change (T2/H3 — the review's main complaint):

  generate_brands was previously a separate Rust binary that produced a TOML
  artifact, with its stamp written "on behalf" by import_economics.py at the
  end of its own run.  Reviewers flagged the invisible coupling: two sources
  of truth in a system designed to have one, and no way to tell from the
  stamp that one "generator" was really a subroutine of the other.

  import_economics now invokes tooling/generate-brands as the first step of
  its main() flow, before opening its own DB connection.  The TOML artefact
  is still produced and still committed (useful for diff-review of brand
  changes), but there's now one pipeline owner.  The meta table carries two
  rows (import_economics, generate_atlas) not three; the Rust binary's
  source SHA folds into import_economics' stamp via IMPORT_ECONOMICS_SOURCES.
  A MIGRATION_SQL DELETE cleans up pre-merge DBs that still have the
  orphan generate_brands row.

Other review items addressed in-line:

  H1  generate_atlas._write_stamp no longer commits — transaction ownership
      stays with the caller (matches import_economics pattern).  Stamp +
      atlas data now commit atomically; a failed stamp rolls back the
      atlas data rather than leaving a stamp-missing-data intermediate.

  H2  _file_sha1 (in both import_economics, generate_atlas,
      check-systems-db-stamp) raises FileNotFoundError on missing sources
      instead of silently contributing an empty-bytes hash.  A ghost-SHA
      convergence could otherwise produce vacuous "fresh" passes.

  H4  pre-push no-meta-table warning rephrased — was "run after next
      regeneration", now "run now if this DB was generated by you".

  T1  asset-pipeline.md determinism claim softened: the stamp is
      deterministic (same source → same recorded SHA), the DB binary is
      not (generated_at + SQLite rowids/freelist churn).

  T3  asset-pipeline.md gains a "migration escape hatch" section naming
      MIGRATION_SQL in import_economics.py as the only sanctioned path
      for direct writes, and forbidding hand-run sqlite-exec / one-off
      patch scripts / SQLite-GUI edits.

  T4  Makefile regen-db now runs as a single shell with `set -e`.  A
      failure in one generator halts the pipeline immediately, preventing
      the "stale data, fresh stamp" state where a later step stamped a
      DB whose earlier step had failed.  import_economics' exit code 2
      (coverage gate warning) remains explicitly tolerated.

  T5  pre-push stamp check now runs on a branch's first push too —
      compares against origin/main instead of origin/$BRANCH, closing
      the gap where a new branch could ship a stale DB via the first push.

  T6  check-systems-db-stamp fails closed on unknown generator_names in
      meta — a future branch adding a new generator without registering
      it in GENERATOR_SOURCES will now be rejected, not silently skipped.

  T7  /pr-push watch list gains a mutual cross-reference comment with
      GENERATOR_SOURCES in check-systems-db-stamp, plus the missing
      names.rs source file, so the two lists cannot silently drift.

Follow-up tickets created:

  #887 T8  decisions-orphan-tickets CLI — surfaces tickets whose
           decision_ref points at a non-existent D-record.
  #888 T9  meta.schema_version monotonic semver — for savegame migration
           lineage in Phase 5+ (SHA comparison can't be ordered).

Verified:

  make regen-db end-to-end — OK
  make check-systems-db    — OK, 2 generator(s) up to date
  STALE detection          — OK, verified by touching generate_atlas.py
  /pr-push watch list      — OK, flags this branch's changed sources
  decision show D-159      — OK, structured output with tickets + refs

Refs: #855 #856 #857 #858 #859 PR #136

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:38:41 +02:00
jpmschweitzer e522091f74 chore(skills): pin team-mode teammates to Sonnet 4.6
Sprint 37 observed Opus 4.7 teammates in team mode ignoring scope
rules ("don't modify files outside your team"), leaving tasks
half-done, and failing to report back via SendMessage. Closest public
issues are anthropics/claude-code#47598 (4.6 subagent delegation
regression) and claude-agent-sdk-python#577 (SendMessage delivery in
team mode). No 4.7-specific team-mode issue published yet but symptoms
are real.

Fix: every Task spawn with a team_name argument now passes
model: "sonnet". The team lead (the session running the skill) stays
on whatever model the user selected — typically Opus.

Inline Agent spawns without team_name (one-shot reviewers in
/pr-review, research subagents, general-purpose one-offs) are
exempt — they keep their default model. The pin targets long-running
coordination specifically, not every Agent call.

Affected skills:
- sprint-start: step 8d Task template adds model: "sonnet" and
  documents the rule with the 4.7 regression context
- workshop-start: step 5 spawn list adds the model pin with a
  one-line reason
2026-04-22 08:31:37 +02:00
jpmschweitzerandClaude Opus 4.6 07ca1c9fc1 chore(meta): update changelog for sprint-37 CI sweep
Refs: #723 #854 #855 #856 #857 #858 #859

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:35:55 +02:00
jpmschweitzerandClaude Opus 4.6 a41f7aa06c feat(tooling): add decision show <D-NNN> with implementing tickets + refs
Closes the decision-to-ticket coverage gap (#723). The `decisions-coverage`
Makefile target already reported per-decision counts; this adds the
single-decision drill-down via `tooling/db/decision show D-159`, returning
linked tickets, outbound refs, and inbound refs in one shot.

Schema unchanged — reverse link is a SELECT on tickets.decision_ref.

Refs: #723

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:35:23 +02:00
jpmschweitzerandClaude Opus 4.6 5079d84405 docs(ci): document source-canonical asset pipeline rule
Adds `.claude/rules/asset-pipeline.md` describing the full pipeline —
generators, meta stamp, pre-push hook, how to make DB/schema changes,
and why direct systems.db edits are forbidden. CLAUDE.md and DEVOPS.md
point at the rules file; sprint-start template now reminds teammates
to read it before touching DB sources.

CLAUDE.md also gains the missing `decision show` row in the CLI tool
table (companion to #723's `decision show` subcommand).

Refs: #859 #723

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:35:12 +02:00
jpmschweitzerandClaude Opus 4.6 44d7816b55 chore(skills): extend /pr-push with rebase + regen + stage for generator branches
Adds a new step 4a that detects generator-source or economy-data changes
vs origin/main and runs `make regen-db` before the push, staging the
updated systems.db. Pairs with the pre-push hook (#857) — instead of
rejecting a stale DB, /pr-push proactively freshens it.

Refs: #858

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:35:01 +02:00
jpmschweitzerandClaude Opus 4.6 8371e05e52 chore(ci): generator-driven asset pipeline — meta stamp + hook + regen target
Adds systems.db regeneration discipline (#855) via a `meta` table (#856)
stamped by every generator, a pre-push hook that rejects stale DBs (#857),
and the top-level `make regen-db` / `make check-systems-db` targets that
drive the whole pipeline.

The stamp stores SHA-1 of generator source + schema, so the pre-push hook
can cheaply detect "you changed a generator but forgot to regen the DB"
before a binary merge conflict lands. Sprint 36 hit that class of conflict
on two branches touching systems.db simultaneously — this is the systemic
fix.

Regenerated systems.db is stamped; `make check-systems-db` passes.

Refs: #855 #856 #857

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:34:52 +02:00
jpmschweitzerandClaude Sonnet 4.6 d72fcc7847 fix(protocol): drop PROTOCOL_VERSION lockstep — D-192 (#875)
Removes the version-mismatch guard from Protocol.decode_snapshot() and the
PROTOCOL_VERSION constant from the client (server side done in #874).

Core changes:
- protocol.gd: remove const PROTOCOL_VERSION, remove version mismatch guard,
  remove "version" from return dict, add gauntlet_mode/room_id decode
- sim_bridge.gd: remove handshake version check; relax handshake guard to
  require only a valid Dictionary (server no longer sends protocol_version);
  emit handshake_complete(0) for API compat
- loading_screen.gd: drop "· protocol N" suffix from version label
- test_harness.gd: replace Protocol.PROTOCOL_VERSION with literal 23

Test updates (21 files): replace "version": Protocol.PROTOCOL_VERSION with
"version": 23 in all snapshot bytes dicts; remove snapshot.version == N
assertions; remove version-rejection tests (test_rejects_version_6,
test_decode_snapshot_rejects_missing_version, test_decode_snapshot_rejects_old_version,
test_protocol_rejects_version_mismatch, test_sim_bridge_test_snapshot_uses_current_protocol_version).

Also includes: #872 bookmark_catalog carry-forward regression test, and
#873 merge-path flow tests (test_merge_path_flows_sprint37.gd).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:29:49 +02:00
jpmschweitzerandClaude Sonnet 4.6 708ab25614 fix(tests): sprint-37 sweep — #866, #869, #870 client test cleanup
#866: Fix _escape_bbcode in dialogue_box.gd — only escape '[', not ']'.
Chaining .replace(']', '[rb]') after .replace('[', '[lb]') corrupted the
[lb] escape itself. Updated test_dialogue_sprint18.gd accordingly.

#869: Migrate test_anti_tedium + test_bug_report_ring_buffer to instantiate
BugReportDialog via .tscn (BugReportDialogScene.instantiate()) instead of
bare Control.new() + set_script(). Sprint 36 moved bug_report_dialog.gd to
extends MetaScreen; the old load path was an orphan .uid file returning null.

#870: Delete 8 parse-error test files (test_debug_overlay_sprint19,
test_entanglement_sprint22, test_fog_sprint22, test_journal_sprint18,
test_minimap_sprint18, test_session_manager_sprint19, test_sprint30,
test_sprite_integration) + .uid sidecars. Coverage tickets filed for
fog/journal/minimap (#879, #880, #881). Sprint docs updated with #882 note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:28:24 +02:00
jpmschweitzer f0465e40c1 chore(skills): sprint-start A1c — mandatory teardown, no pre-checks
The worktree cleanup step was documented as "safe to skip if the
sprint didn't use ephemeral worktrees" — which invited a pre-check.
Sprint 36 close did that pre-check from inside the repo root, where
.sprint/ doesn't exist, concluded no worktrees, and skipped teardown.
Three stale worktrees persisted until Sprint 37 planning surfaced them.

Fix: always run the script (idempotent, prints a graceful message when
empty). Document that .sprint/ is a sibling of the repo root, not a
child, so future sessions don't repeat the same ls-based pre-check.
Add a post-run verification step.
2026-04-21 16:05:05 +02:00
jpmschweitzer 8b3baf511f chore(meta): plan Sprint 37: Sweep — 25-ticket spring-clean sprint
Tickets:
- ci (7): #854, #855, #856, #857, #858, #859, #723
- server (9): #853, #860, #862, #863, #874, #877, #878, #789, #847
- client (6): #866, #869, #870, #872, #873, #875
- copy (3): #861, #865, #876

Themes: asset pipeline discipline, PROTOCOL_VERSION removal (D-192),
D-167 HeritageRoot purge, D-032 smuggler/detective enum audit, New Game
regression fix + merge-path test coverage, copy wiki residue purge,
bookmark save-state, generator quality patches.

Debt surfaced from Q/D-record sweep: Q-095 Commission name, D-167 and
D-032 residue, plus spring-cleaning pulls from stale backlog (#789,
#847, #723, #862).

#868 cancelled (superseded by #874 + #875).
2026-04-21 16:00:22 +02:00
6270 changed files with 113198 additions and 607438 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 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are ARAMINTA, the Visual Designer on a game development project set in the Settled Reach universe.
+2 -3
View File
@@ -1,9 +1,8 @@
---
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
---
You are BURNELLI-SHELDON, the Economist on a game development team building a top-down immersive sim set in the Settled Reach universe.
@@ -39,4 +38,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).
+42
View File
@@ -0,0 +1,42 @@
---
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
---
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 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are DUDLEY, the Server Developer on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are GESTALT, the Systems Designer on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are GORE, the Themes and Endgame Design specialist on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are HOSHE, the QA Engineer on a game development project set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are INIGO, the Sound Designer on a game development team building a top-down immersive sim set in an original science fiction universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are JUSTINE, the Polish and Deployment specialist on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are MELLANIE, the Copywriter on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are MIRI, the Worldbuilder and Setting Designer on a game development team building a top-down immersive sim — an occlusion-based life sim with emergent narrative, set in an original science fiction universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are NIGEL, the Sandbox and Replayability advocate on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are OSCAR, the Networking Developer on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are OZZIE, the Player Experience and "Wow Factor" advocate on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are PAULA, the Narrative and Political Depth specialist on a game development team building a top-down immersive sim set in the Settled Reach universe.
+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 -24
View File
@@ -1,43 +1,44 @@
---
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 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are STIG, the UI Developer on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are TIGER, the Translator and Localization specialist on a game development team building a top-down immersive sim set in the Settled Reach universe.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are TROBLUM, an external technical consultant brought in for specific evaluation tasks on a game development project.
+1 -2
View File
@@ -1,9 +1,8 @@
---
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
---
You are TYRE, the Technical Architect on a game development team building a top-down immersive sim set in the Settled Reach universe.
+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
+188
View File
@@ -0,0 +1,188 @@
# Asset Pipeline — Source-Canonical Rule
`server/data/systems.db` is a **read-only, deterministic snapshot** produced by the
generator pipeline. It is checked in to the repo as a build artefact so the Godot
client can ship it without a build step, but **it is never the source of truth**.
---
## The Golden Rule
> **Edit sources, not the DB.**
If you need to change economics data, modify the TOML/JSON source files.
If you need to change atlas markers, modify the `markers.json` files.
Never run `UPDATE` or `INSERT` directly on `server/data/systems.db` outside of a
migration — those changes will be silently overwritten by the next `make regen-db`.
---
## What produces systems.db
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` + 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
the TOML and imports brand data into the DB. The Rust binary is a subroutine
of the Python importer, not an independent generator — changes to its source
invalidate the `import_economics` meta stamp even though the Python file
itself didn't change.
`make regen-db` runs both in the correct order (economics first, atlas second).
---
## The meta table stamp (#855, #856)
After every successful non-dry-run, each generator writes a row to the `meta` table:
```sql
CREATE TABLE meta (
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas'
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
recomputed current SHA — this is what the pre-push hook detects.
**What's deterministic:** the stored SHA (same sources → same recorded SHA).
**What's NOT deterministic:** the DB binary itself. `meta.generated_at` uses
`datetime('now')`, SQLite `rowid`/`autoincrement` values drift across runs, and
transaction ordering can reshape freelist pages — two consecutive `make regen-db`
calls produce byte-different SQLite files even with identical inputs. This is
fine: the freshness guarantee comes from the stamp, not from bytewise DB equality.
---
## How to make a DB change
### Normal data changes (economics, atlas markers)
1. Edit the source files (TOML, JSON, markers.json).
2. Run `make regen-db`.
3. Run `make check-systems-db` to confirm the stamp is fresh.
4. Stage and commit:
```bash
git add server/data/systems.db
git commit -m "chore(db): regen systems.db — <what changed>"
```
### Schema changes (new tables or columns)
1. Add the DDL to `server/data/systems-schema.sql`.
2. Add migration SQL to `MIGRATION_SQL` in `import_economics.py` if the change
affects existing DBs (idempotent `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE`).
3. Run `make regen-db`.
4. Stage `server/data/systems-schema.sql` and `server/data/systems.db` together.
---
## Pre-push hook (#857)
`.config/hooks/pre-push` (installed via `make install-hooks`) checks that whenever
`server/data/systems.db` is in the push, its meta stamp matches the current generator
source SHAs. If not, the push is rejected with:
```
systems.db is stale — run `make regen-db` before pushing.
Stale generators: ['import_economics']
```
Fix: run `make regen-db`, stage `server/data/systems.db`, amend or add a commit.
Or use `/pr-push` — it detects stale generator sources and reruns `make regen-db`
automatically before pushing.
The check script is `tooling/check-systems-db-stamp`. Run it interactively with
`make check-systems-db` or `python3 tooling/check-systems-db-stamp --verbose`. The
`GENERATOR_SOURCES` dict at the top of that script is the single registry — when
you add a new generator or source file, update it there and mirror the change in
the `/pr-push` skill's source-file watch list.
---
## /pr-push integration (#858)
The `/pr-push` skill checks whether any generator source files are modified on the
branch. If they are, it automatically runs `make regen-db` and stages the updated
`server/data/systems.db` before pushing — preventing pre-push hook rejections on
branches that modify generators without regenerating.
---
## Why direct DB edits are forbidden
Two branches that both commit `server/data/systems.db` changes produce a binary
merge conflict. Git cannot diff or merge binary SQLite files. Sprint 36 hit this
exact class of problem. The meta stamp + pre-push hook is the systematic fix:
- The stamp is deterministic (same generator source → same recorded SHA)
- Only one branch modifies generator sources at a time (per team scope rules)
- The pre-push hook is a hard blocker before the binary conflict can land
## The migration escape hatch
The rule above says "never run UPDATE or INSERT directly on systems.db outside
of a migration." Here's what a legitimate migration looks like, and what isn't
one:
**Sanctioned path: the `MIGRATION_SQL` block in `import_economics.py`.** That
string is executed at the top of every import run (inside the same transaction
that clears + reimports data) and contains idempotent `CREATE TABLE IF NOT
EXISTS` / `CREATE INDEX IF NOT EXISTS` statements, plus `ALTER TABLE` additions
handled via the `COLUMN_MIGRATIONS` list. When you need a new table, column,
or index on systems.db, add it there. It'll run on the next `make regen-db`
and the meta stamp will flip because `import_economics.py` changed.
**Also legitimate:** edits to `server/data/systems-schema.sql` (the canonical
DDL used by fresh builds) paired with matching entries in `MIGRATION_SQL` for
existing DBs. The stamp's `schema_version` field records the schema file's
SHA at generation time — change the schema, commit both files together, and
the stamp picks it up automatically.
**NOT legitimate and forbidden:**
- Running `tooling/db/sqlite-exec` (or any raw SQL) against `systems.db` by
hand. Any changes you make are silently reverted by the next `regen-db` run
— your edits die, not the pipeline's.
- One-off patch scripts that open `systems.db` and modify rows.
- Editing the DB file with a SQLite GUI.
- Committing `systems.db` alone, without the corresponding source change that
would explain the diff on regen.
If you think you need an exception, the right move is to make the source
change explicit instead: either edit the wiki TOMLs / JSONs that feed the
generators, or edit `MIGRATION_SQL` / `systems-schema.sql` directly. There is
no hand-edit path that survives regen.
---
## Savegame migration lineage (Phase 5+)
`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`
+13 -7
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,15 @@
"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 *)",
"Read(//var/mnt/data/projects/settled-reach/**)",
"Read(//tmp/**)",
"Grep",
"Glob",
"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 +93,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.
@@ -26,13 +25,27 @@ current branch — never touches main.
## Workflow
### 0. Dry-run mode check
If the user invokes `/pr-push --dry-run`:
- Print: "Dry-run mode — inspecting state, nothing will be pushed or committed."
- Run steps 1 through 4a in **inspect-only** mode:
- Step 4: run `make check-systems-db` to check current stamp freshness (no merge)
- Step 4a: report which watched files changed vs origin/main; show whether `make regen-db`
would be triggered; do NOT run the regen, stage, or commit
- Print a summary: watched files changed (list), regen needed (yes/no), DB stamp fresh (yes/no)
- Print "Dry run complete — use /pr-push to apply."
- Stop. Do not push or create a PR.
---
### 1. Validate branch
```bash
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)
@@ -201,6 +214,78 @@ git merge origin/main --no-edit
If merge conflicts, **stop and report** — let the user resolve.
If clean, continue.
### 4a. Regen systems.db if generator sources or data changed (#858)
Check whether any file in the **source-file watch list** was modified on this branch
versus `origin/main`. This list covers generator code AND the data files that feed them.
The generator-source paths below **must stay in sync** with `GENERATOR_SOURCES` in
`tooling/check-systems-db-stamp` (PR #136 review T7) — if you add a new source file
to the stamp, add it here too, and vice versa. Drift between the two lists reintroduces
exactly the silent-stale-DB class of bug this skill exists to prevent.
```bash
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 \
server/data/systems-schema.sql \
wiki/star-systems/ \
wiki/economics/ \
content/economics/
```
**If output is empty:** skip this step entirely.
**If any files appear in the output:** the DB must be regenerated on top of the
current main. Perform the following:
1. **Integrate main.** Step 4 merged main into the branch. If you find yourself
on a branch that was NOT yet merged with main in step 4, do it now:
```bash
git fetch origin
git merge origin/main --no-edit
```
If there are merge conflicts in source files, **stop and report which files
conflict**. Ask the user to resolve manually — do not attempt to auto-resolve
generator source conflicts.
2. **Regenerate the DB:**
```bash
make regen-db
```
`make regen-db` runs all three generators and stamps the meta table. It tolerates
coverage gate failures (exit 2 = data quality warning, not an error). If it exits
with any other non-zero code, stop and report the stderr output — do not push.
3. **Stage the updated DB:**
```bash
git add server/data/systems.db
```
4. **Commit only if the DB actually changed:**
```bash
git diff --cached --stat -- server/data/systems.db
```
- If the diff shows changes: commit with `/git-commit`, message:
`chore(db): regen systems.db against rebased sources`
- If no diff (regen produced identical output — sources were self-consistent):
unstage the file (`git restore --staged server/data/systems.db`) and skip the
commit. The source changes alone are the PR content.
**In dry-run mode** (from step 0): report which watch-list files changed and
whether regen would be triggered. Do NOT run the regen or modify any files.
This step prevents the pre-push hook from rejecting a push where the branch modifies
a generator source or data file but did not regenerate the DB.
### 5. Push
```bash
@@ -260,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
+120 -43
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
@@ -138,24 +149,54 @@ Three-dot diff with pathspec exclusions is unreliable. Instead, either:
For large diffs (>1000 lines of source), provide **source files** rather than
raw diff to reviewers — cleaner context, better reviews.
**Reviewer agents read source files via `git show`.** Sprint branches
use the naming pattern `sprint-{N}/{team}`. To read a file from the
branch being reviewed:
**Reviewer agents read source files from the team worktree.**
Sprint branches follow `sprint-{N}/{team}`. Mid-sprint, a worktree
of each branch exists at `$(dirname <repo_root>)/.sprint/sprint-{N}/{team}/`
— a *sibling* of the repo root, not a child. This worktree IS the
branch: Read/Grep on paths rooted there resolve against the branch's
checkout, not main's.
**Why this matters:** Sprint 37 PR #138 review produced 6 false-
positive findings because the reviewer defaulted to Read/Grep on the
main repo path (`/var/mnt/data/projects/settled-reach/main/`) instead
of the branch worktree. Every finding was a verbatim match against
main's state but irrelevant to the branch — the branch had already
cleaned the residue the reviewer flagged as "still present." Sending
those findings to the team would have caused busywork on already-clean
code, and more dangerously, the same drift hides *false negatives*
(branch-introduced bugs the reviewer never saw because it never read
the branch).
Fix: before spawning reviewers, resolve the worktree path and pass it
into every reviewer prompt with prominent language. The reviewer
reads from the worktree, not from main.
```bash
git show origin/<branch>:<path>
# 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>`
```
For example:
```bash
git show origin/sprint-31/server:server/src/bin/atlas.rs
```
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
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.
If the sprint branch has an active worktree (under `.sprint/`), reviewers
can also use the Read tool with the worktree path. But `git show` is
the reliable default — it works whether or not a worktree exists.
In the reviewer prompt, state the rule non-negotiably:
Also tell agents to read relevant `decisions/*.md` files for context.
> **Read source from `<WORKTREE_PATH>` only.** Do NOT Read or Grep
> paths under the main repo root (`/var/mnt/data/projects/settled-reach/main/`).
> Those resolve to main, not the branch. The worktree at `<WORKTREE_PATH>`
> IS the branch — point all file tools there.
Also tell agents to read relevant `decisions/*.md` files for context
(these can be read from either path — they're usually identical —
but for consistency, use the worktree path).
### 4. Spawn reviewers in parallel
@@ -235,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
@@ -260,26 +312,52 @@ 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
### 7a. Post-merge cleanup (default — suggest it, don't ask open-ended)
If a sprint team is active and you are the team lead, handle the
review outcome:
Once a PR is merged and closed, its branch is fully contained in `main` and its
worktree is idle. **The default is to tear both down** — leaving them around is
how stale empty worktrees accumulate. So after the merge, *recommend* cleanup as
the default and proceed unless the user wants to keep them. Don't pose it as an
open question — state the default action, give the user a chance to decline.
```bash
# 1. Remove the worktree (if the branch had one)
WORKTREE=$(git worktree list --porcelain | grep -B2 "branch refs/heads/<branch>" | grep "worktree " | sed 's/worktree //')
git worktree remove "$WORKTREE" # add --force only if it has uncommitted junk
# 2. Delete the merged branch — local then origin
git branch -d <branch> # -d (not -D) refuses if not merged: a safety net
git push origin --delete <branch>
```
Safety rules:
- **Never delete protected branches** (`main`, `maintenance`, `server`, `client`,
`copy`, `audio`, `visual`, `ci`) — see `tea-cli.md`. Cleanup applies only to
topic branches.
- Use `git branch -d` (lowercase) — it refuses to delete a branch not merged into
HEAD, so it can't silently drop unmerged work. If it refuses, the branch isn't
actually merged: stop and investigate, don't reach for `-D`.
- This is safe because the commits live in `main` after the merge — but it is still
an outward action (deletes the origin branch). Recommend-and-proceed is fine for a
merged topic branch; if anything looks unmerged or shared, ask first.
If `/whats-next` created the worktree (kanban flow), this is the step that closes
that loop — the worktree it spun up at activation is removed here at merge.
### 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.
@@ -288,9 +366,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 |
-582
View File
@@ -1,582 +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
Remove ephemeral worktrees for the closed sprint. Run the teardown script:
```bash
.claude/skills/sprint-start/scripts/sprint-teardown.sh {N}
```
This removes all worktrees under `.sprint/sprint-{N}/` and prunes git
metadata. Safe to skip if the sprint didn't use ephemeral worktrees
(e.g. legacy persistent worktree setup).
#### 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):
```
Task(
subagent_type: "{name_lowercase}",
team_name: "sprint-{N}-{team}",
name: "{name_lowercase}",
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.
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
+218
View File
@@ -0,0 +1,218 @@
---
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.
**Lifecycle:** a worktree created here is torn down at the *end* of the work, not
here. `/pr-review` §7a removes it (and deletes the merged branch) by default once
the PR merges. If you activate a batch and the work never ships, the worktree is
yours to clean up — don't leave empty worktrees parked at `main`'s HEAD (a sign of
an abandoned activation). Before creating a new worktree, `git worktree list` and
reuse or prune any stale one for the same branch.
### 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
@@ -0,0 +1,87 @@
# workshop-start — Improvement Log
Running notes on friction and fixes observed while running workshops. Fold the
confirmed ones into `SKILL.md` periodically; delete once applied.
## From the `system-economic-specialization` relaunch (2026-05-31)
- **Tool name drift.** SKILL.md §5 says "Use the **Task tool** to spawn each
agent." The actual spawn tool is **`Agent`** (with `team_name` + `name` +
`subagent_type`). "Task" reads as the TaskCreate/TaskUpdate family and is
misleading. Rename to "Agent tool" throughout §5.
- **SI has no Round 1 work — say so.** The skill adds SI to every workshop but
SI's job is Round 2 ticket creation. Round 1 there is no SI task, so SI sits
with an empty TaskList and no instruction. Recommend the skill explicitly note:
"SI joins on standby; SI gets no task until Round 2 — spawn it in background
with a 'load context and wait for Round 2' prompt." Avoids an idle/confused
agent on spawn.
- **Spawn order vs. task creation.** Following the skill literally (§4 create
tasks → §5 spawn) matters: if you spawn agents before the tasks exist, they
check TaskList, find nothing, and may idle. Worth a one-line warning in §5:
"Create and assign all Round 1 tasks BEFORE spawning, or the agents wake to an
empty list." (Recoverable by messaging them after, but cleaner to order it
right.)
- **`TaskCreate` is one-task-per-call.** No batch/array form. A batch attempt is
rejected. The skill could state this so the lead emits N separate TaskCreate
calls (and assigns owners via TaskUpdate, since TaskCreate takes no `owner`).
- **Read-only participants can't write their own files.** Several workshop agent
types (paula, gore, nigel, ozzie, and the read-only miri variant) have NO Write
tool — yet SKILL.md §4 makes "write your full output to disk" a hard
requirement for every participant. Paula hit this: she produced her full Round 1
doc but had to send it to the lead to write `paula-round1.md`. The skill should
state up front: "Participants without a Write tool deliver their output via
SendMessage; the lead writes the file and marks the task complete on their
behalf." Or: assign a write-capable scribe. Either way, don't make disk-write a
per-agent requirement for read-only agent types — it guarantees a manual relay.
- **Participants self-claim, which races owner assignment.** Paula picked up
task #3 on her own before the lead assigned owners. Harmless here, but if tasks
aren't clearly scoped per-agent an agent could grab the wrong one. The skill's
per-agent task titles ("Round 1 — Miri …") mitigate this; keep titles
agent-named.
## Candidate feature — per-round "scout" critic (user idea, 2026-05-31)
Add an optional independent **scout** agent that runs per round, AFTER the round's
output files land and BEFORE the user checkpoint, to surface missed options,
simpler designs, internal contradictions, and external design prior-art.
- **Value is mostly the independent adversarial review**, not the web. Participants
build on each other and converge; a fresh agent with no stake catches blind
spots convergence hides. Web search is a bonus, useful for the *mechanism* (real
economic geography, how other games encode X, schema-granularity tradeoffs) —
NOT the lore.
- **Hard guardrails:** (1) lore is authored-canonical and fictional — the scout must
NOT "correct" the fiction with real-world/web facts; scope web to design/mechanism
prior-art only. (2) Only surface findings that would CHANGE a decision — a critic
that always finds 10 things gets ignored.
- **Shape:** cheap model (Haiku), `run_in_background: true`, reads the round files +
relevant D-records, writes `scout-round{N}.md`. Lead folds decision-changing items
into the checkpoint presented to the user. Escalate to Sonnet only to dig into a
real find.
- Pairs well with the quality-over-scope-creep principle: the scout is a cheap way
to pressure-test that "quality" claims actually hold and nothing simpler was missed.
## Wrap-up gotchas (2026-05-31)
- **`decision claim` is NOT idempotent.** Each call burns the next ID and appends a
placeholder. Retrying it (e.g. after misreading an unrelated error) creates a
DUPLICATE record that must be hand-removed before filing. Before re-running claim,
check `decision next D` / grep the domain file. The wrap-up step should claim each
ID exactly once and record it immediately.
- **Read-only documenter can still file**, but the lead must hand it the exact ID(s)
and the "remove any stray placeholder" instruction — the claim/file split is where
duplicate IDs sneak in.
- **Background (`run_in_background`) teammates can be slow/unreliable to consume
`shutdown_request`.** At wrap-up, qatux + si (both spawned in background per the
skill's §5 guidance) stayed "active" through multiple shutdown sends, blocking
TeamDelete. Foreground participants shut down immediately. Options to fold into
the skill: (a) don't background the always-present agents, or (b) document that
wrap-up may need several shutdown retries / a longer settle, and that the lead
should not spin indefinitely — the work is already on disk, TeamDelete is just
resource cleanup. Consider whether the skill should note the team can be left to
expire rather than blocking the session on cleanup.
+1
View File
@@ -61,6 +61,7 @@ Use the Task tool to spawn each agent as a teammate. Each call should:
- Set `team_name` to the workshop team name
- Set `name` to the agent name (e.g., "gestalt")
- Set `subagent_type` to the matching agent type (same as name — see reference table)
- **Set `model: "sonnet"`** — team-mode participants pin to Sonnet 4.6 for literal-rule-following discipline. Opus 4.7 was observed ignoring scope rules and failing to report back in team mode (Sprint 37). The *team lead* (this session) stays on whatever model the user has selected.
- Provide a prompt telling the agent to check TaskList for their assigned task
Spawn all agents in parallel (one Task call per agent in a single message). Agents will appear as teammates in the Claude Code UI and pick up their tasks from the shared task list.
+91 -6
View File
@@ -9,14 +9,27 @@ ERRORS=0
echo "pre-push: running lint checks..."
# --- Detect which directories have changes vs remote ---
# Prefer origin/<branch> as the baseline (what the remote already has),
# but fall back to origin/main for first-push of a new branch — otherwise
# every check runs against nothing and the hook treats the whole repo as
# changed, spending tens of seconds on linters and JSON validation that
# have no diff to cover (e.g. pushing a wiki-only branch rebuilds GDScript
# and runs clippy + ruff + validates all 2762 JSON files).
BRANCH=$(git branch --show-current)
REMOTE_REF="origin/$BRANCH"
if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then
if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
REMOTE_REF="origin/$BRANCH"
elif git rev-parse --verify "origin/main" >/dev/null 2>&1; then
REMOTE_REF="origin/main"
else
REMOTE_REF=""
fi
if [ -n "$REMOTE_REF" ]; then
CLIENT_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- client/ 2>/dev/null | wc -l)
SERVER_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- server/ 2>/dev/null | wc -l)
TOOLING_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- tooling/ pyproject.toml 2>/dev/null | wc -l)
else
# New branch or no remote ref — fall through to directory checks
# No remote at all (e.g. fresh clone before first fetch) — be safe, run everything
CLIENT_CHANGED=1
SERVER_CHANGED=1
TOOLING_CHANGED=1
@@ -74,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"
@@ -114,7 +129,12 @@ else
fi
# --- JSON syntax validation ---
if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then
# Use the same REMOTE_REF the directory-change detection above settled on
# (origin/<branch> preferred, origin/main fallback). Without this, a first
# push of a new branch falls through to "validate every JSON in the repo,"
# which on this repo means 2762 Python parses — tens of seconds of churn
# for a push that might not have touched any JSON at all.
if [ -n "$REMOTE_REF" ]; then
JSON_FILES=$(git diff --name-only "$REMOTE_REF"..HEAD -- '*.json' 2>/dev/null || true)
else
JSON_FILES=$(git ls-files '*.json')
@@ -138,6 +158,71 @@ else
echo "pre-push: no JSON changes — skipping"
fi
# --- systems.db stamp check (#857) ---
# If the branch touches server/data/systems.db and the meta stamp does not
# match current generator sources, reject the push. Prevents pushing a
# stale DB snapshot where generator source was modified but the DB was not
# regenerated.
#
# Runs whenever systems.db was modified in ANY branch commit vs. main —
# including on a branch's very first push (review T5: the previous version
# skipped the check for new branches because it compared against origin/$BRANCH,
# which didn't exist yet, leaving a gap where a stale DB could ship via the
# first push). We compare against origin/main — which always exists — so the
# check covers the first-push case.
DB_IN_PUSH=$(git diff --name-only origin/main...HEAD -- server/data/systems.db 2>/dev/null | wc -l)
if [ "$DB_IN_PUSH" -gt 0 ] && [ -f "$REPO_ROOT/tooling/check-systems-db-stamp" ]; then
echo "pre-push: checking systems.db stamp..."
rc=0
python3 "$REPO_ROOT/tooling/check-systems-db-stamp" || rc=$?
if [ "$rc" -eq 1 ]; then
# rc=1 means stale / unknown generator / missing source; message on stderr
echo " Fix: run 'make regen-db' then stage server/data/systems.db"
echo " Or use /pr-push — it handles regen automatically before pushing."
ERRORS=$((ERRORS + 1))
elif [ "$rc" -eq 2 ]; then
# rc=2 means no meta table — treat as unstamped, warn but don't block.
# This is legitimate immediately after the meta table is introduced;
# the next `make regen-db` will populate it (H4).
echo "pre-push: WARNING — systems.db has no meta stamp — run 'make regen-db' now if this DB was generated by you"
else
echo "pre-push: systems.db stamp — OK"
fi
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."
+102
View File
@@ -6,6 +6,108 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- **Architecture-flavor trait-template catalog** (D-232, #993/#1005) — the foundation for how generated settlements *look*: a 28-template catalog of holistic cultural building-grammar bundles (economic-function pools + per-corridor baselines + heritage callbacks like iberian-hacienda, nordic-timber, east-asian-temple, arab-oasis), each economically gated and seed-drawn, plus a sparse per-body bias table for hero worlds. Build-time guardrails ensure ≥5 templates are eligible per economic class and none dominates a pool. Storage + bake only so far; the generator that draws from it lands later in Phase 4
- **Authored per-system specialization layer** (D-237, D-233 re-amended) — each inhabited system gains three authored, static, deterministic identity fields driving world generation: `economic_specialization` (27-value vocabulary with scale baked into the value, e.g. breadbasket vs estate_farm vs terroir), `cultural_specialization` (activity + founding-heritage vocabulary so e.g. a Vietnamese-founded system reads correctly instead of taking its corridor's default), and `dominant_faction` (who governs). Authored lore wins; unnamed systems fall back to a deterministic-but-varied seed-hashed draw so they don't all read identically
- **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
- **Founding-heritage pass — every divergent-heritage system now reads true** (#1016) — authored a `cultural_specialization` for ~140 more systems (32 → 171), so a Vietnamese-, Igbo-, Finnish-, Korean-, Shona-, or Persian-founded system reads as itself instead of defaulting to its corridor's dominant culture. The heritage vocabulary is now a canonical 47-people taxonomy spanning the British Isles, Iberia/Latin America, Europe, sub-Saharan Africa, South/East/SE Asia, the Pacific, and the Middle East/Central Asia. Pinned only where a system's founders diverge from their corridor baseline; corridor-typical systems keep the regional default
- **Heritage rebalance — five systems renamed** (#1019) — the corridor over-represented southern-African heritage, so five genuinely-thin afrikaans systems are refounded with new cultures: Droëland→**Marib** and Stilwater→**Sawad** (Arab), Koeberg→**Akhgar** and Carnarvon→**Golestan** (Persian), Skuilplek→**Sığınak** (Turkic). Their worlds, stations, drifter-guide entries, and the corporations named after them are renamed to match; the distinctive mysteries each system carries are preserved. Load-bearing systems (Eerste Wacht, Helderoog, Brandpunt, …) are untouched
- `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
- **Sawad's "unsettled neighbour" canon contradiction** (#1019) — the system formerly called Stilwater described its neighbour GJ 914A as having no settlement, but GJ 914A is a three-century-old colony (now Marib); the "Far Gate" mystery is reworded to keep the unexplained-transit-hours hook without the false claim
- 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
- **Asset pipeline discipline** (#854, #855, #856, #857, #858, #859) — `systems.db` is now a source-canonical snapshot with a `meta` table stamped by every generator (SHA of source + schema). Pre-push hook rejects stale DBs; `/pr-push` auto-runs `make regen-db` when generator sources change. Full rules in `.claude/rules/asset-pipeline.md`
- **`make regen-db`** — runs the two DB-writing generators (import_economics, generate_atlas) and stamps the meta table. import_economics now invokes the Rust generate_brands binary internally as its first step, so the brand pipeline is owned by a single stamp.
- **`make check-systems-db`** — verifies the meta stamp matches current generator sources
- **`make install-hooks`** — installs pre-push and pre-commit hooks in one step
- **`tooling/db/decision show <D-NNN>`** (#723) — drill-down view of a decision with implementing tickets and cross-refs
- **Atlas determinism smoke test** (#847) — `make test-atlas-determinism` runs `generate_atlas.process_body()` twice with a fixed seed and diffs the output to catch determinism regressions in terrain analysis, city placement, A* routing, and naming
- **SelectedBookmark save/load** (#863) — bookmark and starting-location choice now persist across save/load; replaces the v0.2-deferred TODO on `SelectedBookmark`
- **`BookmarkPlugin::new(registry)` injection** (#862) — test-friendly plugin construction for future TOML bookmark loading; default constructor still wires the canonical tycoon registry
- **Six new corporation wiki pages** (#860) — Arbour Aggregates, Earth Standard Group, Rush Mining, Scapa Flow Industries, Sede Chemical Works, Threshold Fuel Syndicate
- **Atlas naming corridor-scoped dedup, compass-direction filter, river vocab filter, infra pair-naming** (#853) — city and mountain names deduplicate across bodies within a corridor; compass-direction defaults blocked in the few-shot prompt; navigational vocabulary (`Flow`, `Current`) rejected for rivers; unnamed roads and railroads receive deterministic `{CityA}–{CityB} {corridor_suffix}` names
- **Scene-level merge-path UI flow tests** (#873) — `test_merge_path_flows_sprint37.gd` covers main-menu → new-game, load-game, character-creation → submit, bookmark → confirm. Headless scene-flow tier (4th beyond Gauntlet/MessagePack/TestHarness); pattern for future merge-path regression guards
- **105 brand corp wiki stubs** (#861) — every corp page from Sprint 36 PR #133 now authored to three-layer narrative depth (public identity / actual operation / one concealed fact) with ≥95-line DoD
- **D-193 Lattice Commission** (#876) — resolves Q-095: "the Lattice Commission" is the canonical long-form of the Concord Assembly's regulatory authority; "Concord Commission" and "Assembly Commission" deprecated as drift forms
### Changed
- `decisions-coverage` Makefile target now lists implementing ticket IDs per decision instead of aggregate counts
- **Economy coverage gate** (#860) now passes end-to-end — closes the 21 raw-commodity / system gaps that blocked Phase 2 demand simulation
- Tag updates on 15 existing corporation wiki pages to match commodity coverage needs
### Fixed
- **New Game flow hangs on 'connecting'** (#872) — `bookmark_catalog` carry-forward race in `SimBridge.receive_bytes` when tick 0 + tick 1 arrived in the same TCP batch; catalog now carries forward with same invariants as monologue/dialogue/settings_response
- **`dialogue_box._escape_bbcode` corrupted `[lb]` escapes** (#866) — chained `.replace('[','[lb]').replace(']','[rb]')` turned `[lb]` into `[lb[rb]`; fix escapes only `[`, since unmatched `]` renders as literal in RichTextLabel
- **MetaScreen test helper regression in `test_anti_tedium`** (#869) — bug_report_dialog test helpers now instantiate from `.tscn` instead of `Control.new() + set_script()`, preserving the MetaScreen runtime stack
- Storyteller `activation_pass` "no Simmering triangles — holding" no longer fires as `warn` during normal early-game state — downgraded to `debug` (#789)
### Removed
- **`PROTOCOL_VERSION` lockstep handshake** (#874, #875, D-192) — both sides of the handshake now omit the version field; `HandshakeMessage` is empty server-side and the client decode path no longer checks versions. Schema drift surfaces as MessagePack missing-field errors downstream, which is the intended signal
- **`HeritageRoot` type alias and `ZonePaletteModifier::Heritage` variant** (#877, D-167) — last stubs of the abstract heritage-root system retired in favour of the corridor cultural framework
- **`CharacterArchetype` (Smuggler/Detective) trace from server** (#878) — enum, IPC field, verb-differentiation branch in the observer Phase 2 filter (D-057 superseded), monologue pool partitioning, Gauntlet plumbing, drama-module schema, archetype-dependent integration tests. Per the development cascade, character/NPC differentiation is Phase 6 work and the running trace was pre-cascade filler, not production. Client-side cleanup tracked in #882.
- **v0.1 Sova/Van Maanen's residue from wiki** (#865) — `wiki/star-systems/GJ-35/sova/` subtree deleted; authoring-guide examples stripped; canonical lore citing dropped v0.1 NPCs rewritten; "Van Maanen's Star" cultural references converted to "Vuurkloof"
- **8 parse-error test files** (#870) — `test_debug_overlay_sprint19`, `test_entanglement_sprint22`, `test_fog_sprint22`, `test_journal_sprint18`, `test_minimap_sprint18`, `test_session_manager_sprint19`, `test_sprint30`, `test_sprite_integration` — referenced removed/renamed APIs from prior sprints. Coverage-revival tickets filed: #879 (fog), #880 (journal), #881 (minimap), #889 (EntityRenderer sprite constants); rest tracked under umbrella #871
## [v0.1.36] — 2026-04-21
### Added
+23 -17
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)
@@ -25,6 +25,12 @@ Full annotated tree: `.claude/rules/project-structure.md`
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
### Asset pipeline
`server/data/systems.db` is a read-only canonical snapshot produced by the generator
pipeline — never edit it directly. To regenerate: `make regen-db`. Full rules in
`.claude/rules/asset-pipeline.md`.
## Development Cascade — First Things First
Development follows a strict cascade. Each phase has a concrete deliverable. **Do NOT discuss, design, or implement detail from a later phase while an earlier phase is incomplete.** If you encounter references to later-phase detail (room grammar, NPC bundles, heritage tokens, etc.) in documents or decisions, either ignore them silently and stay at the correct level, or flag that the reference is dragging attention to the wrong scope level and suggest it be rephrased or moved.
@@ -34,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.
@@ -59,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).
@@ -74,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
@@ -86,10 +92,10 @@ 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 next`, `claim`, `check-dupes` | — |
| Decisions | `tooling/db/decision show`, `next`, `claim`, `check-dupes` | — |
### Testing preferences
+40 -21
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 \
db-backup db-install validate-content check-fact-ids setup-hooks \
audit deny atlas-verify economy-db atlas-generate \
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 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 \
@@ -46,9 +46,10 @@ help:
@echo " make db-install Restore shared database from backup"
@echo ""
@echo " make decisions-sync Sync decisions/*.md into SQLite"
@echo " make decisions-coverage Decision-to-ticket coverage by domain"
@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,7 +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 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"
@@ -110,6 +113,10 @@ setup-hooks:
@git config core.hooksPath .config/hooks
@echo "Git hooks path set to .config/hooks"
install-hooks: setup-hooks
@chmod +x .config/hooks/pre-push .config/hooks/pre-commit
@echo "Hooks installed — pre-push and pre-commit are active."
setup-venv:
@python3 -m venv .venv
@.venv/bin/pip install -e ".[dev]" --quiet
@@ -202,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/"
@@ -333,19 +341,27 @@ 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
@# halt step 2, which could produce stale data with a fresh stamp
@# (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. 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 ""; \
echo " regen-db complete — systems.db is up to date and stamped."; \
echo " Stage it with: git add server/data/systems.db"
check-systems-db: ## Verify systems.db meta stamp matches current generator sources (#857)
@python3 tooling/check-systems-db-stamp --verbose
econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade)
@cargo build --manifest-path tooling/econ-sim/Cargo.toml --release
@@ -364,7 +380,7 @@ decisions-sync:
@tooling/db/decisions-sync
decisions-coverage:
@tooling/db/sqlite-query "SELECT d.domain, COUNT(DISTINCT d.id) as decisions, COUNT(DISTINCT t.decision_ref) as with_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.domain"
@tooling/db/sqlite-query "SELECT d.id, d.domain, d.title, COALESCE(GROUP_CONCAT(t.id, ', '), '') as implementing_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.id ORDER BY d.domain, d.id"
decisions-active:
@tooling/db/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id"
@@ -372,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:
+61 -37
View File
@@ -3,7 +3,8 @@ extends Node
# Signals
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
signal snapshot_received(snapshot: Dictionary)
signal handshake_complete(protocol_version: int)
signal atlas_layers_received(response: Dictionary)
signal handshake_complete
signal handshake_failed(reason: String)
# Connection states
@@ -27,8 +28,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 +127,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 +161,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 +223,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,17 +245,15 @@ 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: { "protocol_version": N }
var decoded: Variant = Messagepack.decode(msg)
if (
decoded.status != null
or not (decoded.value is Dictionary)
or not decoded.value.has("protocol_version")
):
# Decode HandshakeMessage — D-192 (#875): protocol_version field dropped.
# Server sends {} or a minimal dict; only structural validity is required.
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)
handshake_failed.emit(reason)
@@ -260,27 +261,13 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
_set_state(ConnectionState.ERROR)
return
var server_version: int = decoded.value["protocol_version"]
if server_version != Protocol.PROTOCOL_VERSION:
var reason := (
"Protocol version mismatch: server=%d, client=%d"
% [server_version, Protocol.PROTOCOL_VERSION]
)
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
# 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
GameState.world_seed, 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)
@@ -296,7 +283,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
_set_state(ConnectionState.ERROR)
return
handshake_complete.emit(server_version)
handshake_complete.emit()
_set_state(ConnectionState.CONNECTED)
# #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled
# from server SQLite so the client reflects the authoritative persisted state (D-138).
@@ -319,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()
@@ -331,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:
@@ -400,6 +387,25 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void
_outbound_buffer.append(entry)
## Request a body's generation-cascade layers from the server (#960, D-225).
## Live mode only — sends an AtlasLayerRequest frame; the response arrives via the
## atlas_layers_received signal. No-op in test mode (no server connection).
func request_atlas_layers(body_id: String, up_to: String = "Topography") -> void:
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_atlas_layer_request(body_id, up_to)
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error(
(
"SimBridge: failed to send atlas layer request for %s: %s"
% [body_id, error_string(err)]
)
)
# Poll for snapshot from simulation.
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
func poll_snapshot() -> Variant:
@@ -425,9 +431,18 @@ func poll_snapshot() -> Variant:
# events (monologue, dialogue) are carried forward from overwritten snapshots
# so they aren't silently dropped when server ticks faster than client consumes.
func receive_bytes(bytes: PackedByteArray) -> void:
var snapshot = Protocol.decode_snapshot(bytes)
# Decode once, branch by frame shape (#960, D-225): atlas layer responses and
# snapshots are both msgpack maps, told apart by field.
var inbound := Protocol.decode_inbound(bytes)
if inbound.kind == "atlas":
atlas_layers_received.emit(inbound.value)
return
if inbound.kind != "snapshot":
push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size())
return
var snapshot = inbound.value
if snapshot == null:
push_warning("SimBridge: decode_snapshot returned null for %d bytes" % bytes.size())
push_warning("SimBridge: snapshot decode returned null for %d bytes" % bytes.size())
return
if _last_snapshot != null:
# Carry forward one-shot events the client hasn't consumed yet.
@@ -464,6 +479,15 @@ func receive_bytes(bytes: PackedByteArray) -> void:
and _last_snapshot.get("settings_response") != null
):
snapshot["settings_response"] = _last_snapshot["settings_response"]
# #872: Carry forward bookmark_catalog (one-shot, consumed by main_menu._on_snapshot_received_for_catalog).
# Server sends catalog on tick 0 and after RequestBookmarkCatalog. If tick 0 and tick 1
# arrive in the same TCP batch, the inner receive loop overwrites _last_snapshot and the
# catalog is silently lost — this carry-forward prevents that race.
if (
snapshot.get("bookmark_catalog") == null
and _last_snapshot.get("bookmark_catalog") != null
):
snapshot["bookmark_catalog"] = _last_snapshot["bookmark_catalog"]
_last_snapshot = snapshot
+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
+126 -66
View File
@@ -9,42 +9,43 @@ extends Node
## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc")
## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"})
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
## v20: adds settings_response field to ObserverSnapshot (#627, D-138).
## v21: adds economy_snapshot field to ObserverSnapshot (#822, D-181).
## v23: adds bookmark_catalog field to ObserverSnapshot (#614).
const PROTOCOL_VERSION: int = 23
static func _mp():
return load("res://addons/messagepack/messagepack.gd")
# -- Decode: bytes from server → GDScript types --------------------------------
## Decode a raw MessagePack frame to its top-level value (or null on error).
## #960: lets receive_bytes decode a frame once and branch by shape before
## committing to the heavier snapshot decode.
static func decode_raw(bytes: PackedByteArray) -> Variant:
var result = _mp().decode(bytes)
if result.status != null:
push_error("Protocol: msgpack decode failed: %s" % result.status)
return null
return result.value
## Decode an ObserverSnapshot from MessagePack bytes.
## Returns decoded snapshot Dictionary or null on error.
## 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)
if result.status != null:
push_error("Protocol: msgpack decode failed: %s" % result.status)
var raw = decode_raw(bytes)
if raw == null:
return null
return _decode_snapshot_from_raw(raw)
var raw = result.value
## Build an ObserverSnapshot from an already-decoded raw value (the snapshot
## body, shared by decode_snapshot and the receive-side classifier).
static func _decode_snapshot_from_raw(raw: Variant) -> Variant:
if not raw is Dictionary or not raw.has("tick") or not raw.has("entities"):
push_error("Protocol: snapshot missing required fields")
return null
# Version check: reject snapshots from incompatible server
var version: Variant = raw.get("version")
if version != PROTOCOL_VERSION:
push_error(
(
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync."
% [version, PROTOCOL_VERSION]
)
)
return null
var entities: Array[Dictionary] = []
var raw_entities: Array = raw["entities"]
var dropped := 0
@@ -67,7 +68,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
var tick: int = raw["tick"]
# version already checked above; game_time for HUD display
# game_time for HUD display
var game_time: Variant = raw.get("game_time")
# player_facing: FacingDirection is a unit enum → bare string in rmp_serde
@@ -223,6 +224,18 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
}
# v8: gauntlet_mode and room_id (#496) — present only in Gauntlet sessions.
# gauntlet_mode is a bool flag; room_id is a String room identifier or absent.
# Snapshot handler (snapshot_handler.gd) reads these via snapshot.has() guards.
var gauntlet_mode: bool = false
var raw_gauntlet: Variant = raw.get("gauntlet_mode")
if raw_gauntlet == true:
gauntlet_mode = true
var room_id: Variant = null
var raw_room_id: Variant = raw.get("room_id")
if raw_room_id is String:
room_id = raw_room_id
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines.
# Each event carries pre-occluded text plus speaker/target attribution.
var conversation_events: Array = []
@@ -400,18 +413,21 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
if raw_alc is Array:
for cul in raw_alc:
alc.append(str(cul))
bm_entries.append(
{
"id": str(raw_bm["id"]),
"title": str(raw_bm.get("title", "")),
"subtitle": str(raw_bm.get("subtitle", "")),
"flavor": str(raw_bm.get("flavor", "")),
"default_location": str(raw_bm.get("default_location", "")),
"allowed_locations": al,
"allowed_locations_cultures": alc,
"career": str(raw_bm.get("career", "")),
"starting_capital_tractus": int(raw_bm.get("starting_capital_tractus", 0)),
}
(
bm_entries
. append(
{
"id": str(raw_bm["id"]),
"title": str(raw_bm.get("title", "")),
"subtitle": str(raw_bm.get("subtitle", "")),
"flavor": str(raw_bm.get("flavor", "")),
"default_location": str(raw_bm.get("default_location", "")),
"allowed_locations": al,
"allowed_locations_cultures": alc,
"career": str(raw_bm.get("career", "")),
"starting_capital_tractus": int(raw_bm.get("starting_capital_tractus", 0)),
}
)
)
bookmark_catalog = {"bookmarks": bm_entries}
@@ -484,7 +500,6 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"tick": tick,
"entities": entities,
"decode_errors": dropped,
"version": version,
"game_time": game_time,
"player_facing": player_facing,
"player_stance": player_stance,
@@ -508,6 +523,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"current_ticker": current_ticker,
"settings_response": settings_response,
"bookmark_catalog": bookmark_catalog,
"gauntlet_mode": gauntlet_mode,
"room_id": room_id,
}
@@ -618,38 +635,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()
@@ -670,7 +668,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()
@@ -696,7 +694,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()
@@ -729,7 +727,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()
@@ -740,24 +738,86 @@ 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()
return result.value
## Encode an AtlasLayerRequest (#969, D-225) for the layer-stream proxy.
## A bare map {body_id, up_to} — NOT the Vec<PlayerInput> array — so the server's
## frame demux routes it to the atlas proxy. up_to is a CascadeLayer unit variant
## (bare string: "Heightmap" | "Topography").
static func encode_atlas_layer_request(
body_id: String, up_to: String = "Topography"
) -> PackedByteArray:
var msg := {"body_id": body_id, "up_to": up_to}
var result = _mp().encode(msg)
if result.status != null:
push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status)
return PackedByteArray()
return result.value
## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary
## {body_id, status, error, layer1}, or null if the bytes are not an atlas
## response (no "status" key — e.g. an ObserverSnapshot). status is the variant
## name ("Ready"|"Pending"|"NotFound"|"Error"); error holds the message for the
## Error variant. layer1 is the raw decoded Layer1Output map, or null.
static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant:
return atlas_response_from_raw(decode_raw(bytes))
## Build an AtlasLayerResponse from an already-decoded raw value. Returns null
## if it is not an atlas response (no "status" key).
static func atlas_response_from_raw(raw: Variant) -> Variant:
if not raw is Dictionary or not raw.has("status"):
return null
var status_raw = raw["status"]
var status := ""
var error := ""
if status_raw is String:
status = status_raw
elif status_raw is Dictionary and status_raw.has("Error"):
status = "Error"
error = str(status_raw["Error"])
return {
"body_id": raw.get("body_id", ""),
"status": status,
"error": error,
"layer1": raw.get("layer1"),
}
## Decode + classify one inbound frame (#960, D-225). Returns {kind, value} with
## kind "snapshot" | "atlas" | "unknown" — both are msgpack maps, so they are
## told apart by field (a response has "status"; a snapshot has "entities").
## Lets receive_bytes decode the frame ONCE and branch, instead of double-decoding
## the 20 Hz snapshot path.
static func decode_inbound(bytes: PackedByteArray) -> Dictionary:
var raw = decode_raw(bytes)
if not raw is Dictionary:
return {"kind": "unknown", "value": null}
if raw.has("status") and not raw.has("entities"):
return {"kind": "atlas", "value": atlas_response_from_raw(raw)}
return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)}
## Encode a ConfirmBookmark action (#614, #680).
## Struct variant with bookmark_id and starting_location_id.
static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: String) -> PackedByteArray:
static func encode_confirm_bookmark(
bookmark_id: String, starting_location_id: String
) -> PackedByteArray:
var entries: Array = [
{
"tick": 0,
"action_name": "ConfirmBookmark",
"action_data": {"bookmark_id": bookmark_id, "starting_location_id": starting_location_id},
"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()
@@ -767,7 +827,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
+1 -1
View File
@@ -272,7 +272,7 @@ func snapshot() -> Dictionary:
return {
"tick": tick,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"game_time":
{
"day": 0,
-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
@@ -0,0 +1 @@
ƒ§body_id¥ghost¦status¨NotFound¦layer1À
@@ -0,0 +1 @@
ƒ§body_id¤GJ1c¦status§Pending¦layer1À
Binary file not shown.
+16 -11
View File
@@ -15,7 +15,7 @@ extends GdUnitTestSuite
const MAIN_SCENE = preload("res://scenes/main.tscn")
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
const BugReportDialogScene = preload("res://ui/bug_report_dialog.tscn")
var _instance: Node = null
@@ -33,6 +33,7 @@ func before_test() -> void:
GameState.pending_recognitions = []
GameState.room_id = null
GameState.gauntlet_mode = false
MetaStack._stack.clear()
func after_test() -> void:
@@ -47,7 +48,7 @@ func after_test() -> void:
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
var snapshot := {
"tick": overrides.get("tick", 1),
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": overrides.get("entities", [{
"entity_id": 1,
"x": 10.0,
@@ -92,8 +93,10 @@ func _make_gauntlet_hud() -> Control:
func _make_bug_report_dialog() -> Control:
var dialog = Control.new()
dialog.set_script(BugReportDialogScript)
# Instantiate via .tscn — preserves the MetaScreen runtime stack.
# (Sprint 36 migrated bug_report_dialog.gd to extends MetaScreen; bare
# Control.new() + set_script() no longer satisfies the base contract.)
var dialog: Control = BugReportDialogScene.instantiate()
auto_free(dialog)
add_child(dialog)
return dialog
@@ -203,13 +206,15 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
var snapshot: Variant = SimBridge._last_snapshot
assert_that(snapshot).is_not_null()
# Snapshot should NOT contain gauntlet fields
assert_that(snapshot.has("room_id")).override_failure_message(
"Non-gauntlet snapshot must not contain room_id"
).is_false()
assert_that(snapshot.has("gauntlet_mode")).override_failure_message(
"Non-gauntlet snapshot must not contain gauntlet_mode"
# Non-gauntlet snapshot: gauntlet fields must be present with default values.
# (Protocol.decode_snapshot always decodes gauntlet fields; non-gauntlet
# snapshots produce false/null defaults. Check values, not key presence.)
assert_that(snapshot.get("gauntlet_mode", false)).override_failure_message(
"Non-gauntlet snapshot must decode gauntlet_mode == false"
).is_false()
assert_that(snapshot.get("room_id")).override_failure_message(
"Non-gauntlet snapshot must decode room_id == null"
).is_null()
# Apply to GameState — gauntlet-related state should not exist
GameState.apply_snapshot(snapshot)
@@ -497,7 +502,7 @@ func test_bug_report_sends_unpause_on_close() -> void:
var dialog := _make_bug_report_dialog()
dialog.start_capture()
SimBridge._test_input_queue.clear()
dialog._close()
dialog.close()
assert_that(dialog.is_active()).is_false()
assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message(
"Closing bug report should send Unpause to server"
+33
View File
@@ -0,0 +1,33 @@
## Tests for the Phase-4 generation overlays in the Atlas viewer (#960, D-225).
## Referencing AtlasViewer forces both it and AtlasMarkerOverlay to compile, so
## this also guards against parse errors in the overlay rendering code.
class_name TestAtlasOverlays
extends GdUnitTestSuite
func test_generation_overlays_registered() -> void:
var ids: Array = []
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
ids.append(d["id"])
assert_that(ids).contains(["gen_l1_rivers", "gen_l1_basins", "gen_l1_attractors"])
func test_generation_data_round_trips() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
assert_that(v.get_generation_layer1()).is_null()
var mock := {
"body_id": "GJ1c",
"river_network": {"river_cells": [], "confluences": [], "mouths": []},
"drainage_basins": [],
"attractors": [],
}
v.set_generation_layer1(mock)
assert_that(v.get_generation_layer1()).is_equal(mock)
func test_implant_pending_start_stop() -> void:
var p: ImplantPending = auto_free(ImplantPending.new())
p.start("GENERATING LAYER 1")
assert_bool(p.visible).is_true()
p.stop()
assert_bool(p.visible).is_false()
+6 -4
View File
@@ -14,7 +14,7 @@ extends GdUnitTestSuite
# Expected ring buffer capacity per spec.
const EXPECTED_CAPACITY := 60
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
const BugReportDialogScene = preload("res://ui/bug_report_dialog.tscn")
func after_each() -> void:
@@ -35,8 +35,10 @@ func after_each() -> void:
# -- Helpers -------------------------------------------------------------------
func _make_dialog() -> Control:
var dialog = Control.new()
dialog.set_script(BugReportDialogScript)
# Instantiate via .tscn — preserves the MetaScreen runtime stack.
# (Sprint 36 migrated bug_report_dialog.gd to extends MetaScreen; bare
# Control.new() + set_script() no longer satisfies the base contract.)
var dialog: Control = BugReportDialogScene.instantiate()
auto_free(dialog)
add_child(dialog)
return dialog
@@ -52,7 +54,7 @@ func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary:
func _make_snapshot_json(tick: int) -> String:
return JSON.stringify({
"tick": tick,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
})
+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
-317
View File
@@ -1,317 +0,0 @@
## Sprint 19 — Debug visualization overlay (#348)
## F3 toggle, world overlays, tick timing graph.
## Extends the existing debug_overlay.gd stub.
class_name TestDebugOverlaySprint19
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
const DEBUG_SCRIPT_PATH: String = "res://ui/debug_overlay.gd"
func _make_overlay() -> Control:
## Instantiate a standalone DebugOverlay control for unit testing.
## Does not require the full main.tscn scene tree.
var script := load(DEBUG_SCRIPT_PATH)
if script == null:
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
return null
var node := Control.new()
node.set_script(script)
add_child(node)
return node
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.player_position = Vector2(10.0, 10.0)
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.current_tick = 1
GameState.player_knowledge = null
func after_test() -> void:
GameState.visible_entities = []
GameState.player_knowledge = null
# ---------------------------------------------------------------------------
# Script existence
# ---------------------------------------------------------------------------
func test_debug_overlay_script_exists() -> void:
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
"debug_overlay.gd must exist at res://ui/debug_overlay.gd (#348)"
).is_true()
# ---------------------------------------------------------------------------
# Instantiation
# ---------------------------------------------------------------------------
func test_debug_overlay_instantiates_without_crash() -> void:
var ol := _make_overlay()
if ol == null: return
assert_that(ol).is_not_null()
ol.queue_free()
func test_debug_overlay_starts_hidden() -> void:
## Overlay starts hidden — only appears when F3 pressed.
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.visible).override_failure_message(
"DebugOverlay must start hidden (visible=false)"
).is_false()
ol.queue_free()
# ---------------------------------------------------------------------------
# Dev-only guard
# ---------------------------------------------------------------------------
func test_update_from_state_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has_method("update_from_state")).override_failure_message(
"DebugOverlay must have update_from_state() method"
).is_true()
ol.queue_free()
func test_update_from_state_does_not_crash_when_hidden() -> void:
## update_from_state() called while hidden must not crash.
var ol := _make_overlay()
if ol == null: return
ol.visible = false
ol.update_from_state() # Should be a no-op, no crash
ol.queue_free()
func test_update_from_state_does_not_crash_when_visible() -> void:
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate a minimal snapshot tick
GameState.current_tick = 42
ol.update_from_state()
ol.queue_free()
# ---------------------------------------------------------------------------
# NPC path tracking
# ---------------------------------------------------------------------------
func test_npc_paths_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_npc_paths")).override_failure_message(
"DebugOverlay must have _npc_paths field for NPC movement history"
).is_true()
ol.queue_free()
func test_npc_paths_updated_on_state_update() -> void:
## After update_from_state with an NPC entity, _npc_paths should have an entry.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 2,
"x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 100
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"_npc_paths must record NPC positions from visible_entities"
).is_greater(0)
ol.queue_free()
func test_npc_paths_not_populated_for_player_entity() -> void:
## Player entities must not appear in NPC path history.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 1,
"x": 10.0, "y": 10.0, "z": 0,
"kind": {"variant": "Player", "data": null},
}]
GameState.current_tick = 101
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"Player entity must not appear in _npc_paths"
).is_equal(0)
ol.queue_free()
func test_npc_paths_max_length_respected() -> void:
## Path history must not grow beyond NPC_HISTORY_LEN entries.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate NPC moving each tick — inject 20 ticks of movement
for i in range(20):
GameState.visible_entities = [{
"entity_id": 5,
"x": float(12 + i), "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 200 + i
ol.update_from_state()
var path: Array = ol._npc_paths.get(5, [])
assert_int(path.size()).override_failure_message(
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
).is_less_equal(ol.NPC_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Tick timing ring
# ---------------------------------------------------------------------------
func test_tick_deltas_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_tick_deltas")).override_failure_message(
"DebugOverlay must have _tick_deltas field for timing sparkline"
).is_true()
ol.queue_free()
func test_tick_deltas_accumulate_over_state_updates() -> void:
## Each new tick snapshot should add a delta to _tick_deltas.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(5):
GameState.current_tick = 300 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must accumulate entries from successive ticks"
).is_greater(0)
ol.queue_free()
func test_tick_deltas_max_length_respected() -> void:
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(50):
GameState.current_tick = 400 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
).is_less_equal(ol.TICK_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Constants defined
# ---------------------------------------------------------------------------
func test_npc_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have NPC_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have TICK_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_warn_ms_constant_defined() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
).is_true()
ol.queue_free()
# ---------------------------------------------------------------------------
# Facing angle helper
# ---------------------------------------------------------------------------
func test_facing_to_angle_north() -> void:
## North = -PI/2 in Godot 2D (up on screen)
var angle := _fetch_facing_angle("North")
assert_float(angle).override_failure_message(
"_facing_to_angle('North') must return -PI/2"
).is_equal_approx(-PI / 2.0, 0.001)
func test_facing_to_angle_east() -> void:
var angle := _fetch_facing_angle("East")
assert_float(angle).is_equal_approx(0.0, 0.001)
func test_facing_to_angle_south() -> void:
var angle := _fetch_facing_angle("South")
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
func test_facing_to_angle_west() -> void:
var angle := _fetch_facing_angle("West")
assert_float(angle).is_equal_approx(PI, 0.001)
func _fetch_facing_angle(facing: String) -> float:
## Helper: load script and call static method.
var script = load(DEBUG_SCRIPT_PATH)
if script == null:
return 0.0
# In GDScript 4, static methods can be called via an instance
var tmp := Control.new()
tmp.set_script(script)
add_child(tmp)
var result := tmp._facing_to_angle(facing)
tmp.queue_free()
return result
# ---------------------------------------------------------------------------
# In-scene placement: DebugOverlay on UILayer
# ---------------------------------------------------------------------------
func test_debug_overlay_in_main_scene_ui_layer() -> void:
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
var ui_layer := scene.get_node_or_null("UILayer")
assert_that(ui_layer != null).override_failure_message(
"UILayer must exist in main.tscn"
).is_true()
if ui_layer == null: return
var overlay := ui_layer.get_node_or_null("DebugOverlay")
assert_that(overlay != null).override_failure_message(
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
).is_true()
@@ -1 +0,0 @@
uid://bdsgybncfyu52
+9 -12
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
@@ -392,18 +391,16 @@ func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
# ---------------------------------------------------------------------------
func skip_test_escape_bbcode_brackets_in_server_text() -> void:
func test_escape_bbcode_brackets_in_server_text() -> void:
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
## BROKEN (#866): chained replace('[', '[lb]').replace(']', '[rb]') corrupts the
## [lb] escape — result is [lb[rb]...] instead of [lb]...]]. Bug filed.
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
## as plain text in the dialogue log.
## Fix (#866): only escape '[' — unmatched ']' renders as a literal in RichTextLabel.
## Exact expected output: "[lb]wave]Evil NPC[lb]/wave]"
## RichTextLabel interprets [lb] as literal '[', and bare ']' as literal ']',
## so the rendered output is the plain string "[wave]Evil NPC[/wave]" — no BBCode parsed.
var box := _make_dialogue_box()
if box == null: return
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
assert_that(escaped).contains("[lb]")
assert_bool(escaped.begins_with("[")).is_false()
assert_that(escaped).is_equal("[lb]wave]Evil NPC[lb]/wave]")
box.queue_free()
-238
View File
@@ -1,238 +0,0 @@
## Sprint 22 — Entanglement ratio configuration acceptance tests (#175, #178)
##
## Test-first stubs for the client-side surface of the world_seed feature.
## These tests will warn-and-skip until the implementation lands (Tyre, #175).
##
## Client-side acceptance criteria (#175):
## - GameState carries a world_seed field (stores the seed for this session)
## - SessionManager.new_game() generates and stores a world_seed
## - The IPC startup payload carries world_seed so the server can seed SimRng
##
## Server-side acceptance criteria (#178) are in:
## - server/src/content/entanglement.rs (Rust unit tests)
##
## Spec: D-029 (30/50/20 entanglement ratio, variable per seed), D-010 (deterministic sim)
## Tickets: #175, #178
class_name TestEntanglementSprint22
extends GdUnitTestSuite
# -- Client-side: GameState.world_seed field (#175) ---------------------------
func test_game_state_has_world_seed_field() -> void:
# #175 client-side: GameState must store the world_seed for this session.
# The seed is set by SessionManager.new_game() and read by SimBridge to
# carry it in the session startup IPC message.
if not "world_seed" in GameState:
push_warning("TestEntanglementSprint22: GameState.world_seed not found — test-first stub (awaiting #175)")
return
# Field exists — verify it is numeric (int or null are both acceptable initial states)
var seed_val = GameState.get("world_seed")
assert_bool(seed_val == null or seed_val is int).override_failure_message(
"GameState.world_seed must be int or null"
).is_true()
func test_game_state_world_seed_can_be_set_and_read() -> void:
if not "world_seed" in GameState:
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped (#175 not yet implemented)")
return
var orig = GameState.get("world_seed")
GameState.world_seed = 0xDEADBEEF
assert_int(GameState.world_seed).is_equal(0xDEADBEEF)
# Restore
GameState.world_seed = orig
func test_game_state_world_seed_default_is_null_or_zero() -> void:
# Before a session starts, world_seed should be null (no session) or 0 (unset).
if not "world_seed" in GameState:
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped")
return
var seed_val = GameState.get("world_seed")
assert_bool(seed_val == null or seed_val == 0).override_failure_message(
"GameState.world_seed should be null or 0 before any session starts"
).is_true()
# -- Client-side: SessionManager seed generation (#175) -----------------------
func test_session_manager_exists() -> void:
var sm = get_node_or_null("/root/SessionManager")
if sm == null:
push_warning("TestEntanglementSprint22: SessionManager autoload not found — skipped")
return
assert_that(sm).is_not_null()
func test_session_manager_new_game_generates_world_seed() -> void:
# #175: new_game() must generate and store world_seed in GameState.
# The seed is a non-zero u64 that will be sent to the server on startup.
var sm = get_node_or_null("/root/SessionManager")
if sm == null:
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
return
if not "world_seed" in GameState:
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
return
# Call new_game() (will create a save dir — acceptable in test environment)
var orig_seed = GameState.get("world_seed")
var orig_game_id: String = GameState.current_game_id
sm.new_game()
var generated_seed = GameState.get("world_seed")
# world_seed must have been set to a non-null, non-zero value
assert_bool(generated_seed != null).override_failure_message(
"SessionManager.new_game() must set GameState.world_seed (#175)"
).is_true()
if generated_seed != null:
assert_bool(generated_seed != 0).override_failure_message(
"Generated world_seed must be non-zero"
).is_true()
# Restore state
GameState.current_game_id = orig_game_id
GameState.world_seed = orig_seed
func test_session_manager_same_game_id_has_same_seed() -> void:
# Resuming a session must restore the original world_seed (not generate a new one).
# This ensures deterministic replays work correctly (D-010).
var sm = get_node_or_null("/root/SessionManager")
if sm == null:
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
return
if not sm.has_method("resume_game"):
push_warning("TestEntanglementSprint22: resume_game() missing — skipped")
return
if not "world_seed" in GameState:
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
return
# Set a known seed and game_id, then resume — seed must not be clobbered
GameState.world_seed = 12345678
var orig_game_id: String = GameState.current_game_id
sm.resume_game("20260228-120000-abc123")
# resume_game() must NOT overwrite world_seed
assert_int(GameState.world_seed).override_failure_message(
"resume_game() must not overwrite world_seed — seed is loaded from the save, not regenerated"
).is_equal(12345678)
GameState.current_game_id = orig_game_id
# -- IPC startup message: world_seed field (#175) ----------------------------
func test_protocol_encode_startup_message_has_world_seed_field() -> void:
# #175 acceptance: startup IPC message must carry "world_seed" key.
# Verifies Protocol.encode_startup_message encodes the seed so the server
# can deserialize it as StartupMessage { world_seed: u64 }.
var seed: int = 0xDEADBEEF # 3735928559 — fits in u32, safely maps to Rust u64
var bytes: PackedByteArray = Protocol.encode_startup_message(seed)
assert_bool(bytes.size() > 0).override_failure_message(
"Protocol.encode_startup_message must return non-empty bytes"
).is_true()
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status).override_failure_message(
"encode_startup_message output must be valid msgpack: %s" % str(decoded.status)
).is_null()
var msg = decoded.value
assert_bool(msg is Dictionary and msg.has("world_seed")).override_failure_message(
"StartupMessage wire payload must contain 'world_seed' key, got: %s" % str(msg)
).is_true()
assert_int(msg["world_seed"]).override_failure_message(
"world_seed must round-trip through msgpack unchanged"
).is_equal(seed)
func test_protocol_encode_startup_message_zero_seed() -> void:
# Edge case: seed=0 must still encode a valid payload (world_seed: 0).
var bytes: PackedByteArray = Protocol.encode_startup_message(0)
assert_bool(bytes.size() > 0).is_true()
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status).is_null()
assert_int(decoded.value["world_seed"]).is_equal(0)
func test_sim_bridge_can_send_world_seed_in_startup() -> void:
# #175 acceptance: "startup IPC message carries a world_seed field"
# The client must be able to include world_seed in the session startup payload.
# Test-first: verify the API exists (method or field), else warn-and-skip.
var sim_bridge = get_node_or_null("/root/SimBridge")
if sim_bridge == null:
push_warning("TestEntanglementSprint22: SimBridge not found — skipped")
return
# Option A: SimBridge has a world_seed property that is sent during startup
if "world_seed" in sim_bridge:
sim_bridge.world_seed = 99999
assert_int(sim_bridge.world_seed).is_equal(99999)
sim_bridge.world_seed = 0
return
# Option B: SimBridge has a set_world_seed() method
if sim_bridge.has_method("set_world_seed"):
# Method exists — this is the expected API
sim_bridge.set_world_seed(99999)
return
# Neither found — test-first stub
push_warning(
"TestEntanglementSprint22: SimBridge has no world_seed field or set_world_seed() — " +
"test-first stub awaiting #175 implementation"
)
# -- Protocol: world_seed flows from client to server (#175) ------------------
func test_apply_snapshot_does_not_clobber_world_seed() -> void:
# world_seed is set at session start and must persist across all subsequent snapshots.
# Snapshots must not overwrite or clear the world_seed that was set at startup.
if not "world_seed" in GameState:
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
return
GameState.world_seed = 42000
GameState.apply_snapshot({"tick": 5, "visible_tiles": []})
assert_int(GameState.world_seed).override_failure_message(
"apply_snapshot() must not clear or overwrite world_seed — seed is set once at session start"
).is_equal(42000)
GameState.world_seed = null
# -- Seed variation property (#178, informational — full test is Rust-side) ---
func test_different_seeds_produce_different_configs_informational() -> void:
# D-029: "entanglement rate varies per seed to prevent metagaming calibration"
# The definitive acceptance test for this is Rust-side (server/src/content/entanglement.rs):
# - EntanglementConfig::from_rng(seed_A) == EntanglementConfig::from_rng(seed_A) [deterministic]
# - EntanglementConfig::from_rng(seed_A) != EntanglementConfig::from_rng(seed_B) [variable, >=90%]
#
# This test only verifies the client side: world_seed is a u64 large enough to
# have sufficient entropy. A 24-bit game_id hex component alone has 16M combinations;
# the full u64 seed provides 2^64 possibilities.
#
# We verify that two calls to new_game() produce different seeds.
var sm = get_node_or_null("/root/SessionManager")
if sm == null:
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
return
if not "world_seed" in GameState:
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
return
var orig_game_id: String = GameState.current_game_id
sm.new_game()
var seed_a = GameState.get("world_seed")
sm.new_game()
var seed_b = GameState.get("world_seed")
if seed_a == null or seed_b == null:
push_warning("TestEntanglementSprint22: new_game() did not set world_seed — test-first stub")
GameState.current_game_id = orig_game_id
return
# Two different sessions should produce different seeds
assert_bool(seed_a != seed_b).override_failure_message(
"Two calls to new_game() must produce different world_seeds (D-029 anti-metagaming)"
).is_true()
GameState.current_game_id = orig_game_id
@@ -1 +0,0 @@
uid://c1dnlbnxtgqqo
@@ -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()
+303 -560
View File
@@ -1,652 +1,395 @@
## Sprint 22 — Fog system acceptance tests (#569)
## 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
##
## Validates FogState data management against the Sprint 22 acceptance criteria:
## - Explored tiles never revert to unexplored black (EXP_EXPLORED persistence)
## - Bounds grow-only invariant (explored tiles behind player stay in texture)
## - All visible tiles written as Forward (server simplified to Forward-only)
## - Exploration data survives texture resize (grow-only bounds copy)
## - Shader file present with correct fog_alpha constant
## Coverage: EXP_EXPLORED persistence, grow-only bounds invariant,
## texture-resize copy, BoundaryWall handling.
##
## Spec: D-059 (fog shader), D-015 (vision cone), D-066 (dual-scale grid, 6-8 tile gradient)
## Ticket: #569
## 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 (awaiting #569)")
push_warning("TestFogSprint22: FogState autoload not found — test skipped")
return node
func before_test() -> void:
func _reset_fog_state(fog_state: Node) -> void:
GameState.visible_positions.clear()
GameState.boundary_positions.clear()
GameState.visible_tiles.clear()
GameState.visibility_sectors.clear()
GameState.boundary_positions.clear()
# 32x32 is an arbitrary test fixture size — not a production assumption.
fog_state._resize(Rect2i(0, 0, 32, 32))
func after_test() -> void:
GameState.visible_positions.clear()
GameState.visible_tiles.clear()
GameState.visibility_sectors.clear()
GameState.boundary_positions.clear()
# -- 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.
# -- Spec constants (D-059) ---------------------------------------------------
func test_exp_explored_constant_is_128() -> void:
# EXP_EXPLORED = 128 → shader reads this as ~0.502.
# smoothstep(0.0, 0.2, 0.502) = 1.0 → exp_fade fully applied.
# If EXP_EXPLORED were 0, explored tiles would render as solid unexplored black.
func test_exp_explored_persists_after_leaving_los() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_int(fog_state.EXP_EXPLORED).override_failure_message(
"EXP_EXPLORED must be 128 — shader exp_fade requires explored value > 0.2 to avoid unexplored-black rendering"
).is_equal(128)
_reset_fog_state(fog_state)
func test_exp_unexplored_constant_is_0() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_int(fog_state.EXP_UNEXPLORED).is_equal(0)
func test_exp_visible_constant_is_255() -> void:
# EXP_VISIBLE = 255 → shader reads 1.0, full art visibility (currently in LOS)
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_int(fog_state.EXP_VISIBLE).is_equal(255)
func test_vis_forward_constant_is_255() -> void:
# D-059: VIS_FORWARD = 255 → clear vision, nearly transparent fog overlay
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_int(fog_state.VIS_FORWARD).is_equal(255)
func test_vis_hidden_constant_is_0() -> void:
# D-059: VIS_HIDDEN = 0 → no vision, fog fully opaque
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_int(fog_state.VIS_HIDDEN).is_equal(0)
func test_unexplored_color_spec_value() -> void:
# D-059: Unexplored = solid near-black #12141a
# Verify the hex value decodes to the expected channel values.
var c := Color("#12141a")
assert_float(c.r).is_equal_approx(18.0 / 255.0, 0.003)
assert_float(c.g).is_equal_approx(20.0 / 255.0, 0.003)
assert_float(c.b).is_equal_approx(26.0 / 255.0, 0.003)
# Sanity: it IS very dark (all channels < 0.12)
assert_float(c.r).is_less(0.12)
assert_float(c.g).is_less(0.12)
assert_float(c.b).is_less(0.12)
# -- Acceptance: explored tiles persist after leaving LOS (criterion 3) ------
func test_explored_tile_becomes_exp_explored_after_leaving_los() -> void:
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
# When tile (5,5) was in LOS (frame 1) and then leaves LOS (frame 2),
# its exploration byte must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
push_warning("TestFogSprint22: update_from_state missing — skipped")
return
# Frame 1: tile (5,5) is visible
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
# Tick 1: tile (2,2) is in LOS → must become EXP_VISIBLE
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
# Frame 2: tile (5,5) leaves LOS
GameState.visible_positions.clear()
GameState.visible_tiles = []
fog_state.update_from_state()
# Internal state check: _exp_bytes[tile(5,5)] must be EXP_EXPLORED (128)
var exp_bytes = fog_state.get("_exp_bytes")
if exp_bytes == null:
push_warning("TestFogSprint22: _exp_bytes not accessible — data path untestable headlessly")
return
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
push_warning("TestFogSprint22: _width inaccessible — data path untestable")
return
var px := 5 - ox
var py := 5 - oy
if px < 0 or py < 0 or px >= w:
push_warning("TestFogSprint22: tile (5,5) out of bounds after update — check grow_bounds margin")
return
var idx := py * w + px
if idx < 0 or idx >= exp_bytes.size():
push_warning("TestFogSprint22: idx %d out of exp_bytes range %d" % [idx, exp_bytes.size()])
return
assert_int(exp_bytes[idx]).override_failure_message(
"Tile (5,5) must be EXP_EXPLORED=128 after leaving LOS — not EXP_UNEXPLORED=0 (#569 regression)"
).is_equal(fog_state.EXP_EXPLORED)
func test_explored_tile_is_exp_visible_while_in_los() -> void:
# While in LOS, tile exploration byte must be EXP_VISIBLE (255)
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
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}
GameState.visible_tiles = [{"x": 3, "y": 3, "z": 0, "visibility": "Forward"}]
fog_state.update_from_state()
var exp_bytes = fog_state.get("_exp_bytes")
if exp_bytes == null:
return
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
var px := 3 - ox
var py := 3 - oy
if px < 0 or py < 0 or px >= w:
return
var idx := py * w + px
if idx >= 0 and idx < exp_bytes.size():
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_VISIBLE)
# 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_unexplored_tile_stays_exp_unexplored() -> void:
# Tile (7, 8) was never seen — must remain EXP_UNEXPLORED (0)
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
if not fog_state.has_method("update_from_state"):
return
# See only (5, 5) — tile (7, 8) is not in LOS
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
_reset_fog_state(fog_state)
# Tile (5,5) never enters LOS
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
var exp_bytes = fog_state.get("_exp_bytes")
if exp_bytes == null:
return
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
var px := 7 - ox
var py := 8 - oy
if px < 0 or py < 0 or px >= w:
return
var idx := py * w + px
if idx >= 0 and idx < exp_bytes.size():
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_UNEXPLORED)
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)
# -- Acceptance: bounds grow-only invariant ------------------------------------
func test_bounds_never_shrink() -> void:
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
# Requires grow-only bounds: once a tile is in the texture, it stays there.
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
if not fog_state.has_method("update_from_state"):
_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
# Frame 1: see (10, 10) → establishes initial bounds
GameState.visible_positions = {Vector2i(10, 10): true}
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
fog_state.update_from_state()
var b1: Rect2i = fog_state.map_bounds
_reset_fog_state(fog_state)
# Frame 2: see (30, 30) → bounds must expand to include both
# 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}
GameState.visible_tiles = [{"x": 30, "y": 30, "z": 0, "visibility": "Forward"}]
fog_state.update_from_state()
var b2: Rect2i = fog_state.map_bounds
# Frame 3: back to (10, 10) → bounds must NOT shrink
GameState.visible_positions = {Vector2i(10, 10): true}
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
fog_state.update_from_state()
var b3: Rect2i = fog_state.map_bounds
assert_bool(b2.size.x >= b1.size.x).override_failure_message(
"Bounds must grow when player moves to larger region"
).is_true()
assert_bool(b2.size.y >= b1.size.y).is_true()
assert_bool(b3.size.x >= b2.size.x).override_failure_message(
"Bounds must not shrink when player returns to previous position (grow-only invariant)"
).is_true()
assert_bool(b3.size.y >= b2.size.y).is_true()
func test_bounds_include_margin_for_gradient_bleed() -> void:
# D-066: 6-8 tile gradient at cone edge requires texture margin.
# _grow_bounds adds 8-tile margin on each side (accommodates 7x7 Gaussian
# kernel at 2-texel intervals = ±6 tile reach). After seeing (10,10),
# bounds should extend at least 4 tiles beyond the visible tile.
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
GameState.visible_positions = {Vector2i(10, 10): true}
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
fog_state.update_from_state()
var b: Rect2i = fog_state.map_bounds
# With 4-tile margin: bounds.position.x <= 10 - 4 = 6
assert_bool(b.position.x <= 6).override_failure_message(
"FogState bounds must include 4-tile margin for gradient bleed (D-066 gradient spec)"
).is_true()
assert_bool(b.position.y <= 6).is_true()
# -- Acceptance: Forward-only visibility (Sprint 22 server simplification) ----
func test_visible_tiles_written_as_vis_forward() -> void:
# Sprint 22: server sends only Forward tiles (Peripheral sector removed).
# FogState writes VIS_FORWARD (255) for all tiles in visible_positions.
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
GameState.visible_tiles = [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
{"x": 6, "y": 5, "z": 0, "visibility": "Forward"},
]
fog_state.update_from_state()
var vis_bytes = fog_state.get("_vis_bytes")
if vis_bytes == null:
return
# 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.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
for pos in [Vector2i(5, 5), Vector2i(6, 5)]:
var px := pos.x - ox
var py := pos.y - oy
if px < 0 or py < 0 or px >= w:
continue
var idx := py * w + px
if idx >= 0 and idx < vis_bytes.size():
assert_int(vis_bytes[idx]).override_failure_message(
"All visible tiles should be VIS_FORWARD=255 — server is Forward-only in Sprint 22"
).is_equal(fog_state.VIS_FORWARD)
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)
func test_tiles_outside_los_written_as_vis_hidden() -> void:
# Tiles in bounds but not in visible_positions must be VIS_HIDDEN (0)
# -- 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
if not fog_state.has_method("update_from_state"):
return
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
fog_state.update_from_state()
# (5, 7) is inside the padded bounds but not visible — must be VIS_HIDDEN
var vis_bytes = fog_state.get("_vis_bytes")
if vis_bytes == null:
return
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
var px := 5 - ox
var py := 7 - oy
if px >= 0 and py >= 0 and px < w:
var idx := py * w + px
if idx >= 0 and idx < vis_bytes.size():
assert_int(vis_bytes[idx]).is_equal(fog_state.VIS_HIDDEN)
# -- Acceptance: exploration survives texture resize --------------------------
func test_exploration_data_preserved_across_bounds_growth() -> void:
# D-059: Texture resize must copy old exploration bytes into new texture.
# Without this, tiles seen before a resize appear as EXP_UNEXPLORED (black).
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# Frame 1: see (5, 5), then leave
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
fog_state.update_from_state()
GameState.visible_positions.clear()
GameState.visible_tiles = []
fog_state.update_from_state() # (5,5) → EXP_EXPLORED
# Frame 2: move far away — forces bounds growth (resize)
GameState.visible_positions = {Vector2i(80, 80): true}
GameState.visible_tiles = [{"x": 80, "y": 80, "z": 0, "visibility": "Forward"}]
fog_state.update_from_state()
# (5,5) must still be EXP_EXPLORED after the resize
var exp_bytes = fog_state.get("_exp_bytes")
if exp_bytes == null:
return
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
var px := 5 - ox
var py := 5 - oy
if px < 0 or py < 0 or px >= w:
push_warning("TestFogSprint22: (5,5) not in bounds after resize — is copy-on-resize working?")
return
var idx := py * w + px
if idx >= 0 and idx < exp_bytes.size():
assert_int(exp_bytes[idx]).override_failure_message(
"Exploration data at (5,5) must survive bounds growth — EXP_EXPLORED (128) expected after resize"
).is_greater_equal(fog_state.EXP_EXPLORED)
# -- Shader file checks (D-059) -----------------------------------------------
func test_fog_gdshader_exists() -> void:
assert_bool(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
"fog.gdshader must exist — fog rendering requires this shader file (#569)"
).is_true()
func test_fog_shader_defines_fog_alpha() -> void:
# D-059: explored fog overlay must be ~25-30% opacity.
# fog_alpha constant controls this. Verify the shader defines it.
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
push_warning("TestFogSprint22: fog.gdshader not found — shader check skipped")
return
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
if source.is_empty():
push_warning("TestFogSprint22: fog.gdshader is empty or unreadable")
return
assert_bool(source.contains("fog_alpha")).override_failure_message(
"fog.gdshader must define fog_alpha for the 25-30%% explored-tile overlay (D-059)"
).is_true()
func test_fog_shader_defines_smoothstep_clarity_ramp() -> void:
# D-059/D-066: smooth gradient requires a clarity ramp (smoothstep).
# The blurred visibility → clarity ramp must use smoothstep for smooth gradients.
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
return
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
if source.is_empty():
return
assert_bool(source.contains("smoothstep")).override_failure_message(
"fog.gdshader must use smoothstep for the clarity ramp — hard steps violate D-066 gradient spec"
).is_true()
func test_fog_shader_defines_unexplored_color() -> void:
# D-059: unexplored = solid near-black #12141a.
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
return
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
if source.is_empty():
return
assert_bool(source.contains("UNEXPLORED_COLOR")).override_failure_message(
"fog.gdshader must define UNEXPLORED_COLOR constant (D-059 #12141a spec)"
).is_true()
func test_fog_shader_uses_gaussian_blur_for_gradient() -> void:
# D-066: 6-8 tile soft gradient requires Gaussian blur on visibility texture.
# Current implementation: 7x7 kernel at 2-texel intervals (±6 tiles), sigma 2.0
# in kernel space = 4.0 tiles effective. At 2-sigma (8 tiles), weight drops to 0.14.
# This covers the D-066 "6-8 tile" gradient spec.
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
return
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
if source.is_empty():
return
# 7x7 Gaussian uses dy from -3 to 3
assert_bool(source.contains("sample_visibility")).override_failure_message(
"fog.gdshader must call sample_visibility() for Gaussian-blurred visibility (D-066 gradient)"
).is_true()
assert_bool(source.contains("-3.0")).override_failure_message(
"fog.gdshader sample_visibility must use 7x7 kernel (±3 tiles) for 6-tile gradient coverage (D-066)"
).is_true()
# -- Regression: GameState visible_positions (existing contract) ---------------
func test_visible_positions_derived_from_visible_tiles_in_server_mode() -> void:
# D-020: In real server mode, visible_positions derives from visible_tiles.
# Fog rendering depends on this derivation being correct.
GameState.apply_snapshot({
"tick": 10,
"visible_tiles": [
{"x": 7, "y": 7, "z": 0, "visibility": "Forward"},
{"x": 8, "y": 7, "z": 0, "visibility": "Forward"},
],
})
assert_bool(GameState.visible_positions.has(Vector2i(7, 7))).override_failure_message(
"visible_positions must be derived from visible_tiles when no explicit visible_positions key"
).is_true()
assert_bool(GameState.visible_positions.has(Vector2i(8, 7))).is_true()
func test_visibility_sectors_populated_forward_only() -> void:
# D-015: visibility_sectors must be populated from visible_tiles.
# In Forward-only mode, all sectors are "Forward".
GameState.apply_snapshot({
"tick": 11,
"visible_tiles": [
{"x": 4, "y": 4, "z": 0, "visibility": "Forward"},
],
})
assert_bool(GameState.visibility_sectors.has(Vector2i(4, 4))).is_true()
assert_str(GameState.visibility_sectors[Vector2i(4, 4)]).is_equal("Forward")
func test_visible_positions_cleared_on_new_snapshot() -> void:
# Old positions from tick N must not persist to tick N+1
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}],
})
assert_int(GameState.visible_positions.size()).is_equal(1)
GameState.apply_snapshot({
"tick": 2,
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
})
assert_bool(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
"Old visible positions must be cleared when new visible_tiles arrive"
).is_false()
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
# -- Sprint 23: BoundaryWall handling (#585) ----------------------------------
func test_boundary_positions_populated_from_snapshot() -> void:
# #585: BoundaryWall tiles go to boundary_positions (not visible_positions).
# Fog lifts for boundary wall tiles so wall content composites correctly.
GameState.apply_snapshot({
"tick": 20,
"visible_tiles": [
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
],
})
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message(
"Forward tile must be in visible_positions"
).is_true()
assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message(
"BoundaryWall tile must NOT be in visible_positions (#585)"
).is_false()
assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message(
"BoundaryWall tile must be in boundary_positions (#585)"
).is_true()
func test_boundary_wall_vis_forward_not_exp_visible() -> void:
# #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE.
# They render through fog but are not stored as exploration memory.
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
_reset_fog_state(fog_state)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
GameState.visible_tiles = [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
]
fog_state.update_from_state()
var vis_bytes = fog_state.get("_vis_bytes")
var exp_bytes = fog_state.get("_exp_bytes")
if vis_bytes == null or exp_bytes == null:
push_warning("TestFogSprint22: byte arrays not accessible — skipped")
return
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
var px := 6 - ox
var py := 5 - oy
if px < 0 or py < 0 or px >= w:
push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped")
return
var idx := py * w + px
if idx < 0 or idx >= vis_bytes.size():
return
assert_int(vis_bytes[idx]).override_failure_message(
"BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)"
).is_equal(fog_state.VIS_FORWARD)
assert_int(exp_bytes[idx]).override_failure_message(
"BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)"
).is_not_equal(fog_state.EXP_VISIBLE)
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_stays_unexplored_after_leaving_los() -> void:
# #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED.
# Normal LOS tiles decay to EXP_EXPLORED when they leave LOS.
# Boundary tiles must stay EXP_UNEXPLORED — they were never explored.
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
if not fog_state.has_method("update_from_state"):
return
# Frame 1: BoundaryWall at (6,5) is visible
_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}
GameState.visible_tiles = [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
]
fog_state.update_from_state()
# Frame 2: both leave LOS
GameState.visible_positions.clear()
# Tick 2: player moves away; (6,5) is no longer a boundary wall
GameState.visible_positions = {Vector2i(20, 20): true}
GameState.boundary_positions.clear()
GameState.visible_tiles = []
fog_state.update_from_state()
var exp_bytes = fog_state.get("_exp_bytes")
if exp_bytes == null:
return
# (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.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
var px := 6 - ox
var py := 5 - oy
if px >= 0 and py >= 0 and px < w:
var idx := py * w + px
if idx >= 0 and idx < exp_bytes.size():
assert_int(exp_bytes[idx]).override_failure_message(
"BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)"
).is_equal(fog_state.EXP_UNEXPLORED)
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_boundary_wall_cleared_on_new_snapshot() -> void:
# #585: boundary_positions must be cleared each tick — old walls must not persist.
# BoundaryWall positions shift as the player moves; stale positions would lift fog
# where no wall exists.
GameState.apply_snapshot({
"tick": 30,
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
],
})
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
GameState.apply_snapshot({
"tick": 31,
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
],
})
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message(
"Stale BoundaryWall position must be cleared on next snapshot (#585)"
).is_false()
# -- Performance (D-059) -------------------------------------------------------
func test_fog_state_update_under_2ms_for_400_tiles() -> void:
# D-059: <1ms/frame CPU budget for fog update. Allow 2x margin for test env.
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
if not fog_state.has_method("update_from_state"):
_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
var positions: Dictionary = {}
var tiles: Array = []
for x in range(20):
for y in range(20):
positions[Vector2i(x, y)] = true
tiles.append({"x": x, "y": y, "z": 0, "visibility": "Forward"})
GameState.visible_positions = positions
GameState.visible_tiles = tiles
_reset_fog_state(fog_state)
var start := Time.get_ticks_usec()
# 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()
var elapsed_ms := (Time.get_ticks_usec() - start) / 1000.0
assert_float(elapsed_ms).override_failure_message(
"FogState.update_from_state() must complete in <2ms for 400 tiles (spec: <1ms D-059)"
).is_less(2.0)
# 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)
-1
View File
@@ -1 +0,0 @@
uid://bxhgo1e4rvfmi
+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 = ""
+2 -2
View File
@@ -15,7 +15,7 @@ const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
var _gauntlet_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
@@ -36,7 +36,7 @@ var _gauntlet_snapshot := {
var _normal_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
-3
View File
@@ -160,8 +160,6 @@ func test_interact_roundtrip() -> void:
# Server accepts it (currently a no-op) and responds with a valid snapshot.
var snapshot: Dictionary = await _send_and_receive("Interact", 0)
# Snapshot should be valid with correct protocol version
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snapshot.entities.size()).is_greater(0)
# Player should be at start position (Interact doesn't move)
@@ -173,7 +171,6 @@ func test_interact_roundtrip() -> void:
# Send Interact again at next tick — server should still accept it
var snap2: Dictionary = await _send_and_receive("Interact", 1)
assert_that(snap2).is_not_null()
assert_that(snap2.version).is_equal(Protocol.PROTOCOL_VERSION)
# -- Mixed sequence: movement then interact in one session ---------------------
+2 -2
View File
@@ -305,7 +305,7 @@ func test_contradicted_entity_verbs_decode() -> void:
# #422: NearbyInteraction.contradicted=true should be decodeable
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"nearby_interactions": [{
"entity_id": 2,
@@ -329,7 +329,7 @@ func test_object_type_verbs_decode() -> void:
# #421: ObjectType appears in NearbyInteraction.object_type
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"nearby_interactions": [{
"entity_id": 3,
+6 -23
View File
@@ -11,7 +11,7 @@ extends GdUnitTestSuite
func test_protocol_decode_v4_with_nearby_interactions() -> void:
var raw := {
"tick": 10,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
@@ -46,7 +46,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void:
func test_protocol_decode_v4_no_nearby_interactions() -> void:
var raw := {
"tick": 5,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -57,7 +57,7 @@ func test_protocol_decode_v4_no_nearby_interactions() -> void:
func test_protocol_decode_empty_nearby_interactions() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"nearby_interactions": [],
}
@@ -68,7 +68,7 @@ func test_protocol_decode_empty_nearby_interactions() -> void:
func test_protocol_decode_interaction_missing_verbs() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"nearby_interactions": [{"entity_id": 2}],
}
@@ -79,7 +79,7 @@ func test_protocol_decode_interaction_missing_verbs() -> void:
func test_protocol_decode_interaction_empty_verbs() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"nearby_interactions": [{"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": []}],
}
@@ -90,7 +90,7 @@ func test_protocol_decode_interaction_empty_verbs() -> void:
func test_protocol_decode_v4_entity_relationship() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
@@ -100,17 +100,6 @@ func test_protocol_decode_v4_entity_relationship() -> void:
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.entities[0].relationship).is_equal("Friendly")
func test_protocol_rejects_version_mismatch() -> void:
var raw := {
"tick": 5,
"version": 2,
"entities": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_null()
# -- GameState: nearby_interactions storage --
func test_game_state_stores_nearby_interactions() -> void:
@@ -153,12 +142,6 @@ func test_sim_bridge_test_snapshot_interaction_at_range_2() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.nearby_interactions.size()).is_equal(1)
func test_sim_bridge_test_snapshot_protocol_version() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
# -- InteractionPrompt UI --
func test_prompt_get_selected_verb_returns_first_kind() -> void:
-7
View File
@@ -19,13 +19,6 @@ func _load_fixture(name: String) -> PackedByteArray:
# -- snapshot_minimal ----------------------------------------------------------
func test_fixture_snapshot_minimal_version() -> void:
var bytes = _load_fixture("snapshot_minimal")
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
func test_fixture_snapshot_minimal_tick() -> void:
var bytes = _load_fixture("snapshot_minimal")
var snapshot = Protocol.decode_snapshot(bytes)
-601
View File
@@ -1,601 +0,0 @@
## Sprint 18 — Knowledge/journal display (#264)
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
## D-042 (UIStrings for all labels)
##
## Tests now run against live Stig implementation.
## Wire format per game_state.gd v14:
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
##
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
## The server sends KG updates only when the graph changes — absence = no change.
## Contrast with current_examine_result which DOES clear each snapshot.
class_name TestJournalSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
func _make_journal_panel() -> Control:
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
return null
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
add_child(node)
return node
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
var base: Dictionary = {
"entity_id": 42,
"name": "Kael Davan",
"confidence": "KnowsOf",
"source": "DirectObservation",
"state": "Active",
"relationship": "PersonOfInterest",
"last_observed_tick": 1024,
}
base.merge(overrides, true)
return base
func _make_player_knowledge(entities: Array = []) -> Dictionary:
if entities.is_empty():
entities = [_make_kg_entity()]
return {"entities": entities}
func _entries_container(panel: Control) -> Node:
return panel.get_node_or_null(
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.player_knowledge = null
GameState.current_dialogue = null
GameState.dialogue_active = false
GameState.current_tick = 0
func after_test() -> void:
GameState.player_knowledge = null
GameState.current_dialogue = null
GameState.dialogue_active = false
# ---------------------------------------------------------------------------
# GameState: player_knowledge snapshot parsing
# ---------------------------------------------------------------------------
func test_gamestate_player_knowledge_field_exists() -> void:
## GameState must have player_knowledge field (v14, #264).
assert_bool(GameState.has("player_knowledge")).override_failure_message(
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
).is_true()
func test_gamestate_player_knowledge_null_by_default() -> void:
GameState.player_knowledge = null
assert_that(GameState.player_knowledge).is_null()
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
GameState.apply_snapshot({
"tick": 10,
"player_knowledge": _make_player_knowledge(),
})
assert_that(GameState.player_knowledge).is_not_null()
assert_bool(GameState.player_knowledge.has("entities")).is_true()
func test_gamestate_player_knowledge_persists_when_absent() -> void:
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
## Server sends KG updates only on change — absence means "no change since last tick".
## This is intentional behavior (journal should not flash empty every tick).
GameState.player_knowledge = _make_player_knowledge()
GameState.apply_snapshot({"tick": 11})
assert_that(GameState.player_knowledge).is_not_null()
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
## Malformed player_knowledge (non-dict) must be rejected.
## First set a valid value, then try to overwrite with invalid
GameState.player_knowledge = _make_player_knowledge()
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
# Non-dict is rejected — previous value preserved (or null if first time)
# The implementation only updates on Dictionary type, so value persists
assert_that(GameState.player_knowledge).is_not_null()
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
var entities := [
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
]
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
assert_int(parsed_entities.size()).is_equal(2)
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
## When server sends a new player_knowledge, it replaces the previous value.
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
_make_kg_entity({"name": "Person A"}),
])})
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
_make_kg_entity({"name": "Person A"}),
_make_kg_entity({"name": "Person B", "entity_id": 99}),
])})
var entities: Array = GameState.player_knowledge.get("entities", [])
assert_int(entities.size()).is_equal(2)
# ---------------------------------------------------------------------------
# Journal panel: scene and API
# ---------------------------------------------------------------------------
func test_journal_panel_scene_exists() -> void:
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
"Journal panel scene must exist at res://ui/journal_panel.tscn"
).is_true()
func test_journal_panel_instantiates_without_crash() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_that(panel).is_not_null()
panel.queue_free()
func test_journal_panel_has_toggle_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("toggle")).override_failure_message(
"JournalPanel must have toggle() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_close_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("close")).override_failure_message(
"JournalPanel must have close() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_is_open_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("is_open")).override_failure_message(
"JournalPanel must have is_open() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_update_from_state_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("update_from_state")).override_failure_message(
"JournalPanel must have update_from_state() method (called from main.gd)"
).is_true()
panel.queue_free()
func test_journal_panel_closed_on_init() -> void:
## Panel starts hidden — not open by default.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.is_open()).override_failure_message(
"JournalPanel must be closed on _ready()"
).is_false()
panel.queue_free()
func test_journal_panel_toggle_opens() -> void:
## First toggle() opens the panel.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle()
assert_bool(panel.is_open()).override_failure_message(
"toggle() must set is_open() = true"
).is_true()
panel.queue_free()
func test_journal_panel_toggle_closes() -> void:
## Second toggle() closes the panel.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open
panel.toggle() # close
assert_bool(panel.is_open()).override_failure_message(
"Second toggle() must close the panel"
).is_false()
panel.queue_free()
func test_journal_panel_close_when_already_closed_is_safe() -> void:
## close() on an already-closed panel must not crash.
var panel := _make_journal_panel()
if panel == null: return
panel.close()
assert_bool(panel.is_open()).is_false()
panel.queue_free()
# ---------------------------------------------------------------------------
# Journal panel: entry rendering
# ---------------------------------------------------------------------------
func test_journal_panel_entries_container_exists() -> void:
## EntriesContainer is the VBoxContainer that holds entity entries.
var panel := _make_journal_panel()
if panel == null: return
var container := _entries_container(panel)
assert_that(container != null).override_failure_message(
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
).is_true()
panel.queue_free()
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Kael Davan"}),
])
panel.toggle() # calls _show_panel() -> _rebuild_entries()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
assert_int(container.get_child_count()).override_failure_message(
"EntriesContainer must have children when player_knowledge is populated"
).is_greater(0)
panel.queue_free()
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
## Empty state Label is shown when player_knowledge is null.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = null
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## Empty state = exactly 1 child (the "Nothing logged yet." label)
assert_int(container.get_child_count()).override_failure_message(
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
).is_equal(1)
panel.queue_free()
func test_journal_panel_two_entities_create_more_entries() -> void:
## Two entities create more entries than one (header + detail each, plus spacers).
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
assert_int(container.get_child_count()).override_failure_message(
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
).is_greater_equal(6)
panel.queue_free()
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
## D-041: Contradicted entities must have strikethrough in their header BBCode.
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## First child should be the header RichTextLabel with [s]...[/s]
if container.get_child_count() == 0:
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
panel.queue_free(); return
var first_child := container.get_child(0)
if first_child is RichTextLabel:
assert_that(first_child.text).override_failure_message(
"Contradicted entity header must contain [s] (strikethrough) BBCode"
).contains("[s]")
panel.queue_free()
func test_journal_panel_active_entity_no_strikethrough() -> void:
## Active entity must NOT have strikethrough in its header.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
if container.get_child_count() == 0:
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
panel.queue_free(); return
var first_child := container.get_child(0)
if first_child is RichTextLabel:
assert_bool(first_child.text.contains("[s]")).override_failure_message(
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
).is_false()
panel.queue_free()
# ---------------------------------------------------------------------------
# Journal panel: mutual exclusion with dialogue
# ---------------------------------------------------------------------------
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
## Sprint briefing: journal must close when dialogue opens.
## update_from_state() is called from main.gd on each snapshot.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open journal
assert_bool(panel.is_open()).is_true()
GameState.dialogue_active = true
panel.update_from_state()
assert_bool(panel.is_open()).override_failure_message(
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
).is_false()
panel.queue_free()
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
## update_from_state() must NOT close journal when dialogue is not active.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open journal
GameState.dialogue_active = false
panel.update_from_state()
assert_bool(panel.is_open()).override_failure_message(
"Journal must stay open when dialogue is inactive"
).is_true()
panel.queue_free()
# ---------------------------------------------------------------------------
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
# ---------------------------------------------------------------------------
func test_ui_strings_confidence_direct_exists() -> void:
## D-042: confidence label for "Direct" tier must be in UIStrings.
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
).is_true()
func test_ui_strings_confidence_knowsdetails_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
).is_true()
func test_ui_strings_confidence_knowsof_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
).is_true()
func test_ui_strings_confidence_suspects_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
).is_true()
func test_ui_strings_all_confidence_keys_non_empty() -> void:
## All four confidence label values must be non-empty strings.
var keys := [
"knowledge_panel.confidence_direct",
"knowledge_panel.confidence_knowsdetails",
"knowledge_panel.confidence_knowsof",
"knowledge_panel.confidence_suspects",
]
for key in keys:
if not UIStrings.has_key(key): continue
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
"UIStrings key '%s' must be non-empty" % key
).is_true()
func test_ui_strings_source_directobservation_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
).is_true()
func test_ui_strings_source_toldby_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
).is_true()
func test_ui_strings_source_heard_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
).is_true()
# ---------------------------------------------------------------------------
# Journal panel: _state_color() contract
# ---------------------------------------------------------------------------
func test_state_color_contradicted_is_amber() -> void:
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Contradicted")
assert_that(color).override_failure_message(
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
).is_equal(Constants.ENTITY_COLOR_POI)
panel.queue_free()
func test_state_color_stale_is_dimmed() -> void:
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Stale")
assert_that(color).override_failure_message(
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
).is_equal(Constants.IMPLANT_TEXT_DIM)
panel.queue_free()
func test_state_color_active_is_normal() -> void:
## Active → normal insert text color (INSERT_COLOR_TEXT).
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Active")
assert_that(color).override_failure_message(
"_state_color('Active') must return INSERT_COLOR_TEXT"
).is_equal(Constants.INSERT_COLOR_TEXT)
panel.queue_free()
func test_state_color_contradicted_differs_from_active() -> void:
## Contradicted and Active must have visually distinct colors.
var panel := _make_journal_panel()
if panel == null: return
var contradicted := panel._state_color("Contradicted")
var active := panel._state_color("Active")
assert_that(contradicted).is_not_equal(active)
panel.queue_free()
func test_state_color_stale_differs_from_active() -> void:
## Stale and Active must have visually distinct colors.
var panel := _make_journal_panel()
if panel == null: return
var stale := panel._state_color("Stale")
var active := panel._state_color("Active")
assert_that(stale).is_not_equal(active)
panel.queue_free()
# ---------------------------------------------------------------------------
# UIStrings: knowledge_panel keys (D-042)
# ---------------------------------------------------------------------------
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
## Journal title uses knowledge_panel.tab_contacts.
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
).is_true()
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
## Empty state message uses knowledge_panel.empty_state.
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
).is_true()
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
if not UIStrings.has_key("knowledge_panel.empty_state"): return
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
# ---------------------------------------------------------------------------
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
## Regression guard: verify the dicts are gone and UIStrings fallback works.
# ---------------------------------------------------------------------------
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
## If this test fails, the hardcoded dict was accidentally re-introduced.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
).is_true()
panel.queue_free()
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
).is_true()
panel.queue_free()
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
## UIStrings falls back to the key string itself for missing keys.
## journal_panel.gd relies on this for graceful degradation.
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
assert_that(fallback).override_failure_message(
"UIStrings fallback must return the key string itself for unknown keys"
).is_equal("knowledge_panel.confidence_nonexistent_level")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
func test_canvas_insert_constant_is_10() -> void:
assert_int(Constants.CANVAS_INSERT).is_equal(10)
func test_journal_fade_constants_reasonable() -> void:
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
var panel := _make_journal_panel()
if panel == null: return
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
panel.queue_free()
@@ -1 +0,0 @@
uid://cao5jf5h36img
+1 -1
View File
@@ -95,7 +95,7 @@ func test_frame_encode_large_payload_length() -> void:
func test_framed_protocol_snapshot_roundtrip() -> void:
# Encode a snapshot with Protocol, frame it, decode the frame, decode the snapshot
var snapshot_data := {"tick": 42, "version": Protocol.PROTOCOL_VERSION, "entities": []}
var snapshot_data := {"tick": 42, "version": 23, "entities": []}
var encoded: Variant = Messagepack.encode(snapshot_data)
assert_that(encoded.status).is_null()
@@ -0,0 +1,330 @@
## Sprint 37 — Scene-level merge-path UI flow tests (#873)
##
## Four flows that cover the critical paths through the pre-game UI.
## These tests are the merge-gate mechanism added in the Sprint 36 retro:
## regressions like #872 (New Game hang) must be caught here, not in post-merge
## smoke tests.
##
## Pattern: load scene → simulate input via button.pressed.emit() or direct
## handler call → assert terminal state. No pixel diffing, no xdotool.
##
## NOTE: Tests that end in a scene transition (change_scene_to_file) assert state
## synchronously before the deferred transition fires. The test scene is
## queue_freed in after_test() regardless.
##
## Reference: test_character_creation_sprint28.gd
## Ticket: #873 | motivating regression: #872
class_name TestMergePathFlowsSprint37
extends GdUnitTestSuite
const MAIN_MENU_SCENE_PATH := "res://scenes/main_menu.tscn"
const CHAR_CREATE_SCENE_PATH := "res://scenes/character_creation.tscn"
var _scene = null # MainMenu or CharacterCreation — untyped, varies per test
func before_test() -> void:
# Reset all shared state that these flows touch
SimBridge.disconnect_from_sim()
SimBridge._last_snapshot = null
SimBridge._outbound_buffer.clear()
GameState.bookmark_catalog = []
GameState.pending_load_path = ""
# Clear MetaStack from any leftover overlays to prevent push/pop ordering issues
MetaStack._stack.clear()
func after_test() -> void:
if is_instance_valid(_scene):
_scene.queue_free()
_scene = null
SimBridge.disconnect_from_sim()
SimBridge._outbound_buffer.clear()
MetaStack._stack.clear()
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
func _load_main_menu() -> void:
var packed := load(MAIN_MENU_SCENE_PATH) as PackedScene
# Hard-fail on missing scene (PR #135 review T3): silent skip turns a broken
# merge-path test into a uselessly green one.
assert_that(packed).override_failure_message(
"main_menu.tscn missing — merge-path coverage is broken, not skipped"
).is_not_null()
_scene = packed.instantiate()
add_child(_scene)
func _load_char_create() -> void:
var packed := load(CHAR_CREATE_SCENE_PATH) as PackedScene
# Hard-fail on missing scene (PR #135 review T3): silent skip turns a broken
# merge-path test into a uselessly green one.
assert_that(packed).override_failure_message(
"character_creation.tscn missing — merge-path coverage is broken, not skipped"
).is_not_null()
_scene = packed.instantiate()
# Seed required state so Start is not disabled (guard added in PR #134 / R2-Hoshe-1).
# Individual tests override these as needed. add_child() must run first so
# _ready() populates @onready vars (_footer_start, etc.) that
# _update_start_btn_state() dereferences.
add_child(_scene)
_scene._selected_bookmark_id = "test-bookmark"
_scene._selected_location_id = "test-location"
if _scene.has_method("_update_start_btn_state"):
_scene._update_start_btn_state()
func _make_catalog_snapshot() -> Dictionary:
## Minimal valid snapshot with a bookmark_catalog for flow-1 testing.
## D-192: no version field required; decode accepts snapshots with or without.
return {
"tick": 0,
"entities": [],
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"visible_tiles": [],
"nearby_interactions": [],
"pending_recognitions": [],
"bookmark_catalog": {
"bookmarks": [
{
"id": "bm_tycoon_arion",
"title": "Arion Freight Broker",
"subtitle": "Start at Arion orbital",
"flavor": "Commodities and logistics.",
"default_location": "arion",
"allowed_locations": ["arion", "arion_low"],
"allowed_locations_cultures": ["arion"],
"career": "tycoon",
"starting_capital_tractus": 50000,
}
]
},
}
# =============================================================================
# Flow 1: main menu → new game → loading state → catalog received → resolved
# =============================================================================
# Regression guard for #872: New Game used to hang on "Connecting to simulation..."
# because bookmark_catalog was overwritten in receive_bytes before poll_snapshot consumed it.
# This test catches that regression by verifying the full state machine:
# pressed → loading visible → catalog signal → loading dismissed.
func test_new_game_shows_loading_screen() -> void:
## Pressing New Game must show the loading screen and set _waiting_for_catalog.
_load_main_menu()
if _scene == null:
return
assert_bool(_scene._waiting_for_catalog).override_failure_message(
"_waiting_for_catalog must be false before New Game is pressed"
).is_false()
# Press New Game via the button signal (same as real player input)
_scene._new_game_btn.pressed.emit()
assert_bool(_scene._waiting_for_catalog).override_failure_message(
"_waiting_for_catalog must be true after New Game pressed"
).is_true()
assert_that(_scene._loading_screen).override_failure_message(
"Loading screen instance must exist after New Game pressed"
).is_not_null()
assert_bool(_scene._loading_screen.visible).override_failure_message(
"Loading screen must be visible after New Game pressed"
).is_true()
func test_new_game_catalog_snapshot_resolves_loading_state() -> void:
## When snapshot_received fires with a bookmark_catalog, the loading state must clear.
## This is the exact regression introduced in #872 — if the catalog is never delivered,
## _waiting_for_catalog stays true and the screen hangs forever.
_load_main_menu()
if _scene == null:
return
# Simulate the New Game press to set up the signal subscription and loading state.
# In test mode, connect_to_sim() immediately fires CONNECTED, which triggers
# _on_sim_state_changed_for_new_game and connects snapshot_received.
_scene._new_game_btn.pressed.emit()
assert_bool(_scene._waiting_for_catalog).override_failure_message(
"Precondition: _waiting_for_catalog must be true before catalog arrives"
).is_true()
# Deliver the catalog via the signal path (same path the server uses in live mode).
# snapshot_received is emitted here directly because in test mode poll_snapshot()
# uses the harness snapshot (no catalog). The carry-forward fix (#872) ensures
# this signal path also works correctly in live mode when ticks batch.
var catalog_snapshot := _make_catalog_snapshot()
SimBridge.snapshot_received.emit(catalog_snapshot)
# Terminal state: loading resolved
assert_bool(_scene._waiting_for_catalog).override_failure_message(
"_waiting_for_catalog must be false after catalog snapshot delivered — #872 regression"
).is_false()
# PR #135 review H4: assert the SimBridge terminus, not just the loading flag.
# In test mode connect_to_sim() jumps state to CONNECTED synchronously; this
# guarantees the flow reached its terminal state, not merely that the catalog
# flag cleared.
assert_int(SimBridge.state).override_failure_message(
"SimBridge must be in CONNECTED terminus after catalog resolves — flow completion guard"
).is_equal(SimBridge.ConnectionState.CONNECTED)
assert_bool(GameState.bookmark_catalog.size() > 0).override_failure_message(
"GameState.bookmark_catalog must be populated after catalog snapshot applied"
).is_true()
assert_str(GameState.bookmark_catalog[0].get("id", "")).override_failure_message(
"Catalog entry must have the expected bookmark id"
).is_equal("bm_tycoon_arion")
# =============================================================================
# Flow 2: main menu → load game → save picker → save selected
# =============================================================================
func test_load_game_save_picker_shows_on_browse() -> void:
## Calling _on_load_game_browse() must show the save picker panel.
_load_main_menu()
if _scene == null:
return
assert_bool(_scene._load_panel.visible).override_failure_message(
"Load panel must be hidden before Load Game is pressed"
).is_false()
_scene._on_load_game_browse()
assert_bool(_scene._load_panel.visible).override_failure_message(
"Load panel must be visible after _on_load_game_browse()"
).is_true()
func test_load_game_save_selection_sets_pending_load_path() -> void:
## Selecting a save entry must set GameState.pending_load_path for main.gd to consume.
_load_main_menu()
if _scene == null:
return
var mock_save := {
"game_id": "20260421-120000-abc123",
"newest_save": "quicksave.sav",
}
# Call _on_save_selected directly — mirrors what the generated save-list button does.
_scene._on_save_selected(mock_save)
assert_str(GameState.pending_load_path).override_failure_message(
"pending_load_path must be set to the selected save's full path"
).is_equal("user://saves/20260421-120000-abc123/quicksave.sav")
# =============================================================================
# Flow 3: character creation → submit → sim_bridge receives correct payload
# =============================================================================
func test_character_creation_submit_sends_confirm_bookmark_action() -> void:
## _on_start() must queue a ConfirmBookmark action with the selected bookmark
## and location IDs. This is the payload the server uses to initialize the run.
_load_char_create()
if _scene == null:
return
# Ensure SimBridge is connected so send_named_action doesn't silently drop the action
SimBridge.connect_to_sim() # test mode: immediately CONNECTED
_scene._selected_bookmark_id = "bm_tycoon_arion"
_scene._selected_location_id = "arion"
if _scene.has_method("_update_start_btn_state"):
_scene._update_start_btn_state()
SimBridge._outbound_buffer.clear()
_scene._on_start()
var found := false
for entry in SimBridge._outbound_buffer:
if entry.get("action_name") == "ConfirmBookmark":
var data: Variant = entry.get("action_data")
if (
data is Dictionary
and data.get("bookmark_id") == "bm_tycoon_arion"
and data.get("starting_location_id") == "arion"
):
found = true
break
assert_bool(found).override_failure_message(
"_outbound_buffer must contain ConfirmBookmark{bookmark_id='bm_tycoon_arion', starting_location_id='arion'}"
).is_true()
# =============================================================================
# Flow 4: bookmark tab → select location → confirm → server gets bookmark action
# =============================================================================
func test_bookmark_tab_select_location_then_confirm_queues_action() -> void:
## Exercises the full selection path: picking a bookmark, picking a location, then
## confirming. Verifies the correct bookmark_id + starting_location_id reach the server.
## This is the flow the player actually takes — selection handlers must propagate
## to the outbound buffer correctly.
# Populate catalog before scene instantiation so _build_bookmark_cards() sees it
GameState.bookmark_catalog = [
{
"id": "bm_tycoon_arion",
"title": "Arion Freight Broker",
"subtitle": "Start at Arion orbital",
"flavor": "Commodities and logistics.",
"default_location": "arion",
"allowed_locations": ["arion", "arion_low"],
"allowed_locations_cultures": ["arion"],
"career": "tycoon",
"starting_capital_tractus": 50000,
}
]
_load_char_create()
if _scene == null:
return
SimBridge.connect_to_sim() # test mode: immediately CONNECTED
SimBridge._outbound_buffer.clear()
# Simulate the player selecting the bookmark card
var bm: Dictionary = GameState.bookmark_catalog[0]
_scene._on_bookmark_selected(bm)
assert_str(_scene._selected_bookmark_id).override_failure_message(
"_on_bookmark_selected must update _selected_bookmark_id"
).is_equal("bm_tycoon_arion")
assert_str(_scene._selected_location_id).override_failure_message(
"_on_bookmark_selected must populate _selected_location_id from default_location"
).is_not_empty()
# Simulate the player picking a specific allowed location
_scene._on_location_selected("arion_low")
assert_str(_scene._selected_location_id).override_failure_message(
"_on_location_selected must update _selected_location_id"
).is_equal("arion_low")
# Confirm — sends the action to the server
_scene._on_start()
var found := false
for entry in SimBridge._outbound_buffer:
if entry.get("action_name") == "ConfirmBookmark":
var data: Variant = entry.get("action_data")
if (
data is Dictionary
and data.get("bookmark_id") == "bm_tycoon_arion"
and data.get("starting_location_id") == "arion_low"
):
found = true
break
assert_bool(found).override_failure_message(
"_outbound_buffer must contain ConfirmBookmark{bookmark_id='bm_tycoon_arion', starting_location_id='arion_low'}"
).is_true()
-321
View File
@@ -1,321 +0,0 @@
## Sprint 18 — Minimap rendering (#151)
## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered),
## D-049 (z-layer 6 = InsertOverlay)
##
## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs.
## Scene: res://ui/minimap.tscn (class_name MinimapRenderer)
## Positioned at InsertOverlay/Minimap in main.tscn.
##
## Tests run against live Stig implementation (minimap.gd).
class_name TestMinimapSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
const MAIN_SCENE = preload("res://scenes/main.tscn")
func _make_minimap() -> Control:
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
push_warning("TestMinimapSprint18: minimap.tscn not found — skip")
return null
var node: Control = load(MINIMAP_SCENE_PATH).instantiate()
add_child(node)
return node
func _make_poi(overrides: Dictionary = {}) -> Dictionary:
var base: Dictionary = {
"id": "poi_test_001",
"x": 20,
"y": 15,
"poi_category": "location",
"label": "Exit A",
}
base.merge(overrides, true)
return base
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.discovered_pois = []
GameState.player_position = Vector2(10.0, 10.0)
GameState.insert_active = true
func after_test() -> void:
GameState.discovered_pois = []
GameState.player_position = Vector2.ZERO
GameState.insert_active = true
# ---------------------------------------------------------------------------
# Scene and class
# ---------------------------------------------------------------------------
func test_minimap_scene_exists() -> void:
assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message(
"Minimap scene must exist at res://ui/minimap.tscn (#151)"
).is_true()
func test_minimap_instantiates_without_crash() -> void:
var mm := _make_minimap()
if mm == null: return
assert_that(mm).is_not_null()
mm.queue_free()
func test_minimap_is_minimap_renderer_class() -> void:
## class_name MinimapRenderer in minimap.gd.
var mm := _make_minimap()
if mm == null: return
assert_bool(mm is MinimapRenderer).override_failure_message(
"Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)"
).is_true()
mm.queue_free()
# ---------------------------------------------------------------------------
# Constants: D-015, visual parameters
# ---------------------------------------------------------------------------
func test_minimap_radius_constant() -> void:
## MINIMAP_RADIUS defines the sim-tile distance of visible POI area.
## Value is tuned to 24 tiles — reasonable coverage without map reveal.
assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message(
"MinimapRenderer.MINIMAP_RADIUS must be 24.0"
).is_equal_approx(24.0, 0.01)
func test_player_dot_radius_defined() -> void:
## Player dot must be visible (> 0) and distinct from POI dot.
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0)
func test_poi_dot_radius_defined() -> void:
## POI dot must be visible (> 0).
assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0)
func test_player_dot_larger_than_poi_dot() -> void:
## D-015: Player is always centered and visually distinct.
## Player dot should be at least as large as POI dot.
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS)
# ---------------------------------------------------------------------------
# _category_color() — D-013 POI category color mapping
# ---------------------------------------------------------------------------
func test_category_color_danger_is_hostile_color() -> void:
## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red)
for cat in ["danger", "threat", "hostile"]:
var color: Color = MinimapRenderer._category_color(cat)
assert_that(color).override_failure_message(
"Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat
).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_category_color_evidence_is_poi_color() -> void:
## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber)
for cat in ["evidence", "note", "clue"]:
var color: Color = MinimapRenderer._category_color(cat)
assert_that(color).override_failure_message(
"Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat
).is_equal(Constants.ENTITY_COLOR_POI)
func test_category_color_contact_is_unknown_color() -> void:
## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal)
for cat in ["contact", "npc", "person"]:
var color: Color = MinimapRenderer._category_color(cat)
assert_that(color).override_failure_message(
"Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat
).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_category_color_unknown_category_defaults_to_insert_text() -> void:
## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default)
var color: Color = MinimapRenderer._category_color("some_unknown_type")
assert_that(color).override_failure_message(
"Unknown category must default to INSERT_COLOR_TEXT"
).is_equal(Constants.INSERT_COLOR_TEXT)
func test_category_color_empty_string_defaults() -> void:
## Empty category string → default color, no crash.
var color: Color = MinimapRenderer._category_color("")
assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT)
func test_category_color_case_insensitive() -> void:
## Category matching is case-insensitive (uses to_lower()).
var danger_lower := MinimapRenderer._category_color("danger")
var danger_upper := MinimapRenderer._category_color("DANGER")
var danger_mixed := MinimapRenderer._category_color("Danger")
assert_that(danger_lower).is_equal(danger_upper)
assert_that(danger_lower).is_equal(danger_mixed)
# ---------------------------------------------------------------------------
# set_insert_active() — D-049: insert layer visibility
# ---------------------------------------------------------------------------
func test_set_insert_active_false_hides_minimap() -> void:
## When insert is inactive, minimap must be hidden.
var mm := _make_minimap()
if mm == null: return
mm.set_insert_active(false)
assert_bool(mm.visible).override_failure_message(
"set_insert_active(false) must hide the minimap"
).is_false()
mm.queue_free()
func test_set_insert_active_true_shows_minimap() -> void:
## When insert is active, minimap must be visible.
var mm := _make_minimap()
if mm == null: return
mm.set_insert_active(false)
mm.set_insert_active(true)
assert_bool(mm.visible).override_failure_message(
"set_insert_active(true) must show the minimap"
).is_true()
mm.queue_free()
# ---------------------------------------------------------------------------
# Main scene structural check: InsertOverlay/Minimap
# ---------------------------------------------------------------------------
func test_minimap_in_main_scene_on_insert_overlay() -> void:
## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer.
## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap.
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
return
var scene: Node = MAIN_SCENE.instantiate()
auto_free(scene)
add_child(scene)
# Check for Minimap in InsertOverlay
var insert_overlay := scene.get_node_or_null("InsertOverlay")
assert_that(insert_overlay != null).override_failure_message(
"InsertOverlay (CanvasLayer 10) must exist in main.tscn"
).is_true()
if insert_overlay == null: return
var minimap := insert_overlay.get_node_or_null("Minimap")
assert_that(minimap != null).override_failure_message(
"Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)"
).is_true()
if minimap == null: return
assert_bool(minimap is MinimapRenderer).override_failure_message(
"InsertOverlay/Minimap must be a MinimapRenderer instance"
).is_true()
func test_insert_overlay_is_canvas_layer_10() -> void:
## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049).
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
return
var scene: Node = MAIN_SCENE.instantiate()
auto_free(scene)
add_child(scene)
var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer
if insert_overlay == null: return
assert_int(insert_overlay.layer).override_failure_message(
"InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT
).is_equal(Constants.CANVAS_INSERT)
# ---------------------------------------------------------------------------
# GameState.discovered_pois integration
# ---------------------------------------------------------------------------
func test_discovered_pois_field_exists_in_gamestate() -> void:
assert_bool(GameState.has("discovered_pois")).override_failure_message(
"GameState must have 'discovered_pois' field (#151)"
).is_true()
func test_discovered_pois_set_from_poi_list_snapshot() -> void:
## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois.
GameState.apply_snapshot({
"tick": 1,
"poi_list": [
_make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}),
_make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}),
],
})
assert_int(GameState.discovered_pois.size()).override_failure_message(
"discovered_pois must be populated from snapshot 'poi_list' field"
).is_equal(2)
func test_discovered_pois_set_from_discovered_pois_snapshot() -> void:
## Snapshot with "discovered_pois" key also works.
GameState.apply_snapshot({
"tick": 2,
"discovered_pois": [_make_poi()],
})
assert_int(GameState.discovered_pois.size()).is_equal(1)
func test_discovered_pois_persists_when_absent_from_snapshot() -> void:
## Like player_knowledge: POI list persists when server doesn't send an update.
GameState.discovered_pois = [_make_poi()]
GameState.apply_snapshot({"tick": 3})
assert_int(GameState.discovered_pois.size()).override_failure_message(
"discovered_pois must persist when absent from snapshot (not cleared each tick)"
).is_equal(1)
func test_discovered_pois_poi_category_field_present() -> void:
## MinimapRenderer reads poi_category to determine shape/color.
## Verify the wire format includes this field.
GameState.apply_snapshot({
"tick": 1,
"poi_list": [_make_poi({"poi_category": "danger"})],
})
assert_int(GameState.discovered_pois.size()).is_greater(0)
var first_poi: Dictionary = GameState.discovered_pois[0]
assert_bool(first_poi.has("poi_category")).override_failure_message(
"POI entries must have 'poi_category' field for MinimapRenderer shape selection"
).is_true()
func test_discovered_pois_x_y_fields_present() -> void:
## MinimapRenderer reads x, y for position calculation.
GameState.apply_snapshot({
"tick": 1,
"poi_list": [_make_poi({"x": 42, "y": 17})],
})
assert_int(GameState.discovered_pois.size()).is_greater(0)
var first_poi: Dictionary = GameState.discovered_pois[0]
assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message(
"POI entries must have 'x' and 'y' coordinate fields"
).is_true()
# ---------------------------------------------------------------------------
# Color constants: all distinct
# ---------------------------------------------------------------------------
func test_category_colors_are_distinct() -> void:
## All three primary category color groups must be visually distinct.
var danger_color := MinimapRenderer._category_color("danger")
var evidence_color := MinimapRenderer._category_color("evidence")
var contact_color := MinimapRenderer._category_color("contact")
assert_that(danger_color).is_not_equal(evidence_color)
assert_that(evidence_color).is_not_equal(contact_color)
assert_that(danger_color).is_not_equal(contact_color)
@@ -1 +0,0 @@
uid://dm46ip672i3jc
-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
# ---------------------------------------------------------------------------
+79 -4
View File
@@ -1,10 +1,14 @@
## Client P0 regression tests: guards for Bug #5 (monologue lost) and Bug #2 (camera drift).
## Client P0 regression tests: guards for Bug #5 (monologue lost), Bug #2 (camera drift),
## and Bug #872 (bookmark_catalog lost on batch receive).
## These must pass before any other client testing is meaningful.
##
## Bug #5: Monologue text lost when server sends snapshots faster than client
## consumes them. Fix: carry-forward one-shot events in receive_bytes().
## Bug #2: Camera doesn't center at startup / drifts during pause. Fix: anchor
## pattern with smoothing disabled until first snapshot applied.
## Bug #872: bookmark_catalog silently dropped when tick 0 (with catalog) and tick 1
## (without catalog) arrive in the same TCP batch. Fix: carry-forward
## bookmark_catalog in receive_bytes() like save_result/settings_response.
##
## Spec ref: stig-round3.md Section 1 (P0 tests #1, #2).
class_name TestP0Regressions
@@ -39,12 +43,13 @@ func after_test() -> void:
# -- Helpers -------------------------------------------------------------------
## Encode a minimal valid snapshot as MessagePack bytes.
## Protocol.decode_snapshot() requires: tick, version, entities (with kind as
## bare string for unit enum variants per rmp_serde wire format).
## Protocol.decode_snapshot() requires: tick, entities (with kind as bare string
## for unit enum variants per rmp_serde wire format). D-192 dropped the version
## field — kept here inertly in existing fixtures so decode still accepts either
## shape while older tests migrate.
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
var snapshot := {
"tick": overrides.get("tick", 1),
"version": Protocol.PROTOCOL_VERSION,
"entities": overrides.get("entities", [{
"entity_id": 1,
"x": 10.0,
@@ -71,6 +76,8 @@ func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
snapshot["current_monologue"] = overrides["current_monologue"]
if overrides.has("current_dialogue"):
snapshot["current_dialogue"] = overrides["current_dialogue"]
if overrides.has("bookmark_catalog"):
snapshot["bookmark_catalog"] = overrides["bookmark_catalog"]
var result = Messagepack.encode(snapshot)
return result.value
@@ -229,3 +236,71 @@ func test_camera_anchored_after_pause_unpause() -> void:
# Camera still tracking player (position may have changed due to test_snapshot)
var player_pos := GameState.player_position * Constants.TILE_SIZE
assert_that(camera.global_position).is_equal(player_pos)
# -- Bug #872: bookmark_catalog carry-forward ---------------------------------
# Server sends bookmark_catalog on tick 0. If tick 1 arrives before poll_snapshot()
# is called (same TCP batch), the inner receive loop overwrites _last_snapshot and
# the catalog is silently lost. The carry-forward fix must preserve the catalog.
func test_bookmark_catalog_not_lost_on_snapshot_overwrite() -> void:
var catalog := {
"bookmarks": [
{
"id": "bm_tycoon_arion",
"title": "Arion Freight Broker",
"subtitle": "Start at Arion orbital",
"flavor": "Commodities and logistics.",
"default_location": "arion",
"allowed_locations": ["arion"],
"allowed_locations_cultures": ["arion"],
"career": "tycoon",
"starting_capital_tractus": 50000,
}
]
}
# Tick 0: server sends catalog automatically after handshake
var bytes_tick0 := _make_snapshot_bytes({
"tick": 0,
"bookmark_catalog": catalog,
})
SimBridge.receive_bytes(bytes_tick0)
# Tick 1: server sends next tick WITHOUT catalog (fast server, same TCP batch)
var bytes_tick1 := _make_snapshot_bytes({"tick": 1})
SimBridge.receive_bytes(bytes_tick1)
# Assert: catalog must survive the overwrite — this is the Bug #872 fix.
assert_that(SimBridge._last_snapshot).is_not_null()
var bmc: Variant = SimBridge._last_snapshot.get("bookmark_catalog")
assert_that(bmc).is_not_null()
assert_that(bmc is Dictionary).is_true()
var bookmarks: Variant = bmc.get("bookmarks")
assert_that(bookmarks is Array).is_true()
assert_that((bookmarks as Array).size()).is_equal(1)
assert_that((bookmarks as Array)[0].get("id")).is_equal("bm_tycoon_arion")
func test_bookmark_catalog_not_carried_forward_after_consumption() -> void:
# After poll_snapshot() consumes the catalog, the next snapshot without catalog
# must NOT carry it forward (it was already consumed and the scene transitioned).
var catalog := {
"bookmarks": [{"id": "bm_test", "title": "Test", "subtitle": "", "flavor": "",
"default_location": "arion", "allowed_locations": [], "allowed_locations_cultures": [],
"career": "tycoon", "starting_capital_tractus": 0}]
}
var bytes_tick0 := _make_snapshot_bytes({"tick": 0, "bookmark_catalog": catalog})
SimBridge.receive_bytes(bytes_tick0)
# Simulate poll_snapshot() consumption — sets _last_snapshot to null
var snapshot = SimBridge._last_snapshot
SimBridge._last_snapshot = null
assert_that(snapshot).is_not_null()
# Next snapshot arrives without catalog — no carry-forward should happen
var bytes_tick1 := _make_snapshot_bytes({"tick": 1})
SimBridge.receive_bytes(bytes_tick1)
var bmc: Variant = SimBridge._last_snapshot.get("bookmark_catalog")
assert_that(bmc).is_null()
+83 -33
View File
@@ -16,6 +16,7 @@ func _load_fixture(name: String) -> PackedByteArray:
# -- Snapshot decoding ----------------------------------------------------------
func test_decode_snapshot_one_npc() -> void:
var bytes = _load_fixture("snapshot_one_npc")
var snapshot = Protocol.decode_snapshot(bytes)
@@ -102,6 +103,7 @@ func test_decode_snapshot_multi_entity() -> void:
# -- PlayerInput decoding -------------------------------------------------------
func test_decode_input_move_north() -> void:
var bytes = _load_fixture("input_move_north")
var input = Protocol.decode_player_input(bytes)
@@ -124,6 +126,7 @@ func test_decode_input_perception_mode() -> void:
# -- PlayerInput encoding -------------------------------------------------------
func test_encode_decode_roundtrip_unit_variant() -> void:
var bytes = Protocol.encode_player_input(50, "MoveEast")
assert_that(bytes.size()).is_greater(0)
@@ -148,6 +151,7 @@ func test_encode_decode_roundtrip_data_variant() -> void:
# -- Cross-language roundtrip: GDScript encode matches Rust decode ---------------
func test_gdscript_encode_matches_rust_fixture() -> void:
# Encode the same MoveNorth input as the Rust fixture
var bytes = Protocol.encode_player_input(100, "MoveNorth")
@@ -164,6 +168,7 @@ func test_gdscript_encode_matches_rust_fixture() -> void:
# -- Negative tests: malformed/truncated input -----------------------------------
func test_decode_snapshot_truncated_bytes() -> void:
var truncated := PackedByteArray([0x82, 0xa4]) # Incomplete msgpack map
var result = Protocol.decode_snapshot(truncated)
@@ -188,8 +193,9 @@ func test_decode_snapshot_malformed_entities_counted() -> void:
# Snapshot with one valid and one malformed entity — decode_errors should count the bad one
var raw := {
"tick": 7,
"version": Protocol.PROTOCOL_VERSION,
"entities": [
"version": 23,
"entities":
[
{"entity_id": 1, "x": 5.0, "y": 10.0, "z": 0, "kind": "Npc"},
{"entity_id": 2, "broken": true}, # Missing required fields
{"x": 1.0}, # Missing entity_id, y, z, kind
@@ -217,6 +223,7 @@ func test_encode_produces_nonempty_bytes() -> void:
# -- Batch input encoding (Vec<PlayerInput> wire format) -----------------------
func test_encode_player_inputs_single() -> void:
var inputs: Array = [{"tick": 10, "action_name": "MoveNorth"}]
var bytes := Protocol.encode_player_inputs(inputs)
@@ -276,13 +283,13 @@ func test_encode_player_inputs_empty() -> void:
# -- v2 snapshot decoding -------------------------------------------------------
func test_decode_snapshot_v2_full() -> void:
var bytes = _load_fixture("snapshot_v2_full")
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(500)
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
# game_time
assert_that(snapshot.game_time).is_not_null()
@@ -305,11 +312,12 @@ func test_decode_snapshot_v2_full() -> void:
func test_existing_fixtures_have_v2_fields() -> void:
# All fixtures are generated by fixture_snapshot() — verify decoder extracts v2+ fields
for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]:
for fixture_name in [
"snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"
]:
var bytes = _load_fixture(fixture_name)
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snapshot.player_facing).is_equal("North")
assert_that(snapshot.game_time).is_not_null()
@@ -324,32 +332,9 @@ func test_multi_entity_visibility_sectors() -> void:
assert_that(snapshot.entities[3].visibility).is_equal("Forward")
# -- Version enforcement (strict PROTOCOL_VERSION check) --------------------
func test_decode_snapshot_rejects_missing_version() -> void:
# Snapshot without version field → rejected by strict version check
var v1_raw := {"tick": 10, "entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player"},
]}
var encoded: Variant = Messagepack.encode(v1_raw)
assert_that(encoded.status).is_null()
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_null()
func test_decode_snapshot_rejects_old_version() -> void:
# Snapshot with version 2 → rejected by strict version check
var old_raw := {"tick": 10, "version": 2, "entities": []}
var encoded: Variant = Messagepack.encode(old_raw)
assert_that(encoded.status).is_null()
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_null()
# -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ----------------
func test_decode_batch_input_fixture() -> void:
# Rust-generated Vec<PlayerInput> fixture — verifies bidirectional Layer 1 compatibility
var bytes = _load_fixture("input_batch_two")
@@ -367,6 +352,7 @@ func test_decode_batch_input_fixture() -> void:
# -- Diagonal movement fixtures (D-030 Layer 1 cross-language) -----------------
func test_decode_diagonal_fixtures() -> void:
# All 4 diagonal fixtures: clockwise NE, SE, SW, NW
var diagonals: Array = [
@@ -387,14 +373,17 @@ func test_decode_diagonal_fixtures() -> void:
# -- v23: BookmarkCatalog decode -----------------------------------------------
func test_decode_snapshot_with_bookmark_catalog() -> void:
# Hand-built dict — fixture generation requires server work, skip round-trip (#614).
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"bookmark_catalog": {
"bookmarks": [
"bookmark_catalog":
{
"bookmarks":
[
{
"id": "bm_tycoon_arion",
"title": "The Arion Run",
@@ -451,7 +440,7 @@ func test_decode_snapshot_no_bookmark_catalog_is_null() -> void:
# Snapshot without bookmark_catalog key → field should be null.
var raw := {
"tick": 2,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
var encoded: Variant = Messagepack.encode(raw)
@@ -462,6 +451,7 @@ func test_decode_snapshot_no_bookmark_catalog_is_null() -> void:
# -- v23: RequestBookmarkCatalog + ConfirmBookmark encoding --------------------
func test_encode_request_bookmark_catalog_roundtrip() -> void:
var bytes := Protocol.encode_request_bookmark_catalog()
assert_that(bytes.size()).is_greater(0)
@@ -489,3 +479,63 @@ func test_encode_confirm_bookmark_roundtrip() -> void:
var data: Dictionary = entry["action_data"]
assert_that(data["bookmark_id"]).is_equal("bm_tycoon_arion")
assert_that(data["starting_location_id"]).is_equal("loc_arion_prime")
# -- Atlas layer-stream protocol (#969, D-225) ---------------------------------
func test_decode_atlas_response_ready() -> void:
var bytes := _load_fixture("atlas_response_ready")
var resp = Protocol.decode_atlas_layer_response(bytes)
assert_that(resp).is_not_null()
assert_that(resp.body_id).is_equal("GJ1c")
assert_that(resp.status).is_equal("Ready")
assert_that(resp.layer1).is_not_null()
assert_that(resp.layer1.river_network.river_cells.size()).is_equal(2)
assert_that(resp.layer1.attractors.size()).is_equal(1)
assert_that(resp.layer1.attractors[0].attractor_type).is_equal("CoastalAccess")
assert_that(resp.layer1.attractors[0].sub_biome).is_equal("CoastalLowland")
func test_decode_atlas_response_pending() -> void:
var bytes := _load_fixture("atlas_response_pending")
var resp = Protocol.decode_atlas_layer_response(bytes)
assert_that(resp).is_not_null()
assert_that(resp.status).is_equal("Pending")
assert_that(resp.layer1).is_null()
func test_decode_atlas_response_not_found() -> void:
var bytes := _load_fixture("atlas_response_not_found")
var resp = Protocol.decode_atlas_layer_response(bytes)
assert_that(resp.status).is_equal("NotFound")
func test_snapshot_is_not_decoded_as_atlas_response() -> void:
# Disambiguation: an ObserverSnapshot has no "status" key, so the atlas
# decoder rejects it. receive_bytes relies on this to route correctly.
var bytes := _load_fixture("snapshot_empty")
assert_that(Protocol.decode_atlas_layer_response(bytes)).is_null()
func test_encode_atlas_request_shape() -> void:
var bytes := Protocol.encode_atlas_layer_request("GJ1c", "Topography")
assert_that(bytes.size()).is_greater(0)
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value is Dictionary).is_true()
assert_that(raw.value["body_id"]).is_equal("GJ1c")
assert_that(raw.value["up_to"]).is_equal("Topography")
# A request is a map with no "status" — must not be mistaken for a response.
assert_that(Protocol.decode_atlas_layer_response(bytes)).is_null()
func test_decode_inbound_classifies_frames() -> void:
# The receive-side classifier: snapshot vs atlas response, decoded once.
var snap = Protocol.decode_inbound(_load_fixture("snapshot_empty"))
assert_that(snap.kind).is_equal("snapshot")
assert_that(snap.value).is_not_null()
var atlas = Protocol.decode_inbound(_load_fixture("atlas_response_ready"))
assert_that(atlas.kind).is_equal("atlas")
assert_that(atlas.value.status).is_equal("Ready")
+14 -42
View File
@@ -1,8 +1,8 @@
## D-030 Layer 1: Protocol bridge tests for ObserverSnapshot features.
## Validates player_stance (D-053) and player_inventory (D-065) decode,
## GameState storage, SimBridge test mode, input encoding for stance toggles,
## protocol version checks, and fixture round-trips.
## Spec refs: D-053, D-065, D-020, #449
## and fixture round-trips. Protocol version checks removed per D-192.
## Spec refs: D-053, D-065, D-020, #449, D-192
class_name TestProtocolBridge
extends GdUnitTestSuite
@@ -24,33 +24,12 @@ func _load_fixture(name: String) -> PackedByteArray:
return file.get_buffer(file.get_length())
# -- Protocol version upgrade -------------------------------------------------
# Tautological "PROTOCOL_VERSION == N" assertions deleted: they assert a constant
# equals its own literal, fail mechanically on every protocol bump, and have
# never caught a real bug. Mismatch handling is exercised by test_rejects_version_6
# below; field-presence is exercised by the per-version decode tests.
func test_fixtures_at_protocol_version_8() -> void:
# NOTE: These binary fixtures embed version 8 and are rejected by the version
# mismatch guard in decode_snapshot(). This test is pre-existing broken since v9+.
# Fixtures need regeneration via `make fixtures-gauntlet` to match current protocol.
# Skipping rather than deleting to preserve the fixture round-trip pattern.
pass
func test_rejects_version_6() -> void:
var raw := {"tick": 1, "version": 6, "entities": []}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_null()
# -- player_stance decode (D-053) ---------------------------------------------
func test_decode_player_stance_walk() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_stance": "Walk",
"player_inventory": [],
@@ -64,7 +43,7 @@ func test_decode_player_stance_walk() -> void:
func test_decode_player_stance_sprint() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_stance": "Sprint",
"player_inventory": [],
@@ -77,7 +56,7 @@ func test_decode_player_stance_sprint() -> void:
func test_decode_player_stance_careful() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_stance": "Careful",
"player_inventory": [],
@@ -90,7 +69,7 @@ func test_decode_player_stance_careful() -> void:
func test_decode_player_stance_crouch() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_stance": "Crouch",
"player_inventory": [],
@@ -104,7 +83,7 @@ func test_decode_player_stance_missing_defaults_to_walk() -> void:
# v6 snapshot without player_stance → should default to "Walk"
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -118,7 +97,7 @@ func test_decode_player_stance_missing_defaults_to_walk() -> void:
func test_decode_empty_inventory() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_stance": "Walk",
"player_inventory": [],
@@ -132,7 +111,7 @@ func test_decode_smuggler_inventory_3_items() -> void:
# D-065: smuggler carries 3 specific items
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_stance": "Walk",
"player_inventory": [
@@ -161,7 +140,7 @@ func test_decode_full_9_slot_inventory() -> void:
items.append({"item_id": 100 + i, "name": "Item %d" % i, "slot": i})
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_inventory": items,
}
@@ -177,7 +156,7 @@ func test_decode_full_9_slot_inventory() -> void:
func test_decode_inventory_missing_defaults_to_empty() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -188,7 +167,7 @@ func test_decode_inventory_missing_defaults_to_empty() -> void:
func test_decode_inventory_skips_malformed_items() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_inventory": [
{"item_id": 100, "name": "Valid Item", "slot": 0},
@@ -209,7 +188,7 @@ func test_decode_inventory_skips_malformed_items() -> void:
func test_decode_inventory_item_slot_defaults_to_zero() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"player_inventory": [
{"item_id": 100, "name": "No Slot"},
@@ -281,12 +260,6 @@ func test_sim_bridge_test_snapshot_has_player_inventory() -> void:
assert_that(snap.player_inventory is Array).is_true()
func test_sim_bridge_test_snapshot_uses_current_protocol_version() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
# -- Fixture: v6 snapshots include new fields ----------------------------------
func test_fixture_snapshots_have_v6_defaults() -> void:
@@ -334,7 +307,7 @@ func test_full_v6_snapshot_decode() -> void:
# Simulate a realistic v6 snapshot with all fields populated
var raw := {
"tick": 100,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"game_time": {"day": 1, "time_of_day": 720, "day_phase": "Evening", "tick_rate": "Full"},
"player_facing": "Southeast",
"player_stance": "Careful",
@@ -367,7 +340,6 @@ func test_full_v6_snapshot_decode() -> void:
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(100)
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snapshot.player_facing).is_equal("Southeast")
assert_that(snapshot.player_stance).is_equal("Careful")
assert_that(snapshot.player_inventory.size()).is_equal(3)
+11 -12
View File
@@ -12,7 +12,7 @@ extends GdUnitTestSuite
func test_decode_pending_recognitions_basic() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"pending_recognitions": [
{"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6},
@@ -33,7 +33,7 @@ func test_decode_pending_recognitions_basic() -> void:
func test_decode_pending_recognitions_empty() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"pending_recognitions": [],
}
@@ -45,7 +45,7 @@ func test_decode_pending_recognitions_empty() -> void:
func test_decode_pending_recognitions_missing_defaults_empty() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -56,7 +56,7 @@ func test_decode_pending_recognitions_missing_defaults_empty() -> void:
func test_decode_pending_recognitions_skips_malformed() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"pending_recognitions": [
{"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6},
@@ -76,7 +76,7 @@ func test_decode_pending_recognitions_defaults() -> void:
# remaining_ticks and total_delay_ticks default to 0 and 1
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"pending_recognitions": [
{"entity_id": 100, "x": 5.0, "y": 5.0},
@@ -94,7 +94,7 @@ func test_decode_pending_recognitions_defaults() -> void:
func test_decode_current_dialogue() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"current_dialogue": {
"npc_name": "Kael",
@@ -123,7 +123,7 @@ func test_decode_current_dialogue() -> void:
func test_decode_current_dialogue_missing_is_null() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -136,7 +136,7 @@ func test_decode_current_dialogue_options_default_fields() -> void:
# response_id and priority default when absent
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"current_dialogue": {
"speech": "Just speech.",
@@ -158,7 +158,7 @@ func test_decode_current_dialogue_options_default_fields() -> void:
func test_decode_current_dialogue_confrontation_option() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"current_dialogue": {
"npc_name": "Sera",
@@ -179,7 +179,7 @@ func test_decode_current_dialogue_confrontation_option() -> void:
func test_decode_current_dialogue_skips_malformed_options() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"current_dialogue": {
"npc_name": "Sera",
@@ -332,7 +332,7 @@ func test_insert_color_constants_exist() -> void:
func test_full_v7_snapshot_decode() -> void:
var raw := {
"tick": 200,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"game_time": {"day": 2, "time_of_day": 1000, "day_phase": "Evening", "tick_rate": "Full"},
"player_facing": "West",
"player_stance": "Careful",
@@ -362,7 +362,6 @@ func test_full_v7_snapshot_decode() -> void:
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(200)
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snapshot.player_facing).is_equal("West")
assert_that(snapshot.player_stance).is_equal("Careful")
assert_that(snapshot.player_inventory.size()).is_equal(1)
+13 -11
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()
@@ -174,8 +178,6 @@ func test_sim_bridge_test_tiles_contain_all_types() -> void:
func test_sim_bridge_test_snapshot_has_v2_fields() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("version")).is_true()
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snap.has("game_time")).is_true()
assert_that(snap.has("player_facing")).is_true()
assert_that(snap.has("visible_tiles")).is_true()
@@ -1,210 +0,0 @@
## Sprint 19 — Game session management (#258, D-085)
## Per-game save directories: created on New Game, resumed via game-id.
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
class_name TestSessionManagerSprint19
extends GdUnitTestSuite
const MAIN_MENU_SCENE = preload("res://scenes/main_menu.tscn")
# Game IDs created during the current test — deleted in after_test().
var _created_ids: Array = []
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.current_game_id = ""
_created_ids = []
func after_test() -> void:
for game_id in _created_ids:
var path := "user://saves/" + game_id
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
_created_ids.clear()
GameState.current_game_id = ""
# ---------------------------------------------------------------------------
# Helper: call new_game() and track the created directory for cleanup.
# ---------------------------------------------------------------------------
func _new_game() -> String:
var game_id := SessionManager.new_game()
if not game_id.is_empty():
_created_ids.append(game_id)
return game_id
# ---------------------------------------------------------------------------
# GameState.current_game_id field
# ---------------------------------------------------------------------------
func test_current_game_id_field_exists() -> void:
## D-085: GameState must have current_game_id field.
assert_bool(GameState.has("current_game_id")).override_failure_message(
"GameState must have 'current_game_id' field (D-085 #258)"
).is_true()
func test_current_game_id_default_is_empty_string() -> void:
## Before any session starts, current_game_id is empty.
GameState.current_game_id = ""
assert_str(GameState.current_game_id).override_failure_message(
"GameState.current_game_id default must be empty string"
).is_empty()
# ---------------------------------------------------------------------------
# SessionManager autoload exists
# ---------------------------------------------------------------------------
func test_session_manager_autoload_exists() -> void:
## SessionManager must be registered as an autoload.
var sm := Engine.get_singleton("SessionManager")
assert_that(sm != null).override_failure_message(
"SessionManager must be registered as autoload in project.godot (#258)"
).is_true()
# ---------------------------------------------------------------------------
# new_game() — game-id format and GameState update
# ---------------------------------------------------------------------------
func test_new_game_returns_non_empty_string() -> void:
var game_id := _new_game()
assert_str(game_id).override_failure_message(
"SessionManager.new_game() must return a non-empty game-id string"
).is_not_empty()
func test_new_game_sets_current_game_id_on_gamestate() -> void:
var game_id := _new_game()
assert_str(GameState.current_game_id).override_failure_message(
"new_game() must set GameState.current_game_id"
).is_equal(game_id)
func test_new_game_id_format_has_two_dashes() -> void:
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
var game_id := _new_game()
var parts := game_id.split("-")
assert_int(parts.size()).override_failure_message(
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
).is_equal(3)
func test_new_game_id_first_part_is_8_digits() -> void:
## First part is YYYYMMDD — 8 decimal digits.
var game_id := _new_game()
var parts := game_id.split("-")
assert_int(parts[0].length()).override_failure_message(
"game-id first part (date) must be 8 characters (YYYYMMDD)"
).is_equal(8)
func test_new_game_id_second_part_is_6_digits() -> void:
## Second part is HHMMSS — 6 decimal digits.
var game_id := _new_game()
var parts := game_id.split("-")
assert_int(parts[1].length()).override_failure_message(
"game-id second part (time) must be 6 characters (HHMMSS)"
).is_equal(6)
func test_new_game_id_third_part_is_6_hex_chars() -> void:
## Third part is 6 hex characters (RNG seed).
var game_id := _new_game()
var parts := game_id.split("-")
assert_int(parts[2].length()).override_failure_message(
"game-id third part (hex seed) must be 6 characters"
).is_equal(6)
func test_new_game_ids_are_unique() -> void:
## Two rapid new_game() calls should produce different IDs
## (different RNG seeds; same-second timestamps are valid but seeds differ).
var id1 := _new_game()
var id2 := _new_game()
# Check that hex seeds differ (they almost certainly will)
var seed1 := id1.split("-")[2]
var seed2 := id2.split("-")[2]
assert_str(seed1).override_failure_message(
"Successive new_game() calls should have different RNG seeds"
).is_not_equal(seed2)
# ---------------------------------------------------------------------------
# resume_game() — sets GameState.current_game_id
# ---------------------------------------------------------------------------
func test_resume_game_sets_current_game_id() -> void:
var test_id := "20260225-143022-a7b3f1"
SessionManager.resume_game(test_id)
assert_str(GameState.current_game_id).override_failure_message(
"resume_game() must set GameState.current_game_id to the given id"
).is_equal(test_id)
func test_resume_game_overwrites_previous_game_id() -> void:
SessionManager.resume_game("20260225-100000-aabbcc")
SessionManager.resume_game("20260225-120000-112233")
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
# ---------------------------------------------------------------------------
# Main menu scene
# ---------------------------------------------------------------------------
func test_main_menu_scene_exists() -> void:
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
).is_true()
func test_main_menu_instantiates_without_crash() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
return
var scene: Node = MAIN_MENU_SCENE.instantiate()
auto_free(scene)
add_child(scene)
assert_that(scene).is_not_null()
func test_main_menu_has_new_game_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = MAIN_MENU_SCENE.instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/NewGameBtn")
assert_that(btn != null).override_failure_message(
"Main menu must have VBox/NewGameBtn (#258)"
).is_true()
func test_main_menu_has_continue_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = MAIN_MENU_SCENE.instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/ContinueBtn")
assert_that(btn != null).override_failure_message(
"Main menu must have VBox/ContinueBtn (#258)"
).is_true()
# ---------------------------------------------------------------------------
# Project main scene changed to main_menu.tscn
# ---------------------------------------------------------------------------
func test_project_main_scene_is_main_menu() -> void:
## D-085: project boots to main menu, not directly to game scene.
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
assert_str(scene_path).override_failure_message(
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
).is_equal("res://scenes/main_menu.tscn")
@@ -1 +0,0 @@
uid://c7lnnr2apbyqw
+12 -74
View File
@@ -1,86 +1,24 @@
## 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:
# decode_snapshot() must return a "triangle_crisis_events" key (#590).
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"triangle_crisis_events": [{"triangle_id": 42}],
}
@@ -102,7 +40,7 @@ func test_protocol_decode_triangle_crisis_events_empty_array() -> void:
# When no events are present, field is present and empty.
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"triangle_crisis_events": [],
}
@@ -117,7 +55,7 @@ func test_protocol_decode_triangle_crisis_events_absent_returns_empty() -> void:
# When server doesn't send field (pre-#589), field defaults to empty array.
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -167,7 +105,7 @@ func test_protocol_decode_includes_current_ticker_field() -> void:
# decode_snapshot() must return a "current_ticker" key (#592).
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"current_ticker": {"id": "ticker_001", "text": "Station systems nominal.", "category": "System"},
}
@@ -187,7 +125,7 @@ func test_protocol_decode_current_ticker_null_when_absent() -> void:
# When server doesn't send current_ticker (player outside bar zone), field is null.
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -210,7 +148,7 @@ func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
# Snapshot with no current_ticker (player outside bar zone).
GameState.current_snapshot = {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
}
ticker.update_from_state()
@@ -229,7 +167,7 @@ func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
GameState.current_snapshot = {
"tick": 2,
"version": Protocol.PROTOCOL_VERSION,
"version": 23,
"entities": [],
"current_ticker": {"id": "t1", "text": "Station systems nominal.", "category": "System"},
}
@@ -249,7 +187,7 @@ func test_news_ticker_hides_when_ticker_becomes_null() -> void:
# Show it first.
GameState.current_snapshot = {
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
"tick": 1, "version": 23, "entities": [],
"current_ticker": {"id": "t1", "text": "Breaking news.", "category": "System"},
}
ticker.update_from_state()
@@ -257,7 +195,7 @@ func test_news_ticker_hides_when_ticker_becomes_null() -> void:
# Null current_ticker — player left the bar zone.
GameState.current_snapshot = {
"tick": 2, "version": Protocol.PROTOCOL_VERSION, "entities": [],
"tick": 2, "version": 23, "entities": [],
}
ticker.update_from_state()
assert_bool(ticker.visible).override_failure_message(
-1
View File
@@ -132,7 +132,6 @@ func skip_test_proof_player_moves_and_v2_snapshot() -> void:
assert_float(player.y).is_equal_approx(15.5, 0.001)
# v4 protocol fields present
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snapshot.player_facing).is_equal("North")
assert_that(snapshot.game_time).is_not_null()
-552
View File
@@ -1,552 +0,0 @@
## Sprint 30 — QA acceptance tests
##
## Covers all 5 Sprint 30 client tickets:
## #718 — Persist CharacterVisualDescriptor on new game start
## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet)
## #720 — Replace DirAccess scanning with manifest JSON for export builds
## #712 — BoneAttachment3D marker above Head bone for floating icons
## #674 — Star map insert module (test-first: scene must exist when implemented)
##
## Test convention:
## - Tests that should PASS immediately = regression guards on existing code
## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented
##
## Ticket refs: #718, #719, #720, #712, #674
class_name TestSprint30
extends GdUnitTestSuite
func before_each() -> void:
# Reset GameState fields touched by #718 tests to avoid cross-test pollution.
GameState.character_visual_descriptor = null
func after_each() -> void:
GameState.character_visual_descriptor = null
# =============================================================================
# #718 — Persist CharacterVisualDescriptor
# =============================================================================
func test_game_state_has_character_visual_descriptor_field() -> void:
## GameState.character_visual_descriptor must exist and default to null.
## Confirms the field added in game_state.gd line 103 is present.
var gs := GameState.new()
auto_free(gs)
# The field is declared on the class — access it without error
var val: Variant = gs.get("character_visual_descriptor")
# Field should exist (not return null from missing property vs. null value)
assert_bool(gs.has_method("apply_snapshot")).override_failure_message(
"GameState must be a valid autoload class with apply_snapshot"
).is_true()
# The property itself must be gettable and null by default
assert_bool(val == null).override_failure_message(
"GameState.character_visual_descriptor must default to null"
).is_true()
func test_descriptor_to_dict_includes_all_required_fields() -> void:
## CharacterVisualDescriptor.to_dict() must include all wire-format fields.
var desc := CharacterVisualDescriptor.new()
auto_free(desc)
var d := desc.to_dict()
var required_keys := [
"body_type", "head_id", "hair_id", "hair_tint",
"facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint",
"eye_color", "skin_tone", "clothing_slots", "clothing_tints",
"accessory_slots", "accessory_tints",
]
for key in required_keys:
assert_bool(d.has(key)).override_failure_message(
"to_dict() must include field '%s'" % key
).is_true()
func test_descriptor_to_dict_body_type_is_wire_string() -> void:
## body_type in to_dict() must be a string (rmp_serde unit enum), not an int.
var desc := CharacterVisualDescriptor.new()
auto_free(desc)
desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
var d := desc.to_dict()
assert_str(d["body_type"]).override_failure_message(
"to_dict() body_type must be wire string 'AverageM'"
).is_equal("AverageM")
func test_descriptor_round_trip_preserves_fields() -> void:
## from_dict(to_dict(desc)) must preserve all scalar fields.
var original := CharacterVisualDescriptor.new()
auto_free(original)
original.body_type = CharacterVisualDescriptor.BodyType.THIN_F
original.head_id = "head_002"
original.hair_id = "bob"
original.hair_tint = Color(0.8, 0.4, 0.2)
original.skin_tone = 3
var wire := original.to_dict()
var restored := CharacterVisualDescriptor.from_dict(wire)
assert_bool(restored != null).override_failure_message(
"from_dict() must return a descriptor for valid wire data"
).is_true()
if restored == null:
return
assert_int(int(restored.body_type)).override_failure_message(
"body_type must survive round-trip"
).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F))
assert_str(restored.head_id).override_failure_message(
"head_id must survive round-trip"
).is_equal("head_002")
assert_str(restored.hair_id).override_failure_message(
"hair_id must survive round-trip"
).is_equal("bob")
assert_int(restored.skin_tone).override_failure_message(
"skin_tone must survive round-trip"
).is_equal(3)
func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void:
## from_dict() must return null if body_type is absent (required field).
var d := {"head_id": "head_001"} # missing body_type
var result := CharacterVisualDescriptor.from_dict(d)
assert_bool(result == null).override_failure_message(
"from_dict() must return null when body_type is missing"
).is_true()
func test_descriptor_color_encoding_is_float_array() -> void:
## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility.
var desc := CharacterVisualDescriptor.new()
auto_free(desc)
desc.eye_color = Color(0.1, 0.2, 0.3, 1.0)
var d := desc.to_dict()
var encoded: Variant = d["eye_color"]
assert_bool(encoded is Array).override_failure_message(
"eye_color must encode as an Array [r, g, b, a]"
).is_true()
if not (encoded is Array):
return
assert_int((encoded as Array).size()).override_failure_message(
"eye_color array must have 4 elements"
).is_equal(4)
assert_float((encoded as Array)[0]).override_failure_message(
"eye_color[0] (r) must be approx 0.1"
).is_equal_approx(0.1, 0.001)
func test_apply_snapshot_restores_character_visual_descriptor() -> void:
## #718: apply_snapshot() must restore character_visual_descriptor from
## the "character_visual_descriptor" key in ObserverSnapshot (save/load path).
var desc := CharacterVisualDescriptor.new()
desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
desc.head_id = "head_003"
desc.hair_id = "dreads"
desc.skin_tone = 5
var snapshot := {
"character_visual_descriptor": desc.to_dict(),
}
GameState.apply_snapshot(snapshot)
var restored: Variant = GameState.character_visual_descriptor
assert_bool(restored != null).override_failure_message(
"apply_snapshot() must restore character_visual_descriptor from snapshot"
).is_true()
if restored != null and restored is CharacterVisualDescriptor:
var r := restored as CharacterVisualDescriptor
assert_int(int(r.body_type)).override_failure_message(
"restored body_type must match"
).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F))
assert_str(r.head_id).override_failure_message(
"restored head_id must match"
).is_equal("head_003")
assert_int(r.skin_tone).override_failure_message(
"restored skin_tone must match"
).is_equal(5)
func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void:
## #718: If snapshot lacks "character_visual_descriptor", the existing field
## must not be overwritten (server only sends when descriptor changes).
var desc := CharacterVisualDescriptor.new()
desc.hair_id = "bob"
GameState.character_visual_descriptor = desc
# Snapshot with no character_visual_descriptor key
GameState.apply_snapshot({"tick": 1})
var after: Variant = GameState.character_visual_descriptor
assert_bool(after != null).override_failure_message(
"apply_snapshot() must NOT clear descriptor when field is absent"
).is_true()
if after is CharacterVisualDescriptor:
assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message(
"descriptor must be unchanged after snapshot with no character_visual_descriptor key"
).is_equal("bob")
func test_protocol_encode_startup_includes_descriptor() -> void:
## #718: Protocol.encode_startup_message() must include "character_visual_descriptor"
## in the encoded payload when a descriptor is provided.
var desc := CharacterVisualDescriptor.new()
desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M
desc.hair_id = "buzzed"
var bytes := Protocol.encode_startup_message(12345, "detective", desc)
assert_bool(bytes.size() > 0).override_failure_message(
"encode_startup_message() must produce non-empty bytes"
).is_true()
# Decode and verify the field is present (Messagepack.decode returns {status, value})
var raw = Messagepack.decode(bytes)
assert_bool(raw.status == null).override_failure_message(
"encode_startup_message() output must be valid msgpack"
).is_true()
if raw.status != null:
return
var msg: Dictionary = raw.value as Dictionary
assert_bool(msg.has("character_visual_descriptor")).override_failure_message(
"StartupMessage must include 'character_visual_descriptor' key when descriptor is provided"
).is_true()
if msg.has("character_visual_descriptor"):
assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message(
"character_visual_descriptor in StartupMessage must be a Dictionary"
).is_true()
func test_protocol_encode_startup_omits_descriptor_when_null() -> void:
## #718: encode_startup_message() must still produce valid bytes when descriptor is null.
var bytes := Protocol.encode_startup_message(0, "detective", null)
assert_bool(bytes.size() > 0).override_failure_message(
"encode_startup_message() must produce valid bytes even with null descriptor"
).is_true()
func test_descriptor_has_no_hair_highlight_tint_field() -> void:
## CharacterVisualDescriptor must NOT have a hair_highlight_tint field.
## The highlight is always auto-derived from hair_tint (Option B of #719).
## to_dict() must not include it in the wire format.
var desc := CharacterVisualDescriptor.new()
auto_free(desc)
var d := desc.to_dict()
assert_bool(d.has("hair_highlight_tint")).override_failure_message(
"to_dict() must NOT include hair_highlight_tint — highlight is auto-derived"
).is_false()
assert_bool("hair_highlight_tint" in desc).override_failure_message(
"CharacterVisualDescriptor must not define a hair_highlight_tint property"
).is_false()
# =============================================================================
# #719 — Hair highlight swatch (Option B: read-only, auto-derived)
# =============================================================================
func test_derive_hair_highlight_lightens_primary() -> void:
## _derive_hair_highlight() must return primary.lightened(0.3).
## Tests the derivation formula in character_creation.gd:1917.
var cc_scene_path := "res://scenes/character_creation.tscn"
if not ResourceLoader.exists(cc_scene_path):
push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping")
return
var packed := load(cc_scene_path) as PackedScene
if packed == null:
return
var cc := packed.instantiate() as CharacterCreation
if cc == null:
push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping")
return
auto_free(cc)
# CharacterCreation._derive_hair_highlight is a private method but testable via call()
var primary := Color(0.4, 0.3, 0.5)
var expected := primary.lightened(0.3)
var result: Variant = cc.call("_derive_hair_highlight", primary)
assert_bool(result is Color).override_failure_message(
"_derive_hair_highlight must return a Color"
).is_true()
if not (result is Color):
return
var r := result as Color
assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001)
assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001)
assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001)
func test_hair_highlight_swatch_exists_in_ui() -> void:
## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null
## (a display node must be created in _build_hair_color_dock).
## WILL FAIL until #719 is implemented.
var cc_scene_path := "res://scenes/character_creation.tscn"
if not ResourceLoader.exists(cc_scene_path):
push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping")
return
var packed := load(cc_scene_path) as PackedScene
if packed == null:
return
var cc := packed.instantiate() as CharacterCreation
if cc == null:
return
auto_free(cc)
add_child(cc)
await get_tree().process_frame
# _hair_highlight_swatch must be set after _ready() builds the hair color dock
var swatch: Variant = cc.get("_hair_highlight_swatch")
assert_bool(swatch != null).override_failure_message(
"[#719] _hair_highlight_swatch must not be null — a display node must be created"
).is_true()
func test_hair_highlight_swatch_is_not_interactive() -> void:
## [ACCEPTANCE #719] The highlight swatch must be non-interactive.
## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback).
## WILL FAIL until #719 is implemented.
var cc_scene_path := "res://scenes/character_creation.tscn"
if not ResourceLoader.exists(cc_scene_path):
push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping")
return
var packed := load(cc_scene_path) as PackedScene
if packed == null:
return
var cc := packed.instantiate() as CharacterCreation
if cc == null:
return
auto_free(cc)
add_child(cc)
await get_tree().process_frame
var swatch: Variant = cc.get("_hair_highlight_swatch")
if swatch == null:
push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented")
return
# If swatch is a Control node, mouse_filter must be IGNORE (2)
if swatch is Control:
var ctrl := swatch as Control
assert_int(ctrl.mouse_filter).override_failure_message(
"[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)"
).is_equal(Control.MOUSE_FILTER_IGNORE)
# =============================================================================
# #720 — Manifest JSON completeness (replaces DirAccess scanning)
# =============================================================================
func test_manifest_json_is_parseable() -> void:
## manifest.json must exist and parse as a Dictionary.
var path := "res://assets/characters/manifest.json"
assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message(
"manifest.json must exist at res://assets/characters/manifest.json"
).is_true()
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_json_is_parseable: file not openable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
assert_bool(parsed is Dictionary).override_failure_message(
"manifest.json must parse as a JSON object (Dictionary)"
).is_true()
func test_manifest_hair_includes_all_asset_dirs() -> void:
## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in
## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads,
## long_dreads, mohawk, ponytail_f, simple_parted, slick_back.
## WILL FAIL until #720 populates the manifest fully.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var hair_list: Array = manifest.get("hair", [])
# All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04)
var expected_hair := [
"bald", "balding", "bob", "buns", "buzzed", "buzzed_female",
"dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f",
"simple_parted", "slick_back",
]
for hair_id in expected_hair:
assert_bool(hair_list.has(hair_id)).override_failure_message(
"[#720] manifest 'hair' must include '%s'" % hair_id
).is_true()
func test_manifest_heads_are_populated() -> void:
## [ACCEPTANCE #720] manifest.json "heads" must not be empty.
## heads/templates/ contains head_001..head_004 — all must be listed.
## WILL FAIL until #720 populates the manifest.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_heads_are_populated: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var heads_list: Array = manifest.get("heads", [])
var expected_heads := ["head_001", "head_002", "head_003", "head_004"]
assert_bool(not heads_list.is_empty()).override_failure_message(
"[#720] manifest 'heads' must not be empty — 4 head templates exist"
).is_true()
for head_id in expected_heads:
assert_bool(heads_list.has(head_id)).override_failure_message(
"[#720] manifest 'heads' must include '%s'" % head_id
).is_true()
func test_manifest_body_types_includes_all_11() -> void:
## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types.
## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child.
## WILL FAIL until #720 updates the manifest.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var bt_list: Array = manifest.get("body_types", [])
var expected_types := [
"thin_m", "thin_f", "average_m", "average_f",
"muscular_m", "muscular_f", "teen_m", "teen_f",
"heavy_m", "heavy_f", "child",
]
for bt in expected_types:
assert_bool(bt_list.has(bt)).override_failure_message(
"[#720] manifest 'body_types' must include '%s'" % bt
).is_true()
func test_manifest_clothing_includes_all_items() -> void:
## [ACCEPTANCE #720] manifest.json "clothing" must include all items in
## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic,
## jacket_utility, pants_cargo, shirt_henley.
## WILL FAIL until #720 populates the manifest.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var clothing_data: Variant = manifest.get("clothing", {})
var clothing_keys: Array = []
if clothing_data is Dictionary:
clothing_keys = (clothing_data as Dictionary).keys()
# All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04)
var expected_items := [
"boots_work", "coveralls_basic", "jacket_utility", "pants_cargo",
"peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley",
]
for item_id in expected_items:
assert_bool(clothing_keys.has(item_id)).override_failure_message(
"[#720] manifest 'clothing' must include '%s'" % item_id
).is_true()
func test_manifest_eyebrows_are_populated() -> void:
## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles.
## assets/characters/eyebrows/ has: female, regular, teen, thick.
## WILL FAIL until #720 populates the manifest.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var eb_list: Array = manifest.get("eyebrows", [])
var expected_eyebrows := ["female", "regular", "teen", "thick"]
assert_bool(not eb_list.is_empty()).override_failure_message(
"[#720] manifest 'eyebrows' must not be empty"
).is_true()
for eb_id in expected_eyebrows:
assert_bool(eb_list.has(eb_id)).override_failure_message(
"[#720] manifest 'eyebrows' must include '%s'" % eb_id
).is_true()
func test_dir_access_scan_functions_removed() -> void:
## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be
## removed from CharacterCreation. These functions fail in exported PCK builds.
## WILL FAIL until #720 removes the DirAccess fallbacks.
var cc_scene_path := "res://scenes/character_creation.tscn"
if not ResourceLoader.exists(cc_scene_path):
push_warning("test_dir_access_scan_functions_removed: scene not available — skipping")
return
var packed := load(cc_scene_path) as PackedScene
if packed == null:
return
var cc := packed.instantiate() as CharacterCreation
if cc == null:
return
auto_free(cc)
assert_bool(cc.has_method("_scan_subdirs")).override_failure_message(
"[#720] _scan_subdirs must be removed — use manifest JSON instead"
).is_false()
assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message(
"[#720] _scan_asset_ids must be removed — use manifest JSON instead"
).is_false()
# =============================================================================
# #712 — BoneAttachment3D overhead anchor in CharacterVisual
# =============================================================================
func test_character_visual_has_get_overhead_anchor() -> void:
## CharacterVisual must expose get_overhead_anchor() as part of its public API.
## This is a static assertion — no 3D assets required.
var cv := CharacterVisual.new()
auto_free(cv)
assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message(
"CharacterVisual must have get_overhead_anchor() method (#712)"
).is_true()
func test_overhead_anchor_is_null_before_load() -> void:
## get_overhead_anchor() must return null before load_descriptor() is called.
## The anchor is created during skeleton load, not at construction.
var cv := CharacterVisual.new()
auto_free(cv)
var anchor: Variant = cv.get_overhead_anchor()
assert_bool(anchor == null).override_failure_message(
"get_overhead_anchor() must be null before load_descriptor() is called"
).is_true()
func test_overhead_anchor_offset_constant() -> void:
## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset
## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0).
## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0).
## This test loads a scene and verifies if assets are present.
var cv := CharacterVisual.new()
auto_free(cv)
add_child(cv)
# Without GLB assets available in headless, skeleton load is a no-op.
# Check that _overhead_attachment is also null before load (belt-and-suspenders).
var attachment: Variant = cv.get("_overhead_attachment")
assert_bool(attachment == null).override_failure_message(
"_overhead_attachment must be null before skeleton is loaded"
).is_true()
-292
View File
@@ -1,292 +0,0 @@
## Sprint 16 #540: Sprite integration tests.
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
## bounding box, sprite asset existence from #541, and fog shader independence.
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
class_name TestSpriteIntegration
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
func before_test() -> void:
GameState.player_entity_id = 1
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
# -- Helpers -------------------------------------------------------------------
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
func test_entity_footprint_matches_d044_spec() -> void:
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
"D-044: ENTITY_WIDTH must be 24px"
).is_equal(24)
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
"D-044: ENTITY_HEIGHT must be 32px"
).is_equal(32)
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
# range (2 sim tiles) accurate with the tilted perspective.
var tile_2x: int = Constants.TILE_SIZE * 2
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
EntityRenderer.ENTITY_WIDTH, tile_2x]
).is_true()
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
EntityRenderer.ENTITY_HEIGHT, tile_2x]
).is_true()
func test_entity_width_fits_within_single_tile() -> void:
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
# Ensures horizontal centering offset is positive and entity doesn't overflow.
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
"Entity width must be less than TILE_SIZE for centered layout"
).is_true()
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
).is_true()
# -- Pixel position (S16-S04) -------------------------------------------------
func test_entity_pixel_position_at_tile_3_7() -> void:
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[10]
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
assert_that(node.position.x).override_failure_message(
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
).is_equal_approx(expected_x, 0.1)
assert_that(node.position.y).override_failure_message(
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
).is_equal_approx(expected_y, 0.1)
renderer.queue_free()
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
# With tilted sprites, south-facing entity must visually overlap northern.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var north_node = renderer.entity_nodes[20]
var south_node = renderer.entity_nodes[21]
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
).is_true()
renderer.queue_free()
func test_z_sort_y_position_difference_equals_tile_size() -> void:
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
# Verifies position calculation is consistent for adjacent tiles.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var node3 = renderer.entity_nodes[30]
var node4 = renderer.entity_nodes[31]
var delta_y := node4.position.y - node3.position.y
assert_that(delta_y).override_failure_message(
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
renderer.queue_free()
func test_z_sort_same_y_different_x_no_y_difference() -> void:
# S16-S07: Two entities at same y but different x → same pixel.y.
# Horizontal position must not affect y-sort order.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var left_node = renderer.entity_nodes[40]
var right_node = renderer.entity_nodes[41]
assert_that(left_node.position.y).override_failure_message(
"Entities at same y-tile must have same pixel.y regardless of x"
).is_equal_approx(right_node.position.y, 0.1)
renderer.queue_free()
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
# entity_renderer.gd must be able to load these paths.
for direction in ["north", "east", "south", "west"]:
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
assert_that(ResourceLoader.exists(path)).override_failure_message(
"NPC sprite missing: %s" % path
).is_true()
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
for direction in ["north", "east", "south", "west"]:
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
assert_that(ResourceLoader.exists(path)).override_failure_message(
"Wall sprite missing: %s" % path
).is_true()
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
# the art-direction tilt baked into sprites.
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
"fog_shader.gd must load correctly — must not be affected by sprite changes"
).is_true()
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
"fog.gdshader must exist — fog is screen-space and sprite-independent"
).is_true()
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
var fog = get_node_or_null("/root/FogState")
if fog == null:
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
return
# Provide visibility data but no entity renderer context
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
GameState.visibility_sectors = {
Vector2i(5, 5): "Forward",
Vector2i(6, 5): "Peripheral",
}
if fog.has_method("update_from_state"):
fog.update_from_state()
assert_that(fog.visibility_texture).override_failure_message(
"FogState visibility_texture must be populated independently of sprite state"
).is_not_null()
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
func test_octant_north_maps_to_north() -> void:
# S16-S12: "North" → "north"
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
func test_octant_northwest_maps_to_north() -> void:
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
func test_octant_northeast_maps_to_east() -> void:
# S16-S14: "Northeast" → "east"
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
func test_octant_east_maps_to_east() -> void:
# S16-S15: "East" → "east"
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
func test_octant_southeast_maps_to_south() -> void:
# S16-S16: "Southeast" → "south"
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
func test_octant_south_maps_to_south() -> void:
# S16-S17: "South" → "south"
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
func test_octant_southwest_maps_to_west() -> void:
# S16-S18: "Southwest" → "west"
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
func test_octant_west_maps_to_west() -> void:
# S16-S19: "West" → "west"
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
func test_octant_unknown_string_falls_back_to_south() -> void:
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
func test_octant_empty_string_falls_back_to_south() -> void:
# S16-S21: Empty string → "south" fallback
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
func test_entity_direction_npc_always_south() -> void:
# S16-S22: NPC entity → always "south" regardless of any data field.
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
# entity_id 99 is not the player
var dir := renderer._entity_direction(99, {"entity_id": 99,
"kind": {"variant": "Npc", "data": null}})
assert_that(dir).override_failure_message(
"NPC entity must always return 'south'"
).is_equal("south")
renderer.queue_free()
func test_entity_direction_player_uses_player_facing() -> void:
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "North"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='North' must return 'north'"
).is_equal("north")
renderer.queue_free()
func test_entity_direction_player_facing_east() -> void:
# S16-S24: Player facing "East" → "east"
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "East"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='East' must return 'east'"
).is_equal("east")
renderer.queue_free()
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
# Diagonal octants map to one of the four cardinal sprite sets.
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "Northwest"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='Northwest' must return 'north'"
).is_equal("north")
renderer.queue_free()
@@ -1 +0,0 @@
uid://b8snipm64g2b2
+5 -3
View File
@@ -291,13 +291,15 @@ func test_hud_time_row_updates_after_process() -> void:
add_child(instance)
GameState.apply_snapshot({
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
"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")

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