Spaces:
Running on Zero
Running on Zero
| """Entry point: a full run — battles, the sandbox shell, the altar, drafts.""" | |
| from __future__ import annotations | |
| import asyncio | |
| import random | |
| from collections import Counter | |
| from dataclasses import replace | |
| from textual.app import App | |
| from scrypt.data import Content, load_content | |
| from scrypt.engine import legacy as legacy_store | |
| from scrypt.engine.cards import Cost, CostType, CardInstance | |
| from scrypt.engine.combat import CombatState, EncounterScript, Result, ScriptedPlay | |
| from scrypt.engine.run import NodeKind, RunState, new_run | |
| from scrypt.inference import ScriptedBackend, build_backend | |
| from scrypt.sandbox.fabricate import fabricate_home | |
| from scrypt.sandbox.inheritance import plant_legacy, warden_quarters | |
| from scrypt.sandbox.puzzles import PASSWORDS, Puzzle, plant_all | |
| from scrypt.sandbox.shell import Shell | |
| from scrypt.sandbox.vfs import VFS | |
| from scrypt.warden import author, moments | |
| from scrypt.warden.context import combat_digest | |
| from scrypt.warden.director import Director | |
| from scrypt.warden.memory import ShardStore, distill_with_voice | |
| from scrypt.warden.presence import WardenPresence | |
| from scrypt.warden.voice import WardenVoice | |
| from scrypt.ui import render | |
| from scrypt.ui.altar import AltarScreen | |
| from scrypt.ui.board import BoardScreen | |
| from scrypt.ui.choice import CardChoiceScreen, RunEndScreen | |
| from scrypt.ui.shell import ShellScreen | |
| RUN_ENCOUNTERS = ["first_blood", "crossroads_audit", "crossroads_final"] | |
| ALTAR_OFFERS = ["segfault", "kernel-panic", "root"] | |
| ROOT_MOTD = """\ | |
| scryptOS 0.1 — root session (irregular) | |
| you should not be here. the door was only open because nothing has | |
| ever gotten this far. look around. take nothing. `exit` when done. | |
| """ | |
| INTROS = { | |
| "first_blood": "Another process wakes in my machine. Show me what you are.", | |
| "audit_sweep": "You survived PID 2. The audit will be less forgiving.", | |
| "swap_storm": "You chose the hungrier door. I respect the appetite. It won't help.", | |
| "scheduled_doom": "I have scheduled your termination. The cron job runs now.", | |
| "init_zero": "You asked for the parent of everything. It has been expecting you since boot.", | |
| "retry": "Back again? I left your corpse in the lane where it fell.", | |
| } | |
| class SandboxSession: | |
| """The sandbox persists across a whole run: one VFS, one shell, one | |
| set of puzzles, one ledger of what the player has lost. Previous runs | |
| leave artifacts in it: crash dumps, a sealed estate, a dormant daemon.""" | |
| def __init__(self, seed: int, use_mirror: bool = False, legacy: dict | None = None): | |
| self.vfs = VFS() | |
| mirrored = 0 | |
| if use_mirror: | |
| from scrypt.sandbox.mirror import mirror_home | |
| mirrored = mirror_home(self.vfs) | |
| if mirrored == 0: | |
| fabricate_home(self.vfs, seed=seed) | |
| self.mirrored = mirrored | |
| self.shell = Shell(self.vfs) | |
| legacy = legacy or {} | |
| # The diary password persists — the Warden told you it never changes. | |
| self.puzzles: list[Puzzle] = plant_all( | |
| self.vfs, seed=seed, forced_password=legacy.get("intel") | |
| ) | |
| self.puzzles.extend(plant_legacy(self.vfs, legacy)) | |
| self.weaken_next_encounter = False | |
| self.daemon_armed: str | None = None # card id, set by the daemon puzzle | |
| def weakened(script: EncounterScript) -> EncounterScript: | |
| """The cron job was defused: the strongest scheduled play never comes.""" | |
| out: EncounterScript = [] | |
| dropped = False | |
| for turn in script: | |
| kept = [] | |
| for play in turn: | |
| if not dropped and play.card.power >= 3: | |
| dropped = True | |
| continue | |
| kept.append(play) | |
| out.append(kept) | |
| return out | |
| class ScryptApp(App): | |
| TITLE = "SCRYPT" | |
| def __init__( | |
| self, | |
| content: Content | None = None, | |
| seed: int | None = None, | |
| backend=None, | |
| use_mirror: bool = False, | |
| deck_id: str = "vanilla", | |
| skip_menu: bool = False, | |
| ): | |
| super().__init__() | |
| self.content = content or load_content() | |
| self.rng = random.Random(seed) | |
| self.use_mirror = use_mirror | |
| self.deck_id = deck_id | |
| self.skip_menu = skip_menu # tests jump straight to the table | |
| self.run_state: RunState | None = None | |
| self.session: SandboxSession | None = None | |
| self._backend = backend # None -> resolved from env on mount | |
| self._server = None | |
| self.backend_mode = "scripted" | |
| self.backend_error: str | None = None | |
| self.backend_waking = backend is None | |
| self.can_carve = False | |
| self.legacy = legacy_store.load() | |
| self.shards = ShardStore() | |
| # The Warden remembers previous runs: rehydrate its memory. | |
| for shard in self.legacy.get("shards", []): | |
| try: | |
| self.shards.add(str(shard["text"]), set(shard.get("tags", []))) | |
| except (TypeError, KeyError): | |
| continue | |
| self.voice: WardenVoice | None = None | |
| self._fresh_game = False # true for the run right after orientation | |
| # The one brain-queue every screen talks through. Starts voiceless | |
| # (scripted fallbacks); gains the model when the backend wakes. | |
| self.presence = WardenPresence() | |
| def on_mount(self) -> None: | |
| from scrypt.ui import palette | |
| self.register_theme(palette.textual_theme()) | |
| self.theme = palette.THEME_NAME | |
| self.run_worker(self._wake_backend()) | |
| self.run_worker(self._lobby()) | |
| async def _wake_backend(self) -> None: | |
| """Resolve the Warden's brain off the UI path. A 22GB model load | |
| must never freeze the menu, and a failure must say WHY.""" | |
| import asyncio | |
| if self._backend is None: | |
| try: | |
| self._backend, self._server, self.backend_mode = await asyncio.to_thread( | |
| build_backend | |
| ) | |
| except Exception as err: | |
| self._backend, self.backend_mode = ScriptedBackend(), "scripted" | |
| self.backend_error = str(err) | |
| # Scripted mode keeps the curated lines; a real backend gets a voice. | |
| if not isinstance(self._backend, ScriptedBackend): | |
| self.voice = WardenVoice(self._backend, self.shards) | |
| self.presence.voice = self.voice | |
| # Pay the cold-prefill cost here, behind the menu, so the first | |
| # in-fight line lands in ~2s instead of ~16s. | |
| self.run_worker(self._prewarm_voice()) | |
| self.backend_waking = False | |
| # Sleeping Warden + enough RAM + no GGUF on disk = offer the ritual. | |
| if self.backend_mode == "scripted": | |
| from scrypt.inference import local | |
| self.can_carve = ( | |
| local.choose_quant() is not None and local.installed_model() is None | |
| ) | |
| from scrypt.ui.menu import MenuScreen | |
| if isinstance(self.screen, MenuScreen): | |
| self.screen.set_brain( | |
| self.backend_mode, self.backend_error, | |
| waking=False, carvable=self.can_carve, | |
| ) | |
| async def _prewarm_voice(self) -> None: | |
| async for _ in self.voice.react("a new player sits down at your table"): | |
| pass # discard; this call exists to warm the prompt cache | |
| def on_unmount(self) -> None: | |
| self.presence.stop() | |
| if self._server is not None: | |
| self._server.stop() | |
| async def _wipe(self) -> None: | |
| """Two breaths of static between scenes.""" | |
| from scrypt.ui import fx as fxmod | |
| from scrypt.ui.boot import NoiseScreen | |
| if not fxmod.reduced_motion() and not self.skip_menu: | |
| await self.push_screen_wait(NoiseScreen()) | |
| async def _lobby(self) -> None: | |
| from scrypt.ui import fx as fxmod | |
| from scrypt.ui.boot import BootScreen | |
| from scrypt.ui.menu import MenuScreen | |
| if not self.skip_menu and not fxmod.reduced_motion(): | |
| await self.push_screen_wait(BootScreen()) | |
| while True: | |
| if not self.skip_menu: | |
| choice = await self.push_screen_wait( | |
| MenuScreen( | |
| self.content.starter_decks, | |
| self.backend_mode, | |
| backend_error=self.backend_error, | |
| waking=self.backend_waking, | |
| carvable=self.can_carve, | |
| ) | |
| ) | |
| if choice is None: | |
| self.exit() | |
| return | |
| if choice == "::carve::": | |
| from scrypt.ui.totem import TotemScreen | |
| carved = await self.push_screen_wait(TotemScreen()) | |
| if carved: | |
| # The totem is whole: wake the Warden for real. | |
| self._backend = None | |
| self.backend_waking = True | |
| self.can_carve = False | |
| self.run_worker(self._wake_backend()) | |
| continue | |
| self.deck_id = choice | |
| if not self.legacy.get("initiated"): | |
| # First game on this machine, ever: orientation. | |
| from scrypt.ui.tutorial import OrientationScreen | |
| await self.push_screen_wait(OrientationScreen()) | |
| self.legacy["initiated"] = True | |
| self._fresh_game = True | |
| legacy_store.save(self.legacy) | |
| await self._run_loop() | |
| if self.skip_menu: | |
| self.exit() | |
| return | |
| async def _run_loop(self) -> None: | |
| content = self.content | |
| deck = list(content.starter_decks[self.deck_id]["cards"]) | |
| run = self.run_state = new_run( | |
| deck, RUN_ENCOUNTERS, fork_ids=frozenset(content.forks) | |
| ) | |
| # Contraband from a won run rides in with you. The Warden knows. | |
| contraband = None | |
| if self.legacy.get("contraband") in content.pool: | |
| contraband = content.card(self.legacy["contraband"]) | |
| run.deck.append(contraband) | |
| session = self.session = SandboxSession( | |
| seed=self.rng.randrange(2**32), use_mirror=self.use_mirror, | |
| legacy=self.legacy, | |
| ) | |
| director = Director( | |
| content=content, | |
| rng=random.Random(self.rng.randrange(2**32)), | |
| backend=self._backend if self.voice is not None else None, | |
| audit_level=legacy_store.audit_level(self.legacy), | |
| ) | |
| altar_offers = list(ALTAR_OFFERS) | |
| retrying = False | |
| pending_bounty: dict = {} | |
| run_plays: Counter = Counter() # per-card plays, for daemonization | |
| conscripted = False | |
| last_state: CombatState | None = None | |
| fork_context: tuple[str, str | None] | None = None | |
| self._authored_scripts: dict[str, tuple[EncounterScript, str | None]] = {} | |
| while not run.over: | |
| node = run.current | |
| if node.kind is NodeKind.FORK: | |
| from scrypt.ui.choice import PathChoiceScreen | |
| fork = content.forks[node.payload] | |
| names = {e: meta["name"] for e, meta in content.encounters.items()} | |
| picked = await self.push_screen_wait(PathChoiceScreen(fork, names)) | |
| run.resolve_fork(picked["encounter"]) | |
| pending_bounty = picked["bounty"] | |
| spurned = next( | |
| (o["label"] for o in fork["options"] if o is not picked), None | |
| ) | |
| fork_context = (picked["label"], spurned) | |
| continue # the node is a BATTLE now; fall through next pass | |
| if node.kind is NodeKind.BATTLE: | |
| script = content.encounters[node.payload]["script"] | |
| authored_label = None | |
| composed = self._authored_scripts.get(node.payload) | |
| if composed is not None and not retrying: | |
| script, authored_label = composed | |
| if session.weaken_next_encounter: | |
| script = weakened(script) | |
| session.weaken_next_encounter = False | |
| intro = INTROS["retry"] if retrying else INTROS.get( | |
| node.payload, "Sit. The board is set. Your move." | |
| ) | |
| # Conscription: a process you died holding fights for the | |
| # Warden — once per run, never in the opening fight. | |
| if ( | |
| not conscripted and not retrying and run.position > 0 | |
| and self.legacy.get("crashes") | |
| and self.rng.random() < 0.5 | |
| ): | |
| drafted = legacy_store.conscript(self.legacy["crashes"][-1]) | |
| if drafted is not None: | |
| script = [list(turn) for turn in script] | |
| turn_at = min(2, len(script) - 1) | |
| script[turn_at].append( | |
| ScriptedPlay(lane=self.rng.randrange(4), card=drafted) | |
| ) | |
| conscripted = True | |
| intro += " I drafted someone you might recognize." | |
| state = CombatState( | |
| main_deck=run.deck, | |
| side_deck=[content.card("bit")] * 20, | |
| script=script, | |
| seed=self.rng.randrange(2**32), | |
| ) | |
| # An armed daemon reports for duty: one free copy, one fight. | |
| if session.daemon_armed and session.daemon_armed in content.pool: | |
| spec = content.card(session.daemon_armed) | |
| free = replace(spec, id=f"{spec.id}-svc", cost=Cost(CostType.FREE)) | |
| state.hand.append(CardInstance(spec=free)) | |
| session.daemon_armed = None | |
| director.new_fight() | |
| await self._wipe() | |
| tutorial = run.position == 0 and not retrying | |
| intro_moment = moments.fight_intro( | |
| content.encounters[node.payload]["name"], | |
| retry=retrying, | |
| tutorial=tutorial, | |
| chose_door=fork_context[0] if fork_context else None, | |
| spurned_door=fork_context[1] if fork_context else None, | |
| authored=authored_label, | |
| ) | |
| # Speculation: cook the greeting while the table is dealt. | |
| self.presence.prefetch( | |
| "intro", intro_moment, digest=combat_digest(state) | |
| ) | |
| result = await self.push_screen_wait( | |
| BoardScreen( | |
| state, intro=intro, presence=self.presence, director=director, | |
| tutorial=tutorial, intro_moment=intro_moment, | |
| track=render.run_track(run.nodes, run.position), | |
| ) | |
| ) | |
| self.presence.clear_cache() # the fight's speculation dies with it | |
| fork_context = None | |
| if result is None: # player abandoned the run | |
| return # back to the menu | |
| last_state = state | |
| run_plays.update( | |
| e.data["card"] for e in state.events | |
| if e.kind == "played" and e.data.get("player") | |
| ) | |
| self.shards.tick() | |
| # The Warden writes its own memory of the fight; the | |
| # deterministic extractor stands in if the model rambles. | |
| self.run_worker(self._distill(state)) | |
| if result is Result.PLAYER_WIN: | |
| run.cycles += state.overkill_cycles | |
| if pending_bounty.get("kind") == "cycles": | |
| run.cycles += pending_bounty["amount"] | |
| elif pending_bounty.get("kind") == "draft": | |
| options = self.rng.sample(content.draftable, 3) | |
| progress = f"ttys {run.ttys} cycles {run.cycles}" | |
| choice = await self.push_screen_wait(CardChoiceScreen( | |
| options, progress, | |
| line="You won the hungrier door. Loot the wreckage.", | |
| )) | |
| run.deck.append(choice) | |
| pending_bounty = {} | |
| retrying = False | |
| run.advance() | |
| else: | |
| run.lose_tty() | |
| retrying = True | |
| elif node.kind is NodeKind.SHELL: | |
| # The author works while the player explores: compose every | |
| # encounter they might walk into next. Never awaited — the | |
| # battle takes whatever is ready and the base script otherwise. | |
| for enc_id in self._upcoming_encounters(run): | |
| if enc_id not in self._authored_scripts: | |
| self.run_worker(self._compose(enc_id, run)) | |
| await self._wipe() | |
| deck_names = [c.name for c in run.deck] | |
| rewards = await self.push_screen_wait( | |
| ShellScreen( | |
| session.shell, session.puzzles, deck_names, | |
| presence=self.presence, | |
| track=render.run_track(run.nodes, run.position), | |
| guide=self._fresh_game, | |
| ) | |
| ) | |
| self._fresh_game = False # the guide prints once | |
| for reward in rewards: | |
| if reward.kind == "cycles": | |
| run.cycles += reward.value | |
| elif reward.kind == "card": | |
| run.deck.append(content.card(reward.value)) | |
| elif reward.kind == "mercy": | |
| session.weaken_next_encounter = True | |
| elif reward.kind == "estate": | |
| run.cycles += reward.value.get("cycles", 0) | |
| card_id = reward.value.get("card") | |
| if card_id in content.pool: | |
| run.deck.append(content.card(card_id)) | |
| self.legacy["estate"] = None # claimed; the dead rest | |
| legacy_store.save(self.legacy) | |
| elif reward.kind == "daemon": | |
| session.daemon_armed = reward.value | |
| run.advance() | |
| elif node.kind is NodeKind.ALTAR: | |
| offered = content.card(altar_offers.pop(0)) if altar_offers else None | |
| if offered is None: | |
| run.advance() | |
| continue | |
| deal = await self.push_screen_wait( | |
| AltarScreen( | |
| session.shell, offered, self.rng, | |
| contraband=contraband, presence=self.presence, | |
| ) | |
| ) | |
| if deal["paid"] == "contraband": | |
| # Evidence recovered: out of the deck, off the books. | |
| run.deck.remove(contraband) | |
| contraband = None | |
| self.legacy["contraband"] = None | |
| legacy_store.save(self.legacy) | |
| if deal["card"] is not None: | |
| run.deck.append(content.card(deal["card"])) | |
| run.advance() | |
| elif node.kind is NodeKind.CARD_CHOICE: | |
| options = self.rng.sample(content.draftable, 3) | |
| progress = f"ttys {run.ttys} cycles {run.cycles}" | |
| choice = await self.push_screen_wait(CardChoiceScreen(options, progress)) | |
| run.deck.append(choice) | |
| run.advance() | |
| if run.victorious: | |
| await self._aftermath_win(run, run_plays) | |
| else: | |
| await self._aftermath_loss(run, last_state) | |
| self.legacy["shards"] = [ | |
| {"text": s.text, "tags": sorted(s.tags)} for s in self.shards.shards | |
| ] | |
| legacy_store.save(self.legacy) | |
| await self.push_screen_wait( | |
| RunEndScreen(run.victorious, run.cycles, presence=self.presence) | |
| ) | |
| def _upcoming_encounters(self, run: RunState) -> list[str]: | |
| """Encounter ids the player could face next — both doors of a fork.""" | |
| for n in run.nodes[run.position:]: | |
| if n.kind is NodeKind.BATTLE: | |
| return [n.payload] | |
| if n.kind is NodeKind.FORK: | |
| return [o["encounter"] for o in self.content.forks[n.payload]["options"]] | |
| return [] | |
| async def _compose(self, enc_id: str, run: RunState) -> None: | |
| backend = self._backend if self.voice is not None else None | |
| script, label = await author.compose( | |
| backend, | |
| self.content, | |
| enc_id, | |
| list(run.deck), | |
| self.shards.render({"deck", "style"}), | |
| random.Random(self.rng.randrange(2**32)), | |
| ) | |
| if label is not None: | |
| self._authored_scripts[enc_id] = (script, label) | |
| async def _distill(self, state: CombatState) -> None: | |
| backend = self._backend if self.voice is not None else None | |
| for fact, tags in await distill_with_voice(backend, state): | |
| self.shards.add(fact, tags) | |
| async def _author(self, moment: str, fallback: str, timeout_s: float = 8.0) -> str: | |
| """One line of artifact prose, written now, read in a future run. | |
| Bounded wait: a slow model gets its words ghostwritten. A leaked | |
| reasoning comment belongs to live speech, never to written files.""" | |
| if self.voice is None: | |
| return fallback | |
| try: | |
| async with asyncio.timeout(timeout_s): | |
| async for line in self.voice.react(moment): | |
| return line.rpartition("\n")[2] | |
| except Exception: | |
| pass | |
| return fallback | |
| async def _aftermath_loss(self, run: RunState, last_state: CombatState | None) -> None: | |
| """The exit interview: a statement for the record, then the crash | |
| is filed — post-mortem, sealed estate, and a conscriptable corpse.""" | |
| from scrypt.ui.interview import ExitInterviewScreen | |
| statement = await self.push_screen_wait(ExitInterviewScreen(voice=self.voice)) | |
| strongest = None | |
| if last_state is not None: | |
| on_board = [c for c in last_state.player_row if c is not None] | |
| if on_board: | |
| best = max(on_board, key=lambda c: c.power + c.health) | |
| strongest = { | |
| "name": best.name, | |
| "power": max(1, best.power), | |
| "health": best.spec.health, | |
| "sigils": list(best.spec.sigils), | |
| } | |
| estate_card = ( | |
| max(run.deck, key=lambda c: c.power + c.health).id if run.deck else None | |
| ) | |
| node = run.current | |
| enc_id = node.payload if node is not None else "?" | |
| turn = (last_state.turn + 1) if last_state is not None else 0 | |
| legacy_store.record_crash( | |
| self.legacy, | |
| encounter=enc_id, | |
| turn=turn, | |
| deck_ids=[c.id for c in run.deck], | |
| strongest=strongest, | |
| statement=statement, | |
| cycles=run.cycles, | |
| estate_card=estate_card, | |
| password=self.rng.choice(PASSWORDS), | |
| ) | |
| # The Warden annotates the corpse; the note surfaces in the .core | |
| # file a future run will read. | |
| enc_name = self.content.encounters.get(enc_id, {}).get("name", enc_id) | |
| self.legacy["crashes"][-1]["note"] = await self._author( | |
| moments.crash_note(enc_name, turn), | |
| fallback="cause of death: optimism, untreated.", | |
| ) | |
| self.shards.add( | |
| f'their last words on record: "{legacy_store.clean_statement(statement)}"', | |
| {"player", "record"}, | |
| ) | |
| async def _aftermath_win(self, run: RunState, run_plays: Counter) -> None: | |
| """The victory lap: walk the Warden's quarters, then smuggle one | |
| process out. The ledger remembers; the machine adjusts.""" | |
| played = [(cid, n) for cid, n in run_plays.most_common() if cid != "bit"] | |
| most_played = next((cid for cid, _ in played if cid in self.content.pool), None) | |
| diary_password = self.rng.choice(PASSWORDS) | |
| legacy_store.record_win( | |
| self.legacy, most_played=most_played, diary_password=diary_password | |
| ) | |
| # Tonight's diary entry is about THIS run — written before the | |
| # quarters are built, so the player reads it minutes after earning it. | |
| entry = await self._author( | |
| moments.diary_entry(run.cycles), | |
| fallback="a drifter escaped tonight. the audit will correct it.", | |
| ) | |
| diary = self.legacy.setdefault("diary", []) | |
| diary.append(legacy_store.clean_statement(entry)) | |
| self.legacy["diary"] = diary[-5:] | |
| vfs = warden_quarters(self.legacy, diary_password) | |
| deck_names = [c.name for c in run.deck] | |
| await self.push_screen_wait( | |
| ShellScreen(Shell(vfs), [], deck_names, motd=ROOT_MOTD, | |
| presence=self.presence) | |
| ) | |
| unique = list({c.id: c for c in run.deck}.values())[:6] | |
| pick = await self.push_screen_wait(CardChoiceScreen( | |
| unique, | |
| progress=f"cycles {run.cycles}", | |
| line="One process leaves with you. It will be missed. It will be looked for.", | |
| )) | |
| self.legacy["contraband"] = pick.id | |
| # _lobby decides what's next: the menu again, or exit in test mode. | |
| MIRROR_CONSENT = """\ | |
| SCRYPT can mirror a shallow snapshot of your real home directory into its | |
| sandbox, so what the Warden threatens feels like yours. The snapshot is: | |
| - read-only against your machine (the game NEVER touches real files) | |
| - capped (depth 3, 250 entries, text files under 8KB) | |
| - filtered (no dotfiles, keys, tokens, .ssh, .config, or binaries' contents) | |
| Everything 'deleted' in the game is a copy living in memory. | |
| Mirror your home directory? [y/N] """ | |
| def main() -> None: | |
| import argparse | |
| parser = argparse.ArgumentParser(prog="scrypt", description="The Warden is waiting.") | |
| parser.add_argument("--mirror", action="store_true", | |
| help="offer to mirror your real home dir into the sandbox (asks first)") | |
| parser.add_argument("--seed", type=int, default=None, help="seed the run") | |
| args = parser.parse_args() | |
| use_mirror = False | |
| if args.mirror: | |
| use_mirror = input(MIRROR_CONSENT).strip().lower() in ("y", "yes") | |
| ScryptApp(seed=args.seed, use_mirror=use_mirror).run() | |
| if __name__ == "__main__": | |
| main() | |