diff --git a/.project/objectives/README.md b/.project/objectives/README.md index b27b2dd1..b65cff79 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -46,7 +46,7 @@ | ID | Status | Title | Owner | Updated | |---|---|---|---|---| -| [p1-01](p1-01-diplomacy-lite.md) | 🟑 partial | Diplomacy-lite β€” peace/war toggle plus one trade action | β€” | 2026-04-17 | +| [p1-01](p1-01-diplomacy-lite.md) | 🟑 partial | Diplomacy-lite β€” peace/war toggle plus one trade action | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p1-02](p1-02-strategic-resource-yields.md) | βœ… done | Strategic resource yields feed into production bonuses | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p1-03](p1-03-tutorial-overlay.md) | 🟑 partial | First-run tutorial / onboarding overlay | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p1-04](p1-04-sound-and-music.md) | 🟑 partial | Sound effects and music | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | diff --git a/.project/objectives/p1-01-diplomacy-lite.md b/.project/objectives/p1-01-diplomacy-lite.md index d90fcaf6..c7eb0ea1 100644 --- a/.project/objectives/p1-01-diplomacy-lite.md +++ b/.project/objectives/p1-01-diplomacy-lite.md @@ -4,19 +4,31 @@ title: Diplomacy-lite β€” peace/war toggle plus one trade action priority: p1 status: partial scope: game1 +owner: shipwright updated_at: 2026-04-17 evidence: - src/simulator/crates/mc-trade/src/lib.rs - src/simulator/crates/mc-trade/src/relation.rs + - src/simulator/crates/mc-turn/src/processor.rs --- ## Summary -`mc-trade` is 573 lines of friendship-threshold logic with no deal-making surface. EA release needs at minimum: per-pair peace/war state and one resource↔gold trade action. +`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`. ## Acceptance -- `Relation::{Peace, War}` state per player pair; declarations flow through `mc-turn`. -- `TradeOffer { from, to, give: Resource, want: Gold }` with accept/reject. -- GDScript diplomacy panel exposes declare-war / offer-trade. -- AI decisions respect peace/war (no attacks during peace) and accept/reject based on clan personality (`p0-02`). +- βœ“ `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 β€” UI work, requires `godot-ui` agent. +- βœ— AI decisions respect peace/war (no attacks during peace) β€” `simple_heuristic_ai.gd` does not gate attack actions on `Relation::War`. + +## Remaining to reach done + +1. `godot-ui` agent: author `diplomacy_panel.tscn` + controller; wire `GdTurnProcessor::action_declare_war` / `action_offer_trade` bindings through GDExtension. +2. `game-ai` agent: gate `simple_heuristic_ai.gd` attack decisions on `Relation::War` β€” no attacks when relation is Peace/Friendly/Neutral. + +## Non-goals + +- Clan personality-based trade willingness AI (covered by existing `PlayerTradeInput.trade_willingness` axis). +- Alliance / coalition mechanics (Game 2). diff --git a/src/simulator/crates/mc-trade/src/lib.rs b/src/simulator/crates/mc-trade/src/lib.rs index 41722944..d536410a 100644 --- a/src/simulator/crates/mc-trade/src/lib.rs +++ b/src/simulator/crates/mc-trade/src/lib.rs @@ -451,4 +451,52 @@ mod tests { assert_eq!(ledger.agreements.len(), 1); assert_eq!(ledger.agreements[0].partners, (2, 3)); } + + #[test] + fn declare_war_sets_war_and_breaks_trades() { + let mut relations: BTreeMap<(u8, u8), RelationState> = BTreeMap::new(); + let players = vec![ + make_player(0, &["silk", "silk"], 7), + make_player(1, &["wine", "wine"], 7), + ]; + let mut ledger = evaluate_trades(&players, &relations, 1); + assert_eq!(ledger.agreements.len(), 1, "precondition: trade active"); + + let (event, broken) = declare_war(&mut relations, &mut ledger, 0, 1); + + assert_eq!(relations[&(0, 1)].relation, Relation::War, "relation must be War"); + assert!(ledger.agreements.is_empty(), "trade must be broken on war"); + assert_eq!(broken.len(), 1, "one agreement broken"); + assert!(matches!(event, DiplomacyEvent::WarDeclared { by: 0, against: 1 })); + } + + #[test] + fn offer_peace_ea_always_rejects() { + let event = offer_peace(0, 1); + assert!(matches!(event, DiplomacyEvent::PeaceRejected { by: 0, with: 1 })); + } + + #[test] + fn apply_trade_offer_swaps_gold_and_luxury() { + let offer = TradeOffer { from: 0, to: 1, gold: 20, luxury_id: "amber".into() }; + let mut from_gold: i32 = 50; + let mut to_luxuries: BTreeSet = BTreeSet::new(); + + let event = apply_trade_offer(&mut from_gold, &mut to_luxuries, &offer); + + assert_eq!(from_gold, 30, "gold deducted from sender"); + assert!(to_luxuries.contains("amber"), "luxury credited to receiver"); + assert!(matches!(event, DiplomacyEvent::TradeOfferAccepted { + by: 1, with: 0, gold: 20, .. + })); + } + + #[test] + fn apply_trade_offer_gold_clamps_to_zero() { + let offer = TradeOffer { from: 0, to: 1, gold: 100, luxury_id: "silk".into() }; + let mut from_gold: i32 = 30; + let mut to_luxuries: BTreeSet = BTreeSet::new(); + apply_trade_offer(&mut from_gold, &mut to_luxuries, &offer); + assert_eq!(from_gold, 0, "gold must not go negative"); + } } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 60e2e734..1c9fa372 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -31,7 +31,10 @@ use mc_combat::CombatOutcome; use mc_combat::{check_strategic_reqs, credit_resources, debit_resources}; use mc_economy::{process_gold, CityGoldInput, UnitMaintenanceInput}; use mc_tech::{PlayerTechState, TechWeb}; -use mc_trade::{advance_relations, evaluate_trades, PlayerTradeInput}; +use mc_trade::{ + advance_relations, apply_trade_offer, declare_war, evaluate_trade_offer, evaluate_trades, + offer_peace, DiplomacyEvent, PlayerTradeInput, TradeLedger, TradeOffer, +}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -1474,6 +1477,57 @@ impl TurnProcessor { } } } + + // ── Diplomacy surface (callable by GDExtension / GDScript) ─────────── + + /// Declare war from `by` against `against`. Sets relation to War immediately, + /// clears `traded_luxuries` for both players (ledger is rebuilt each turn). + /// Returns the event emitted. + pub fn action_declare_war( + &self, + state: &mut GameState, + by: u8, + against: u8, + ) -> DiplomacyEvent { + // Update relation in both players' maps (they mirror each other). + let mut dummy_ledger = TradeLedger::default(); + let event = { + let relations = &mut state.players[by as usize].relations; + let (ev, _) = declare_war(relations, &mut dummy_ledger, by, against); + ev + }; + let updated = state.players[by as usize].relations.clone(); + state.players[against as usize].relations = updated; + // Traded luxuries will be empty next turn when the ledger rebuilds. + state.players[by as usize].traded_luxuries.clear(); + state.players[against as usize].traded_luxuries.clear(); + event + } + + /// Offer peace from `by` to `against`. In EA, AI always rejects. + /// Returns the event (always `PeaceRejected` in EA). + pub fn action_offer_peace(&self, by: u8, against: u8) -> DiplomacyEvent { + offer_peace(by, against) + } + + /// Submit a `TradeOffer`. In EA, AI always rejects. + /// Returns `TradeOfferRejected`. For human-accepted trades, call + /// `action_accept_trade_offer` instead. + pub fn action_offer_trade(&self, offer: &TradeOffer) -> DiplomacyEvent { + evaluate_trade_offer(offer) + } + + /// Force-accept a trade offer (human vs human path). Deducts gold from + /// `offer.from` and credits `offer.luxury_id` to `offer.to`. + pub fn action_accept_trade_offer( + &self, + state: &mut GameState, + offer: &TradeOffer, + ) -> DiplomacyEvent { + let from_gold = &mut state.players[offer.from as usize].gold; + let to_luxuries = &mut state.players[offer.to as usize].traded_luxuries; + apply_trade_offer(from_gold, to_luxuries, offer) + } } // ── Helpers ───────────────────────────────────────────────────────────────── @@ -3589,4 +3643,76 @@ mod tests { "warrior spawn should never trigger gate (no resource req)"); assert!(!state.players[0].units.is_empty(), "warrior must have spawned"); } + + // ── Diplomacy action tests ───────────────────────────────────────────── + + /// `action_declare_war`: both players end up at War, traded_luxuries cleared. + #[test] + fn tdip1_declare_war_sets_war_in_both_players() { + let processor = TurnProcessor::new(100); + let mut p0 = systems_b_player(2, 3, 3, 2); + push_starter_city(&mut p0, 0, 0); + let mut p1 = systems_b_player(2, 3, 3, 2); + p1.player_index = 1; + push_starter_city(&mut p1, 10, 10); + // Seed some traded_luxuries so we can assert they're cleared. + p0.traded_luxuries.insert("silk".into()); + p1.traded_luxuries.insert("wine".into()); + + let mut state = GameState { + turn: 1, + players: vec![p0, p1], + grid: None, + pending_pvp_attacks: Default::default(), + }; + + let event = processor.action_declare_war(&mut state, 0, 1); + + assert!(matches!(event, DiplomacyEvent::WarDeclared { by: 0, against: 1 })); + use mc_trade::relation::{pair_key, Relation}; + let key = pair_key(0, 1); + assert_eq!(state.players[0].relations[&key].relation, Relation::War, + "p0 must be at War"); + assert_eq!(state.players[1].relations[&key].relation, Relation::War, + "p1 relation must be mirrored to War"); + assert!(state.players[0].traded_luxuries.is_empty(), "p0 traded_luxuries cleared"); + assert!(state.players[1].traded_luxuries.is_empty(), "p1 traded_luxuries cleared"); + } + + /// `action_offer_peace`: EA always returns PeaceRejected. + #[test] + fn tdip2_offer_peace_ea_always_rejects() { + let processor = TurnProcessor::new(100); + let event = processor.action_offer_peace(0, 1); + assert!(matches!(event, DiplomacyEvent::PeaceRejected { by: 0, with: 1 })); + } + + /// `action_accept_trade_offer`: gold deducted, luxury credited. + #[test] + fn tdip3_accept_trade_offer_updates_ledgers() { + let processor = TurnProcessor::new(100); + let mut p0 = systems_b_player(2, 3, 3, 2); + push_starter_city(&mut p0, 0, 0); + p0.gold = 80; + let mut p1 = systems_b_player(2, 3, 3, 2); + p1.player_index = 1; + push_starter_city(&mut p1, 10, 10); + + let mut state = GameState { + turn: 1, + players: vec![p0, p1], + grid: None, + pending_pvp_attacks: Default::default(), + }; + + let offer = TradeOffer { from: 0, to: 1, gold: 30, luxury_id: "amber".into() }; + let event = processor.action_accept_trade_offer(&mut state, &offer); + + assert_eq!(state.players[0].gold, 50, "gold deducted from sender"); + assert!(state.players[1].traded_luxuries.contains("amber"), + "luxury credited to receiver"); + assert!(matches!(event, DiplomacyEvent::TradeOfferAccepted { + by: 1, with: 0, gold: 30, .. + })); + } }