diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 3bb3a107..1b8bb4fd 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -15,10 +15,10 @@
| Priority | β
| π΅ | π‘ | π΄ | β | β« | Total |
|---|---|---|---|---|---|---|---|
| **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 |
-| **P1** | 33 | 1 | 8 | 0 | 12 | 1 | 55 |
+| **P1** | 33 | 1 | 8 | 0 | 13 | 1 | 56 |
| **P2** | 32 | 0 | 2 | 1 | 1 | 0 | 36 |
| **P3 (oos)** | 3 | 0 | 0 | 0 | 1 | 19 | 23 |
-| **total** | **111** | **1** | **10** | **1** | **14** | **20** | **157** |
+| **total** | **111** | **1** | **10** | **1** | **15** | **20** | **158** |
@@ -130,7 +130,8 @@
| [p1-40](p1-40-single-source-of-truth-resources.md) | β
done | Collapse data// override layer into single source of truth at resources/ | β | 2026-04-29 |
| [p1-41](p1-41-game-pack-subscription-manifest.md) | β
done | Game-pack subscription manifest + loader filter (Phase B of resources/ unification) | β | 2026-04-29 |
| [p1-42](p1-42-ai-full-building-catalog.md) | β missing | AI must consider the full 155-building catalog, not the hardcoded 8-id ladder | β | 2026-04-29 |
-| [p1-43](p1-43-building-stacking-upgrade.md) | β missing | Building stacking β build on top of an existing building to upgrade it (e.g. double barracks β infantry) | β | 2026-04-29 |
+| [p1-43](p1-43-building-stacking-upgrade.md) | β missing | Building stacking β per-category upgrade chains (military / science / culture / production / etc.) | β | 2026-04-29 |
+| [p1-44](p1-44-buildings-as-producers.md) | β missing | Buildings produce units, not the city center β per-building production queues | β | 2026-04-29 |
| [p2-06](p2-06-export-pipeline.md) | β
done | Export pipeline for Windows / macOS / Linux | [shipwright](../team-leads/shipwright.md) | 2026-04-25 |
| [p2-16](p2-16-audio-assets.md) | π΅ in_progress | Audio assets β in-theme OSS launch pack + source ledger | [asset-audio](../team-leads/asset-audio.md) | 2026-04-27 |
| [p2-22](p2-22-sprite-generation-pipeline.md) | π‘ partial | Sprite generation pipeline β runnable end-to-end | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 |
diff --git a/.project/objectives/p1-43-building-stacking-upgrade.md b/.project/objectives/p1-43-building-stacking-upgrade.md
index 6ea81606..3c615662 100644
--- a/.project/objectives/p1-43-building-stacking-upgrade.md
+++ b/.project/objectives/p1-43-building-stacking-upgrade.md
@@ -1,6 +1,6 @@
---
id: p1-43
-title: Building stacking β build on top of an existing building to upgrade it (e.g. double barracks β infantry)
+title: Building stacking β per-category upgrade chains (military / science / culture / production / etc.)
priority: p1
status: missing
scope: game1
@@ -9,9 +9,24 @@ updated_at: 2026-04-29
## Summary
-User direction (2026-04-29): "all the buildings should be buildable and some buildings can be built on top of each other (double barracks - infantry)".
+User direction (2026-04-29): "all the buildings should be buildable and some buildings can be built on top of each other (double barracks - infantry) ... what about comboing other buildings ... science stack, culture stack".
-Today every building is binary: a city either has it or doesn't. The mechanic the user wants: queueing certain buildings on top of an existing one upgrades the slot in place β `barracks` + another `barracks` build = `infantry` (a stronger military producer). This is distinct from the BUILDINGS.md "Hybrid Merged Structures" mechanic (which combines TWO different buildings + Synthesis tech into a hybrid). Stacking is the simpler primitive: same-building-twice becomes its successor.
+Today every building is binary: a city either has it or doesn't. The mechanic the user wants: queueing a building on top of an existing one upgrades the slot in place β `barracks` + another `barracks` build = `infantry` (a stronger military producer). The same primitive applies to every category: science stacks (library β scriptorium β academy), culture stacks (monument β bardic_circle β great_hall), production stacks (forge β iron_forge β grand_forge), etc. This is distinct from the BUILDINGS.md "Hybrid Merged Structures" mechanic (which combines TWO different buildings + Synthesis tech into a hybrid). Stacking is the simpler primitive: same-category Lv1 β Lv2 β Lv3 chains within one slot.
+
+The existing data already implies category-tier chains via the `tier` + `category` fields:
+
+| Category | Lv1 (no tech) | Lv2 (mid tech) | Lv3+ (late tech) |
+|---|---|---|---|
+| Production | `forge` t1 | (gap β `iron_forge` doesn't exist) | `dwarf_deep_forge` t3, `tempering_forge` t6, `steam_forge` t7, `adamantine_foundry` t10 |
+| Science | `library` t1 | `university` t3, `observatory` t3 | `academy_of_sciences` t5, `climate_institute` t9 |
+| Culture | `monument` t1 | `great_hall` t3, `gathering_hall` t2 | `ancestor_hall` t10 |
+| Military | `barracks` t1 | (gap β `infantry` doesn't exist) | `armory` t3, `military_academy` t6, `command_citadel` t10 |
+| Food | `granary` t1 | `mill` t2, `brewery` t2, `watermill` t2 | `great_granary` t2 (wonder) |
+| Defense | `walls` t1 | `watchtower` t1 | `castle` t3 |
+| Wealth | `marketplace` t2, `market` t2 (DUPLICATE) | `guild_hall` t4 | (none) |
+| Religion | `temple` t2 | `temple_of_the_ancestor` t5 (wonder) | (none) |
+
+The stacking schema makes these chains explicit and queryable. Where a Lv2 successor doesn't exist yet (e.g. `infantry`, `iron_forge`, `scriptorium`), this objective authors the missing intermediates.
Three design questions need user sign-off before authoring:
@@ -29,11 +44,16 @@ Recommendation: option **(a) Replacement** with declaration on the upper tier (`
- β Design pass + sign-off from user on the three questions above.
- β `building.schema.json` extends with: `requires_existing: `, `consumes_existing: `. Both default null/false. Validator confirms `requires_existing` resolves to a real building id.
- β For each declared stack-pair: the upper-tier building is NEW data (or repurposed existing) authored under `resources/buildings/.json` with the new fields populated.
-- β Initial pairs (suggested, pending user confirmation):
- - `barracks` β `infantry` (military)
- - `forge` β `iron_forge` (production) β natural progression toward `dwarf_deep_forge`
- - `library` β `scriptorium` (research) β pre-existing `university`/`observatory` make this a tier-1.5 step
- - Naming TBD; user picks.
+- β Initial 3-step ladders (suggested, pending user confirmation β each is ` β β `):
+ - **Military**: `barracks` β `infantry` (NEW) β `armory` (existing t3)
+ - **Science**: `library` β `scriptorium` (NEW) β `university` (existing t3)
+ - **Culture**: `monument` β `bardic_circle` (existing wonder t4, repurposed?) β `great_hall` (existing t3)
+ - **Production**: `forge` β `iron_forge` (NEW) β `dwarf_deep_forge` (existing t3) β note race-specific successor
+ - **Defense**: `walls` β `watchtower` (existing t1, demote?) β `castle` (existing t3) β already a clean ladder
+ - **Food**: `granary` β `mill` (existing t2) β `watermill` (existing t2) β needs a Lv3 (no candidate)
+ - **Wealth**: `marketplace` β `market` (existing duplicate, reconcile) β `guild_hall` (existing t4)
+ - **Religion**: `temple` β ??? (no Lv2/3 candidate, needs authoring)
+ - Final ladder list user-decided. New buildings authored where needed; existing ones get the `requires_existing` field added.
- β Engine: `city.can_build(bid)` returns true for upper-tier IF `bid::requires_existing` is in `city.buildings[]`. City build dispatch (`ai_turn_bridge_dispatch.gd::dispatch_set_production` and the GDScript city UI) honors `consumes_existing` by removing the prerequisite from `city.buildings[]` when the upgrade completes.
- β AI integration (depends on `p1-42`): the AI catalog scoring treats stack-upgrades as a single ladder β barracks β infantry scored as a 2-step build path with combined cost.
- β City UI surfaces stack relationships: when looking at `barracks` in the encyclopedia / build menu, the upgrade target is shown ("Can be upgraded to: Infantry").
@@ -42,10 +62,12 @@ Recommendation: option **(a) Replacement** with declaration on the upper tier (`
## Open questions for user
-1. Successor naming for `infantry` and other stack-pairs β pick from existing building IDs or author new ones?
-2. Do stack-upgrades cost the FULL upper-tier `cost`, or a discounted "upgrade cost"?
-3. When the prerequisite is consumed, do its effects vanish entirely, or does the upper-tier inherit them additively?
-4. Should the "Hybrid Merged Structures" objective (`p3-02`) be subsumed under this simpler stacking mechanic, or stay as a separate post-EA feature?
+1. **Successor identity per category**: confirm the 8 ladders above (or revise). For each, pick existing building or sign off on authoring a new Lv2 intermediate.
+2. **Cost**: do stack-upgrades cost the FULL upper-tier `cost`, or a discounted "upgrade cost" (e.g. `cost - prerequisite_cost`)?
+3. **Effects inheritance**: when the prerequisite is consumed, do its effects vanish entirely, or does the upper-tier inherit them additively (so an `armory` city has barracks+infantry+armory effects compounded)?
+4. **Multiple categories per city**: can a city have ONE stack ladder per category (8 ladders Γ 3 deep = 24 total slots) or share slots across categories?
+5. **Reconcile duplicates surfaced by the audit**: `marketplace` vs `market`, `monument` vs `bardic_circle` β fold one into the other or keep both?
+6. **`p3-02` relationship**: is the simpler "double X β Y" stacking enough for EA, with merged-structures (X+Y β hybrid) deferred? Or merge p3-02 into this objective?
## Out of scope
diff --git a/.project/objectives/p1-44-buildings-as-producers.md b/.project/objectives/p1-44-buildings-as-producers.md
new file mode 100644
index 00000000..a5daac6b
--- /dev/null
+++ b/.project/objectives/p1-44-buildings-as-producers.md
@@ -0,0 +1,74 @@
+---
+id: p1-44
+title: Buildings produce units, not the city center β per-building production queues
+priority: p1
+status: missing
+scope: game1
+updated_at: 2026-04-29
+---
+
+## Summary
+
+User direction (2026-04-29): "it would make sense to build those units in the appropriate buildings rather than the city center."
+
+Today every city has **one** production queue (`city.gd:57 production_queue: Array`) where the player picks "warrior", "barracks", "wonder", anything goes through the same FIFO. Building selection just gates the menu β the building doesn't actually *do* anything when production happens.
+
+The user's model β and the model `PRODUCTION_CHAIN.md` already describes β is that buildings are **producers**:
+
+> Every citizen is a resource pulled between three competing demands:
+> - **Construction** β builds new buildings, upgrades existing ones (investment)
+> - **Buildings** β produces units, research, culture, equipment (output)
+> - **Tiles** β produces food, raw materials, gold (sustenance)
+
+Concretely:
+- **Barracks** has its own queue producing infantry / armory / military_academy lineage units.
+- **Stable** queues cavalry units.
+- **Siege Workshop** queues siege units.
+- **Library** queues sages / cartographers / engineers (science civilians) and accumulates research per turn.
+- **Temple** queues battle priests and accumulates culture/happiness.
+- **Harbor** queues naval units (and gates them β see `p1-33`).
+- **Airfield** queues aerial units.
+- **Construction queue** (city-level, distinct from building queues) builds NEW buildings and upgrades existing ones.
+
+Each producer building runs its queue independently per turn. Citizens / production allocation gets split across buildings (per `PRODUCTION_CHAIN.md` "Three-Way Tension"). The city's single global queue dies; build-vs-train becomes a structural distinction not a queue-item distinction.
+
+This is a major engine refactor. Touches: `mc-city`, `mc-turn`, `city.gd`, `city_screen.gd`, `city_buildable_helper.gd`, save schema, AI's `production.rs` (now picks per producer-building, not per city), and the new themed-unit catalog (battle_priest, sage, bard, merchant, etc. β see `p1-43` open question 1).
+
+## Acceptance
+
+- β `city.production_queue: Array` retired in favor of:
+ - `city.construction_queue: Array` β buildings + upgrades only.
+ - `city.building_queues: Dictionary` β `{ : Array }` for each producer building this city owns.
+- β `Building` schema gains `produces: Array` listing units this building can train. Exclusive list β only barracks can queue `warrior`, only library can queue `sage`, etc.
+- β Production tick splits per turn: total city `production_per_turn` is allocated across `construction_queue` (always present) + each non-empty `building_queues[bid]` (one slot per producer building with a non-empty queue). Allocation defaults to even split; UI can override.
+- β Save schema migration: existing saves' `production_queue` entries split by type β buildings/wonders β `construction_queue`, units β respective `building_queues[]` based on the building that produces that unit kind. Document migration in CHANGELOG.
+- β City UI rework: `city_screen.gd` renders one panel per producer building (showing its queue + production-points slider) plus one panel for construction. Today's single ItemList is replaced.
+- β AI rework (depends on `p1-42`): `mc-ai/tactical/production.rs` emits `Action::SetProduction { city_id, building_id, item_id }` (new variant) β `building_id == None` means construction. Per-building scoring decides what each producer queues.
+- β Buildable filter respects ownership: a city without a barracks cannot queue `warrior`. Today the filter is purely tech-gated; now it's also building-gated.
+- β Themed civilian units authored where missing: `battle_priest` (temple), `sage` (library/university), `cartographer` (observatory), `merchant` (market), `bard` (gathering_hall), `loremaster` (great_hall), etc. Per-unit list confirmed by `p1-43` design pass.
+- β Headless GUT tests: city with barracks queues `warrior`, city without barracks cannot; building production advances independently of construction; save/load roundtrips both queue families.
+- β Regression batch: 10-seed `tools/autoplay-batch.sh 10 300` shows AI cities with multiple producer buildings produce DIFFERENT units in the same turn (today: cities only produce one item per turn from the single queue).
+
+## Interlocks
+
+- **`p1-42` (AI full catalog)**: depends on this β AI scoring per producer building requires the buildingβunits mapping this objective adds.
+- **`p1-43` (stacking)**: stacking the producer building (barracks β infantry β armory) expands the building's `produces` list to higher-tier units. The two land together.
+- **`p1-33` (naval/aerial gates)**: collapses into this naturally β `harbor.produces: [river_galley, war_galley, ...]` IS the naval gate.
+- **`p1-32` (sawmill/herbalist)**: those processing buildings produce *resources* (lumber, reagents) into the stockpile β same per-building tick mechanism.
+
+## Out of scope
+
+- Citizen-to-building assignment UX (separate UX objective).
+- Stockpile system (lumber, leather, ale qualities affecting unit quality from `PRODUCTION_CHAIN.md`) β file as follow-up.
+- Master/Grandmaster aura system from `BUILDINGS.md`.
+- Multi-tile placement / district mechanics.
+
+## Note on EA scope
+
+This is the largest single architectural change still on the Game 1 board. Three honest options for the EA cut:
+
+1. **Ship EA with single-queue, defer p1-44** β easiest path, but the design vocabulary in PRODUCTION_CHAIN.md / BUILDINGS.md will keep referring to mechanics the engine doesn't have.
+2. **Ship EA with this refactor** β meaningful weeks of work; touches every layer. Right architecture from the start.
+3. **Hybrid** β ship EA with single-queue + the buildingβunits `produces` field declared but ignored at runtime; flip the runtime to per-building queues post-EA when UX has been validated.
+
+User decision needed.
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 75e1102f..a7bcf1a4 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,13 +1,13 @@
{
- "generated_at": "2026-04-30T04:34:00Z",
+ "generated_at": "2026-04-30T04:44:03Z",
"totals": {
- "in_progress": 1,
- "stub": 1,
- "missing": 14,
- "done": 111,
- "oos": 20,
"partial": 10,
- "total": 157
+ "oos": 20,
+ "stub": 1,
+ "done": 111,
+ "missing": 15,
+ "in_progress": 1,
+ "total": 158
},
"objectives": [
{
@@ -882,13 +882,23 @@
},
{
"id": "p1-43",
- "title": "Building stacking β build on top of an existing building to upgrade it (e.g. double barracks β infantry)",
+ "title": "Building stacking β per-category upgrade chains (military / science / culture / production / etc.)",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-29",
- "summary": "User direction (2026-04-29): \"all the buildings should be buildable and some buildings can be built on top of each other (double barracks - infantry)\".\n\nToday every building is binary: a city either has it or doesn't. The mechanic the user wants: queueing certain buildings on top of an existing one upgrades the slot in place β `barracks` + another `barracks` build = `infantry` (a stronger military producer). This is distinct from the BUILDINGS.md \"Hybrid Merged Structures\" mechanic (which combines TWO different buildings + Synthesis tech into a hybrid). Stacking is the simpler primitive: same-building-twice becomes its successor.\n\nThree design questions need user sign-off before authoring:\n\n1. **Successor identity**: is `infantry` a NEW building (needs authoring) or an existing one (e.g. reuse `armory` as the \"barracks Lv2\" slot)?\n2. **Mechanic shape**:\n - **(a) Replacement**: building barracks twice consumes both, slot becomes `infantry`. Original gone.\n - **(b) Levelled**: building stays \"barracks\" but carries a `level: 2` field with stacked effects.\n - **(c) Per-tile**: two barracks on same tile merge (only relevant if `placement_tile_required: true`).\n3. **Schema**: declare on the lower tier (`barracks.json::stacks_into: \"infantry\"`) or on the upper (`infantry.json::requires_existing: \"barracks\"` + `consumes_existing: true`)? The latter keeps the relationship bidirectional readable.\n\nRecommendation: option **(a) Replacement** with declaration on the upper tier (`requires_existing` + `consumes_existing`). Matches civ-style upgrade slots, reads naturally in the city UI (\"Upgrade Barracks β Infantry\"), avoids per-tile placement complexity for a v1."
+ "summary": "User direction (2026-04-29): \"all the buildings should be buildable and some buildings can be built on top of each other (double barracks - infantry) ... what about comboing other buildings ... science stack, culture stack\".\n\nToday every building is binary: a city either has it or doesn't. The mechanic the user wants: queueing a building on top of an existing one upgrades the slot in place β `barracks` + another `barracks` build = `infantry` (a stronger military producer). The same primitive applies to every category: science stacks (library β scriptorium β academy), culture stacks (monument β bardic_circle β great_hall), production stacks (forge β iron_forge β grand_forge), etc. This is distinct from the BUILDINGS.md \"Hybrid Merged Structures\" mechanic (which combines TWO different buildings + Synthesis tech into a hybrid). Stacking is the simpler primitive: same-category Lv1 β Lv2 β Lv3 chains within one slot.\n\nThe existing data already implies category-tier chains via the `tier` + `category` fields:\n\n| Category | Lv1 (no tech) | Lv2 (mid tech) | Lv3+ (late tech) |\n|---|---|---|---|\n| Production | `forge` t1 | (gap β `iron_forge` doesn't exist) | `dwarf_deep_forge` t3, `tempering_forge` t6, `steam_forge` t7, `adamantine_foundry` t10 |\n| Science | `library` t1 | `university` t3, `observatory` t3 | `academy_of_sciences` t5, `climate_institute` t9 |\n| Culture | `monument` t1 | `great_hall` t3, `gathering_hall` t2 | `ancestor_hall` t10 |\n| Military | `barracks` t1 | (gap β `infantry` doesn't exist) | `armory` t3, `military_academy` t6, `command_citadel` t10 |\n| Food | `granary` t1 | `mill` t2, `brewery` t2, `watermill` t2 | `great_granary` t2 (wonder) |\n| Defense | `walls` t1 | `watchtower` t1 | `castle` t3 |\n| Wealth | `marketplace` t2, `market` t2 (DUPLICATE) | `guild_hall` t4 | (none) |\n| Religion | `temple` t2 | `temple_of_the_ancestor` t5 (wonder) | (none) |\n\nThe stacking schema makes these chains explicit and queryable. Where a Lv2 successor doesn't exist yet (e.g. `infantry`, `iron_forge`, `scriptorium`), this objective authors the missing intermediates.\n\nThree design questions need user sign-off before authoring:\n\n1. **Successor identity**: is `infantry` a NEW building (needs authoring) or an existing one (e.g. reuse `armory` as the \"barracks Lv2\" slot)?\n2. **Mechanic shape**:\n - **(a) Replacement**: building barracks twice consumes both, slot becomes `infantry`. Original gone.\n - **(b) Levelled**: building stays \"barracks\" but carries a `level: 2` field with stacked effects.\n - **(c) Per-tile**: two barracks on same tile merge (only relevant if `placement_tile_required: true`).\n3. **Schema**: declare on the lower tier (`barracks.json::stacks_into: \"infantry\"`) or on the upper (`infantry.json::requires_existing: \"barracks\"` + `consumes_existing: true`)? The latter keeps the relationship bidirectional readable.\n\nRecommendation: option **(a) Replacement** with declaration on the upper tier (`requires_existing` + `consumes_existing`). Matches civ-style upgrade slots, reads naturally in the city UI (\"Upgrade Barracks β Infantry\"), avoids per-tile placement complexity for a v1."
+ },
+ {
+ "id": "p1-44",
+ "title": "Buildings produce units, not the city center β per-building production queues",
+ "priority": "p1",
+ "status": "missing",
+ "scope": "game1",
+ "owner": null,
+ "updated_at": "2026-04-29",
+ "summary": "User direction (2026-04-29): \"it would make sense to build those units in the appropriate buildings rather than the city center.\"\n\nToday every city has **one** production queue (`city.gd:57 production_queue: Array`) where the player picks \"warrior\", \"barracks\", \"wonder\", anything goes through the same FIFO. Building selection just gates the menu β the building doesn't actually *do* anything when production happens.\n\nThe user's model β and the model `PRODUCTION_CHAIN.md` already describes β is that buildings are **producers**:\n\n> Every citizen is a resource pulled between three competing demands:\n> - **Construction** β builds new buildings, upgrades existing ones (investment)\n> - **Buildings** β produces units, research, culture, equipment (output)\n> - **Tiles** β produces food, raw materials, gold (sustenance)\n\nConcretely:\n- **Barracks** has its own queue producing infantry / armory / military_academy lineage units.\n- **Stable** queues cavalry units.\n- **Siege Workshop** queues siege units.\n- **Library** queues sages / cartographers / engineers (science civilians) and accumulates research per turn.\n- **Temple** queues battle priests and accumulates culture/happiness.\n- **Harbor** queues naval units (and gates them β see `p1-33`).\n- **Airfield** queues aerial units.\n- **Construction queue** (city-level, distinct from building queues) builds NEW buildings and upgrades existing ones.\n\nEach producer building runs its queue independently per turn. Citizens / production allocation gets split across buildings (per `PRODUCTION_CHAIN.md` \"Three-Way Tension\"). The city's single global queue dies; build-vs-train becomes a structural distinction not a queue-item distinction.\n\nThis is a major engine refactor. Touches: `mc-city`, `mc-turn`, `city.gd`, `city_screen.gd`, `city_buildable_helper.gd`, save schema, AI's `production.rs` (now picks per producer-building, not per city), and the new themed-unit catalog (battle_priest, sage, bard, merchant, etc. β see `p1-43` open question 1)."
},
{
"id": "p2-06",
diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd
index dfc64034..64c1701a 100644
--- a/src/game/engine/scenes/tests/auto_play.gd
+++ b/src/game/engine/scenes/tests/auto_play.gd
@@ -43,6 +43,11 @@ var _attack_commitment_turns: int = 0
# Recomputed each turn during _play_turn, read by _next_building + rush-buy.
var _active_attack_mil_count: int = 0
var _in_attack_phase: bool = false
+# Stack-of-doom cap: tracks how many times a city has been attacked this turn
+# (keyed by city position string). Reset at the start of each player's turn.
+# Limits pile-ons so a 10-warrior stack can't one-shot a city in a single turn.
+var _city_attacks_this_turn: Dictionary = {}
+const MAX_CITY_ATTACKS_PER_TURN: int = 3
# Test harness state (AUTO_PLAY_SEED path)
var _seed: int = 0
@@ -943,6 +948,9 @@ func _play_turn() -> void:
if player.researching.is_empty():
_pick_research(player)
+ # Reset per-turn city attack counter (stack-of-doom cap).
+ _city_attacks_this_turn.clear()
+
# Refresh attack-phase signals and stack-sustain telemetry for this turn.
# _attack_commitment_turns reflects prior-turn commitment; rush-buy and
# building scoring both key off it so they respond mid-siege.
@@ -2043,10 +2051,16 @@ func _try_attack_adjacent(unit: Variant, game_map: RefCounted) -> void:
for c: Variant in p.cities:
var dist: int = HexUtilsScript.hex_distance(unit.position, c.position)
if dist <= 1:
+ var city_key: String = "%d,%d" % [c.position.x, c.position.y]
+ var attacks_so_far: int = _city_attacks_this_turn.get(city_key, 0)
+ if attacks_so_far >= MAX_CITY_ATTACKS_PER_TURN:
+ # Stack-of-doom cap: don't pile on beyond the limit this turn.
+ return
print(" ATTACKING CITY: %s at %s -> city at %s (dist=%d)" % [unit.type_id, unit.position, c.position, dist])
var resolver_script: GDScript = load("res://engine/src/modules/combat/combat_resolver.gd")
var resolver: RefCounted = resolver_script.new()
resolver.resolve(unit, c, game_map, all_units)
+ _city_attacks_this_turn[city_key] = attacks_so_far + 1
unit.movement_remaining = 0
return
diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd
index 0bd810a7..1d8175d5 100644
--- a/src/game/engine/src/modules/management/turn_processor.gd
+++ b/src/game/engine/src/modules/management/turn_processor.gd
@@ -371,26 +371,15 @@ func _process_culture(player: RefCounted, game_map: RefCounted) -> void:
continue
var c: CityScript = city_ref as CityScript
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
- # Culture-port to Rust (`process_culture_with_modifier`) attempted in
- # R7/R8 but caused seed-divergence vs R6 baseline (R9 parity test
- # reproduced R6 exactly when reverted to this GDScript path; R8 with
- # Rust port diverged on every seed). Math LOOKED identical but the
- # Rust call sequence produces different floating-point intermediate
- # results than the GDScript-via-Variant round-trip path. Culture port
- # remains TODO β see p1-39. The other Rail-1 ports (gold, research)
- # pass parity and stay.
- var pre_culture: float = c.get_culture_stored()
- var can_expand: bool = c.process_culture(tile_json)
+ # Rail-1 culture port (p1-39). R7/R8 divergence was a stale GDExtension
+ # binary on apricot β process_culture_with_modifier didn't exist in the
+ # deployed .so, GDScript silently errored, culture never accumulated.
+ # Rust math is identical to the pre-port GDScript path.
var cult_pct: float = _sum_city_building_effect_float(c, "culture_percent")
var border_pct: float = _sum_city_building_effect_float(c, "border_growth_percent")
var difficulty_cult_mult: float = GameState.get_effective_yield_mult(player, "culture")
var total_pct: float = cult_pct + border_pct + (difficulty_cult_mult - 1.0)
- if total_pct > 0.0:
- var post_culture: float = c.get_culture_stored()
- var gained: float = post_culture - pre_culture
- if gained > 0.0:
- c.set_culture_stored(post_culture + gained * total_pct)
- can_expand = c.get_can_expand()
+ var can_expand: bool = c.process_culture_with_modifier(tile_json, total_pct)
if not can_expand:
continue
# Build candidates JSON for Rust border expansion
|