"""Branching paths, the legacy system, card art, and the newer puzzles.""" from __future__ import annotations import pytest from scrypt.data import load_content from scrypt.engine import legacy as legacy_store from scrypt.engine.run import NodeKind, new_run from scrypt.sandbox.fabricate import fabricate_home from scrypt.sandbox.inheritance import plant_legacy, warden_quarters from scrypt.sandbox.puzzles import plant_all from scrypt.sandbox.shell import Shell from scrypt.sandbox.vfs import VFS @pytest.fixture def content(): return load_content() # ----------------------------------------------------------------- forks def test_fork_ids_become_fork_nodes(content): deck = content.starter_decks["vanilla"]["cards"] run = new_run(deck, ["first_blood", "crossroads_audit"], fork_ids=frozenset(content.forks)) kinds = [n.kind for n in run.nodes] assert kinds[0] is NodeKind.BATTLE assert kinds[-1] is NodeKind.FORK def test_resolve_fork_swaps_in_the_chosen_battle(content): deck = content.starter_decks["vanilla"]["cards"] run = new_run(deck, ["crossroads_audit"], fork_ids=frozenset(content.forks)) run.resolve_fork("swap_storm") assert run.current.kind is NodeKind.BATTLE assert run.current.payload == "swap_storm" def test_resolve_fork_requires_standing_at_one(content): deck = content.starter_decks["vanilla"]["cards"] run = new_run(deck, ["first_blood"]) with pytest.raises(ValueError): run.resolve_fork("swap_storm") def test_fork_options_reference_real_encounters(content): for fork in content.forks.values(): assert len(fork["options"]) >= 2 for opt in fork["options"]: assert opt["encounter"] in content.encounters if opt["bounty"]: assert opt["bounty"]["kind"] in ("cycles", "draft") # ---------------------------------------------------------------- legacy def _crash(data, statement="no regrets", strongest=None, cycles=12): legacy_store.record_crash( data, encounter="audit_sweep", turn=6, deck_ids=["daemon", "tarpit"], strongest=strongest, statement=statement, cycles=cycles, estate_card="oom-killer", password="stoat", ) def test_clean_statement_is_printable_and_bounded(): cleaned = legacy_store.clean_statement("\x1b[31mEVIL\x07 " + "x" * 500) assert "\x1b" not in cleaned and "\x07" not in cleaned assert len(cleaned) <= legacy_store.MAX_STATEMENT assert legacy_store.clean_statement(" ") == "(they said nothing)" def test_record_crash_caps_history_and_seals_estate(tmp_path, monkeypatch): monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) data = legacy_store.load() for i in range(legacy_store.MAX_CRASHES + 2): _crash(data, statement=f"death {i}") legacy_store.save(data) again = legacy_store.load() assert len(again["crashes"]) == legacy_store.MAX_CRASHES assert again["runs"] == legacy_store.MAX_CRASHES + 2 assert again["estate"] == {"cycles": 12, "card": "oom-killer", "password": "stoat"} def test_legacy_survives_a_corrupt_store(tmp_path, monkeypatch): monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) (tmp_path / "legacy.json").write_text("not even json") assert legacy_store.load() == legacy_store.EMPTY (tmp_path / "legacy.json").write_text('{"crashes": "what", "wins": 3}') data = legacy_store.load() assert data["crashes"] == [] and data["wins"] == 3 def test_audit_level_spares_the_first_win(): data = dict(legacy_store.EMPTY) assert legacy_store.audit_level(data) == 0 legacy_store.record_win(data, most_played="daemon", diary_password="moth-orbit") assert legacy_store.audit_level(data) == 0 # first win: pure payoff legacy_store.record_win(data, most_played="daemon", diary_password="moth-orbit") assert legacy_store.audit_level(data) == 1 data["wins"] = 99 assert legacy_store.audit_level(data) == 2 # hard cap def test_conscript_is_clamped_no_matter_the_json(): card = legacy_store.conscript( {"strongest": {"name": "Root", "power": 99, "health": -3, "sigils": ["privileged", "fake_sigil"]}} ) assert card.power == 4 and card.health == 1 assert card.sigils == ("privileged",) assert legacy_store.conscript({"strongest": None}) is None assert legacy_store.conscript({"strongest": {"power": "lots"}}) is None # ----------------------------------------------- inheritance in the sandbox def _legacy_with_everything(): data = dict(legacy_store.EMPTY) _crash(data, statement="see you in the logs", strongest={"name": "OOM Killer"}) data["daemon"] = "fork-bomb" return data def test_crash_dump_leaks_the_estate_key(): sh = _shell() plant_legacy(sh.vfs, _legacy_with_everything()) dump = sh.run("cat /var/crash/run-1.core").out assert 'ESTATE_KEY="stoat"' in dump assert "see you in the logs" in dump assert "reassigned" in dump # the conscription notice def test_estate_puzzle_pays_out_with_the_dump_password(): sh = _shell() puzzles = {p.id: p for p in plant_legacy(sh.vfs, _legacy_with_everything())} sh.run("cd /home/drifter") assert sh.run("unzip estate.zip wrongpass").err assert puzzles["estate"].poll(sh) is None assert "inflating" in sh.run("unzip estate.zip stoat").out sh.run("cat estate.txt") reward = puzzles["estate"].poll(sh) assert reward is not None and reward.kind == "estate" assert reward.value == {"cycles": 12, "card": "oom-killer"} def test_daemon_arms_via_chmod(): sh = _shell() puzzles = {p.id: p for p in plant_legacy(sh.vfs, _legacy_with_everything())} assert puzzles["daemon"].poll(sh) is None assert sh.run("chmod +x /etc/init.d/missing").err sh.run("chmod +x /etc/init.d/fork-bomb") reward = puzzles["daemon"].poll(sh) assert reward is not None and reward.kind == "daemon" and reward.value == "fork-bomb" def test_warden_quarters_leak_the_diary_password(): data = _legacy_with_everything() vfs = warden_quarters(data, diary_password="lanternfish") sh = Shell(vfs) assert sh.vfs.cwd_path == "/root" diary = sh.run("cat diary.log").out assert '"lanternfish"' in diary record = sh.run("cat drifters/run-1.txt").out assert "see you in the logs" in record def test_authored_artifacts_surface_in_future_runs(): data = _legacy_with_everything() data["crashes"][-1]["note"] = "lane two was open the whole time." data["diary"] = ["a drifter escaped tonight. unacceptable; logged."] sh = _shell() plant_legacy(sh.vfs, data) dump = sh.run("cat /var/crash/run-1.core").out assert "warden annotation: lane two was open the whole time." in dump quarters = Shell(warden_quarters(data, diary_password="stoat")) diary = quarters.run("cat /root/diary.log").out assert "a drifter escaped tonight" in diary def test_legacy_load_coerces_a_broken_diary(tmp_path, monkeypatch): monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) (tmp_path / "legacy.json").write_text('{"diary": "not a list", "wins": 1}') data = legacy_store.load() assert data["diary"] == [] and data["wins"] == 1 def test_intel_forces_next_runs_archive_password(): sh = _shell() plant_all(sh.vfs, seed=3, forced_password="lanternfish") sh.run("cd ~/downloads") assert "inflating" in sh.run("unzip severance.zip lanternfish").out # -------------------------------------------------------------- card art def test_every_card_has_compact_art(content): for card in content.pool.values(): assert card.art, f"{card.id} has no art" lines = card.art.splitlines() assert len(lines) <= 3, f"{card.id} art too tall" indent = min(len(l) - len(l.lstrip(" ")) for l in lines if l.strip()) assert all(len(l.rstrip()) - indent <= 9 for l in lines), f"{card.id} art too wide" # ------------------------------------------------------------- card info def test_every_engine_sigil_has_rules_text(): from scrypt.engine.cards import KNOWN_SIGILS from scrypt.ui.render import SIGIL_EXPLAIN, SIGIL_GLYPHS assert set(SIGIL_EXPLAIN) == KNOWN_SIGILS assert set(SIGIL_GLYPHS) == KNOWN_SIGILS def test_card_info_states_cost_and_every_sigil(content): from scrypt.engine.cards import CardInstance from scrypt.ui.render import card_info info = card_info(CardInstance(spec=content.card("fork-bomb"))).plain assert "costs ♦" in info assert "copy of it is added to your hand" in info info = card_info(CardInstance(spec=content.card("zombie-process"))).plain assert "4⊙" in info and "core dumps" in info info = card_info(CardInstance(spec=content.card("bit"))).plain assert "free" in info def test_card_info_for_a_foe_skips_costs(content): from scrypt.engine.cards import CardInstance from scrypt.ui.render import card_info info = card_info(CardInstance(spec=content.card("fork-bomb")), foe=True).plain assert "the warden's" in info assert "costs" not in info # the foe never pays assert "copy of it is added" in info # but its sigils are still explained # ------------------------------------------------------- table furniture def _combat(content, main=None, side=None): from scrypt.engine.combat import CombatState return CombatState( main_deck=main if main is not None else list( content.starter_decks["vanilla"]["cards"] ), side_deck=side if side is not None else [content.card("bit")] * 5, script=[[]], seed=3, ) def test_deck_stacks_show_counts_and_shout_when_empty(content): from rich.console import Console from scrypt.ui.render import deck_stacks console = Console(width=40, force_terminal=False) state = _combat(content) with console.capture() as cap: console.print(deck_stacks(state)) out = cap.get() assert "your deck" in out and "bits" in out assert f"{len(state._draw_pile)} left" in out drained = _combat(content, side=[]) drained._draw_pile.clear() with console.capture() as cap: console.print(deck_stacks(drained)) assert cap.get().count("EMPTY") == 2 def test_scoreboard_carries_the_win_condition(content): from rich.console import Console from scrypt.ui.render import scoreboard console = Console(width=80, force_terminal=False) with console.capture() as cap: console.print(scoreboard(2)) out = cap.get() assert "+5 you escape" in out and "−5 you are reaped" in out assert "██" in out # the pips themselves def test_preview_bell_predicts_without_moving_the_table(content): from scrypt.engine.cards import CardInstance, make_card from scrypt.engine.combat import preview_bell state = _combat(content) # still in DRAW; preview must work anyway striker = make_card("striker", power=2, health=1) state.player_row[0] = CardInstance(spec=striker) # open lane -> face foe = make_card("foe", power=3, health=9) state.foe_row[1] = CardInstance(spec=foe) # your lane 1 is open -> face snapshot = (state.scale, state.turn, len(state.events), state.phase) events = preview_bell(state) kinds = {e.kind for e in events} assert "face_damage" in kinds mine = next(e for e in events if e.kind == "face_damage" and e.data["player"]) assert mine.data["amount"] == 2 and mine.data["lane"] == 0 theirs = next(e for e in events if e.kind == "face_damage" and not e.data["player"]) assert theirs.data["amount"] == 3 and theirs.data["lane"] == 1 # The ghost did all the work; the real table never moved. assert (state.scale, state.turn, len(state.events), state.phase) == snapshot def test_preview_marks_digest_the_event_log(content): from scrypt.engine.cards import CardInstance, make_card from scrypt.engine.combat import preview_bell from scrypt.ui.render import preview_marks state = _combat(content) state.player_row[0] = CardInstance(spec=make_card("hit", power=2, health=1)) state.foe_row[2] = CardInstance(spec=make_card("wall", power=1, health=1)) state.player_row[2] = CardInstance(spec=make_card("axe", power=4, health=2)) state.foe_queue[3] = CardInstance(spec=make_card("next", power=1, health=1)) adv, out, inc, verdict = preview_marks(preview_bell(state)) assert "▲2" in out[0].plain # open lane: face damage assert "⚔4" in out[2].plain and "☠" in out[2].plain # kills the wall assert 3 in adv # the queued card will drop in assert verdict is None def test_preview_warns_when_the_bell_would_end_you(content): from scrypt.engine.cards import CardInstance, make_card from scrypt.engine.combat import preview_bell from scrypt.ui.render import preview_marks state = _combat(content) state.scale = -3 state.foe_row[0] = CardInstance(spec=make_card("reaper", power=2, health=9)) _, _, inc, verdict = preview_marks(preview_bell(state)) assert verdict == "player_loss" assert "▼2" in inc[0].plain def test_run_track_marks_where_you_stand(content): from scrypt.engine.run import new_run from scrypt.ui.render import run_track run = new_run( list(content.starter_decks["vanilla"]["cards"]), ["first_blood", "crossroads_audit", "crossroads_final"], fork_ids=frozenset(content.forks), ) track = run_track(run.nodes, run.position).plain assert track.startswith("path ") assert "[⚔]" in track # the first battle is bracketed assert "⑂" in track and "$" in track and "†" in track and "+" in track run.advance() later = run_track(run.nodes, run.position).plain assert "[$]" in later and "[⚔]" not in later # --------------------------------------------------------------- puzzles def _shell() -> Shell: vfs = VFS() fabricate_home(vfs, seed=11) return Shell(vfs) def test_log_trail_pays_when_the_spool_is_read(): sh = _shell() puzzles = {p.id: p for p in plant_all(sh.vfs, seed=11)} assert puzzles["log_trail"].poll(sh) is None out = sh.run("grep CYCLES /var/log/warden.log").out assert "/var/spool/cycles.dat" in out sh.run("cat /var/spool/cycles.dat") reward = puzzles["log_trail"].poll(sh) assert reward is not None and reward.kind == "cycles" and reward.value == 8 def test_zombie_reap_rewards_the_unlinker(): sh = _shell() puzzles = {p.id: p for p in plant_all(sh.vfs, seed=11)} assert puzzles["zombie_reap"].poll(sh) is None pidfile = sh.run("find /run -name *.pid").out.strip().splitlines()[0] sh.run(f"rm {pidfile}") reward = puzzles["zombie_reap"].poll(sh) assert reward is not None and reward.kind == "card" and reward.value == "zombie-process"