1
0

WIP: Claude Code progress on #1094

Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
This commit is contained in:
Alexander Whitestone
2026-03-23 14:26:15 -04:00
parent 852fec3681
commit 46ef9d3aba
14 changed files with 2189 additions and 0 deletions

View File

View File

@@ -0,0 +1,102 @@
"""Unit tests for bannerlord.campaign_actions."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from bannerlord.campaign_actions import (
GabsTool,
buy_item,
engage_party,
move_to_settlement,
recruit_all,
)
from infrastructure.world.types import ActionStatus
def _mock_client(return_value=None, raise_exc=None):
"""Build a mock GabsClient."""
client = MagicMock()
if raise_exc is not None:
client.call = AsyncMock(side_effect=raise_exc)
else:
client.call = AsyncMock(return_value=return_value)
return client
class TestMoveToSettlement:
async def test_success(self):
client = _mock_client({"eta_days": 2})
result = await move_to_settlement(client, "town_A1", settlement_name="Marunath")
assert result.status == ActionStatus.SUCCESS
client.call.assert_called_once_with(
GabsTool.MOVE_TO_SETTLEMENT, {"settlement_id": "town_A1"}
)
async def test_failure_on_gabs_error(self):
client = _mock_client(raise_exc=RuntimeError("GABS timeout"))
result = await move_to_settlement(client, "town_A1")
assert result.status == ActionStatus.FAILURE
assert "GABS timeout" in result.message
async def test_uses_settlement_id_as_label_when_no_name(self):
client = _mock_client({})
result = await move_to_settlement(client, "town_B2")
assert result.status == ActionStatus.SUCCESS
assert "town_B2" in result.message
class TestBuyItem:
async def test_success(self):
client = _mock_client({"cost": 100})
result = await buy_item(client, "grain", 5)
assert result.status == ActionStatus.SUCCESS
assert "grain" in result.message
client.call.assert_called_once_with(
GabsTool.BUY_ITEM, {"item_id": "grain", "quantity": 5}
)
async def test_includes_settlement_id_when_given(self):
client = _mock_client({})
await buy_item(client, "iron", 2, settlement_id="town_A1")
call_params = client.call.call_args[0][1]
assert call_params["settlement_id"] == "town_A1"
async def test_failure_logged_gracefully(self):
client = _mock_client(raise_exc=Exception("inventory full"))
result = await buy_item(client, "wool", 10)
assert result.status == ActionStatus.FAILURE
class TestRecruitAll:
async def test_success(self):
client = _mock_client({"recruited": 15})
result = await recruit_all(client)
assert result.status == ActionStatus.SUCCESS
assert "15" in result.message
async def test_success_with_settlement(self):
client = _mock_client({"recruited": 8})
result = await recruit_all(client, settlement_id="town_A1")
call_params = client.call.call_args[0][1]
assert call_params["settlement_id"] == "town_A1"
async def test_failure_graceful(self):
client = _mock_client(raise_exc=RuntimeError("no recruits"))
result = await recruit_all(client)
assert result.status == ActionStatus.FAILURE
class TestEngageParty:
async def test_success(self):
client = _mock_client({"outcome": "victory", "loot": 200})
result = await engage_party(client, "bandit_1", party_name="Forest Bandits")
assert result.status == ActionStatus.SUCCESS
assert "victory" in result.message
async def test_failure_graceful(self):
client = _mock_client(raise_exc=RuntimeError("party not found"))
result = await engage_party(client, "bandit_1")
assert result.status == ActionStatus.FAILURE

View File

@@ -0,0 +1,200 @@
"""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

View File

@@ -0,0 +1,150 @@
"""Unit tests for bannerlord.campaign_state."""
from __future__ import annotations
import pytest
from bannerlord.campaign_state import (
M2_GOLD_GOAL,
M2_TROOP_GOAL,
CampaignState,
NearbyParty,
Settlement,
parse_campaign_state,
)
class TestParseCampaignState:
def test_empty_dict_returns_defaults(self):
state = parse_campaign_state({})
assert state.party.party_size == 0
assert state.economy.gold == 0
assert state.nearby_parties == []
assert state.settlements == []
def test_full_payload_parsed(self):
raw = {
"tick": 5,
"party": {
"size": 30,
"wounded": 2,
"prisoners": 1,
"food_days": 3.5,
"morale": 75.0,
"current_settlement": "town_A1",
"speed": 5.2,
},
"economy": {
"gold": 4500,
"daily_income": 200,
"daily_expenses": 150,
},
"nearby_parties": [
{
"id": "bandit_1",
"name": "Forest Bandits",
"faction": "bandit",
"is_hostile": True,
"troop_count": 10,
"distance": 3.0,
}
],
"settlements": [
{
"id": "town_A1",
"name": "Marunath",
"faction": "aserai",
"is_friendly": True,
"distance": 0.0,
"has_recruits": True,
"has_trade_goods": False,
}
],
}
state = parse_campaign_state(raw)
assert state.tick == 5
assert state.party.party_size == 30
assert state.party.wounded == 2
assert state.economy.gold == 4500
assert state.economy.net_income == 50
assert len(state.nearby_parties) == 1
assert state.nearby_parties[0].name == "Forest Bandits"
assert len(state.settlements) == 1
assert state.settlements[0].name == "Marunath"
def test_malformed_entries_skipped(self):
raw = {
"nearby_parties": [{"id": "ok", "name": "Good", "faction": "bandit",
"is_hostile": True, "troop_count": 5, "distance": 2.0},
{"bad": "data"}],
"settlements": [None, "not_a_dict"],
}
state = parse_campaign_state(raw)
assert len(state.nearby_parties) == 1
assert state.settlements == []
class TestCampaignStateProperties:
def _make_state(self, *, troops: int, gold: int) -> CampaignState:
state = CampaignState()
state.party.party_size = troops
state.economy.gold = gold
return state
def test_m2_not_complete_by_default(self):
state = self._make_state(troops=20, gold=0)
assert not state.m2_complete
def test_m2_complete_when_both_goals_met(self):
state = self._make_state(troops=M2_TROOP_GOAL, gold=M2_GOLD_GOAL)
assert state.m2_complete
def test_m2_not_complete_if_only_troops_met(self):
state = self._make_state(troops=M2_TROOP_GOAL, gold=M2_GOLD_GOAL - 1)
assert not state.m2_complete
def test_m2_not_complete_if_only_gold_met(self):
state = self._make_state(troops=M2_TROOP_GOAL - 1, gold=M2_GOLD_GOAL)
assert not state.m2_complete
def test_troops_progress_string(self):
state = self._make_state(troops=45, gold=0)
assert state.troops_progress == f"45/{M2_TROOP_GOAL}"
def test_gold_progress_string(self):
state = self._make_state(troops=0, gold=3000)
assert "3,000" in state.gold_progress
def test_hostile_bandits_nearby_filter(self):
state = CampaignState()
state.nearby_parties = [
NearbyParty("b1", "Bandits", "bandit", True, 10, 2.0),
NearbyParty("l1", "Lord", "empire", False, 50, 1.0),
NearbyParty("b2", "Far Bandits", "bandit", True, 5, 10.0),
]
nearby = state.hostile_bandits_nearby(max_distance=5.0)
assert len(nearby) == 1
assert nearby[0].party_id == "b1"
def test_nearest_settlement_returns_closest(self):
state = CampaignState()
state.settlements = [
Settlement("s1", "Far Town", "empire", True, 10.0),
Settlement("s2", "Near Town", "empire", True, 2.0),
]
nearest = state.nearest_settlement()
assert nearest.settlement_id == "s2"
def test_nearest_recruit_settlement(self):
state = CampaignState()
state.settlements = [
Settlement("s1", "Town A", "empire", True, 5.0, has_recruits=False),
Settlement("s2", "Town B", "empire", True, 8.0, has_recruits=True),
]
recruit = state.nearest_recruit_settlement()
assert recruit.settlement_id == "s2"
def test_nearest_settlement_none_when_empty(self):
state = CampaignState()
assert state.nearest_settlement() is None

View File

@@ -0,0 +1,154 @@
"""Unit tests for bannerlord.decision."""
from __future__ import annotations
import json
import pytest
from bannerlord.campaign_state import (
CampaignState,
EconomyState,
NearbyParty,
PartyState,
Settlement,
)
from bannerlord.decision import (
M2Action,
CampaignDecision,
build_decision_prompt,
parse_decision,
)
def _make_state(
*,
troops: int = 30,
gold: int = 2000,
food_days: float = 5.0,
morale: float = 80.0,
settlements: list | None = None,
nearby_parties: list | None = None,
) -> CampaignState:
state = CampaignState()
state.party = PartyState(
party_size=troops,
food_days=food_days,
morale=morale,
)
state.economy = EconomyState(gold=gold, daily_income=200, daily_expenses=150)
state.settlements = settlements or []
state.nearby_parties = nearby_parties or []
return state
class TestBuildDecisionPrompt:
def test_returns_two_messages(self):
state = _make_state()
messages = build_decision_prompt(state)
assert len(messages) == 2
assert messages[0]["role"] == "system"
assert messages[1]["role"] == "user"
def test_user_message_includes_party_info(self):
state = _make_state(troops=45, gold=3000)
messages = build_decision_prompt(state)
user_content = messages[1]["content"]
assert "45" in user_content
assert "3,000" in user_content
def test_bandits_appear_in_prompt_when_nearby(self):
state = _make_state(
nearby_parties=[NearbyParty("b1", "Forest Bandits", "bandit", True, 10, 2.0)]
)
messages = build_decision_prompt(state)
user_content = messages[1]["content"]
assert "Forest Bandits" in user_content
def test_settlements_appear_in_prompt(self):
state = _make_state(
settlements=[Settlement("s1", "Marunath", "aserai", True, 3.0, has_recruits=True)]
)
messages = build_decision_prompt(state)
user_content = messages[1]["content"]
assert "Marunath" in user_content
def test_system_prompt_contains_action_vocabulary(self):
state = _make_state()
messages = build_decision_prompt(state)
system = messages[0]["content"]
for action in ("MOVE", "TRADE", "RECRUIT", "ENGAGE", "WAIT"):
assert action in system
class TestParseDecision:
def test_valid_move_decision(self):
raw = json.dumps({
"action": "MOVE",
"settlement_id": "town_A1",
"settlement_name": "Marunath",
"item_id": "",
"quantity": 1,
"party_id": "",
"party_name": "",
"reasoning": "Moving to recruit troops",
})
decision = parse_decision(raw)
assert decision.action == M2Action.MOVE
assert decision.settlement_id == "town_A1"
assert decision.settlement_name == "Marunath"
def test_valid_recruit_decision(self):
raw = json.dumps({
"action": "RECRUIT",
"settlement_id": "town_A1",
"settlement_name": "Marunath",
"item_id": "",
"quantity": 1,
"party_id": "",
"party_name": "",
"reasoning": "Has recruits available",
})
decision = parse_decision(raw)
assert decision.action == M2Action.RECRUIT
def test_valid_engage_decision(self):
raw = json.dumps({
"action": "ENGAGE",
"settlement_id": "",
"settlement_name": "",
"item_id": "",
"quantity": 1,
"party_id": "bandit_1",
"party_name": "Forest Bandits",
"reasoning": "Weak bandits — easy XP",
})
decision = parse_decision(raw)
assert decision.action == M2Action.ENGAGE
assert decision.party_id == "bandit_1"
def test_wait_on_invalid_json(self):
decision = parse_decision("not json at all")
assert decision.action == M2Action.WAIT
def test_wait_on_unknown_action(self):
raw = json.dumps({"action": "TELEPORT", "reasoning": "hack"})
decision = parse_decision(raw)
assert decision.action == M2Action.WAIT
def test_strips_markdown_fences(self):
raw = '```json\n{"action": "WAIT", "reasoning": "low food"}\n```'
decision = parse_decision(raw)
assert decision.action == M2Action.WAIT
def test_quantity_minimum_one(self):
raw = json.dumps({"action": "TRADE", "item_id": "grain", "quantity": -5, "reasoning": "x"})
decision = parse_decision(raw)
assert decision.quantity == 1
def test_missing_optional_fields_default_to_empty(self):
raw = json.dumps({"action": "WAIT", "reasoning": "resting"})
decision = parse_decision(raw)
assert decision.settlement_id == ""
assert decision.party_id == ""
assert decision.item_id == ""

View File

@@ -0,0 +1,120 @@
"""Unit tests for bannerlord.gabs_client."""
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bannerlord.gabs_client import GabsClient, GabsError
class TestGabsClientCall:
"""Tests for GabsClient.call() using mock StreamReader/Writer."""
def _make_client(self, response: dict) -> GabsClient:
"""Return a pre-connected GabsClient with mocked I/O."""
client = GabsClient(host="localhost", port=4825, timeout=5.0)
client._connected = True
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
raw_response = json.dumps(response).encode() + b"\n"
reader = MagicMock()
reader.readline = AsyncMock(return_value=raw_response)
client._reader = reader
client._writer = writer
return client
async def test_successful_call_returns_result(self):
client = self._make_client({"jsonrpc": "2.0", "id": 1, "result": {"status": "ok"}})
result = await client.call("game/ping")
assert result == {"status": "ok"}
async def test_error_response_raises_gabs_error(self):
client = self._make_client({
"jsonrpc": "2.0",
"id": 1,
"error": {"code": -32601, "message": "Method not found"},
})
with pytest.raises(GabsError) as exc_info:
await client.call("unknown/method")
assert exc_info.value.code == -32601
async def test_not_connected_raises_runtime_error(self):
client = GabsClient()
with pytest.raises(RuntimeError, match="not connected"):
await client.call("game/ping")
async def test_request_id_increments(self):
client = self._make_client({"jsonrpc": "2.0", "id": 1, "result": {}})
await client.call("game/ping")
# Reset reader for second call
client._reader.readline = AsyncMock(
return_value=json.dumps({"jsonrpc": "2.0", "id": 2, "result": {}}).encode() + b"\n"
)
await client.call("game/ping")
assert client._req_id == 2
async def test_get_game_state_returns_empty_on_error(self):
client = GabsClient()
client._connected = True
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
reader.readline = AsyncMock(side_effect=OSError("connection reset"))
client._reader = reader
client._writer = writer
result = await client.get_game_state()
assert result == {}
async def test_ping_returns_true_on_success(self):
client = self._make_client({"jsonrpc": "2.0", "id": 1, "result": "pong"})
result = await client.ping()
assert result is True
async def test_ping_returns_false_on_failure(self):
client = GabsClient()
result = await client.ping()
assert result is False
class TestGabsClientLifecycle:
async def test_connect_failure_sets_not_connected(self):
client = GabsClient(host="localhost", port=9999, timeout=0.1)
with pytest.raises(Exception):
await client.connect()
assert not client.is_connected
async def test_context_manager_calls_connect_and_disconnect(self):
client = GabsClient()
connect_called = False
disconnect_called = False
async def _fake_connect():
nonlocal connect_called
connect_called = True
client._connected = True
async def _fake_disconnect():
nonlocal disconnect_called
disconnect_called = True
client._connected = False
client.connect = _fake_connect
client.disconnect = _fake_disconnect
async with client as c:
assert c is client
assert connect_called
assert disconnect_called