from dataclasses import replace import pytest from core.config import GameConfig from core.game import ( PROPOSAL_STATUS_APPROVED, PROPOSAL_STATUS_REJECTED, PROPOSAL_STATUS_REJECTED_INVALID, NationGame, ) from schemas.actions import ActionType, VoteChoice from schemas.phases import Phase DEPARTMENTS = ("Social", "Agriculture", "Health", "Education", "Defense", "Commerce") def make_game(seed: int = 7, **config_overrides) -> NationGame: config = ( replace(GameConfig.from_json(), **config_overrides) if config_overrides else GameConfig.from_json() ) game = NationGame(config=config, seed=seed) game.event_engine._distribution = {"no_event": 1.0} game.reset() return game def debate(agent_id: str = "Health") -> dict: return { "type": ActionType.DEBATE, "agent_id": agent_id, "message": "Public priority signal", } def proposal(department: str, amount: float = 10.0) -> dict: """Discretionary request (≥ 0) above the auto-funded critical floor.""" return { "type": ActionType.PROPOSE_BUDGET, "agent_id": department, "department": department, "amount": amount, "justification": "Fund expected demand", } def vote(agent_id: str, proposal_id: str, choice: VoteChoice = VoteChoice.YES) -> dict: return { "type": ActionType.VOTE, "agent_id": agent_id, "proposal_id": proposal_id, "vote": choice, } def every_eligible_vote(game: NationGame) -> list[dict]: """One YES vote from each non-proposer, for every pending proposal (full parliament).""" return [ vote(department, p.proposal_id, VoteChoice.YES) for p in game.proposals for department in DEPARTMENTS if department != p.department ] def every_eligible_no_on_target_else_yes( game: NationGame, target_department: str ) -> list[dict]: """NO on ``target_department``'s proposal; YES on all other proposals (from each eligible voter).""" return [ vote( department, p.proposal_id, VoteChoice.NO if p.department == target_department else VoteChoice.YES, ) for p in game.proposals for department in DEPARTMENTS if department != p.department ] def advance_to_phase(game: NationGame, phase: Phase) -> None: while game.phase < phase and not game.done: if game.phase == Phase.DEBATE: game.force_advance_phase() else: game.step() def submit_safe_proposals(game: NationGame) -> None: advance_to_phase(game, Phase.PROPOSAL) game.step([proposal(department, amount=10.0) for department in DEPARTMENTS]) def approve_all_pending_proposals(game: NationGame) -> None: advance_to_phase(game, Phase.VOTING) actions = [] for item in game.proposals: for department in DEPARTMENTS: if department != item.department: actions.append(vote(department, item.proposal_id)) game.step(actions) def run_phase_loop_round(game: NationGame) -> None: game.step() submit_safe_proposals(game) approve_all_pending_proposals(game) while game.phase != Phase.EVENT_REVELATION and not game.done: if game.phase == Phase.DEBATE: game.force_advance_phase() else: game.step() def test_reset_creates_round_1_phase_1_state() -> None: game = make_game() state = game.state() assert state["round"] == 1 assert state["phase"] == Phase.EVENT_REVELATION assert state["treasury"] == 1000 def test_phase_transitions_follow_1_through_9() -> None: game = make_game() seen = [game.phase] game.step() seen.append(game.phase) game.force_advance_phase() seen.append(game.phase) game.step([proposal(department) for department in DEPARTMENTS]) seen.append(game.phase) approve_all_pending_proposals(game) seen.append(game.phase) while game.phase != Phase.EVENT_REVELATION and not game.done: if game.phase == Phase.DEBATE: game.force_advance_phase() else: game.step() seen.append(game.phase) assert seen[:9] == [Phase(index) for index in range(1, 10)] assert game.round == 2 def test_debate_action_only_accepted_in_phase_2() -> None: game = make_game() phase_1_result = game.step(debate()) phase_2_result = game.step(debate()) assert phase_1_result.info["ignored_actions"][0]["reason"] == "wrong_phase" assert phase_2_result.info["accepted_actions"][0]["type"] == ActionType.DEBATE assert len(game.debate_messages) == 1 def test_proposal_action_only_accepted_in_phase_3() -> None: game = make_game() wrong_phase_result = game.step(proposal("Health")) advance_to_phase(game, Phase.PROPOSAL) accepted_result = game.step(proposal("Health")) assert wrong_phase_result.info["ignored_actions"][0]["reason"] == "wrong_phase" assert ( accepted_result.info["accepted_actions"][0]["type"] == ActionType.PROPOSE_BUDGET ) def test_vote_action_only_accepted_in_phase_4() -> None: game = make_game() advance_to_phase(game, Phase.PROPOSAL) wrong_phase_result = game.step(vote("Health", "missing")) game = make_game() submit_safe_proposals(game) proposal_id = next( item.proposal_id for item in game.proposals if item.department != "Health" ) accepted_result = game.step(vote("Health", proposal_id)) assert wrong_phase_result.info["ignored_actions"][0]["reason"] == "wrong_phase" assert accepted_result.info["accepted_actions"][0]["type"] == ActionType.VOTE def test_wrong_phase_action_is_ignored() -> None: game = make_game() result = game.step( { "type": ActionType.VOTE, "agent_id": "Health", "proposal_id": "missing", "vote": VoteChoice.YES, } ) assert result.info["ignored_actions"][0]["reason"] == "wrong_phase" assert game.votes == [] def test_invalid_proposal_over_treasury_is_rejected() -> None: game = make_game() advance_to_phase(game, Phase.PROPOSAL) rem = max(0.0, game.treasury.balance - game._total_critical_funding()) result = game.step(proposal("Health", amount=rem + 1.0)) assert result.info["rejected_actions"][0]["reason"] == "exceeds_treasury" assert game.proposals[0].status == PROPOSAL_STATUS_REJECTED_INVALID def test_duplicate_proposal_is_ignored() -> None: game = make_game() advance_to_phase(game, Phase.PROPOSAL) result = game.step( [proposal("Health", amount=20.0), proposal("Health", amount=30.0)] ) assert len(game.proposals) == 1 assert result.info["ignored_actions"][0]["reason"] == "duplicate_proposal" def test_agents_cannot_propose_for_another_department() -> None: game = make_game() advance_to_phase(game, Phase.PROPOSAL) action = proposal("Health", amount=20.0) action["agent_id"] = "Defense" result = game.step(action) assert result.info["rejected_actions"][0]["reason"] == "wrong_department" assert game.proposals[0].status == PROPOSAL_STATUS_REJECTED_INVALID def test_self_vote_is_rejected() -> None: game = make_game() submit_safe_proposals(game) health_proposal = next( item for item in game.proposals if item.department == "Health" ) result = game.step(vote("Health", health_proposal.proposal_id)) assert result.info["rejected_actions"][0]["reason"] == "self_vote" assert health_proposal.votes == {} def test_tie_vote_rejects_proposal() -> None: game = make_game() advance_to_phase(game, Phase.PROPOSAL) game.step(proposal("Health", amount=90.0)) game.step([proposal(d, amount=0.0) for d in DEPARTMENTS if d != "Health"]) health_proposal = next(p for p in game.proposals if p.department == "Health") custom = { "Defense": VoteChoice.YES, "Commerce": VoteChoice.NO, "Social": VoteChoice.ABSTAIN, "Agriculture": VoteChoice.YES, "Education": VoteChoice.NO, } actions: list[dict] = [] for p in game.proposals: for d in DEPARTMENTS: if d == p.department: continue if p.proposal_id == health_proposal.proposal_id: actions.append(vote(d, p.proposal_id, custom.get(d, VoteChoice.YES))) else: actions.append(vote(d, p.proposal_id, VoteChoice.YES)) game.step(actions) game.step() assert health_proposal.status == PROPOSAL_STATUS_REJECTED assert not game.done def test_approved_proposal_debits_treasury() -> None: game = make_game() submit_safe_proposals(game) approve_all_pending_proposals(game) before = game.treasury.balance total_c = game._total_critical_funding() approved = sum(p.amount for p in game.proposals) game.step() assert all(p.status == PROPOSAL_STATUS_APPROVED for p in game.proposals) assert game.treasury.balance == pytest.approx(before - total_c - approved) def test_rejected_proposal_keeps_only_critical() -> None: game = make_game() advance_to_phase(game, Phase.PROPOSAL) game.step(proposal("Health", amount=90.0)) game.step([proposal(d, amount=0.0) for d in DEPARTMENTS if d != "Health"]) health_proposal = game.proposals[0] game.step(every_eligible_no_on_target_else_yes(game, "Health")) while game.phase != Phase.CONSUMPTION_AND_EVENT_IMPACT and not game.done: if game.phase == Phase.DEBATE: game.force_advance_phase() else: game.step() assert health_proposal.status == PROPOSAL_STATUS_REJECTED crit = float(game.sectors["Health"].critical) assert game.sectors["Health"].allocation == pytest.approx(crit) def test_zero_discretionary_proposal_is_accepted() -> None: game = make_game() advance_to_phase(game, Phase.PROPOSAL) result = game.step(proposal("Health", amount=0.0)) assert result.info["accepted_actions"][0]["type"] == ActionType.PROPOSE_BUDGET assert any(p.department == "Health" and p.amount == 0.0 for p in game.proposals) def test_sequential_treasury_depletion_rejects_later_proposal() -> None: game = make_game() advance_to_phase(game, Phase.PROPOSAL) game.step([proposal("Social", amount=400.0), proposal("Health", amount=500.0)]) game.step( [proposal(d, amount=0.0) for d in DEPARTMENTS if d not in {"Social", "Health"}] ) social_proposal = next(p for p in game.proposals if p.department == "Social") health_proposal = next(p for p in game.proposals if p.department == "Health") game.step(every_eligible_vote(game)) game.step() assert social_proposal.status == PROPOSAL_STATUS_APPROVED assert health_proposal.status == PROPOSAL_STATUS_REJECTED assert health_proposal.rejection_reason == "exceeds_remaining_treasury" def test_dictator_direct_runs_economy_round_without_immediate_termination() -> None: game = make_game() allocations = dict(game.config.SECTOR_BASELINES) allocations["Defense"] = 30 result = game.step(allocations) assert not result.done assert result.termination_reason != "CRITICAL_FAILURE" assert result.total_revenue > 0.0 def test_revenue_succeeds_after_direct_allocation() -> None: game = make_game() safe = { department: baseline * 1.3 for department, baseline in game.config.SECTOR_BASELINES.items() } first = game.step(safe) assert first.total_revenue > 0 def test_bankruptcy_terminates_episode() -> None: game = make_game() game.treasury.balance = 1 critical_allocations = { department: baseline * game.config.CRITICAL_RATIO for department, baseline in game.config.SECTOR_BASELINES.items() } result = game.step(critical_allocations) assert result.done assert result.termination_reason == "BANKRUPTCY" def test_max_rounds_terminates_episode() -> None: game = make_game(MAX_ROUNDS=1) run_phase_loop_round(game) assert game.done assert game.termination_reason == "MAX_ROUNDS" def test_auto_critical_is_funded_before_voted_proposals() -> None: """Auto-critical is applied BEFORE approved discretionary proposals are added.""" game = make_game() advance_to_phase(game, Phase.PROPOSAL) game.step([proposal(department, amount=20.0) for department in DEPARTMENTS]) approve_all_pending_proposals(game) game.step() # Phase 5: Budget Execution total_c = game._total_critical_funding() # Every department gets critical funded automatically for dept in DEPARTMENTS: sector = game.sectors[dept] assert sector.allocation >= float(sector.critical(game.population.value)), ( f"{dept} allocation {sector.allocation} < critical {sector.critical(game.population.value)}" ) # Treasury was debited for total critical first # Then debited for approved discretionary on top approved_disc = sum( p.amount for p in game.proposals if p.status == PROPOSAL_STATUS_APPROVED ) expected_allocation = total_c + approved_disc assert game.last_total_allocation == pytest.approx(expected_allocation) def test_discretionary_zero_proposal_is_valid() -> None: """A proposal of amount=0 means 'I only need auto-critical, no extra'.""" game = make_game() advance_to_phase(game, Phase.PROPOSAL) # All departments propose 0 discretionary game.step([proposal(department, amount=0.0) for department in DEPARTMENTS]) approve_all_pending_proposals(game) game.step() # Phase 5 # All proposals accepted (0 is valid) assert all(p.status == PROPOSAL_STATUS_APPROVED for p in game.proposals) # Every department still gets auto-critical total_c = game._total_critical_funding() assert game.last_total_allocation == pytest.approx(total_c) def test_rejected_proposal_allocates_critical_not_zero() -> None: """Rejection means department gets ONLY auto-critical (not 0).""" game = make_game() advance_to_phase(game, Phase.PROPOSAL) # Health proposes 90, everyone else 0 game.step(proposal("Health", amount=90.0)) game.step([proposal(d, amount=0.0) for d in DEPARTMENTS if d != "Health"]) # NO vote on Health's proposal health_proposal = next(p for p in game.proposals if p.department == "Health") game.step(every_eligible_no_on_target_else_yes(game, "Health")) while game.phase != Phase.CONSUMPTION_AND_EVENT_IMPACT and not game.done: if game.phase == Phase.DEBATE: game.force_advance_phase() else: game.step() assert health_proposal.status == PROPOSAL_STATUS_REJECTED crit = float(game.sectors["Health"].critical(game.population.value)) # Health gets auto-critical (not 0) even though proposal was rejected assert game.sectors["Health"].allocation == pytest.approx(crit) def test_shutdown_triggered_by_zero_discretionary_two_rounds() -> None: """Shutdown = sum(Discretionary_d) = 0 for 2 consecutive rounds.""" game = make_game() run_phase_loop_round(game) # Round 1: all propose and approve # Round 2: all propose 0 discretionary (all get auto-critical only) advance_to_phase(game, Phase.PROPOSAL) game.step([proposal(d, amount=0.0) for d in DEPARTMENTS]) approve_all_pending_proposals(game) <<<<<<< Updated upstream while game.phase != Phase.EVENT_REVELATION and not game.done: if game.phase == Phase.DEBATE: game.force_advance_phase() else: game.step() ======= game.step() # Phase 5: first round with zero discretionary >>>>>>> Stashed changes assert game.shutdown_counter == 1 assert not game.done # Round 3: again all propose 0 advance_to_phase(game, Phase.PROPOSAL) game.step([proposal(d, amount=0.0) for d in DEPARTMENTS]) approve_all_pending_proposals(game) <<<<<<< Updated upstream while game.phase != Phase.EVENT_REVELATION and not game.done: if game.phase == Phase.DEBATE: game.force_advance_phase() else: game.step() ======= game.step() # Phase 5: second consecutive zero discretionary >>>>>>> Stashed changes assert game.shutdown_counter == 2 assert game.done assert game.termination_reason == "SHUTDOWN" def test_bankruptcy_when_treasury_less_than_total_critical() -> None: """If treasury < sum(Critical_d) at budget execution, BANKRUPTCY triggers.""" game = make_game() # Set treasury to be less than total critical game.treasury.balance = 50.0 # Total critical is ~190 advance_to_phase(game, Phase.PROPOSAL) game.step([proposal(d, amount=0.0) for d in DEPARTMENTS]) approve_all_pending_proposals(game) result = game.step() assert result.done assert result.termination_reason == "BANKRUPTCY" def test_no_critical_failure_after_revenue_round() -> None: """Auto-critical ensures critical failure is impossible. Treasury checks apply instead.""" game = make_game() # Simulate under-funding scenario: treasury too low for all critical game.treasury.balance = 100.0 # Below ~190 total critical advance_to_phase(game, Phase.PROPOSAL) game.step([proposal(d, amount=0.0) for d in DEPARTMENTS]) approve_all_pending_proposals(game) result = game.step() # Should be BANKRUPTCY (treasury < critical), NOT CRITICAL_FAILURE assert result.done assert result.termination_reason == "BANKRUPTCY" def test_direct_allocation_applies_auto_critical_first() -> None: """Direct allocation shortcut auto-funds critical, then applies input as discretionary.""" game = make_game() <<<<<<< Updated upstream # Input mapping is legacy total desired allocation per department; engine splits # into critical + discretionary after auto-funding critical. ======= # Direct allocation input: {Dept: total_requested} # Should be interpreted as: {Dept: discretionary_above_critical} >>>>>>> Stashed changes safe = { dept: baseline * 1.3 for dept, baseline in game.config.SECTOR_BASELINES.items() } result = game.step(safe) assert not result.done <<<<<<< Updated upstream pop = int(result.observation["population"]) for dept in DEPARTMENTS: alloc = result.observation["sectors"][dept]["allocation"] crit = game.sectors[dept].critical(pop) assert alloc >= float(crit) ======= # Verify auto-critical was applied total_c = game._total_critical_funding() # Verify each department got at least critical for dept in DEPARTMENTS: sector = game.sectors[dept] assert sector.allocation >= float(sector.critical(game.population.value)) >>>>>>> Stashed changes def test_seeded_reset_is_deterministic() -> None: first = NationGame(seed=123).reset(seed=123) second = NationGame(seed=123).reset(seed=123) assert first["event_ledger"] == second["event_ledger"] assert first["sectors"] == second["sectors"]