feat(@projects/@magic-civilization): implement treaty renewal and diplomacy mechanics

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-07 07:05:40 -07:00
parent 77dcf51986
commit ab49db5e63
7 changed files with 134 additions and 6 deletions

View file

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

View file

@ -1167,6 +1167,7 @@ dependencies = [
name = "mc-trade"
version = "0.1.0"
dependencies = [
"mc-core",
"rand 0.8.6",
"serde",
"serde_json",

View file

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

View file

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

View file

@ -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<MechanicKey>,
}
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<Item = &'a str>,
) {
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<Item = MechanicKey> + '_ {
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());
}
}

View file

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

View file

@ -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() {