diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index ad016b45..69355a25 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -521,4 +521,5 @@
| [p3-13d](p3-13d-anomalous-events.md) | β
done | P3 | Anomalous events β aurora, fog_bank, thermal_anomaly | [unassigned](../team-leads/unassigned.md) | π’ |
| [p3-14](p3-14-game-start-script.md) | β
done | P3 | Declarative game-start script + runner β data-driven, moddable opening sequence | [shipwright](../team-leads/shipwright.md) | π’ |
| [p3-15](p3-15-hotseat-multiplayer.md) | β
done | P3 | Local hotseat multiplayer β multiple humans alternating on one device | [shipwright](../team-leads/shipwright.md) | π’ |
+| [p3-16](p3-16-ai-proactive-war-declaration.md) | π‘ partial | P3 | AI proactive war-declaration via the courier system | [warcouncil](../team-leads/warcouncil.md) | π’ |
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 832e1072..1701eb22 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -17,8 +17,8 @@
| **P0** | 0 | 0 | 0 | 0 | 0 | 44 | 44 |
| **P1** | 0 | 0 | 0 | 0 | 1 | 88 | 89 |
| **P2** | 0 | 0 | 0 | 0 | 1 | 132 | 133 |
-| **P3 (oos)** | 0 | 0 | 0 | 0 | 29 | 26 | 55 |
-| **total** | **0** | **0** | **0** | **0** | **31** | **290** | **321** |
+| **P3 (oos)** | 0 | 1 | 0 | 0 | 29 | 26 | 56 |
+| **total** | **0** | **1** | **0** | **0** | **31** | **290** | **322** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| β | 0 |
+| [warcouncil](../team-leads/warcouncil.md) | 1 |
|
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index 602eb81f..d83387c3 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,13 +1,13 @@
{
- "generated_at": "2026-06-23T17:17:04Z",
+ "generated_at": "2026-06-24T00:06:25Z",
"totals": {
"done": 290,
"in_progress": 0,
- "partial": 0,
+ "partial": 1,
"stub": 0,
"missing": 0,
"oos": 31,
- "total": 321
+ "total": 322
},
"objectives": [
{
@@ -549,7 +549,7 @@
"owner": "shipwright",
"updated_at": "2026-04-17",
"blocked_by": [],
- "summary": "`mc-trade` now has a full diplomacy surface: `declare_war` / `offer_peace` / `evaluate_trade_offer` / `apply_trade_offer` free functions plus `DiplomacyEvent` enum and `TradeOffer` struct. `TurnProcessor` exposes `action_declare_war`, `action_offer_peace`, `action_offer_trade`, and `action_accept_trade_offer` as public methods callable from GDExtension. EA policy: AI always rejects player-initiated peace offers and gold-for-luxury offers; automated luxury swaps flow through the existing `evaluate_trades` path. Relation state machine (`Relation::Neutral/Peace/Friendly/War`) was already present in `mc-trade::relation`.\n\nAI attack decisions are gated on `Relation::War` via `_is_at_war` in `simple_heuristic_ai.gd`. `_collect_enemy_units`, `_collect_enemy_city_positions`, and `_enemy_within` all skip players whose relation is Peace or Friendly. Missing key defaults to War (EA: all pairs start at war). GUT coverage in `test_simple_heuristic_ai_war_gate.gd`."
+ "summary": "`mc-trade` now has a full diplomacy surface: `declare_war` / `offer_peace` / `evaluate_trade_offer` / `apply_trade_offer` free functions plus `DiplomacyEvent` enum and `TradeOffer` struct. `TurnProcessor` exposes `action_declare_war`, `action_offer_peace`, `action_offer_trade`, and `action_accept_trade_offer` as public methods callable from GDExtension. EA policy: AI always rejects player-initiated peace offers and gold-for-luxury offers; automated luxury swaps flow through the existing `evaluate_trades` path. Relation state machine (`Relation::Neutral/Peace/Friendly/War`) was already present in `mc-trade::relation`.\n\nAI attack decisions are gated on `Relation::War` via `_is_at_war` in `simple_heuristic_ai.gd`. `_collect_enemy_units`, `_collect_enemy_city_positions`, and `_enemy_within` all skip players whose relation is Peace or Friendly. ~~Missing key defaults to War (EA: all pairs start at war).~~ **[SUPERSEDED β see banner: canonical start state is PEACE; war begins on war-dec dispatch per COMMUNICATIONS.md / p3-01.]** GUT coverage in `test_simple_heuristic_ai_war_gate.gd`."
},
{
"id": "p1-02",
@@ -3567,6 +3567,17 @@
"updated_at": "2026-06-19",
"blocked_by": [],
"summary": ""
+ },
+ {
+ "id": "p3-16",
+ "title": "AI proactive war-declaration via the courier system",
+ "priority": "p3",
+ "status": "partial",
+ "scope": "game1",
+ "owner": "warcouncil",
+ "updated_at": "2026-06-23",
+ "blocked_by": [],
+ "summary": "The canonical courier-diplomacy model (`COMMUNICATIONS.md` Β§\"War declaration\nsemantics\") assumes a player **dispatches a war-dec envelope** to enter war: the\n**sender** enters `War` immediately on dispatch and its units can attack the same\nturn; the **recipient** flips to `War` on delivery/interception; **defenders\nalways retaliate when struck** regardless of formal `RelationState`. Pairs start\nat **peace** (`mc-player-api/src/projection.rs::project_tactical_relations` defaults\nunset pairs to 0 = peace; `mc-player-api/src/comms_dispatch.rs` flips the shared\n`RelationState` cell to `War` only on delivery).\n\n**The gap:** the AI has **no proactive war-declaration logic**. `mc-ai`'s tactical\nentry point `decide_tactical_actions` (`src/simulator/crates/mc-ai/src/tactical/mod.rs`)\nruns movement β combat β settle β production β citizens with **no diplomacy step**,\nand there is **zero** `DeclareWar` / war-dec action anywhere in `mc-ai` (no\nstrategic action variant, no caller of `comms_dispatch::dispatch_war_declaration`\nβ the only non-test caller is the human dispatch path at\n`mc-player-api/src/dispatch.rs:1605`). `mc-ai/src/diplomacy.rs` covers only the\np3-01 courier open-borders / shared-map offer/accept heuristics β not war.\n\nConsequence (observed in a 200-turn hotseat self-play): AIs never initiate war β\n`collect_enemy_city_positions` (`tactical/movement.rs`) is always empty because it\nis gated through `is_at_war` (movement.rs:366) β no `locked_target` is ever set β\nmilitary units never maneuver (48 successful moves across 200 turns). Because war\nnever comes in AI-vs-AI play, the five clan personalities\n(`public/games/age-of-dwarves/data/ai_personalities.json`, `aggression` 1β10,\nwarmonger = 9) do **not** manifest distinct aggression: per\n`mc-ai/src/tactical/thresholds.rs`, `aggression` only tunes combat posture **once\nalready at war**, so a difference that only fires after war-declaration never\nfires at all.\n\nThis objective adds the missing strategic decision step so the AI dispatches a\nwar-dec envelope through the courier system when conditions warrant, entering the\nexisting at-war combat path and making personality `aggression` actually drive\nbehavior."
}
],
"blocked": [
@@ -3813,5 +3824,10 @@
]
}
],
- "remaining_by_lead": []
+ "remaining_by_lead": [
+ {
+ "owner": "warcouncil",
+ "remaining": 1
+ }
+ ]
}
diff --git a/.project/objectives/p1-01-diplomacy-lite.md b/.project/objectives/p1-01-diplomacy-lite.md
index 2f575223..596ce11a 100644
--- a/.project/objectives/p1-01-diplomacy-lite.md
+++ b/.project/objectives/p1-01-diplomacy-lite.md
@@ -21,18 +21,31 @@ evidence:
- ~/Desktop/magic_civ_diplomacy.png
---
+> ## β»οΈ Start-state semantics SUPERSEDED (2026-06-23)
+>
+> This objective's original EA rule β **"missing relation key defaults to War (EA: all pairs start at war)"** β has been **superseded by the courier-diplomacy model**. The canonical, current rule is:
+>
+> - **Pairs start at PEACE.** Unset relation pairs default to peace (0), not war.
+> - **War begins on dispatch of a war-dec envelope.** Per `public/games/age-of-dwarves/docs/military/COMMUNICATIONS.md` Β§"War declaration semantics": the **sender** enters `War` immediately on envelope dispatch; the **recipient** flips to `War` on delivery/interception; **defenders always retaliate when struck** regardless of formal `RelationState`.
+>
+> Authoritative sources for the current model: `COMMUNICATIONS.md` Β§"War declaration semantics" (canonical design) and **`.project/objectives/p3-01-courier-diplomacy.md`** (implementing objective, `scope: game1-stretch`). The Rust implementation matches courier-diplomacy: `mc-player-api/src/comms_dispatch.rs` manages `relations` as a map (`relations.entry(key).or_default()` = peace), and `mc-player-api/src/projection.rs::project_tactical_relations` defaults unset pairs to 0 = peace.
+>
+> The four "missing β war / all pairs start at war" mentions below are **retained as history** and annotated inline with `[SUPERSEDED]`. The GDScript `_is_at_war` reference cited in `evidence:` reflects the old GDScript-AI prototype, which is itself tech-debt per Rail 1 (`p0-26-ai-tactical-rust-port.md`). Do not treat the war-start behavior in this file as current.
+>
+> **Open follow-up:** the canonical model assumes the AI dispatches war-decs ("the AI's 'war mode' branch", `COMMUNICATIONS.md:180`), but `mc-ai` has no proactive war-declaration logic β tracked by **`p3-16-ai-proactive-war-declaration.md`**.
+
## Summary
`mc-trade` now has a full diplomacy surface: `declare_war` / `offer_peace` / `evaluate_trade_offer` / `apply_trade_offer` free functions plus `DiplomacyEvent` enum and `TradeOffer` struct. `TurnProcessor` exposes `action_declare_war`, `action_offer_peace`, `action_offer_trade`, and `action_accept_trade_offer` as public methods callable from GDExtension. EA policy: AI always rejects player-initiated peace offers and gold-for-luxury offers; automated luxury swaps flow through the existing `evaluate_trades` path. Relation state machine (`Relation::Neutral/Peace/Friendly/War`) was already present in `mc-trade::relation`.
-AI attack decisions are gated on `Relation::War` via `_is_at_war` in `simple_heuristic_ai.gd`. `_collect_enemy_units`, `_collect_enemy_city_positions`, and `_enemy_within` all skip players whose relation is Peace or Friendly. Missing key defaults to War (EA: all pairs start at war). GUT coverage in `test_simple_heuristic_ai_war_gate.gd`.
+AI attack decisions are gated on `Relation::War` via `_is_at_war` in `simple_heuristic_ai.gd`. `_collect_enemy_units`, `_collect_enemy_city_positions`, and `_enemy_within` all skip players whose relation is Peace or Friendly. ~~Missing key defaults to War (EA: all pairs start at war).~~ **[SUPERSEDED β see banner: canonical start state is PEACE; war begins on war-dec dispatch per COMMUNICATIONS.md / p3-01.]** GUT coverage in `test_simple_heuristic_ai_war_gate.gd`.
## Acceptance
- β `Relation::{Peace, War}` state per player pair; `declare_war` sets relation to War, clears traded_luxuries, mirrors to both players via `action_declare_war` on `TurnProcessor`. Covered by `processor.rs::tdip1_declare_war_sets_war_in_both_players` (4 assertions).
- β `TradeOffer { from, to, gold: u32, luxury_id: String }` with `evaluate_trade_offer` (reject, EA) and `apply_trade_offer` (accept, human path). `action_accept_trade_offer` on `TurnProcessor` deducts gold + credits luxury. Covered by `mc-trade/lib.rs::apply_trade_offer_swaps_gold_and_luxury` and `processor.rs::tdip3_accept_trade_offer_updates_ledgers`.
- β GDScript diplomacy panel exposes declare-war / offer-trade β `scenes/hud/diplomacy_panel.{tscn,gd}` renders one row per AI rival (clan name, relation badge, action buttons). At-war rows show **Offer Peace** + **Offer Trade**; at-peace rows show **Declare War** + **Offer Trade**. Trade submodal has gold `SpinBox` + luxury `OptionButton` (populated from human's `owned_luxuries`) + Send/Close. Actions route through `Diplomacy.declare_war`, `Diplomacy.offer_peace`, `Diplomacy.offer_trade` (new static methods in `src/modules/empire/diplomacy.gd`). `declare_war` flips `GameState.diplomacy[key]` to `"war"`, clears both players' `traded_luxuries`, emits `EventBus.relation_changed` + new `EventBus.war_declared`. EA policy: `offer_peace` and `offer_trade` always emit `peace_rejected` / `trade_offer_rejected` (no state mutation β mirrors Rust `action_offer_peace`/`action_offer_trade` EA behavior). Wired into `top_bar.tscn` via new `DiplomacyButton` + `KEY_F8` hotkey (F1/F9 already taken by Encyclopedia/Stats). GUT coverage: `test_diplomacy_panel.gd` β **9/9 passing on apricot** (panel instantiates with 2 rows, empty-label fallback, declare_war flips relation + emits signal, declare_war clears luxuries, peace/trade rejections emit with correct payload, self-target is no-op, idempotent war, all 3 signals registered on EventBus). Screenshot at `~/Desktop/magic_civ_diplomacy.png` shows both action-state paths (Ironhold at War / Goldvein at Neutral) in one frame.
-- β AI decisions respect peace/war (no attacks during peace) β `_is_at_war` static helper in `simple_heuristic_ai.gd` gates `_collect_enemy_units`, `_collect_enemy_city_positions`, and `_enemy_within`; defaults to War when no relation key (EA: all pairs start at war). GUT tests in `test_simple_heuristic_ai_war_gate.gd` cover peace/war/friendly/missing-key cases.
+- β AI decisions respect peace/war (no attacks during peace) β `_is_at_war` static helper in `simple_heuristic_ai.gd` gates `_collect_enemy_units`, `_collect_enemy_city_positions`, and `_enemy_within`; ~~defaults to War when no relation key (EA: all pairs start at war)~~ **[SUPERSEDED β canonical start state is PEACE per COMMUNICATIONS.md / p3-01]**. GUT tests in `test_simple_heuristic_ai_war_gate.gd` cover peace/war/friendly/missing-key cases.
## Non-goals
diff --git a/.project/objectives/p3-16-ai-proactive-war-declaration.md b/.project/objectives/p3-16-ai-proactive-war-declaration.md
new file mode 100644
index 00000000..598d9448
--- /dev/null
+++ b/.project/objectives/p3-16-ai-proactive-war-declaration.md
@@ -0,0 +1,130 @@
+---
+id: p3-16
+title: AI proactive war-declaration via the courier system
+priority: p3
+status: partial
+scope: game1
+owner: warcouncil
+updated_at: 2026-06-23
+evidence:
+ - src/simulator/crates/mc-ai/src/tactical/mod.rs (decide_tactical_actions β no diplomacy step)
+ - src/simulator/crates/mc-ai/src/tactical/movement.rs (is_at_war gate + collect_enemy_city_positions)
+ - src/simulator/crates/mc-player-api/src/comms_dispatch.rs (dispatch_war_declaration β the war-dec entry point the AI must call)
+ - public/games/age-of-dwarves/docs/military/COMMUNICATIONS.md (Β§"War declaration semantics" β canonical model)
+ - public/games/age-of-dwarves/data/ai_personalities.json (aggression axis 1β10)
+---
+
+## Summary
+
+The canonical courier-diplomacy model (`COMMUNICATIONS.md` Β§"War declaration
+semantics") assumes a player **dispatches a war-dec envelope** to enter war: the
+**sender** enters `War` immediately on dispatch and its units can attack the same
+turn; the **recipient** flips to `War` on delivery/interception; **defenders
+always retaliate when struck** regardless of formal `RelationState`. Pairs start
+at **peace** (`mc-player-api/src/projection.rs::project_tactical_relations` defaults
+unset pairs to 0 = peace; `mc-player-api/src/comms_dispatch.rs` flips the shared
+`RelationState` cell to `War` only on delivery).
+
+**The gap:** the AI has **no proactive war-declaration logic**. `mc-ai`'s tactical
+entry point `decide_tactical_actions` (`src/simulator/crates/mc-ai/src/tactical/mod.rs`)
+runs movement β combat β settle β production β citizens with **no diplomacy step**,
+and there is **zero** `DeclareWar` / war-dec action anywhere in `mc-ai` (no
+strategic action variant, no caller of `comms_dispatch::dispatch_war_declaration`
+β the only non-test caller is the human dispatch path at
+`mc-player-api/src/dispatch.rs:1605`). `mc-ai/src/diplomacy.rs` covers only the
+p3-01 courier open-borders / shared-map offer/accept heuristics β not war.
+
+Consequence (observed in a 200-turn hotseat self-play): AIs never initiate war β
+`collect_enemy_city_positions` (`tactical/movement.rs`) is always empty because it
+is gated through `is_at_war` (movement.rs:366) β no `locked_target` is ever set β
+military units never maneuver (48 successful moves across 200 turns). Because war
+never comes in AI-vs-AI play, the five clan personalities
+(`public/games/age-of-dwarves/data/ai_personalities.json`, `aggression` 1β10,
+warmonger = 9) do **not** manifest distinct aggression: per
+`mc-ai/src/tactical/thresholds.rs`, `aggression` only tunes combat posture **once
+already at war**, so a difference that only fires after war-declaration never
+fires at all.
+
+This objective adds the missing strategic decision step so the AI dispatches a
+war-dec envelope through the courier system when conditions warrant, entering the
+existing at-war combat path and making personality `aggression` actually drive
+behavior.
+
+## Why partial (not done)
+
+The downstream consumption path (at-war combat, `collect_enemy_city_positions`,
+`locked_target` maneuver, `aggression`-tuned combat posture) **already exists and
+works** once a pair is at war β that half is shipped. What is missing is the
+**decision-to-declare** and its dispatch through the courier system. No new code
+has landed for the decision step yet; this file specs it.
+
+## Acceptance
+
+- [ ] **Strategic war-dec decision step.** Add a diplomacy decision step to the AI
+ turn so that, per turn, the AI evaluates each *contacted* rival (first-contact
+ gate per `COMMUNICATIONS.md` Β§4) and decides whether to declare war. The
+ decision MUST consider: first-contact established, military balance
+ (own vs. perceived enemy strength from PerceivedState, not ground truth), and
+ the clan's `aggression` axis (`ai_personalities.json`). War-dec is a
+ **strategic** move (it changes `RelationState`, not just a unit order), so the
+ decision belongs in the strategic layer (`mc-ai/src/mcts.rs` /
+ `evaluator.rs` action set, or a dedicated `mc-ai/src/diplomacy.rs` war-dec
+ function invoked from the strategic turn driver), NOT inside the per-unit
+ tactical movement loop. Mirror the existing courier-offer pattern in
+ `mc-ai/src/diplomacy.rs` (clan hard-rules + axis-driven fallback).
+- [ ] **Dispatch through the courier system.** When the AI decides to declare war
+ it MUST route through `mc_player_api::comms_dispatch::dispatch_war_declaration(state, sender, target)`
+ β the same entry point the human war-dec action uses
+ (`mc-player-api/src/dispatch.rs:1605`). This makes the **sender** enter `War`
+ immediately on dispatch (sender units can attack that turn) and the
+ **recipient** flip on delivery/interception, exactly per
+ `COMMUNICATIONS.md` Β§"War declaration semantics". The AI MUST NOT directly
+ mutate the relation cell or bypass the envelope β that would break the
+ perceived-state / comm-tier asymmetry the courier model is built on.
+- [ ] **Personality differentiation manifests.** With the decision step live, a
+ warmonger clan (`aggression` 9, e.g. Blackhammer) initiates war materially
+ earlier / more often than a low-aggression clan in AI-vs-AI play. Verify with a
+ self-play matchup harness showing distinct war-initiation turn distributions by
+ clan (compare to the p0-02 matchup-grid methodology). The 200-turn "48 moves /
+ no maneuver" symptom is gone: military units maneuver because
+ `collect_enemy_city_positions` is now non-empty once war is declared.
+- [ ] **Tests headless.** `mc-ai` unit tests for the war-dec decision function
+ (clan Γ military-balance Γ first-contact cases, including the floor where a
+ low-aggression clan declines). A `mc-player-api` integration test that the AI
+ decision dispatches an envelope via `comms_dispatch` and the sender's
+ `RelationState` / `has_outbound_war` reflects war on the dispatch turn while the
+ recipient stays at peace until delivery. All green on apricot.
+
+## Code-fidelity cleanup (do alongside)
+
+`mc-ai/src/tactical/movement.rs::is_at_war` (lines 366β369) still carries a stale
+comment β "default to war when a relation slot is missing β¦ so the attack gate
+stays open in fresh games where the diplomacy table has not been initialized" β
+that mirrors the **superseded** p1-01 "all pairs start at war" model. It is
+harmless today only because `project_tactical_relations`
+(`mc-player-api/src/projection.rs:1312`) pre-fills the relations vec so no slot is
+ever missing. Once the canonical peace-start model is fully wired, this fallback's
+comment (and ideally the missing-slot β war default itself) should be corrected to
+reflect peace-start semantics. **This is a `.rs` change β not part of this
+docs-and-plan sync; flagged for the implementing specialist (warcouncil / mc-ai).**
+
+## Dependencies
+
+- `p1-01` (diplomacy-lite, β
β peace/war state + the at-war combat path the AI
+ enters; start-state semantics superseded by courier-diplomacy, see that file's
+ banner).
+- `p3-01` (courier-diplomacy, β
game1-stretch β the war-dec envelope substrate
+ and `comms_dispatch` dispatch path).
+- `COMMUNICATIONS.md` Β§"War declaration semantics" β canonical design this is
+ built to.
+- Coordinates with `p0-02` (clan personalities) β this is what makes the
+ `aggression` axis manifest in AI-vs-AI war initiation.
+
+## Non-goals
+
+- Peace-making / treaty AI beyond declaring war (the AI's offer/accept of peace is
+ a separate decision; out of scope here).
+- Alliances / coalitions / joint-attack coordination (Game 2).
+- New `aggression`-axis tuning values β this objective wires the axis into a new
+ decision, it does not retune the personalities themselves (that is p0-02 /
+ p1-36 territory).
diff --git a/.project/team-leads/warcouncil.md b/.project/team-leads/warcouncil.md
index 82e5e0a6..1b867e13 100644
--- a/.project/team-leads/warcouncil.md
+++ b/.project/team-leads/warcouncil.md
@@ -18,6 +18,7 @@ objectives:
- p1-29h-stateful-tactical-decisiveness
- p1-29i-refound-suppression
- p1-29j-autoplay-rust-action-application
+ - p3-16
---
## Mandate
diff --git a/public/games/age-of-dwarves/docs/military/COMMUNICATIONS.md b/public/games/age-of-dwarves/docs/military/COMMUNICATIONS.md
index 45f82e50..b399fcf3 100644
--- a/public/games/age-of-dwarves/docs/military/COMMUNICATIONS.md
+++ b/public/games/age-of-dwarves/docs/military/COMMUNICATIONS.md
@@ -28,7 +28,7 @@ All three dispatch types produce the same payload shape:
Envelope { sender, recipient, payload, route, dispatched_turn, eta_turn }
```
-Effects (war state, treaty signature, vision-share activation, intel-log entry) apply at envelope **arrival**, not dispatch.
+Effects on the **recipient** (recipient-side war state, treaty signature, vision-share activation, intel-log entry) apply at envelope **arrival**, not dispatch. The one **sender-side** exception is the war declaration: the sender enters `War` the instant it dispatches a war-dec envelope, so its units can attack the same turn β see [Β§War declaration semantics](#war-declaration-semantics). All effects on the *recipient* (including the recipient's own flip to `War`) still gate on arrival/interception.
---