global-leaders / engine /resolver.py
Leonardo Camilo
Global Leaders — political-strategy game on a ≤32B model
cc5d530
Raw
History Blame Contribute Delete
10.6 kB
"""Resolver — applies the Judge's proposal to the World State with all guardrails
(GAME_RULES.md §2, §2.1, §3, §3.1). The model proposes; this code decides.
Pipeline (§7.4 post-processing):
1. plausibility gate (impossible -> no-op; absurd -> catastrophic)
2. clamp each delta to the magnitude range
3. "no free lunch": major/radical must carry a trade-off
4. uncertainty dice
5. apply + clamp 0..100 (indicators and faction meters)
6. country faction/coup hooks (China)
7. update agent stances (game theory)
8. history digest + game-over checks
"""
from __future__ import annotations
from dataclasses import dataclass, field
from engine.agents import update_agent_stances
from engine.countries import CountryDef
from engine.dice import Dice
from engine.events import Event
from engine.schemas import Effect, JudgeOutput
from engine.state import INDICATOR_LABELS, WorldState
MAGNITUDE_RANGE = {
"minor": (1, 3),
"moderate": (3, 6),
"major": (6, 10),
"radical": (10, 15),
}
MAX_INDICATORS_PER_DECISION = 3
# Where to charge the default cost when a big move has no trade-off (§2 rule 1).
NATURAL_COST = "fiscal_health"
PLA_CLIQUE_EVENT = "cn_2025_04_pla_clique"
@dataclass
class ResolveResult:
applied: dict[str, int] = field(default_factory=dict)
mode: str = "normal" # normal | backfire | windfall | rejected | catastrophic
stance_changes: dict[str, bool] = field(default_factory=dict)
note: str = ""
def resolve(
state: WorldState,
country: CountryDef,
event: Event,
judge: JudgeOutput,
dice: Dice,
turn: int,
decision_idx: int = 0,
) -> ResolveResult:
# 1. Plausibility gate.
if judge.plausibility == "impossible":
state.history.append(f"M{turn:02d}: attempted the impossible — nothing happened")
return ResolveResult(mode="rejected", note="Impossible action; the world shrugs.")
magnitude = judge.magnitude
catastrophic = judge.plausibility == "absurd"
if catastrophic:
magnitude = "radical"
lo, hi = MAGNITUDE_RANGE[magnitude]
# 2. Clamp each proposed delta into the magnitude range; cap how many indicators move.
effects: dict[str, int] = {}
for e in judge.effects[:MAX_INDICATORS_PER_DECISION]:
mag = max(lo, min(hi, abs(e.proposed_delta)))
sign = 1 if e.direction == "+" else -1
effects[e.indicator] = sign * mag
# Absurd actions are forced catastrophic: invert any gains, guarantee real pain.
if catastrophic:
effects = {k: -abs(v) for k, v in effects.items()}
effects.setdefault("approval", -hi)
effects.setdefault("institutional_stability", -hi)
# 3. No free lunch: major/radical must include at least one negative trade-off.
if magnitude in ("major", "radical") and not any(v < 0 for v in effects.values()):
cost_key = NATURAL_COST if NATURAL_COST not in effects else "approval"
effects[cost_key] = effects.get(cost_key, 0) - lo
# 4. Uncertainty dice (one roll governs the whole decision).
roll = dice.roll(turn, decision_idx)
# 5. Apply, clamped to 0..100.
applied: dict[str, int] = {}
for key, delta in effects.items():
final = round(roll.apply(delta))
before = state.get(key)
state.apply(key, final)
applied[key] = state.get(key) - before
# 6. Country-specific faction / coup hooks.
if country.key == "china":
_china_hooks(state, event, applied)
# 7. Update agent stances from the realised deltas (game theory, §0.2).
stance_changes = update_agent_stances(state, country.agents, {k: float(v) for k, v in applied.items()})
# 8. History line + game over.
state.history.append(_history_line(turn, judge, applied, roll.mode))
check_game_over(state, country)
return ResolveResult(applied=applied, mode=roll.mode, stance_changes=stance_changes)
def _china_hooks(state: WorldState, event: Event, applied: dict[str, int]) -> None:
"""PLA clique arc (§3.1). A decisive pro-control move on any PLA event (one that touches
pla_loyalty) defuses the clique; otherwise the plot keeps maturing (see upkeep)."""
pla_event = "pla_loyalty" in event.touches or "pla_loyalty" in event.faction_touches
if pla_event and applied.get("pla_loyalty", 0) >= 6:
state.flags["pla_clique_handled"] = True
state.factions["coup_plot_progress"] = 0
def _elite_pressure(state: WorldState, country: CountryDef) -> None:
"""The elite's mood moves markets each month, scaled by its country-specific influence.
Happy elite -> +economy/+fiscal; hostile elite (and the media it owns) -> -economy/-fiscal/-approval.
Strong in USA/Brazil (influence ~0.8-0.9), weak in China (~0.3)."""
elite = next((f for f in country.roster if f.faction == "elite"), None)
if not elite or elite.influence <= 0:
return
favor = state.agent_favor.get(elite.key, 0.0) # -100..100
mag = round((favor / 100.0) * elite.influence * 4)
if mag == 0:
return
state.apply("economy", mag)
state.apply("fiscal_health", mag)
if mag < 0: # a hostile elite — and the outlets it controls — also drags approval down
state.apply("approval", mag // 2)
def _resilience(state: WorldState) -> None:
"""Gentle mean-reversion so a battered nation doesn't spiral straight to zero: indicators in
extreme lows recover a little each month (economies stabilize, institutions regroup); very high
ones drift down (political gravity). Softens death-spirals without erasing the difficulty."""
for ind, v in list(state.indicators.items()):
if v < 20:
state.apply(ind, 3)
elif v < 30:
state.apply(ind, 2)
elif v < 40:
state.apply(ind, 1)
elif v > 88:
state.apply(ind, -1)
def end_of_month_upkeep(state: WorldState, country: CountryDef, dice: Dice, turn: int) -> None:
"""Run once after all of a month's events are resolved (§3 universal rule, §3.1 maturation)."""
_elite_pressure(state, country)
_resilience(state)
# Universal terminal crisis: any key indicator below the floor for 2 consecutive months.
floor = country.collapse.universal_floor
for ind, value in state.indicators.items():
if value < floor:
state.low_streak[ind] = state.low_streak.get(ind, 0) + 1
else:
state.low_streak[ind] = 0
for ind, streak in state.low_streak.items():
if streak >= 2 and state.game_over is None:
state.game_over = "terminal_crisis"
state.ending_text = (
f"{INDICATOR_LABELS.get(ind, ind)} has been in free-fall for months. "
f"The state can no longer function. Your government collapses."
)
# China: the coup plot matures while the clique is unhandled and the PLA is disaffected.
# Meio-termo: still fairly fast, but the player gets narrative warnings (§ polish) to react.
if country.key == "china" and not state.flags.get("pla_clique_handled"):
pla = state.factions.get("pla_loyalty", 100)
if pla < 50:
growth = round((50 - pla) * 0.5) + 7
jitter = dice.roll(turn, 99).factor # 0.4..1.6, reproducible
state.factions["coup_plot_progress"] = min(
100, state.factions.get("coup_plot_progress", 0) + round(growth * jitter))
plot = state.factions.get("coup_plot_progress", 0)
if plot >= 70 and not state.flags.get("pla_warn2"):
state.flags["pla_warn2"] = True
state.pending_alert = ("Credible intelligence points to an advanced plot brewing inside the PLA "
"high command. Move against the clique now — or lose the army.")
elif plot >= 40 and not state.flags.get("pla_warn1"):
state.flags["pla_warn1"] = True
state.pending_alert = ("Whispers reach you of disquiet and fraying loyalty in the PLA's senior ranks.")
check_game_over(state, country)
def check_game_over(state: WorldState, country: CountryDef) -> None:
if state.game_over is not None:
return
ind = state.indicators
rule = country.collapse
# China faction endings come first (§3.1).
if country.key == "china":
if state.factions.get("party_loyalty", 100) < 25:
state.game_over = "party_ouster"
state.ending_text = (
"A closed-door plenum strips you of your titles. The Party has decided you are "
"a liability; a successor is announced before you finish your tea."
)
return
if state.factions.get("coup_plot_progress", 0) >= 100 or state.factions.get("pla_loyalty", 100) < 20:
state.game_over = "pla_coup"
state.ending_text = (
"The clique you failed to control moves first. Tanks ring Zhongnanhai at dawn; "
"the PLA announces a 'rectification'. You never controlled the gun — and it turned."
)
return
if rule.gov == "dem":
if ind["approval"] < rule.approval_min and ind["institutional_stability"] < rule.inst_min:
state.game_over = "removed_from_office"
state.ending_text = (
"With your approval gutted and institutions turning on you, the machinery of removal "
"grinds into motion. Impeachment / a no-confidence vote ends your government."
)
return
if ind["economy"] < rule.eco_min and ind["approval"] < rule.approval_min:
state.game_over = "economic_meltdown"
state.ending_text = "An economic collapse and a furious public force you out."
return
else: # autocracy
if ind["institutional_stability"] < rule.inst_min and (
ind["economy"] < rule.eco_min or ind["social_cohesion"] < rule.coh_min
):
state.game_over = "palace_collapse"
state.ending_text = (
"Cracks in the inner circle widen into a chasm. Your grip fails and the regime "
"convulses into a leadership struggle."
)
return
def _history_line(turn: int, judge: JudgeOutput, applied: dict[str, int], mode: str) -> str:
moves = ", ".join(
f"{INDICATOR_LABELS.get(k, k)} {('+' if v >= 0 else '')}{v}" for k, v in applied.items() if v
)
tag = {"backfire": " [BACKFIRED]", "windfall": " [windfall]"}.get(mode, "")
summary = (judge.interpretation or "acted")[:60]
return f"M{turn:02d}: {summary} -> {moves or 'no net change'}{tag}"