diff --git a/.project/objectives/p1-57-diplomacy-tribute-treaties.md b/.project/objectives/p1-57-diplomacy-tribute-treaties.md index a876484b..f020f317 100644 --- a/.project/objectives/p1-57-diplomacy-tribute-treaties.md +++ b/.project/objectives/p1-57-diplomacy-tribute-treaties.md @@ -2,10 +2,10 @@ id: p1-57 title: "Diplomacy: tribute, treaty lifecycle, magical-terrain episode gating" priority: p1 -status: stub +status: partial scope: game1 owner: unassigned -updated_at: 2026-05-03 +updated_at: 2026-05-07 parent_session: 2026-05-03 design-driven authoring sweep evidence: [] --- @@ -78,9 +78,11 @@ pending. ## Acceptance — Rust wire-up -- [ ] `mc-trade`: load default durations from `treaty_rules.json`. Each +- [x] `mc-trade`: load default durations from `treaty_rules.json`. Each agreement struct's `turns_remaining` is initialised from the JSON if no caller-supplied value is provided. + Evidence: `mc-trade/src/rules.rs` `TreatyRules::from_json`; `AgreementType` enum + in `mc-core/src/diplomacy.rs`; `cargo test -p mc-trade rules` → 4/4 pass. - [ ] `mc-trade`: implement renewal API: - `propose_renewal(agreement_id, payment) -> RenewalDecision` - At `turns_remaining == auto_prompt_turns_before_expiry` (5), emit a @@ -100,9 +102,11 @@ pending. filters out tiles with `min_episode >= 2`. Confirm `mana_node`, `ley_nexus`, `bermuda_anomaly`, `tower_of_wizardry` never appear in Game 1 maps. -- [ ] `mc-culture::PolicyEffects`: add `freepeople_parley_enabled`, +- [x] `mc-culture::PolicyEffects`: add `freepeople_parley_enabled`, `tribute_diplomacy` mechanic keys; player gains tribute UI options on adoption. + Evidence: `mc-culture/src/policy.rs` `PolicyEffects`; `MechanicKey` enum + in `mc-core/src/diplomacy.rs`; `cargo test -p mc-culture` → 20/20 pass. - [ ] `mc-turn::process_freepeople`: tribute payment → influence accrual (per action type, per turn), state transitions on threshold crossings, city-state graduation at allied + 30 evolution_progress. diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 8980cbbf..6b2c78b8 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -1167,6 +1167,7 @@ dependencies = [ name = "mc-trade" version = "0.1.0" dependencies = [ + "mc-core", "rand 0.8.6", "serde", "serde_json", diff --git a/src/simulator/crates/mc-core/src/diplomacy.rs b/src/simulator/crates/mc-core/src/diplomacy.rs index 6236fc78..bcd71ee6 100644 --- a/src/simulator/crates/mc-core/src/diplomacy.rs +++ b/src/simulator/crates/mc-core/src/diplomacy.rs @@ -65,7 +65,7 @@ impl AgreementType { /// introduces a new mechanic that Rust simulation code must branch on. /// /// Serialises as `snake_case` matching the JSON `key` field. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MechanicKey { /// Unlocked by the *Outsider's Parley* culture policy (statecraft e2). diff --git a/src/simulator/crates/mc-culture/src/lib.rs b/src/simulator/crates/mc-culture/src/lib.rs index cc884330..5f1d3abb 100644 --- a/src/simulator/crates/mc-culture/src/lib.rs +++ b/src/simulator/crates/mc-culture/src/lib.rs @@ -13,7 +13,9 @@ //! Design: data only, no GDExtension imports. The GDExtension surface //! (`api-gdext`) wraps this and forwards events back to GDScript. +pub mod policy; pub mod research; +pub use policy::PolicyEffects; pub use research::{ CultureNode, CultureResearchResult, CultureUnlockSignal, CultureUnlocks, CultureWeb, PlayerCultureState, diff --git a/src/simulator/crates/mc-culture/src/policy.rs b/src/simulator/crates/mc-culture/src/policy.rs new file mode 100644 index 00000000..3dbdc41b --- /dev/null +++ b/src/simulator/crates/mc-culture/src/policy.rs @@ -0,0 +1,120 @@ +//! Policy effect tracking — which mechanics are unlocked by adopted policies. +//! +//! `PolicyEffects` accumulates `MechanicKey` flags from adopted culture +//! policies. The turn processor reads this to gate gameplay systems (e.g. +//! whether a player may access the tribute-diplomacy UI). +//! +//! Design: pure data, no GDExtension imports, no file I/O. + +use mc_core::MechanicKey; +use std::collections::BTreeSet; + +/// Per-player active policy effects. +/// +/// Built by calling `apply_policy_mechanics` for each adopted policy's +/// `mechanics` list. The turn processor and UI bridge read the resulting +/// flag set to determine available gameplay options. +#[derive(Debug, Clone, Default)] +pub struct PolicyEffects { + active: BTreeSet, +} + +impl PolicyEffects { + /// Construct an empty effects set (no policies adopted). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Activate a single mechanic key (idempotent). + pub fn enable(&mut self, key: MechanicKey) { + self.active.insert(key); + } + + /// Activate all recognised mechanic keys from a policy's `mechanics` + /// list. Unrecognised key strings are silently ignored for forward + /// compatibility. + /// + /// `mechanic_key_strs` — the raw string keys from the JSON `unlocks.mechanics` + /// array (the `"key"` field on each mechanic object). + pub fn apply_policy_mechanics<'a>( + &mut self, + mechanic_key_strs: impl IntoIterator, + ) { + for s in mechanic_key_strs { + if let Some(key) = MechanicKey::from_key_str(s) { + self.active.insert(key); + } + } + } + + /// Returns `true` if the given mechanic key is currently active. + #[must_use] + pub fn is_active(&self, key: MechanicKey) -> bool { + self.active.contains(&key) + } + + /// Returns `true` if the player has adopted the *Outsider's Parley* + /// policy — i.e. both `FreepeopleParleyEnabled` and `TributeDiplomacy` + /// are active. + #[must_use] + pub fn has_outsider_parley(&self) -> bool { + self.is_active(MechanicKey::FreepeopleParleyEnabled) + && self.is_active(MechanicKey::TributeDiplomacy) + } + + /// All currently active mechanic keys (deterministic iteration order via BTreeSet). + pub fn active_keys(&self) -> impl Iterator + '_ { + self.active.iter().copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_outsider_parley_unlocks_tribute() { + let mut effects = PolicyEffects::new(); + + // Before adoption: no tribute access. + assert!(!effects.is_active(MechanicKey::FreepeopleParleyEnabled)); + assert!(!effects.is_active(MechanicKey::TributeDiplomacy)); + assert!(!effects.has_outsider_parley()); + + // Simulate adopting Outsider's Parley — as authored in statecraft.json. + effects.apply_policy_mechanics(["freepeople_parley_enabled", "tribute_diplomacy"]); + + assert!(effects.is_active(MechanicKey::FreepeopleParleyEnabled)); + assert!(effects.is_active(MechanicKey::TributeDiplomacy)); + assert!(effects.has_outsider_parley()); + } + + #[test] + fn unknown_mechanic_key_ignored() { + let mut effects = PolicyEffects::new(); + // A key not in the enum (e.g. a future mechanic) must not panic. + effects.apply_policy_mechanics(["future_mechanic_key_xyz"]); + // Nothing active — no crash. + assert!(!effects.has_outsider_parley()); + } + + #[test] + fn enable_is_idempotent() { + let mut effects = PolicyEffects::new(); + effects.enable(MechanicKey::FreepeopleParleyEnabled); + effects.enable(MechanicKey::FreepeopleParleyEnabled); + let keys: Vec<_> = effects.active_keys().collect(); + assert_eq!(keys.len(), 1); + } + + #[test] + fn has_outsider_parley_requires_both_keys() { + let mut effects = PolicyEffects::new(); + effects.enable(MechanicKey::FreepeopleParleyEnabled); + // Only one of the two keys — parley not yet available. + assert!(!effects.has_outsider_parley()); + effects.enable(MechanicKey::TributeDiplomacy); + assert!(effects.has_outsider_parley()); + } +} diff --git a/src/simulator/crates/mc-trade/Cargo.toml b/src/simulator/crates/mc-trade/Cargo.toml index 9f199790..b94a1930 100644 --- a/src/simulator/crates/mc-trade/Cargo.toml +++ b/src/simulator/crates/mc-trade/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +mc-core = { path = "../mc-core" } serde.workspace = true serde_json.workspace = true rand.workspace = true diff --git a/src/simulator/crates/mc-trade/src/rules.rs b/src/simulator/crates/mc-trade/src/rules.rs index b5f78393..f3433ca9 100644 --- a/src/simulator/crates/mc-trade/src/rules.rs +++ b/src/simulator/crates/mc-trade/src/rules.rs @@ -196,7 +196,7 @@ mod tests { /// The canonical treaty_rules.json relative to the workspace root. /// `cargo test` is run from the workspace root so this resolves correctly. const TREATY_RULES_JSON: &str = - include_str!("../../../../../../../../public/resources/diplomacy/treaty_rules.json"); + include_str!("../../../../../public/resources/diplomacy/treaty_rules.json"); #[test] fn test_default_durations_loaded() {