Spaces:
Running on Zero
Running on Zero
| """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 | |
| 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" | |