"""Phase 5 (leverage + ruin): loans, margin calls, short P&L, bankruptcy/exile. GPU-free and deterministic. Mechanics tests drive the engine with a no-op policy (so trading never perturbs prices) and control prices / positions / pebbles directly, mirroring tests/test_information_war.py. Loan settlement is timed off LOAN_TERM_TURNS; margin calls are driven by writing last_price against an open short held by the no-op policy. Run: python -m pytest tests/ -q """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from ttw.dummy import make_random_policy from ttw.game import Game from ttw.moves import MOVE_COSTS from ttw.world import ( EXILE_CHAPTER_TURNS, LOAN_INTEREST_RATE, MARGIN_MAINTENANCE, MARGIN_POST, ShortPosition, short_unrealized_pnl, ) def _noop_policy(world, name): return [], [] def _game(policy=None, seed=1): return Game(policy or _noop_policy, deck_seed=seed) def _step_to_turn(g, turn: int) -> None: """Step the no-op world forward until world.turn reaches `turn`.""" while g.world.turn < turn: g.step() # --- (1) a loan that is paid in full repays and warms the borrower ------------- def test_loan_repaid_debits_borrower_credits_patron_and_warms(): g = _game() borrower = g.world.creatures["Mossback"] borrower.pebbles = 1000 # plenty to repay principal + interest g.queue_player_move("lend", target="Mossback", amount=100) g.step() # turn 1: loan issued loan = g.world.patron.loans[-1] interest = 100 * LOAN_INTEREST_RATE // 100 assert loan.outstanding == 100 + interest borrower_after_loan = borrower.pebbles # includes the +100 principal credit purse_before_settle = g.world.patron.purse _step_to_turn(g, loan.due_turn) # step to the due turn -> settlement runs assert loan.repaid assert borrower.pebbles == borrower_after_loan - (100 + interest) assert g.world.patron.purse == purse_before_settle + (100 + interest) assert any(e["type"] == "loan_repaid" and e["borrower"] == "Mossback" for e in g.world.log) rel = g.world.creatures["Mossback"].relationships.get("Patron") assert rel is not None and rel.sentiment > 0 # gratitude landed # --- (2) a borrower drained below the balance defaults, souring it ------------- def test_loan_default_drains_borrower_and_sours(): g = _game() g.queue_player_move("lend", target="Bramble", amount=100) g.step() # turn 1: loan issued loan = g.world.patron.loans[-1] sentiment_before = ( g.world.creatures["Bramble"].relationships.get("Patron").sentiment ) g.world.creatures["Bramble"].pebbles = 5 # too poor to clear the balance _step_to_turn(g, loan.due_turn) assert loan.defaulted assert g.world.creatures["Bramble"].pebbles == 0 # paid all it had assert loan.outstanding > 0 assert any(e["type"] == "loan_defaulted" and e["borrower"] == "Bramble" for e in g.world.log) rel = g.world.creatures["Bramble"].relationships.get("Patron") assert rel.sentiment < sentiment_before # resentment landed # --- (3) a breached short with an empty purse is force-covered ----------------- def test_margin_call_forced_cover_realizes_loss_and_closes(): g = _game() short = ShortPosition(good="honey", qty=5, entry_price=9.0, margin=MARGIN_POST) g.world.patron.shorts.append(short) g.world.patron.purse = 0 # cannot top up g.world.last_price["honey"] = 20.0 # price rose against the short -> loss g.step() pnl = int((9.0 - 20.0) * 5) # -55 assert not short.open assert g.world.patron.realized_pnl == pnl cover = [e for e in g.world.log if e["type"] == "margin_call"] assert cover and cover[-1]["resolution"] == "forced_cover" assert any(e["type"] == "short_covered" and e["good"] == "honey" for e in g.world.log) # --- (4) the same breach with a funded purse tops up instead ------------------- def test_margin_call_top_up_keeps_short_open_and_debits_purse(): g = _game() short = ShortPosition(good="honey", qty=5, entry_price=9.0, margin=MARGIN_POST) g.world.patron.shorts.append(short) g.world.patron.purse = 500 # can fund the shortfall g.world.last_price["honey"] = 20.0 # same breach as the forced-cover case pnl = short_unrealized_pnl(short, g.world.last_price) equity = MARGIN_POST + pnl need = int(MARGIN_MAINTENANCE * MARGIN_POST) - equity purse_before = g.world.patron.purse g.step() assert short.open # the position survives assert short.margin == MARGIN_POST + need assert g.world.patron.purse == purse_before - need call = [e for e in g.world.log if e["type"] == "margin_call"] assert call and call[-1]["resolution"] == "topped_up" # --- (5) unrealized P&L formula and a no-double-count realization --------------- def test_unrealized_pnl_formula_and_clean_realization(): g = _game() short = ShortPosition(good="honey", qty=4, entry_price=9.0, margin=MARGIN_POST) g.world.patron.shorts.append(short) g.world.last_price["honey"] = 15.0 expected = int((9.0 - 15.0) * 4) # -24 assert short_unrealized_pnl(short, g.world.last_price) == expected g.world.patron.purse = 0 # force the cover so the unrealized P&L is realized realized_before = g.world.patron.realized_pnl g.step() assert not short.open assert g.world.patron.realized_pnl == realized_before + expected # moved exactly once realized_after_first = g.world.patron.realized_pnl g.step() # a closed short must not be re-settled assert g.world.patron.realized_pnl == realized_after_first # --- (6) creature bankruptcy routes through reversible exile, conserves -------- def test_bankruptcy_routes_through_exile_and_conserves_the_system(): g = Game(make_random_policy(seed=7), deck_seed=1) # System invariant for pure loan flows: creature pebbles + purse + open margin. def system_total(): return ( sum(c.pebbles for c in g.world.creatures.values()) + g.world.patron.purse + sum(s.margin for s in g.world.patron.shorts if s.open) ) g.queue_player_move("lend", target="Pip", amount=80) g.step() # turn 1: loan issued (a pure transfer + Patron purse debit) loan = g.world.patron.loans[-1] _step_to_turn(g, loan.due_turn - 1) # up to the turn before settlement # Leave Pip with a few pebbles, below the loan balance: the default pays what # it has (a transfer) and writes off the rest (a ledger number, not pebbles), # so the pebble/margin system stays conserved. Capture the baseline AFTER this # edit so the manual top-down does not itself look like a leak, then assert the # invariant holds from here across the default, exile, and re-entry. g.world.creatures["Pip"].pebbles = 5 total0 = system_total() g.step() # the due turn: default -> bankruptcy candidate -> exile assert loan.defaulted assert "Pip" in g.world.exiled assert "Pip" not in {c.name for c in g.world.alive()} assert any(e["type"] == "bankruptcy" and e["who"] == "Pip" for e in g.world.log) assert system_total() == total0 # the default + exile turn conserved the ledger # Clearing keeps working and pebbles/margin stay conserved across the window # and the re-entry, because loan settlement only transfers pebbles. for _ in range(EXILE_CHAPTER_TURNS + 2): g.step() for c in g.world.creatures.values(): assert c.pebbles >= 0 assert all(q >= 0 for q in c.inventory.values()) assert system_total() == total0 assert "Pip" in {c.name for c in g.world.alive()} # re-entered intact # --- (7) constant honesty: a short's margin equals its purse cost -------------- def test_margin_post_equals_short_purse_cost(): assert MARGIN_POST == -MOVE_COSTS["short"]["purse"] # --- (8) the Patron's own ruin is reachable and does not halt the step --------- def test_patron_ruin_sets_flag_and_does_not_raise(): g = _game() g.world.patron.purse = 0 g.world.patron.realized_pnl = -10 # net worth <= 0 with no shorts or loans assert g.world.patron.net_worth(g.world.last_price) <= 0 g.step() # must not raise assert g.world.patron.ruined assert any(e["type"] == "bankruptcy" and e["who"] == "Patron" for e in g.world.log) # --- (9) lend still pays the target and injects context (Phase 1 intact) ------- def test_lend_still_pays_target_and_injects_context(): g = _game() target = g.world.creatures["Mossback"] before = target.pebbles g.world.pending_moves.append({"kind": "lend", "target": "Mossback", "amount": 50}) # Inspect the credit + context inside the turn, before patron_context is cleared. from ttw import moves ev = moves.apply_move(g.world, {"kind": "lend", "target": "Mossback", "amount": 50}) assert ev["applied"] assert target.pebbles == before + 50 assert any("lent you 50 pebbles" in line for line in target.patron_context) assert g.world.patron.loans # the additive Loan ledger entry exists