[claude] Bannerlord M5: sovereign victory stack (src/bannerlord/) (#1097) (#1155)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled

Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
This commit was merged in pull request #1155.
This commit is contained in:
2026-03-23 19:26:05 +00:00
committed by rockachopa
parent 495c1ac2bd
commit 2b9a55fa6d
14 changed files with 2233 additions and 0 deletions

View File

View File

@@ -0,0 +1,307 @@
"""Unit tests for bannerlord agents — King, Vassals, Companions."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bannerlord.agents.companions import (
CaravanCompanion,
LogisticsCompanion,
ScoutCompanion,
)
from bannerlord.agents.king import KingAgent
from bannerlord.agents.vassals import DiplomacyVassal, EconomyVassal, WarVassal
from bannerlord.gabs_client import GABSClient, GABSUnavailable
from bannerlord.ledger import Ledger
from bannerlord.models import (
KingSubgoal,
ResultMessage,
SubgoalMessage,
TaskMessage,
VictoryCondition,
)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _mock_gabs(state: dict | None = None) -> GABSClient:
"""Return a disconnected GABS stub that returns *state* from get_state."""
gabs = MagicMock(spec=GABSClient)
gabs.connected = False
if state is not None:
gabs.get_state = AsyncMock(return_value=state)
else:
gabs.get_state = AsyncMock(side_effect=GABSUnavailable("no game"))
gabs.call = AsyncMock(return_value={})
gabs.recruit_troops = AsyncMock(return_value={"recruited": 10})
gabs.move_party = AsyncMock(return_value={"moving": True})
return gabs
def _mock_ledger(tmp_path) -> Ledger:
ledger = Ledger(db_path=tmp_path / "ledger.db")
ledger.initialize()
return ledger
# ── King agent ────────────────────────────────────────────────────────────────
class TestKingAgent:
async def test_victory_detected(self, tmp_path):
"""Campaign stops immediately when victory condition is met."""
gabs = _mock_gabs({"player_title": "King", "territory_control_pct": 55.0})
ledger = _mock_ledger(tmp_path)
king = KingAgent(gabs_client=gabs, ledger=ledger, tick_interval=0)
victory = await king.run_campaign(max_ticks=10)
assert victory.achieved
async def test_max_ticks_respected(self, tmp_path):
"""Campaign stops after max_ticks when victory not yet achieved."""
gabs = _mock_gabs({"player_title": "Lord", "territory_control_pct": 10.0})
ledger = _mock_ledger(tmp_path)
# Patch LLM to return a valid subgoal without calling Ollama
king = KingAgent(gabs_client=gabs, ledger=ledger, tick_interval=0)
with patch.object(king, "_decide", AsyncMock(return_value=KingSubgoal(token="RECRUIT"))):
victory = await king.run_campaign(max_ticks=3)
assert not victory.achieved
assert king._tick == 3
async def test_llm_failure_falls_back_to_recruit(self, tmp_path):
"""If LLM fails, King defaults to RECRUIT subgoal."""
gabs = _mock_gabs({"player_title": "Lord", "territory_control_pct": 5.0})
ledger = _mock_ledger(tmp_path)
king = KingAgent(gabs_client=gabs, ledger=ledger, tick_interval=0)
with patch.object(king, "_llm_decide", side_effect=RuntimeError("Ollama down")):
subgoal = await king._decide({})
assert subgoal.token == "RECRUIT"
async def test_subgoal_broadcast_to_all_vassals(self, tmp_path):
"""King broadcasts subgoal to all three vassals."""
gabs = _mock_gabs({})
ledger = _mock_ledger(tmp_path)
king = KingAgent(gabs_client=gabs, ledger=ledger)
subgoal = KingSubgoal(token="EXPAND_TERRITORY", target="Epicrotea")
await king._broadcast_subgoal(subgoal)
messages = []
while not king.subgoal_queue.empty():
messages.append(king.subgoal_queue.get_nowait())
assert len(messages) == 3
recipients = {m.to_agent for m in messages}
assert recipients == {"war_vassal", "economy_vassal", "diplomacy_vassal"}
async def test_gabs_unavailable_uses_empty_state(self, tmp_path):
"""King handles GABS being offline gracefully."""
gabs = _mock_gabs() # raises GABSUnavailable
ledger = _mock_ledger(tmp_path)
king = KingAgent(gabs_client=gabs, ledger=ledger)
state = await king._fetch_state()
assert state == {}
def test_evaluate_victory_king_with_majority(self, tmp_path):
gabs = _mock_gabs()
ledger = _mock_ledger(tmp_path)
king = KingAgent(gabs_client=gabs, ledger=ledger)
v = king._evaluate_victory({"player_title": "King", "territory_control_pct": 60.0})
assert v.achieved
def test_evaluate_victory_not_king(self, tmp_path):
gabs = _mock_gabs()
ledger = _mock_ledger(tmp_path)
king = KingAgent(gabs_client=gabs, ledger=ledger)
v = king._evaluate_victory({"player_title": "Lord", "territory_control_pct": 80.0})
assert not v.achieved
# ── Vassals ───────────────────────────────────────────────────────────────────
class TestWarVassal:
async def test_expand_territory_emits_move_task(self):
gabs = _mock_gabs({"territory_delta": 1.0, "army_strength_ratio": 1.5})
queue = asyncio.Queue()
vassal = WarVassal(gabs_client=gabs, subgoal_queue=queue)
subgoal = KingSubgoal(token="EXPAND_TERRITORY", target="Seonon")
await vassal._tick(subgoal)
task: TaskMessage = vassal.task_queue.get_nowait()
assert task.primitive == "move_party"
assert task.args["destination"] == "Seonon"
async def test_recruit_emits_recruit_task(self):
gabs = _mock_gabs({})
queue = asyncio.Queue()
vassal = WarVassal(gabs_client=gabs, subgoal_queue=queue)
subgoal = KingSubgoal(token="RECRUIT", quantity=15)
await vassal._tick(subgoal)
task: TaskMessage = vassal.task_queue.get_nowait()
assert task.primitive == "recruit_troop"
assert task.args["quantity"] == 15
async def test_irrelevant_token_emits_no_task(self):
gabs = _mock_gabs({})
queue = asyncio.Queue()
vassal = WarVassal(gabs_client=gabs, subgoal_queue=queue)
subgoal = KingSubgoal(token="ALLY")
await vassal._tick(subgoal)
assert vassal.task_queue.empty()
class TestEconomyVassal:
async def test_fortify_emits_build_task(self):
gabs = _mock_gabs({"daily_income": 200.0})
queue = asyncio.Queue()
vassal = EconomyVassal(gabs_client=gabs, subgoal_queue=queue)
subgoal = KingSubgoal(token="FORTIFY", target="Epicrotea")
await vassal._tick(subgoal)
task: TaskMessage = vassal.task_queue.get_nowait()
assert task.primitive == "build_project"
assert task.args["settlement"] == "Epicrotea"
async def test_trade_emits_assess_prices(self):
gabs = _mock_gabs({})
queue = asyncio.Queue()
vassal = EconomyVassal(gabs_client=gabs, subgoal_queue=queue)
subgoal = KingSubgoal(token="TRADE", target="Pravend")
await vassal._tick(subgoal)
task: TaskMessage = vassal.task_queue.get_nowait()
assert task.primitive == "assess_prices"
class TestDiplomacyVassal:
async def test_ally_emits_track_lord(self):
gabs = _mock_gabs({"allies_count": 1})
queue = asyncio.Queue()
vassal = DiplomacyVassal(gabs_client=gabs, subgoal_queue=queue)
subgoal = KingSubgoal(token="ALLY", target="Derthert")
await vassal._tick(subgoal)
task: TaskMessage = vassal.task_queue.get_nowait()
assert task.primitive == "track_lord"
assert task.args["name"] == "Derthert"
async def test_spy_emits_assess_garrison(self):
gabs = _mock_gabs({})
queue = asyncio.Queue()
vassal = DiplomacyVassal(gabs_client=gabs, subgoal_queue=queue)
subgoal = KingSubgoal(token="SPY", target="Marunath")
await vassal._tick(subgoal)
task: TaskMessage = vassal.task_queue.get_nowait()
assert task.primitive == "assess_garrison"
assert task.args["settlement"] == "Marunath"
# ── Companions ────────────────────────────────────────────────────────────────
class TestLogisticsCompanion:
async def test_recruit_troop(self):
gabs = _mock_gabs()
gabs.recruit_troops = AsyncMock(return_value={"recruited": 10, "type": "infantry"})
q: asyncio.Queue[TaskMessage] = asyncio.Queue()
comp = LogisticsCompanion(gabs_client=gabs, task_queue=q)
task = TaskMessage(
from_agent="war_vassal",
to_agent="logistics_companion",
primitive="recruit_troop",
args={"troop_type": "infantry", "quantity": 10},
)
result = await comp._execute(task)
assert result.success is True
assert result.outcome["recruited"] == 10
async def test_unknown_primitive_fails_gracefully(self):
gabs = _mock_gabs()
q: asyncio.Queue[TaskMessage] = asyncio.Queue()
comp = LogisticsCompanion(gabs_client=gabs, task_queue=q)
task = TaskMessage(
from_agent="war_vassal",
to_agent="logistics_companion",
primitive="launch_nukes",
args={},
)
result = await comp._execute(task)
assert result.success is False
assert "Unknown primitive" in result.outcome["error"]
async def test_gabs_unavailable_returns_failure(self):
gabs = _mock_gabs()
gabs.recruit_troops = AsyncMock(side_effect=GABSUnavailable("offline"))
q: asyncio.Queue[TaskMessage] = asyncio.Queue()
comp = LogisticsCompanion(gabs_client=gabs, task_queue=q)
task = TaskMessage(
from_agent="war_vassal",
to_agent="logistics_companion",
primitive="recruit_troop",
args={"troop_type": "infantry", "quantity": 5},
)
result = await comp._execute(task)
assert result.success is False
class TestCaravanCompanion:
async def test_assess_prices(self):
gabs = _mock_gabs()
gabs.call = AsyncMock(return_value={"grain": 12, "linen": 45})
q: asyncio.Queue[TaskMessage] = asyncio.Queue()
comp = CaravanCompanion(gabs_client=gabs, task_queue=q)
task = TaskMessage(
from_agent="economy_vassal",
to_agent="caravan_companion",
primitive="assess_prices",
args={"town": "Pravend"},
)
result = await comp._execute(task)
assert result.success is True
async def test_abandon_route(self):
gabs = _mock_gabs()
gabs.call = AsyncMock(return_value={"abandoned": True})
q: asyncio.Queue[TaskMessage] = asyncio.Queue()
comp = CaravanCompanion(gabs_client=gabs, task_queue=q)
task = TaskMessage(
from_agent="economy_vassal",
to_agent="caravan_companion",
primitive="abandon_route",
args={},
)
result = await comp._execute(task)
assert result.success is True
assert result.outcome["abandoned"] is True
class TestScoutCompanion:
async def test_assess_garrison(self):
gabs = _mock_gabs()
gabs.call = AsyncMock(return_value={"garrison_size": 120, "settlement": "Marunath"})
q: asyncio.Queue[TaskMessage] = asyncio.Queue()
comp = ScoutCompanion(gabs_client=gabs, task_queue=q)
task = TaskMessage(
from_agent="diplomacy_vassal",
to_agent="scout_companion",
primitive="assess_garrison",
args={"settlement": "Marunath"},
)
result = await comp._execute(task)
assert result.success is True
assert result.outcome["garrison_size"] == 120
async def test_report_intel(self):
gabs = _mock_gabs()
gabs.call = AsyncMock(return_value={"intel": ["Derthert at Epicrotea"]})
q: asyncio.Queue[TaskMessage] = asyncio.Queue()
comp = ScoutCompanion(gabs_client=gabs, task_queue=q)
task = TaskMessage(
from_agent="diplomacy_vassal",
to_agent="scout_companion",
primitive="report_intel",
args={},
)
result = await comp._execute(task)
assert result.success is True

View File

@@ -0,0 +1,147 @@
"""Unit tests for bannerlord.gabs_client — TCP JSON-RPC client."""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bannerlord.gabs_client import GABSClient, GABSError, GABSUnavailable
# ── Connection ────────────────────────────────────────────────────────────────
class TestGABSClientConnection:
async def test_connect_success(self):
mock_reader = AsyncMock()
mock_writer = MagicMock()
mock_writer.close = MagicMock()
mock_writer.wait_closed = AsyncMock()
with patch(
"bannerlord.gabs_client.asyncio.open_connection",
return_value=(mock_reader, mock_writer),
):
client = GABSClient()
await client.connect()
assert client.connected is True
await client.close()
async def test_connect_failure_degrades_gracefully(self):
with patch(
"bannerlord.gabs_client.asyncio.open_connection",
side_effect=OSError("Connection refused"),
):
client = GABSClient()
await client.connect() # must not raise
assert client.connected is False
async def test_connect_timeout_degrades_gracefully(self):
with patch(
"bannerlord.gabs_client.asyncio.open_connection",
side_effect=asyncio.TimeoutError(),
):
client = GABSClient()
await client.connect()
assert client.connected is False
async def test_context_manager(self):
mock_reader = AsyncMock()
mock_writer = MagicMock()
mock_writer.close = MagicMock()
mock_writer.wait_closed = AsyncMock()
with patch(
"bannerlord.gabs_client.asyncio.open_connection",
return_value=(mock_reader, mock_writer),
):
async with GABSClient() as client:
assert client.connected is True
assert client.connected is False
# ── RPC ───────────────────────────────────────────────────────────────────────
class TestGABSClientRPC:
def _make_connected_client(self, response_data: dict):
"""Return a client with mocked reader/writer."""
client = GABSClient()
client._connected = True
raw_response = json.dumps(response_data) + "\n"
client._reader = AsyncMock()
client._reader.readline = AsyncMock(return_value=raw_response.encode())
client._writer = MagicMock()
client._writer.write = MagicMock()
client._writer.drain = AsyncMock()
return client
async def test_call_returns_result(self):
client = self._make_connected_client({"jsonrpc": "2.0", "id": 1, "result": {"foo": "bar"}})
result = await client.call("game.getState")
assert result == {"foo": "bar"}
async def test_call_raises_on_error(self):
client = self._make_connected_client(
{"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}
)
with pytest.raises(GABSError, match="Method not found"):
await client.call("game.nonexistent")
async def test_call_raises_unavailable_when_not_connected(self):
client = GABSClient()
assert client.connected is False
with pytest.raises(GABSUnavailable):
await client.call("game.getState")
async def test_sequence_increments(self):
client = self._make_connected_client({"jsonrpc": "2.0", "id": 1, "result": {}})
await client.call("game.getState")
assert client._seq == 1
client._reader.readline = AsyncMock(
return_value=(json.dumps({"jsonrpc": "2.0", "id": 2, "result": {}}) + "\n").encode()
)
await client.call("game.getState")
assert client._seq == 2
async def test_get_state_calls_correct_method(self):
client = self._make_connected_client(
{"jsonrpc": "2.0", "id": 1, "result": {"campaign_day": 10}}
)
result = await client.get_state()
written = client._writer.write.call_args[0][0].decode()
payload = json.loads(written.strip())
assert payload["method"] == "game.getState"
assert result == {"campaign_day": 10}
async def test_move_party_sends_target(self):
client = self._make_connected_client(
{"jsonrpc": "2.0", "id": 1, "result": {"moving": True}}
)
await client.move_party("Epicrotea")
written = client._writer.write.call_args[0][0].decode()
payload = json.loads(written.strip())
assert payload["method"] == "party.move"
assert payload["params"]["target"] == "Epicrotea"
async def test_connection_lost_marks_disconnected(self):
client = GABSClient()
client._connected = True
client._reader = AsyncMock()
client._reader.readline = AsyncMock(side_effect=OSError("connection reset"))
client._writer = MagicMock()
client._writer.write = MagicMock()
client._writer.drain = AsyncMock()
with pytest.raises(GABSUnavailable):
await client.call("game.getState")
assert client.connected is False

View File

@@ -0,0 +1,190 @@
"""Unit tests for bannerlord.models — data contracts and reward functions."""
import pytest
from bannerlord.models import (
SUBGOAL_TOKENS,
DiplomacyReward,
EconomyReward,
KingSubgoal,
ResultMessage,
StateUpdateMessage,
SubgoalMessage,
TaskMessage,
VictoryCondition,
WarReward,
)
# ── KingSubgoal ───────────────────────────────────────────────────────────────
class TestKingSubgoal:
def test_valid_token(self):
s = KingSubgoal(token="EXPAND_TERRITORY", target="Epicrotea")
assert s.token == "EXPAND_TERRITORY"
assert s.target == "Epicrotea"
assert s.priority == 1.0
def test_all_tokens_valid(self):
for token in SUBGOAL_TOKENS:
KingSubgoal(token=token)
def test_invalid_token_raises(self):
with pytest.raises(ValueError, match="Unknown subgoal token"):
KingSubgoal(token="NUKE_CALRADIA")
def test_priority_clamp(self):
with pytest.raises(Exception):
KingSubgoal(token="TRADE", priority=3.0)
def test_optional_fields_default_none(self):
s = KingSubgoal(token="HEAL")
assert s.target is None
assert s.quantity is None
assert s.deadline_days is None
assert s.context is None
# ── Messages ──────────────────────────────────────────────────────────────────
class TestSubgoalMessage:
def test_defaults(self):
msg = SubgoalMessage(
to_agent="war_vassal",
subgoal=KingSubgoal(token="RAID_ECONOMY"),
)
assert msg.msg_type == "subgoal"
assert msg.from_agent == "king"
assert msg.to_agent == "war_vassal"
assert msg.issued_at is not None
def test_subgoal_roundtrip(self):
subgoal = KingSubgoal(token="RECRUIT", quantity=30, priority=1.5)
msg = SubgoalMessage(to_agent="war_vassal", subgoal=subgoal)
assert msg.subgoal.quantity == 30
assert msg.subgoal.priority == 1.5
class TestTaskMessage:
def test_construction(self):
t = TaskMessage(
from_agent="war_vassal",
to_agent="logistics_companion",
primitive="recruit_troop",
args={"troop_type": "cavalry", "quantity": 5},
priority=1.2,
)
assert t.msg_type == "task"
assert t.primitive == "recruit_troop"
assert t.args["quantity"] == 5
class TestResultMessage:
def test_success(self):
r = ResultMessage(
from_agent="logistics_companion",
to_agent="war_vassal",
success=True,
outcome={"recruited": 10},
reward_delta=0.15,
)
assert r.success is True
assert r.reward_delta == 0.15
def test_failure(self):
r = ResultMessage(
from_agent="scout_companion",
to_agent="diplomacy_vassal",
success=False,
outcome={"error": "GABS unavailable"},
)
assert r.success is False
assert r.reward_delta == 0.0
class TestStateUpdateMessage:
def test_construction(self):
msg = StateUpdateMessage(
game_state={"campaign_day": 42, "player_title": "Lord"},
tick=42,
)
assert msg.msg_type == "state"
assert msg.tick == 42
assert msg.game_state["campaign_day"] == 42
# ── Reward functions ──────────────────────────────────────────────────────────
class TestWarReward:
def test_positive_expansion(self):
r = WarReward(territory_delta=2.0, army_strength_ratio=1.2, subgoal_bonus=0.1)
assert r.total > 0
def test_casualty_cost_penalizes(self):
no_cost = WarReward(territory_delta=1.0, army_strength_ratio=1.0)
with_cost = WarReward(territory_delta=1.0, army_strength_ratio=1.0, casualty_cost=5.0)
assert with_cost.total < no_cost.total
def test_zero_state(self):
r = WarReward()
# army_strength_ratio default 1.0, rest 0 → 0.25 * 1.0 = 0.25
assert abs(r.total - 0.25) < 1e-9
class TestEconomyReward:
def test_income_positive(self):
r = EconomyReward(daily_denars_income=100.0, food_stock_buffer=7.0, loyalty_average=80.0)
assert r.total > 0
def test_construction_queue_penalizes(self):
no_queue = EconomyReward(daily_denars_income=50.0)
long_queue = EconomyReward(daily_denars_income=50.0, construction_queue_length=10)
assert long_queue.total < no_queue.total
def test_loyalty_contributes(self):
low_loyalty = EconomyReward(loyalty_average=10.0)
high_loyalty = EconomyReward(loyalty_average=90.0)
assert high_loyalty.total > low_loyalty.total
class TestDiplomacyReward:
def test_allies_positive(self):
r = DiplomacyReward(allies_count=3)
assert r.total > 0
def test_active_wars_penalizes(self):
peace = DiplomacyReward(allies_count=2)
war = DiplomacyReward(allies_count=2, active_wars_front=4)
assert war.total < peace.total
# ── Victory condition ─────────────────────────────────────────────────────────
class TestVictoryCondition:
def test_not_achieved_without_title(self):
v = VictoryCondition(holds_king_title=False, territory_control_pct=70.0)
assert not v.achieved
def test_not_achieved_without_majority(self):
v = VictoryCondition(holds_king_title=True, territory_control_pct=40.0)
assert not v.achieved
def test_achieved_when_king_with_majority(self):
v = VictoryCondition(holds_king_title=True, territory_control_pct=55.0)
assert v.achieved
def test_exact_threshold(self):
v = VictoryCondition(holds_king_title=True, territory_control_pct=51.0)
assert v.achieved
def test_custom_threshold(self):
v = VictoryCondition(
holds_king_title=True,
territory_control_pct=70.0,
majority_threshold=75.0,
)
assert not v.achieved