"""Tests for the Patron power moves: cost table, effects, and guards. No GPU/Gradio. Run: python -m pytest tests/ -q """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import pytest from ttw import moves from ttw.dummy import make_random_policy from ttw.game import Game from ttw.world import ShortPosition, seed_world def _game(): return Game(make_random_policy(seed=3), deck_seed=1) def _total_pebbles(world): return sum(c.pebbles for c in world.alive()) # (kind, kwargs, expected purse/rep/heat deltas) COST_CASES = [ ("lend", {}, {"purse": -50, "reputation": 2, "heat": 1}), ("lend", {"amount": 80}, {"purse": -80, "reputation": 2, "heat": 1}), ("tip", {}, {"purse": -5, "reputation": 0, "heat": 2}), ("short", {}, {"purse": -20, "reputation": -1, "heat": 5}), ("bribe", {}, {"purse": -40, "reputation": -3, "heat": 8}), ("alliance", {}, {"purse": -15, "reputation": 1, "heat": 2}), ("corner", {}, {"purse": -60, "reputation": -2, "heat": 6}), ] @pytest.mark.parametrize("kind,kw,expected", COST_CASES) def test_move_cost_table(kind, kw, expected): assert moves.move_cost(kind, **kw) == expected def test_lend_pays_target_and_injects_context(): world = seed_world() before = world.creatures["Mossback"].pebbles purse0, rep0, heat0 = world.patron.purse, world.patron.reputation, world.patron.heat ev = moves.apply_move(world, {"kind": "lend", "target": "Mossback", "amount": 50}) assert ev["applied"] and ev["move"] == "lend" and ev["kind"] == "lend" assert world.creatures["Mossback"].pebbles == before + 50 assert any("lent you 50" in s for s in world.creatures["Mossback"].patron_context) assert world.patron.purse == purse0 - 50 assert world.patron.reputation == rep0 + 2 assert world.patron.heat == heat0 + 1 def test_tip_caps_text_and_injects(): world = seed_world() long_text = "x" * 500 moves.apply_move(world, {"kind": "tip", "target": "Fenn", "text": long_text}) ctx = world.creatures["Fenn"].patron_context assert ctx and "overheard a tip" in ctx[0] assert len(ctx[0]) < 250 # the 200-char cap kept it bounded def test_short_registers_position_at_last_price(): world = seed_world() ev = moves.apply_move(world, {"kind": "short", "good": "honey", "qty": 3}) assert ev["applied"] assert len(world.patron.shorts) == 1 pos = world.patron.shorts[0] assert isinstance(pos, ShortPosition) assert pos.good == "honey" assert pos.qty == 3 assert pos.entry_price == world.last_price["honey"] assert pos.open is True # A short is whispered to EVERY creature. for c in world.alive(): assert any("shorting honey" in s for s in c.patron_context) def test_short_bad_good_not_applied(): world = seed_world() ev = moves.apply_move(world, {"kind": "short", "good": "diamonds"}) assert ev["applied"] is False assert ev["reason"] == "bad_good" assert world.patron.shorts == [] def test_alliance_missing_pair_skips_injection(): world = seed_world() ev = moves.apply_move(world, {"kind": "alliance", "target": "Mossback", "other": "Mossback"}) assert ev["reason"] == "no_pair" assert world.creatures["Mossback"].patron_context == [] def test_unknown_kind_no_crash(): world = seed_world() ev = moves.apply_move(world, {"kind": "teleport"}) assert ev["applied"] is False assert ev["reason"] == "unknown" assert ev["move"] == "teleport" def test_noop_alias_yields_move_noop(): world = seed_world() purse0 = world.patron.purse ev = moves.apply_move(world, {"kind": "noop"}) assert ev["applied"] is True assert ev["move"] == "noop" and ev["kind"] == "noop" assert world.patron.purse == purse0 # no effect def test_reputation_and_heat_clamps(): world = seed_world() world.patron.reputation = 1 # a bribe (-3) must clamp at 0, not go negative world.patron.heat = 0 moves.apply_move(world, {"kind": "bribe", "target": "Pip", "amount": 40}) assert world.patron.reputation == 0 assert world.patron.heat == 8 world.patron.reputation = 99 # a lend (+2) must clamp at 100 moves.apply_move(world, {"kind": "lend", "target": "Pip", "amount": 10}) assert world.patron.reputation == 100 def test_negative_purse_blocks_at_queue_no_pending_append(): g = _game() g.world.patron.purse = 10 # cannot afford a 50p lend before_creature_pebbles = _total_pebbles(g.world) before_purse = g.world.patron.purse msg = g.queue_player_move("lend", target="Mossback", amount=50) assert "Not enough" in msg assert g.world.pending_moves == [] # nothing queued assert g.world.patron.purse == before_purse # purse untouched assert _total_pebbles(g.world) == before_creature_pebbles # no pebbles minted def test_can_afford_message_and_purse_never_negative(): world = seed_world() world.patron.purse = 5 ok, msg = moves.can_afford(world.patron, "lend", amount=50) assert ok is False and "Not enough" in msg # Belt and suspenders: apply_move re-checks and refuses, purse stays >= 0. ev = moves.apply_move(world, {"kind": "lend", "target": "Mossback", "amount": 50}) assert ev["applied"] is False assert world.patron.purse == 5