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:
0
tests/unit/test_bannerlord/__init__.py
Normal file
0
tests/unit/test_bannerlord/__init__.py
Normal file
307
tests/unit/test_bannerlord/test_agents.py
Normal file
307
tests/unit/test_bannerlord/test_agents.py
Normal 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
|
||||
147
tests/unit/test_bannerlord/test_gabs_client.py
Normal file
147
tests/unit/test_bannerlord/test_gabs_client.py
Normal 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
|
||||
190
tests/unit/test_bannerlord/test_models.py
Normal file
190
tests/unit/test_bannerlord/test_models.py
Normal 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
|
||||
Reference in New Issue
Block a user