"""Same-turn creature reactions to Patron moves, driven by stub clients (no GPU). Reuses the StubClient pattern from test_council_policy.py: chat_batch returns one valid JSON decision per convo, and the test inspects each creature's prompt (state["messages"]) to prove the injected patron_context reached the model. Run: python -m pytest tests/ -q """ import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from ttw.agents import make_council_policy from ttw.council import CREATURE_ENGINE from ttw.game import Game class StubClient: """Fake engine handle. Echoes the creature name into its thought, and for any creature in `accept_for` adds a {"patron":{"respond":"accept"}} reply block.""" accept_for: set[str] = set() def __init__(self, engine_id: str): self.engine_id = engine_id def chat_batch(self, conversations, max_tokens=256, temperature=0.7): out = [] for convo in conversations: system = convo[0]["content"] name = system.split("You are ", 1)[1].split(",", 1)[0] decision = { "thought": f"{name} via {self.engine_id}", "offers": [{"side": "sell", "good": "honey", "price": 9, "qty": 1}], "gossip": "", } if name in StubClient.accept_for: decision["patron"] = {"respond": "accept"} out.append(json.dumps(decision)) return out def _clients(): return {eid: StubClient(eid) for eid in set(CREATURE_ENGINE.values())} def _fresh(): StubClient.accept_for = set() return Game(make_council_policy(_clients(), temperature=0.7), deck_seed=1) def test_lend_reaches_target_and_pays(): g = _fresh() before = g.world.creatures["Mossback"].pebbles g.queue_player_move("lend", target="Mossback", amount=50) g.step() assert g.world.creatures["Mossback"].pebbles >= before + 50 prompt = g.policy.state["messages"]["Mossback"][1]["content"] assert "lent you" in prompt def test_accept_reply_yields_player_response_and_rep_delta(): g = _fresh() StubClient.accept_for = {"Mossback"} rep0 = g.world.patron.reputation g.queue_player_move("lend", target="Mossback", amount=50) g.step() responses = [e for e in g.world.log if e["type"] == "player_response"] assert any(e["creature"] == "Mossback" and e["respond"] == "accept" for e in responses) # lend (+2 rep) then an accept (+1 rep), clamped to 100. assert g.world.patron.reputation == min(100, rep0 + 3) def test_tip_reaches_target_prompt(): g = _fresh() g.queue_player_move("tip", target="Fenn", text="the honey crop will fail") g.step() prompt = g.policy.state["messages"]["Fenn"][1]["content"] assert "overheard a tip" in prompt def test_short_injects_to_all_creatures(): g = _fresh() g.queue_player_move("short", good="honey", qty=3) g.step() for name in g.world.creatures: prompt = g.policy.state["messages"][name][1]["content"] assert "shorting honey" in prompt def test_malformed_reply_no_crash_no_response(): g = _fresh() class JunkClient(StubClient): def chat_batch(self, conversations, max_tokens=256, temperature=0.7): return ["not json at all" for _ in conversations] g.policy = make_council_policy( {eid: JunkClient(eid) for eid in set(CREATURE_ENGINE.values())} ) g.queue_player_move("bribe", target="Pip", amount=40) g.step() # must not raise assert [e for e in g.world.log if e["type"] == "player_response"] == [] def test_patron_context_cleared_after_step(): g = _fresh() g.queue_player_move("lend", target="Mossback", amount=50) g.step() for c in g.world.alive(): assert c.patron_context == []