| """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 |
| |
| 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" |
| 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: |
| |
| 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] |
|
|
| |
| 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 |
|
|
| |
| if catastrophic: |
| effects = {k: -abs(v) for k, v in effects.items()} |
| effects.setdefault("approval", -hi) |
| effects.setdefault("institutional_stability", -hi) |
|
|
| |
| 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 |
|
|
| |
| roll = dice.roll(turn, decision_idx) |
|
|
| |
| 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 |
|
|
| |
| if country.key == "china": |
| _china_hooks(state, event, applied) |
|
|
| |
| stance_changes = update_agent_stances(state, country.agents, {k: float(v) for k, v in applied.items()}) |
|
|
| |
| 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) |
| mag = round((favor / 100.0) * elite.influence * 4) |
| if mag == 0: |
| return |
| state.apply("economy", mag) |
| state.apply("fiscal_health", mag) |
| if mag < 0: |
| 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) |
|
|
| |
| 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." |
| ) |
|
|
| |
| |
| 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 |
| 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 |
|
|
| |
| 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: |
| 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}" |
|
|