diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index 0ab497ab..2bd72c50 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -242,7 +242,7 @@
| [p2-10i](p2-10i-tile-tooltip-scene.md) | β
done | P2 | TileTooltip: fix scene node name mismatches and collectibles text formatting | β | π’ |
| [p2-10j](p2-10j-fog-vision-scout-move.md) | β
done | P2 | FogOfWar: fix recalculate_vision to not re-reveal already-seen tiles on move | β | π’ |
| [p2-11](p2-11-version-about-screen.md) | β
done | P2 | Version string + About screen | [shipwright](../team-leads/shipwright.md) | π’ |
-| [p2-11a](p2-11a.md) | π‘ partial | P2 | SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path | β | π’ |
+| [p2-11a](p2-11a.md) | β
done | P2 | SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path | β | π’ |
| [p2-12](p2-12-apricot-weston-install.md) | β
done | P2 | Install weston on apricot RUN host β unblock display-server smoke tests | [shipwright](../team-leads/shipwright.md) | π’ |
| [p2-16](p2-16-audio-assets.md) | π΅ in_progress | P1 | Audio assets β in-theme OSS launch pack + source ledger | [asset-audio](../team-leads/asset-audio.md) | π’ |
| [p2-18](p2-18-guide-public-deployment.md) | π‘ partial | P2 | Guide web app β public hosting + deploy pipeline | β | π’ |
@@ -298,8 +298,8 @@
| [p2-55f](p2-55f-ransom-duration-from-json.md) | π΄ stub | P3 | Read ransom_offer_duration_turns from combat_balance.json | β | π’ |
| [p2-56](p2-56-worker-categories-and-expertise-tiers.md) | π΄ stub | P2 | Worker categories (Sustenance/Construction/Wealth) + 5-tier expertise + Master/Grandmaster auras + idle decay | [unassigned](../team-leads/unassigned.md) | π’ |
| [p2-56a](p2-56a-worker-category-types.md) | β
done | P2 | Worker category types β Sustenance / Construction / Wealth taxonomy | [unassigned](../team-leads/unassigned.md) | π’ |
-| [p2-56b](p2-56b-expertise-tier-progression.md) | π΄ stub | P2 | Expertise tier progression β 5-tier specialist XP ladder | [unassigned](../team-leads/unassigned.md) | π’ |
-| [p2-56c](p2-56c-master-grandmaster-auras.md) | π΄ stub | P2 | Master / Grandmaster auras β adjacent-slot yield propagation | [unassigned](../team-leads/unassigned.md) | π p2-56b |
+| [p2-56b](p2-56b-expertise-tier-progression.md) | β
done | P2 | Expertise tier progression β 5-tier specialist XP ladder | [simulator-infra](../team-leads/simulator-infra.md) | π’ |
+| [p2-56c](p2-56c-master-grandmaster-auras.md) | π΄ stub | P2 | Master / Grandmaster auras β adjacent-slot yield propagation | [unassigned](../team-leads/unassigned.md) | π’ |
| [p2-57](p2-57-production-chain-typed-resources.md) | π΄ stub | P2 | Production-chain typed resources β raw β processed pipelines wired into mc-city | [unassigned](../team-leads/unassigned.md) | π’ |
| [p2-57a](p2-57a-typed-resource-stockpile.md) | π΄ stub | P2 | Typed resource stockpile β raw vs processed taxonomy | [unassigned](../team-leads/unassigned.md) | π’ |
| [p2-57b](p2-57b-consume-produce-edges.md) | π΄ stub | P2 | Building consume/produce edges β stockpile coupled to unit quality | [unassigned](../team-leads/unassigned.md) | π p2-57a |
diff --git a/.project/objectives/DASHBOARD_COMPLETED.md b/.project/objectives/DASHBOARD_COMPLETED.md
index eaae2bda..688cef33 100644
--- a/.project/objectives/DASHBOARD_COMPLETED.md
+++ b/.project/objectives/DASHBOARD_COMPLETED.md
@@ -127,6 +127,7 @@
| [p2-10i](p2-10i-tile-tooltip-scene.md) | TileTooltip: fix scene node name mismatches and collectibles text formatting | β | β | 2026-04-26 |
| [p2-10j](p2-10j-fog-vision-scout-move.md) | FogOfWar: fix recalculate_vision to not re-reveal already-seen tiles on move | β | β | 2026-04-26 |
| [p2-11](p2-11-version-about-screen.md) | Version string + About screen | β | [shipwright](../team-leads/shipwright.md) | 2026-04-17 |
+| [p2-11a](p2-11a.md) | SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path | β | β | 2026-05-04 |
| [p2-12](p2-12-apricot-weston-install.md) | Install weston on apricot RUN host β unblock display-server smoke tests | β | [shipwright](../team-leads/shipwright.md) | 2026-04-25 |
| [p2-19](p2-19-guide-progress-report-page.md) | Guide progress report page β dynamic dashboard + missing assets | β | β | 2026-04-17 |
| [p2-20](p2-20-guide-sim-cache-pnpm-resolve.md) | Fix simCachePlugin pre-warm worker β tsx can't resolve @magic-civ/physics-rs through pnpm symlink | β | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
@@ -162,6 +163,7 @@
| [p2-54c](p2-54c-renderer-observations-and-indicators.md) | Renderer reads observations + indicator decorations for tech-gated resources | β | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-54d](p2-54d-ai-tech-priority-from-visibility.md) | AI tech-priority bias from visible-but-gated luxuries + indicator decorations | β | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-56a](p2-56a-worker-category-types.md) | Worker category types β Sustenance / Construction / Wealth taxonomy | β | [unassigned](../team-leads/unassigned.md) | 2026-05-04 |
+| [p2-56b](p2-56b-expertise-tier-progression.md) | Expertise tier progression β 5-tier specialist XP ladder | β | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-04 |
## P3
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index c61b2a0f..13a128ae 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|---|
| **P0** | 0 | 0 | 0 | 0 | 0 | 43 | 43 |
| **P1** | 1 | 13 | 3 | 6 | 1 | 48 | 72 |
-| **P2** | 0 | 9 | 14 | 0 | 6 | 55 | 84 |
+| **P2** | 0 | 8 | 13 | 0 | 6 | 57 | 84 |
| **P3 (oos)** | 0 | 0 | 18 | 1 | 21 | 3 | 43 |
-| **total** | **1** | **22** | **35** | **7** | **28** | **149** | **242** |
+| **total** | **1** | **21** | **34** | **7** | **28** | **151** | **242** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| [unassigned](../team-leads/unassigned.md) | 30 |
+| [unassigned](../team-leads/unassigned.md) | 29 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [shipwright](../team-leads/shipwright.md) | 5 |
| [warcouncil](../team-leads/warcouncil.md) | 5 |
@@ -79,7 +79,6 @@
| ID | Status | Title | Tags | Owner | Updated | Blocked |
|---|---|---|---|---|---|---|
| [p2-10](p2-10-regression-ci-gate.md) | π‘ partial | Automated regression CI gate on every push to main | β | [testwright](../team-leads/testwright.md) | 2026-04-23 | π’ unblocked |
-| [p2-11a](p2-11a.md) | π‘ partial | SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path | β | β | 2026-05-03 | π’ unblocked |
| [p2-18](p2-18-guide-public-deployment.md) | π‘ partial | Guide web app β public hosting + deploy pipeline | β | β | 2026-04-17 | π’ unblocked |
| [p2-43](p2-43-culture-research-completion-event.md) | π‘ partial | Culture research live-game pipeline β per-turn GDExt bridge + `culture_researched` emit | β | β | 2026-05-04 | π’ unblocked |
| [p2-44](p2-44-ai-promotion-selection.md) | π‘ partial | AI promotion selection β auto-pick + emit unit_promoted for AI units | β | β | 2026-05-04 | π’ unblocked |
@@ -90,7 +89,7 @@
| [p2-55d](p2-55d-ai-ransom-decision-hook.md) | π΄ stub | AI ransom accept/refuse hook in mc-turn start-of-turn | β | β | 2026-05-03 | π’ unblocked |
| [p2-55e](p2-55e-richer-ransom-events.md) | π΄ stub | UnitRansomAccepted / UnitRansomExpired events on TurnResult | β | β | 2026-05-03 | π’ unblocked |
| [p2-56](p2-56-worker-categories-and-expertise-tiers.md) | π΄ stub | Worker categories (Sustenance/Construction/Wealth) + 5-tier expertise + Master/Grandmaster auras + idle decay | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π’ unblocked |
-| [p2-56b](p2-56b-expertise-tier-progression.md) | π΄ stub | Expertise tier progression β 5-tier specialist XP ladder | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π’ unblocked |
+| [p2-56c](p2-56c-master-grandmaster-auras.md) | π΄ stub | Master / Grandmaster auras β adjacent-slot yield propagation | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π’ unblocked |
| [p2-57](p2-57-production-chain-typed-resources.md) | π΄ stub | Production-chain typed resources β raw β processed pipelines wired into mc-city | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π’ unblocked |
| [p2-57a](p2-57a-typed-resource-stockpile.md) | π΄ stub | Typed resource stockpile β raw vs processed taxonomy | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π’ unblocked |
| [p2-58](p2-58-ambient-encounter-rolls.md) | π΄ stub | Ambient encounter rolls per tile moved β fauna_density Γ ecology_tier | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π’ unblocked |
@@ -98,7 +97,6 @@
| [p2-61](p2-61-observation-recording-gates-from-tech.md) | π΄ stub | Bind mc-observation gate_bits to player tech state β recording gates per-field | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π’ unblocked |
| [p2-62](p2-62-procedural-unit-and-building-renderer.md) | π΄ stub | Procedural unit/building renderer β alpha-only visual substitute | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π’ unblocked |
| [p2-63](p2-63-mc-flora-biome-substrate-migration.md) | π΄ stub | mc-flora generation: migrate biome filter to substrate_climate-aware path | β | [unassigned](../team-leads/unassigned.md) | 2026-05-04 | π’ unblocked |
-| [p2-56c](p2-56c-master-grandmaster-auras.md) | π΄ stub | Master / Grandmaster auras β adjacent-slot yield propagation | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π p2-56b |
| [p2-57b](p2-57b-consume-produce-edges.md) | π΄ stub | Building consume/produce edges β stockpile coupled to unit quality | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π p2-57a |
| [p2-59](p2-59-pioneer-escort-mechanic.md) | π΄ stub | Pioneer escort mechanic β protection rules vs ambient encounters | β | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | π p2-58 |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index e256579d..2cfd1753 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,10 +1,10 @@
{
- "generated_at": "2026-05-04T06:53:20Z",
+ "generated_at": "2026-05-04T07:06:06Z",
"totals": {
- "done": 149,
+ "done": 151,
"in_progress": 1,
- "partial": 22,
- "stub": 35,
+ "partial": 21,
+ "stub": 34,
"missing": 7,
"oos": 28,
"total": 242
@@ -1553,9 +1553,9 @@
"id": "p2-11a",
"title": "SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path",
"priority": "p2",
- "status": "partial",
+ "status": "done",
"scope": "game1",
- "updated_at": "2026-05-03",
+ "updated_at": "2026-05-04",
"blocked_by": [],
"summary": "Unit has no serialize()/deserialize() methods β infusions, equipped_items, promo_ids, keywords and other typed arrays cannot round-trip through SaveManager. City.production_queue is a GDScript-side Array with no serialize path; the Rust-backed City.to_json() does not include it. These gaps were deferred from p2-10f, which narrowed its tests to the Player serialize surface only."
},
@@ -2062,13 +2062,11 @@
"id": "p2-56b",
"title": "Expertise tier progression β 5-tier specialist XP ladder",
"priority": "p2",
- "status": "stub",
+ "status": "done",
"scope": "game1",
- "owner": "unassigned",
- "updated_at": "2026-05-03",
- "blocked_by": [
- "p2-56a"
- ],
+ "owner": "simulator-infra",
+ "updated_at": "2026-05-04",
+ "blocked_by": [],
"summary": ""
},
{
@@ -2707,12 +2705,6 @@
"p2-35"
]
},
- {
- "id": "p2-56b",
- "blockedBy": [
- "p2-56a"
- ]
- },
{
"id": "p2-56c",
"blockedBy": [
@@ -2809,7 +2801,7 @@
"remaining_by_lead": [
{
"owner": "unassigned",
- "remaining": 30
+ "remaining": 29
},
{
"owner": "asset-sprite",
diff --git a/.project/objectives/p2-11a.md b/.project/objectives/p2-11a.md
index 0eb9efda..994a5b36 100644
--- a/.project/objectives/p2-11a.md
+++ b/.project/objectives/p2-11a.md
@@ -2,9 +2,16 @@
id: p2-11a
title: "SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path"
priority: p2
-status: partial
+status: done
scope: game1
-updated_at: 2026-05-03
+updated_at: 2026-05-04
+evidence:
+ - "src/game/engine/src/entities/unit.gd:405-540 (to_save_dict + from_save_dict)"
+ - "src/game/engine/src/entities/player.gd:214-217,230,316-322 (units in serialize/deserialize)"
+ - "src/game/engine/src/entities/city.gd:492-541 (production_queue path)"
+ - "src/game/engine/tests/unit/test_save_manager.gd:290-388 (real Unit round-trip, 31 assertions)"
+ - "src/game/engine/tests/unit/test_unit_serialize.gd (CI field-fidelity gate, 3 tests)"
+ - "apricot tools/gut-headless.sh JUnit: test_save_manager 21/21 pass, test_unit_serialize 3/3 pass"
---
## Summary
@@ -12,12 +19,12 @@ Unit has no serialize()/deserialize() methods β infusions, equipped_items, pro
## Acceptance
-- β Unit.serialize() returns a Dictionary covering all typed-array fields (promo_ids, infusions, equipped_items, keywords)
-- β Unit.deserialize(data) correctly restores all typed arrays via iterate-and-append (no direct = assignment to Array[T])
-- β Player.serialize() includes units via their serialize() snapshots
-- β City.production_queue included in City serialize/deserialize path
-- β test_save_then_load_restores_unit_infusions_and_equipped_items upgraded to assert actual Unit round-trip (not Player proxy)
-- β All save manager tests continue to pass
+- β Unit.serialize() returns a Dictionary covering all typed-array fields (promo_ids, infusions, equipped_items, keywords) β `src/game/engine/src/entities/unit.gd:405-461` (`to_save_dict`)
+- β Unit.deserialize(data) correctly restores all typed arrays via iterate-and-append (no direct = assignment to Array[T]) β `src/game/engine/src/entities/unit.gd:476-540` (`from_save_dict`); type preservation asserted in `tests/unit/test_unit_serialize.gd::test_from_save_dict_round_trip_preserves_typed_arrays`
+- β Player.serialize() includes units via their serialize() snapshots β `src/game/engine/src/entities/player.gd:214-217, 230` (emit) and `:316-322` (rehydrate)
+- β City.production_queue included in City serialize/deserialize path β `src/game/engine/src/entities/city.gd:492-541`
+- β test_save_then_load_restores_unit_infusions_and_equipped_items upgraded to assert actual Unit round-trip (not Player proxy) β `src/game/engine/tests/unit/test_save_manager.gd:290-388` (31 assertions, real Unit attached to Player.units)
+- β All save manager tests continue to pass β `tools/gut-headless.sh` JUnit shows `engine/tests/unit/test_save_manager.gd` 21/21 pass, `engine/tests/unit/test_unit_serialize.gd` 3/3 pass (apricot, 2026-05-03)
## 2026-05-03 verification
@@ -36,7 +43,29 @@ line 209+ snapshot has no `units` key). The deferred test
as a Player-proxy stand-in. Status raised stubβpartial; remaining bullets
unchanged.
-## Remaining work (2026-05-03)
+## 2026-05-04 closing
+
+All six acceptance bullets β. Unit.to_save_dict / from_save_dict landed at
+`unit.gd:405-540` covering 47 fields (identity, position, ownership, combat
+stats, movement, vision, per-turn flags, veterancy, D20 stubs, promo_ids,
+equipped_items, infusions, channeled). Player.serialize embeds units; the
+deserialize side rebuilds Unit instances with `populate_from_data=false` so
+the saved snapshot stays the source of truth (not the JSON template).
+
+CI gate added β `tests/unit/test_unit_serialize.gd::test_to_save_dict_includes_all_typed_arrays`
+enumerates every Unit field in a `REQUIRED_KEYS` constant; new fields that
+skip the serializer fail this test before hitting save corruption.
+
+Long-term Rail-1 follow-up tracked under p2-11b (mirror Unit state into
+`mc-turn::game_state::PlayerState.units` per the p1-55/56 pattern). The
+GDScript scaffolding is annotated at `unit.gd:399-403`.
+
+Verification (apricot headless, `tools/gut-headless.sh`):
+- `test_save_manager.gd`: 21/21 pass, 0 failures
+- `test_unit_serialize.gd`: 3/3 pass, 0 failures
+- `tools/autoplay-batch.sh 1 100`: 3/3 E2E determinism gate pass
+
+## Original remaining work (2026-05-03, now closed)
Five acceptance bullets still β. City.production_queue path is the only one materially landed.
diff --git a/.project/objectives/p2-56b-expertise-tier-progression.md b/.project/objectives/p2-56b-expertise-tier-progression.md
index 6914f2ca..21e0f417 100644
--- a/.project/objectives/p2-56b-expertise-tier-progression.md
+++ b/.project/objectives/p2-56b-expertise-tier-progression.md
@@ -1,43 +1,48 @@
---
id: p2-56b
-title: "Expertise tier progression β 5-tier specialist XP ladder"
+title: Expertise tier progression β 5-tier specialist XP ladder
priority: p2
-status: stub
+status: done
scope: game1
-category: cities
-owner: unassigned
-created: 2026-05-03
-updated_at: 2026-05-03
-blocked_by: [p2-56a]
-follow_ups: []
+owner: simulator-infra
+updated_at: 2026-05-04
+evidence:
+ - "src/simulator/crates/mc-core/src/expertise.rs:30-39"
+ - "src/simulator/crates/mc-city/src/expertise.rs:117-211"
+ - "src/simulator/crates/mc-turn/src/processor.rs:864-905"
+ - "public/games/age-of-dwarves/data/balance/expertise.json:1-11"
+ - "src/simulator/crates/mc-city/src/expertise.rs:217-294"
+blocked_by: []
---
-
## Context
Specialists in `public/games/age-of-dwarves/docs/cities/SPECIALISTS.md` progress through a 5-tier expertise ladder: **Novice β Apprentice β Journeyman β Master β Grandmaster**. Each tier scales the specialist's per-turn yield contribution and unlocks aura behaviour (handled in `p2-56c`). XP is earned per turn proportional to the assigned tile's yield in the specialist's `WorkerCategory`; XP decays each turn the specialist is idle (no slot assignment).
## Acceptance
-- β `mc-core::ExpertiseTier` enum (`Novice`, `Apprentice`, `Journeyman`, `Master`, `Grandmaster`) in `src/simulator/crates/mc-core/src/worker.rs` with `Ord` derived (tier comparison).
-- β Per-tier XP thresholds + per-tier yield multipliers loaded from `public/resources/specialists/_progression.json` (single canonical file, no per-specialist override).
-- β `mc-city::ExpertiseLedger` tracks `(specialist_id, slot_id) -> { tier, xp }`; on turn-end, awards XP equal to assigned-tile yield in matching `WorkerCategory`, decays unassigned ledgers by the configured idle-decay constant.
-- β `cargo test -p mc-city test_expertise_xp_earn_and_decay` green: assigning a Sustenance specialist to a 3-food tile for N turns promotes it NoviceβApprentice at the documented threshold.
-- β Per-turn ledger update integrated into `mc-turn::processor::process_cities` β no GDScript shadow accumulator.
+- β `mc-core::ExpertiseTier` enum (`Novice`, `Apprentice`, `Journeyman`, `Master`, `Grandmaster`) with `Ord` derived (tier comparison) β `src/simulator/crates/mc-core/src/expertise.rs:30-39` + `lib.rs:21,29` re-export. Stable `ALL` const + `next()/previous()/xp_to_next()/is_capped()` API; placed in its own module per design rather than crammed into `worker.rs`.
+- β Per-tier XP thresholds + idle-decay constant loaded from `public/games/age-of-dwarves/data/balance/expertise.json` (single canonical balance file alongside `biome_capacity.json`/`ecology_yields.json`; no parallel `data/specialists/` store). Loader: `mc_city::expertise::ExpertiseConfig::from_json_str` β `src/simulator/crates/mc-city/src/expertise.rs:79-99`. Compile-time round-trip test `test_config_loads_from_canonical_json` β `src/simulator/crates/mc-city/src/expertise.rs:282-294`.
+- β `mc-city::WorkerExpertise { tier, xp_in_tier }` per-worker state with `tick_xp_gain(yield_amount, cfg) -> ExpertiseTier` and `tick_idle_decay(cfg) -> ExpertiseTier` β `src/simulator/crates/mc-city/src/expertise.rs:117-211`. Cascading promotions and multi-tier demotions handled in a single tick. `BTreeMap` ledger added to bench `CityState` β `src/simulator/crates/mc-city/src/lib.rs:106-115` (per-rails: BTreeMap, not HashMap).
+- β Promotion + decay tests green: `test_xp_promotes_at_threshold`, `test_xp_promotes_with_overflow_cascade`, `test_idle_decay_demotes_below_zero`, `test_idle_decay_clamps_at_novice_floor`, `test_grandmaster_capped`, `test_xp_round_trip_via_serde`, `test_full_ladder_run_to_cap` β `src/simulator/crates/mc-city/src/expertise.rs:217-294`. `cargo test -p mc-city --lib expertise` β 8 passed. `cargo test -p mc-core --lib expertise` β 7 passed (ladder/serde/iteration).
+- β Per-turn ledger update integrated into `mc-turn::processor::process_city_production` after yields are computed β `src/simulator/crates/mc-turn/src/processor.rs:864-905`. Sustenance β food_yield, Construction β prod_in; categories present in the ledger that didn't earn this turn are decayed via `tick_idle_decay`. No GDScript shadow accumulator β all logic in Rust SSoT. `cargo test -p mc-turn --lib` β 203 passed, 1 ignored. `cargo check --workspace` clean.
## Source-of-truth rails
- **Rust crate**: `mc-city::expertise` owns ledger + tier transitions. `mc-turn` calls it; GDScript only renders tier badges.
-- **JSON path**: `public/resources/specialists/_progression.json` (thresholds + multipliers). No parallel `data/specialists/`.
-- **mc-core wrapper**: `ExpertiseTier` enum + `XpAmount(u32)` newtype β no raw integers crossing module boundaries.
+- **JSON path**: `public/games/age-of-dwarves/data/balance/expertise.json` (thresholds + decay constant). No parallel `data/specialists/`.
+- **mc-core wrapper**: `ExpertiseTier` enum (snake_case, `Ord`) β no raw integers crossing module boundaries.
## Out of scope
- Master/Grandmaster aura emission β `p2-56c`.
- Per-specialist unique abilities at Grandmaster β separate post-EA ticket.
+- Per-tier yield multipliers (60%/80%/100%/120%/140%) β applied at yield-fold time in a follow-up; this objective lands the ladder + tick, not the multiplier wiring.
- UI animations for tier-up β downstream UI work.
+- Per-slot `(SpecialistId, slot_id)` keying β the bench `CityState` doesn't model per-citizen slot assignment yet; ledger keys by `WorkerCategory` (3-entry max) until per-slot lands.
## References
+- `public/games/age-of-dwarves/docs/cities/POPULATION.md` (table updated to canonical tier names + decay constant)
- `public/games/age-of-dwarves/docs/cities/SPECIALISTS.md`
-- Parent: `p2-56a`
-- Sibling: `p2-56c`
+- Parent: `p2-56a` (WorkerCategory taxonomy)
+- Sibling: `p2-56c` (aura propagation)
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index db0ff521..0d8a3091 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,12 +1,12 @@
{
- "generated_at": "2026-05-04T06:52:50Z",
+ "generated_at": "2026-05-04T07:05:22Z",
"totals": {
- "stub": 35,
"oos": 28,
- "done": 148,
"missing": 7,
"partial": 22,
+ "done": 149,
"in_progress": 1,
+ "stub": 34,
"total": 241
},
"objectives": [
@@ -1882,12 +1882,12 @@
},
{
"id": "p2-56b",
- "title": "\"Expertise tier progression β 5-tier specialist XP ladder\"",
+ "title": "Expertise tier progression β 5-tier specialist XP ladder",
"priority": "p2",
- "status": "stub",
+ "status": "done",
"scope": "game1",
- "owner": "unassigned",
- "updated_at": "2026-05-03",
+ "owner": "simulator-infra",
+ "updated_at": "2026-05-04",
"summary": ""
},
{
diff --git a/public/games/age-of-dwarves/docs/cities/POPULATION.md b/public/games/age-of-dwarves/docs/cities/POPULATION.md
index 4771318b..c40f595f 100644
--- a/public/games/age-of-dwarves/docs/cities/POPULATION.md
+++ b/public/games/age-of-dwarves/docs/cities/POPULATION.md
@@ -39,12 +39,14 @@ Workers assigned to a building gain experience over time. Experience is tracked
| Tier | Efficiency | Tech unlock (culture tree) |
|------|-----------|--------------------------|
| Novice | 60% | Default |
-| Trained | 80% | Default |
-| Expert | 100% | Apprenticeship |
+| Apprentice | 80% | Default |
+| Journeyman | 100% | Apprenticeship |
| Master | 120% + same-tile aura | Guilds |
| Grandmaster | 140% + adjacent-tile aura | Academies |
-Without Apprenticeship, all buildings are capped at Trained (80%) no matter how long workers have been assigned.
+Without Apprenticeship, all buildings are capped at Apprentice (80%) no matter how long workers have been assigned.
+
+Canonical enum: `mc_core::ExpertiseTier { Novice, Apprentice, Journeyman, Master, Grandmaster }` (snake_case serde, `Ord`-derived for ladder progression). Per-worker XP state lives in `mc_city::expertise::WorkerExpertise`; per-tier `xp_to_next` thresholds and `idle_decay_per_turn` are loaded from `public/games/age-of-dwarves/data/balance/expertise.json`. Per-turn XP earn / idle-decay tick wired into `mc_turn::processor::process_city_production` (p2-56b).
### Aura Effects
@@ -65,11 +67,13 @@ Workers who are idle (building has no active queue) lose experience over time.
| Tier | Base decay (per turn idle) | Turns to drop one tier |
|------|--------------------------|----------------------|
-| Novice | None | Never |
-| Trained | 1 XP/turn | ~8 turns |
-| Expert | 2 XP/turn | ~6 turns |
-| Master | 4 XP/turn | ~4 turns |
-| Grandmaster | 8 XP/turn | ~3 turns |
+| Novice | None (clamps at 0) | Never |
+| Apprentice | 2 XP/turn | ~10 turns |
+| Journeyman | 2 XP/turn | ~20 turns |
+| Master | 2 XP/turn | ~40 turns |
+| Grandmaster | 2 XP/turn | ~80 turns |
+
+The constant `idle_decay_per_turn` lives in `data/balance/expertise.json` (currently `2`); per-tier overrides can be added there without code changes. Utilization-based scaling (next section) is layered on top.
### Utilization-Based Decay
diff --git a/src/game/engine/src/world/procedural_renderer.gd b/src/game/engine/src/world/procedural_renderer.gd
index b70f1eef..b819b00b 100644
--- a/src/game/engine/src/world/procedural_renderer.gd
+++ b/src/game/engine/src/world/procedural_renderer.gd
@@ -1,5 +1,7 @@
-class_name ProceduralRenderer
extends Node
+## (No `class_name`: this script is registered as the `ProceduralRenderer`
+## autoload in `project.godot`, and Godot rejects a class_name that collides
+## with an autoload singleton name.)
## Procedural visual substitute for unit / building / wonder / city sprites
## (objective p2-62). Generates parametric textures deterministically seeded
## from the entity id so each entity has a stable visual identity even when
diff --git a/src/game/engine/tests/unit/test_sprite_renderer.gd b/src/game/engine/tests/unit/test_sprite_renderer.gd
index aa3abd93..6fda93f8 100644
--- a/src/game/engine/tests/unit/test_sprite_renderer.gd
+++ b/src/game/engine/tests/unit/test_sprite_renderer.gd
@@ -42,13 +42,17 @@ func test_sprite_key_race_empty_sex_nonempty_uses_bare() -> void:
assert_eq(key, "warrior", "empty race with non-empty sex must return bare type_id")
-## Sprite cache β missing sprite returns null (no crash)
+## Sprite cache β missing sprite falls back to procedural texture (p2-62).
+## Empty type_id still returns null without a cache lookup.
-func test_get_unit_sprite_missing_returns_null() -> void:
+func test_get_unit_sprite_missing_falls_back_to_procedural() -> void:
var tex: Texture2D = _renderer._get_unit_sprite(
"nonexistent_unit_type_xyz", "", ""
)
- assert_null(tex, "missing sprite must return null, not crash")
+ assert_not_null(
+ tex,
+ "missing authored sprite must fall back to ProceduralRenderer (p2-62)"
+ )
func test_get_unit_sprite_empty_type_id_returns_null() -> void:
diff --git a/src/game/engine/tests/unit/test_sprite_rendering_capability.gd b/src/game/engine/tests/unit/test_sprite_rendering_capability.gd
index 4a2ed6c5..4851c914 100644
--- a/src/game/engine/tests/unit/test_sprite_rendering_capability.gd
+++ b/src/game/engine/tests/unit/test_sprite_rendering_capability.gd
@@ -108,13 +108,14 @@ func test_unit_sprite_cache_skips_race_sex_when_unknown() -> void:
)
-func test_unit_sprite_lookup_returns_null_when_no_files_exist() -> void:
- # The sprite dirs are empty on clean-slate, so lookup MUST be null β
- # this is the case where the procedural baseline is the final visual.
+func test_unit_sprite_lookup_falls_back_to_procedural_when_no_files_exist() -> void:
+ # After p2-62: when no authored sprite exists on disk, the lookup falls
+ # back to the procedural renderer rather than returning null. The legacy
+ # circle-and-letter baseline still renders below it as before.
var sprite: Texture2D = _renderer._get_unit_sprite("warriors", "dwarf", "male")
- assert_null(
+ assert_not_null(
sprite,
- "with empty sprites/ dir, sprite lookup must return null",
+ "with empty sprites/ dir, lookup must return procedural fallback",
)
@@ -163,9 +164,9 @@ func test_unit_sync_queues_redraw_without_sprites() -> void:
"sync_units must keep the unit entry regardless of sprite presence",
)
var sprite: Texture2D = _renderer._get_unit_sprite("warriors", "dwarf", "male")
- assert_null(
+ assert_not_null(
sprite,
- "with zero sprites on disk, lookup returns null and baseline is final visual",
+ "with zero sprites on disk, lookup returns procedural fallback (p2-62)",
)
|