"""Phase 3 (agent memory + relationships): grudges, alliances, cartels. GPU-free and deterministic. Mechanics tests drive the engine with a no-op or small stub policy and seed sentiment directly; the firewall test reuses the StubClient pattern (one valid JSON decision per convo) to prove the new feeling summary reaches the prompt as buckets while a planted tip's hidden truth, the raw notes, and the banned tokens never do. 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 import relationships from ttw.actions import Offer from ttw.agents import make_council_policy from ttw.council import CREATURE_ENGINE from ttw.dummy import make_random_policy from ttw.game import Game from ttw.world import ( MEMORY_CAP, REL_HOSTILE_THRESHOLD, REL_NOTE_CAP, SENTIMENT_MAX, SENTIMENT_MIN, ) def _noop_policy(world, name): return [], [] def _game(policy=None, seed=1): return Game(policy or _noop_policy, deck_seed=seed) # --- StubClient (mirrors tests/test_council_policy.py) ------------------------ class StubClient: """Fake engine handle: one valid JSON decision per convo, echoing the creature name (parsed from the system prompt) into its thought.""" 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] out.append( json.dumps( { "thought": f"{name} via {self.engine_id}", "offers": [{"side": "sell", "good": "honey", "price": 9, "qty": 1}], "gossip": "", } ) ) return out def _clients(): return {eid: StubClient(eid) for eid in set(CREATURE_ENGINE.values())} # --- (1) a shorted creature goes hostile, then auto-rejects the Patron's pebbles def test_shorted_creature_goes_hostile_and_auto_rejects_while_neutral_accepts(): g = _game() baseline = g.relationship_frame().set_index("creature")["Patron"] assert (baseline == 0).all() # neutral baseline: nobody feels anything yet # Short acorns repeatedly: Mossback is the acorn-maker, so it sours toward the # Patron until it crosses the hostile threshold. while relationships.ensure(g.world.creatures["Mossback"], "Patron").sentiment > REL_HOSTILE_THRESHOLD: g.world.pending_moves.append({"kind": "short", "good": "acorns", "qty": 1}) g.step() moss_sent = g.relationship_frame().set_index("creature").loc["Mossback", "Patron"] assert moss_sent < 0 # measurably negative vs the neutral baseline assert moss_sent <= REL_HOSTILE_THRESHOLD # A lend to the hostile creature costs the Patron but is auto-rejected: no # pebbles change hands. A neutral creature (Bramble) accepts the credit. moss_before = g.world.creatures["Mossback"].pebbles bram_before = g.world.creatures["Bramble"].pebbles g.world.pending_moves.append({"kind": "lend", "target": "Mossback", "amount": 50}) g.world.pending_moves.append({"kind": "lend", "target": "Bramble", "amount": 50}) g.step() assert g.world.creatures["Mossback"].pebbles == moss_before # hostile: nothing paid assert g.world.creatures["Bramble"].pebbles >= bram_before + 50 # neutral: paid rejects = [ e for e in g.world.log if e["type"] == "player_response" and e["creature"] == "Mossback" and e.get("reason") == "hostile_auto_reject" ] assert rejects # --- (1b) a hostile creature's refused bribe must NOT buy goodwill ------------- def test_hostile_rejected_bribe_does_not_raise_goodwill(): g = _game() while relationships.ensure(g.world.creatures["Mossback"], "Patron").sentiment > REL_HOSTILE_THRESHOLD: g.world.pending_moves.append({"kind": "short", "good": "acorns", "qty": 1}) g.step() before = relationships.ensure(g.world.creatures["Mossback"], "Patron").sentiment # Bribe the hostile creature: it reverts the pebbles AND refuses, so its # feeling toward the Patron must not improve (it should sour, not warm). g.world.pending_moves.append({"kind": "bribe", "target": "Mossback", "amount": 40}) g.step() after = relationships.ensure(g.world.creatures["Mossback"], "Patron").sentiment assert after <= before # a spurned bribe never raises goodwill # --- (2) allied same-good sellers do not undercut (equalized sell price) ------- def _two_sell_policy(low_seller, high_seller, good, low, high): """Two mutual allies each post a SELL on `good` at different prices; everyone else stays out so the books are clean for the cartel-anchor assertion.""" def policy(world, name): if name == low_seller: return [Offer(name, "sell", good, low, 3)], [] if name == high_seller: return [Offer(name, "sell", good, high, 3)], [] return [], [] return policy def test_allied_sellers_do_not_undercut_each_other(): good = "honey" g = _game(_two_sell_policy("Mossback", "Bramble", good, low=5, high=9)) a, b = g.world.creatures["Mossback"], g.world.creatures["Bramble"] relationships.ensure(a, "Bramble").ally = True relationships.ensure(b, "Mossback").ally = True a.inventory[good] = 10 b.inventory[good] = 10 captured: dict[str, list[Offer]] = {} orig = g.policy def spy(world, name): offers, gossip = orig(world, name) captured[name] = offers return offers, gossip g.policy = spy # The anchor mutates the offer objects in place before clearing, so the # captured offers reflect the equalized price. g.step() moss_price = next(o.price for o in captured["Mossback"] if o.good == good and o.side == "sell") bram_price = next(o.price for o in captured["Bramble"] if o.good == good and o.side == "sell") assert moss_price == bram_price == 9 # the cheaper ally was lifted, not lowered # --- (3) TRUTH FIREWALL: summary buckets reach the prompt, truth/notes never do - def test_feeling_summary_in_prompt_without_notes_or_banned_tokens(): g = Game(make_council_policy(_clients(), temperature=0.7), deck_seed=1) # Populate a relationship with a strong sentiment AND a raw note that must NOT # leak, then plant a tip whose hidden truth must NOT leak either. fenn = g.world.creatures["Fenn"] relationships.adjust(fenn, "Mossback", "tipped_true", note="secret note about a true tip") relationships.adjust(fenn, "Mossback", "tipped_true") # push sentiment to a notable bucket g.queue_player_move("tip", target="Fenn", text="the honey crop will fail", truth=True) g.step() messages = g.policy.state["messages"] assert messages # The summary line is present for Fenn and uses only a sentiment bucket word. fenn_blob = json.dumps(messages["Fenn"]) assert "How you feel about others" in fenn_blob assert "toward Mossback" in fenn_blob banned = ("true", "false", "truth", "legend_key") for name, convo in messages.items(): blob = json.dumps(convo).lower() for token in banned: assert token not in blob, f"{token!r} leaked into {name}'s prompt" # The raw note never reaches the prompt; the planted tip text still does. assert "secret note about a true tip" not in fenn_blob assert "the honey crop will fail" in fenn_blob # --- (4) bounded growth over 30 dummy turns ----------------------------------- def test_bounded_growth_over_thirty_turns(): g = Game(make_random_policy(seed=5), deck_seed=1) for t in range(30): # Sprinkle Patron moves so relationships and notes actually accumulate. if t % 3 == 0: g.world.pending_moves.append({"kind": "short", "good": "acorns", "qty": 1}) if t % 4 == 0: g.world.pending_moves.append({"kind": "lend", "target": "Bramble", "amount": 10}) g.step() for c in g.world.creatures.values(): assert len(c.memory) <= MEMORY_CAP for rel in c.relationships.values(): assert len(rel.notes) <= REL_NOTE_CAP assert SENTIMENT_MIN <= rel.sentiment <= SENTIMENT_MAX # --- (5) an alliance persists across steps (not cleared like patron_context) --- def test_alliance_persists_across_steps(): g = _game() g.queue_player_move("alliance", target="Mossback", other="Bramble") g.step() a, b = g.world.creatures["Mossback"], g.world.creatures["Bramble"] assert a.relationships["Bramble"].ally is True assert b.relationships["Mossback"].ally is True boost_a = a.relationships["Bramble"].sentiment boost_b = b.relationships["Mossback"].sentiment assert boost_a > 0 and boost_b > 0 assert any(e["type"] == "alliance_formed" for e in g.world.log) # Several more turns: the ally flag and the boost survive (relationships are # the long memory, unlike the single-turn patron_context). for _ in range(3): g.step() assert a.relationships["Bramble"].ally is True assert b.relationships["Mossback"].ally is True assert a.relationships["Bramble"].sentiment == boost_a assert b.relationships["Mossback"].sentiment == boost_b # --- (6) neutral init: an uninteracted creature is absent/neutral -------------- def test_neutral_init_uninteracted_creature(): g = _game() g.step() # a quiet turn, no Patron moves and no trades (no-op policy) for c in g.world.alive(): rel = c.relationships.get("Patron") assert rel is None or rel.sentiment == 0 assert relationships.summary_line(c) == "" # The markdown view renders the empty form, not a crash. assert "no strong feelings yet" in g.relationship_markdown()