feat(@projects/@magic-civilization): ✨ implement treaty renewal and diplomacy mechanics
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
77dcf51986
commit
ab49db5e63
7 changed files with 134 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
1
src/simulator/Cargo.lock
generated
1
src/simulator/Cargo.lock
generated
|
|
@ -1167,6 +1167,7 @@ dependencies = [
|
|||
name = "mc-trade"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"mc-core",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
120
src/simulator/crates/mc-culture/src/policy.rs
Normal file
120
src/simulator/crates/mc-culture/src/policy.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue