docs(diplomacy): 📝 reconcile start-state spec to courier model + track AI war-dec gap

Two diplomacy models contradicted each other in the written record: p1-01
diplomacy-lite ('all pairs start at war, missing key → war') vs the newer
courier-diplomacy (COMMUNICATIONS.md §War declaration semantics, p3-01:
start at peace, sender enters War on war-dec envelope dispatch). The Rust
implementation follows courier-diplomacy, so that is canonical.

- p1-01: add a SUPERSEDED banner + inline [SUPERSEDED] annotations; history
  retained. Canonical rule is start-at-peace, war via dispatched war-dec.
- COMMUNICATIONS.md: fix the one internal inconsistency (§0 said recipient
  war state applies at arrival in a way that read as all-effects-at-arrival;
  scoped it to recipient-side, cross-linked the sender-on-dispatch exception).
- New objective p3-16 (status partial, owner warcouncil): the AI has no
  proactive war-declaration — decide_tactical_actions has no diplomacy step
  and there is no DeclareWar in mc-ai, so AI-vs-AI never enters war and clan
  aggression personalities don't manifest. Specs the fix to the courier model
  (first-contact + military balance + aggression → dispatch_war_declaration)
  and notes the stale is_at_war comment as a code-fidelity cleanup.
- Register p3-16 under warcouncil; regen objectives dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-23 20:08:56 -04:00
parent 5eed0bb579
commit 6b3b571806
7 changed files with 172 additions and 11 deletions

View file

@ -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) | 🟢 |

View file

@ -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** |
</td><td valign='top' style='padding-left:2em'>
@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
| — | 0 |
| [warcouncil](../team-leads/warcouncil.md) | 1 |
</td></tr></table>

View file

@ -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` 110,\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
}
]
}

View file

@ -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

View file

@ -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 110)
---
## 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` 110,
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 366369) 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).

View file

@ -18,6 +18,7 @@ objectives:
- p1-29h-stateful-tactical-decisiveness
- p1-29i-refound-suppression
- p1-29j-autoplay-rust-action-application
- p3-16
---
## Mandate

View file

@ -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.
---