| """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() |
|
|
|
|
| |
|
|
| 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") |
|
|
|
|
| |
|
|
| 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 |
| 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 |
|
|
|
|
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| 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 |
|
|
|
|
| |
|
|
| 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" |
|
|
|
|
| |
|
|
| 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 |
| assert "copy of it is added" in info |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| 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) |
| striker = make_card("striker", power=2, health=1) |
| state.player_row[0] = CardInstance(spec=striker) |
| foe = make_card("foe", power=3, health=9) |
| state.foe_row[1] = CardInstance(spec=foe) |
| 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 |
| |
| 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 |
| assert "⚔4" in out[2].plain and "☠" in out[2].plain |
| assert 3 in adv |
| 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 |
| 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 |
|
|
|
|
| |
|
|
| 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" |
|
|