nation_optimizer / tests /integration /test_game_loop.py
thewildofficial
Fix Spaces config: app_file at root, add app.py wrapper
bbc4b4d
Raw
History Blame Contribute Delete
19 kB
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"]