forked from Rockachopa/Timmy-time-dashboard
Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation.
201 lines
7.4 KiB
Python
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
|