1
0
This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/tests/bannerlord/test_campaign_loop.py
Alexander Whitestone 46ef9d3aba WIP: Claude Code progress on #1094
Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
2026-03-23 14:26:15 -04:00

201 lines
7.4 KiB
Python

"""Unit tests for bannerlord.campaign_loop."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bannerlord.campaign_loop import CampaignLoop, TickResult
from bannerlord.decision import CampaignDecision, M2Action
from infrastructure.world.types import ActionResult, ActionStatus
def _make_game_state(*, troops: int = 30, gold: int = 2000) -> dict:
return {
"tick": 0,
"party": {
"size": troops,
"wounded": 0,
"food_days": 5.0,
"morale": 80.0,
"current_settlement": "town_A1",
},
"economy": {"gold": gold, "daily_income": 200, "daily_expenses": 150},
"nearby_parties": [],
"settlements": [
{
"id": "town_A1",
"name": "Marunath",
"faction": "aserai",
"is_friendly": True,
"distance": 0.0,
"has_recruits": True,
"has_trade_goods": False,
}
],
}
class TestCampaignLoopDispatch:
"""Tests for the internal _dispatch() routing."""
def _loop(self) -> CampaignLoop:
return CampaignLoop(tick_seconds=0.0, max_ticks=1)
async def test_dispatch_move(self):
loop = self._loop()
client = MagicMock()
decision = CampaignDecision(
action=M2Action.MOVE,
settlement_id="town_A1",
settlement_name="Marunath",
)
with patch("bannerlord.campaign_loop.move_to_settlement", new_callable=AsyncMock) as mock_move:
mock_move.return_value = ActionResult(status=ActionStatus.SUCCESS, message="ok")
await loop._dispatch(decision, client)
mock_move.assert_called_once_with(client, "town_A1", settlement_name="Marunath")
async def test_dispatch_recruit(self):
loop = self._loop()
client = MagicMock()
decision = CampaignDecision(
action=M2Action.RECRUIT,
settlement_id="town_A1",
)
with patch("bannerlord.campaign_loop.recruit_all", new_callable=AsyncMock) as mock_recruit:
mock_recruit.return_value = ActionResult(status=ActionStatus.SUCCESS, message="15 recruited")
await loop._dispatch(decision, client)
mock_recruit.assert_called_once()
async def test_dispatch_engage(self):
loop = self._loop()
client = MagicMock()
decision = CampaignDecision(
action=M2Action.ENGAGE,
party_id="bandit_1",
party_name="Forest Bandits",
)
with patch("bannerlord.campaign_loop.engage_party", new_callable=AsyncMock) as mock_engage:
mock_engage.return_value = ActionResult(status=ActionStatus.SUCCESS, message="victory")
await loop._dispatch(decision, client)
mock_engage.assert_called_once_with(client, "bandit_1", party_name="Forest Bandits")
async def test_dispatch_trade(self):
loop = self._loop()
client = MagicMock()
decision = CampaignDecision(
action=M2Action.TRADE,
item_id="grain",
quantity=5,
)
with patch("bannerlord.campaign_loop.buy_item", new_callable=AsyncMock) as mock_buy:
mock_buy.return_value = ActionResult(status=ActionStatus.SUCCESS, message="bought")
await loop._dispatch(decision, client)
mock_buy.assert_called_once_with(client, "grain", 5, settlement_id="")
async def test_dispatch_wait_returns_noop(self):
loop = self._loop()
client = MagicMock()
decision = CampaignDecision(action=M2Action.WAIT, reasoning="low food")
result = await loop._dispatch(decision, client)
assert result.status == ActionStatus.NOOP
async def test_dispatch_move_missing_settlement_id(self):
loop = self._loop()
client = MagicMock()
decision = CampaignDecision(action=M2Action.MOVE, settlement_id="")
result = await loop._dispatch(decision, client)
assert result.status == ActionStatus.FAILURE
async def test_dispatch_engage_missing_party_id(self):
loop = self._loop()
client = MagicMock()
decision = CampaignDecision(action=M2Action.ENGAGE, party_id="")
result = await loop._dispatch(decision, client)
assert result.status == ActionStatus.FAILURE
class TestCampaignLoopRun:
"""Integration-level tests for the full run() loop (mocked GABS)."""
async def test_run_stops_at_max_ticks(self):
"""Loop respects max_ticks and returns correct number of results."""
game_state = _make_game_state()
with (
patch("bannerlord.campaign_loop.GabsClient") as MockClient,
patch("bannerlord.campaign_loop.decide", new_callable=AsyncMock) as mock_decide,
patch("bannerlord.campaign_loop.move_to_settlement", new_callable=AsyncMock) as mock_move,
):
# Setup fake client
fake_client = AsyncMock()
fake_client.get_game_state = AsyncMock(return_value=game_state)
fake_client.connect = AsyncMock()
fake_client.disconnect = AsyncMock()
MockClient.return_value = fake_client
mock_decide.return_value = CampaignDecision(
action=M2Action.MOVE,
settlement_id="town_B1",
settlement_name="Epicrotea",
reasoning="moving",
)
mock_move.return_value = ActionResult(status=ActionStatus.SUCCESS, message="ok")
loop = CampaignLoop(tick_seconds=0.0, max_ticks=3)
results = await loop.run()
assert len(results) == 3
assert all(isinstance(r, TickResult) for r in results)
async def test_run_stops_when_m2_complete(self):
"""Loop exits early when M2 conditions are met."""
# State with M2 already complete
game_state = _make_game_state(troops=100, gold=10000)
with (
patch("bannerlord.campaign_loop.GabsClient") as MockClient,
patch("bannerlord.campaign_loop.decide", new_callable=AsyncMock) as mock_decide,
):
fake_client = AsyncMock()
fake_client.get_game_state = AsyncMock(return_value=game_state)
fake_client.connect = AsyncMock()
fake_client.disconnect = AsyncMock()
MockClient.return_value = fake_client
mock_decide.return_value = CampaignDecision(
action=M2Action.WAIT,
reasoning="done",
)
loop = CampaignLoop(tick_seconds=0.0, max_ticks=10)
results = await loop.run()
# Should exit after first tick (m2_complete = True)
assert len(results) == 1
assert results[0].m2_complete is True
async def test_run_aborts_on_connect_failure(self):
"""Loop returns empty history if GABS cannot be reached."""
with patch("bannerlord.campaign_loop.GabsClient") as MockClient:
fake_client = AsyncMock()
fake_client.connect = AsyncMock(side_effect=OSError("refused"))
fake_client.disconnect = AsyncMock()
MockClient.return_value = fake_client
loop = CampaignLoop(tick_seconds=0.0, max_ticks=5)
results = await loop.run()
assert results == []
def test_stop_sets_running_false(self):
loop = CampaignLoop()
loop._running = True
loop.stop()
assert not loop.is_running