"""Invariant tests for the deterministic engine. No GPU, no network. Run: python -m pytest tests/ -q (from the project root) """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from ttw import shocks from ttw.actions import Offer, parse_actions from ttw.dummy import make_random_policy from ttw.market import clear_market, drift_prices, gini from ttw.sim import _burn_fuel, _eat, _spoil, _update_mood, step from ttw.world import SPOIL_RATE, fuel_need, seed_world def _total_pebbles(world): return sum(c.pebbles for c in world.alive()) def test_trade_conserves_pebbles_and_is_nonnegative(): """A round of trading moves pebbles around but never creates/destroys them, and no creature ends with negative pebbles or inventory.""" world = seed_world() before = _total_pebbles(world) # Force a crossing pair: Fenn buys acorns high, Mossback sells low. offers = [ Offer("Fenn", "buy", "acorns", 8, 5), Offer("Mossback", "sell", "acorns", 4, 5), ] events = clear_market(world, offers) assert events, "a crossing buy/sell should produce at least one trade" assert _total_pebbles(world) == before for c in world.alive(): assert c.pebbles >= 0 assert all(q >= 0 for q in c.inventory.values()) def test_no_trade_when_not_crossing(): world = seed_world() offers = [ Offer("Fenn", "buy", "honey", 3, 2), Offer("Oona", "sell", "honey", 10, 2), ] assert clear_market(world, offers) == [] def test_cannot_oversell_inventory(): """Seller offering more than it holds only trades what it actually has.""" world = seed_world() world.creatures["Oona"].inventory["honey"] = 2 offers = [ Offer("Fenn", "buy", "honey", 12, 10), Offer("Oona", "sell", "honey", 6, 10), ] clear_market(world, offers) assert world.creatures["Oona"].inventory["honey"] == 0 assert world.creatures["Fenn"].inventory["honey"] == 3 # started with 1, bought 2 def test_full_sim_runs_and_stays_valid(): """20 turns of the random policy: economy runs, invariants hold throughout.""" world = seed_world() policy = make_random_policy(seed=42) pebbles_start = _total_pebbles(world) for _ in range(20): step(world, policy) for c in world.alive(): assert c.pebbles >= 0, f"{c.name} went bankrupt below zero" assert all(q >= 0 for q in c.inventory.values()) # Trading is pebble-conserving; production/consumption don't touch pebbles, # so the total purse of the wood is invariant across the whole run. assert _total_pebbles(world) == pebbles_start assert world.turn == 20 def test_leveraged_run_conserves_pebbles_purse_and_open_margin(): """A loaned (leveraged) seeded run conserves the system ledger. Lending and loan settlement only move pebbles between a creature and the Patron's purse, and posting margin only moves pebbles from the purse into a short's collateral, so the total sum(creature pebbles) + patron.purse + sum(open short margin) is invariant across the window even with a loan issued, matured, and repaid. """ from ttw.game import Game g = Game(make_random_policy(seed=11), deck_seed=2) 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.world.creatures["Mossback"].pebbles = 1000 # so the loan repays in full g.queue_player_move("lend", target="Mossback", amount=100) g.step() total0 = system_total() for _ in range(8): # spans the loan's term so it matures and settles g.step() assert system_total() == total0 assert g.world.patron.loans[-1].repaid def test_drift_prices_moves_with_pressure_and_spares_untouched_goods(): world = seed_world() honey0 = world.last_price["honey"] drift_prices(world, [Offer("Fenn", "buy", "honey", 9, 10)]) # all demand assert world.last_price["honey"] > honey0 # price rises acorns0 = world.last_price["acorns"] drift_prices(world, [Offer("Mossback", "sell", "acorns", 4, 10)]) # all supply assert world.last_price["acorns"] < acorns0 # price falls mush0 = world.last_price["mushrooms"] drift_prices(world, [Offer("Fenn", "buy", "honey", 9, 1)]) # no mushroom offers assert world.last_price["mushrooms"] == mush0 # untouched good unchanged def test_gini_bounds(): assert gini([10, 10, 10, 10]) == 0.0 assert gini([]) == 0.0 assert gini([0, 0, 100]) > 0.5 # very unequal def test_parse_actions_tolerates_junk(): """The parse-and-repair layer salvages valid offers and drops the rest.""" text = ( "Sure! Here's my move:\n```json\n" '{"thought":"buy the dip","offers":[' '{"side":"buy","good":"acorns","price":7,"qty":3},' '{"side":"sell","good":"glass","price":5,"qty":1},' # invalid good -> dropped '{"side":"buy","good":"honey","price":-2,"qty":4}],' # bad price -> dropped '"gossip":"the mushrooms are cursed"}\n```' ) offers, gossip = parse_actions("Fenn", text) assert len(offers) == 1 assert offers[0].good == "acorns" and offers[0].price == 7 and offers[0].qty == 3 assert gossip and "mushrooms" in gossip[0].message def test_parse_actions_handles_garbage(): offers, gossip = parse_actions("Pip", "I refuse to answer in JSON, sorry.") assert offers == [] and gossip == [] def test_parse_actions_rejects_non_finite_without_crashing(): """json.loads accepts Infinity/NaN; the parser must drop them, not raise.""" text = '{"offers":[{"side":"buy","good":"acorns","price":Infinity,"qty":1},' text += '{"side":"sell","good":"berries","price":5,"qty":NaN}]}' offers, gossip = parse_actions("Fenn", text) # must not raise assert offers == [] def test_affordability_blocked_buyer_does_not_hang_or_misfill(): """A broke buyer crossing a seller it can't pay yields no trade and no hang.""" world = seed_world() world.creatures["Pip"].pebbles = 3 # too poor to pay the clearing price honey_before = world.creatures["Oona"].inventory["honey"] offers = [ Offer("Pip", "buy", "honey", 10, 5), Offer("Oona", "sell", "honey", 8, 5), ] events = clear_market(world, offers) # must terminate assert events == [] assert world.creatures["Pip"].pebbles == 3 # untouched assert world.creatures["Oona"].inventory["honey"] == honey_before # unsold def test_diet_variety_punishes_monoculture(): """Holding only one food cannot satisfy a multi-food meal -> hunger shortfall.""" world = seed_world() c = world.creatures["Mossback"] c.inventory = {"acorns": 99, "berries": 0, "mushrooms": 0, "honey": 0, "firewood": 9} c.food_need = 3 c.wellbeing = 10 ev = _eat(c, turn=1) assert ev["type"] == "hunger" and ev["shortfall"] == 2 # ate 1 acorn, missed 2 assert c.inventory["acorns"] == 98 # only one unit counted toward the meal def test_fuel_burn_consumes_firewood_and_reports_cold_when_short(): world = seed_world() c = world.creatures["Oona"] c.inventory["firewood"] = 0 ev = _burn_fuel(c, turn=1) assert ev["type"] == "cold" and ev["shortfall"] == fuel_need(1) # _burn_fuel no longer touches wellbeing; mood is updated separately. def test_mood_mean_reverts_and_never_dies(): """Wellbeing drifts toward a comfort target, recovers, and floors above zero.""" world = seed_world() c = world.creatures["Pip"] # Sustained double shortfall pulls mood DOWN toward target 4, never to 0. c.wellbeing = 10 for _ in range(20): _update_mood(c, food_short=1, fuel_short=1) assert c.wellbeing == 4 # floor for a fully-short creature, not death # Once provisioned, it recovers back to 10. for _ in range(20): _update_mood(c, food_short=0, fuel_short=0) assert c.wellbeing == 10 def test_spoilage_floors_and_spares_firewood(): world = seed_world() c = world.creatures["Bramble"] c.inventory = {"acorns": 10, "berries": 2, "mushrooms": 0, "honey": 0, "firewood": 10} lost = _spoil(c) assert lost["acorns"] == int(10 * SPOIL_RATE) # spoils at any rate >=0.1 assert "berries" not in lost # floor(2*rate)=0 for rate<=0.49, no loss assert c.inventory["firewood"] == 10 # fuel never rots def test_drought_cuts_production(): """A drought shock reduces a good's production on subsequent turns.""" world = seed_world() shocks.drought(world, "acorns", severity=0.0) # total crop failure before = world.creatures["Mossback"].inventory["acorns"] # Freeze decisions to no-ops so we isolate production. step(world, lambda w, n: ([], [])) after = world.creatures["Mossback"].inventory["acorns"] # Mossback produced 0 acorns this turn; only spoilage/eating reduced stock. assert after <= before def test_gold_rush_conserves_nothing_but_is_bounded(): world = seed_world() total_before = sum(c.pebbles for c in world.alive()) shocks.gold_rush(world, amount=40) total_after = sum(c.pebbles for c in world.alive()) assert total_after == total_before + 40 * len(world.alive()) def test_event_deck_applies_effects_and_no_repeats_until_exhausted(): """Drawing a Wood Legend perturbs the world, logs it, and cycles all events before any repeats.""" from ttw.events import EVENTS, EventDeck world = seed_world() deck = EventDeck(seed=1) drawn = [deck.draw(world).key for _ in range(len(EVENTS))] assert set(drawn) == {e.key for e in EVENTS} # every legend told exactly once assert len(set(drawn)) == len(EVENTS) # no repeats within one cycle # Each draw left an event record with a real-world attribution for the reveal. event_logs = [e for e in world.log if e["type"] == "event"] assert len(event_logs) == len(EVENTS) assert all(e["inspired_by"] for e in event_logs) def test_acorn_mania_injects_pebbles_and_a_rumor(): from ttw.events import EventDeck from ttw.events import EVENTS world = seed_world() mania = next(e for e in EVENTS if e.key == "acorn_mania") deck = EventDeck(seed=0, events=[mania]) before = sum(c.pebbles for c in world.alive()) deck.draw(world) assert sum(c.pebbles for c in world.alive()) > before # gold rush fired assert any("acorn" in r.lower() for r in world.rumors) # rumor planted