Spaces:
Running on Zero
Running on Zero
| """The combat screen: one fight against a scripted encounter. | |
| Keyboard-first, card-table feel: foe queue on top, the balance meter in the | |
| middle, your row and hand below, the Warden muttering above it all. | |
| Dismisses with the combat Result, or None if the player abandons the run. | |
| """ | |
| from __future__ import annotations | |
| import time | |
| from enum import Enum | |
| from rich import box | |
| from rich.panel import Panel | |
| from rich.text import Text | |
| from textual import events | |
| from textual.app import ComposeResult | |
| from textual.containers import Horizontal, Vertical | |
| from textual.screen import Screen | |
| from textual.widgets import Static | |
| import asyncio | |
| from scrypt.engine.cards import CostType, mem_value | |
| from scrypt.engine.combat import ( | |
| LANES, CombatState, IllegalMove, Phase, Result, preview_bell, | |
| ) | |
| from scrypt.ui import fx as fxmod | |
| from scrypt.ui import palette as pal | |
| from scrypt.ui import render | |
| from scrypt.ui.fx import BoardFX | |
| from scrypt.warden import moments | |
| from scrypt.warden.context import combat_digest | |
| from scrypt.warden.presence import AMBIENT, REACTION | |
| class Mode(Enum): | |
| NORMAL = "normal" | |
| SACRIFICE = "sacrifice" | |
| LANE = "lane" | |
| # Scripted Warden lines (the LLM takes this seat in Phase 2). | |
| LINES = { | |
| "start": "Another process wakes in my machine. Show me what you are.", | |
| "sacrificed": "Yes. Feed it.", | |
| "big_hit": "You strike at me? Bold, for something I can kill with a signal.", | |
| "self_replicating": "It multiplies. How unsanitary.", | |
| "player_win": "Hm. The balance tips. Savor it — entropy is on my side.", | |
| "player_loss": "And so you are reaped. SIGKILL. Goodnight.", | |
| } | |
| # Fallback lines when the director intervenes and no voice is wired. | |
| DIRECTOR_LINES = { | |
| "throttle": "Your favorite looks tired. I wonder why that could be.", | |
| "reinforce": "I have added something to the schedule. Do not thank me.", | |
| "withdraw": "That lane bores me. Consider yourself reprieved.", | |
| } | |
| # First-fight tutorial hints, keyed by what the player is looking at. | |
| HINTS = { | |
| "draw": "your two piles stand at the right. bits are free 0/1 fodder born to be sacrificed — low on bodies? take the bit.", | |
| "normal": "the thin rows preview the bell exactly: ⚔/▲/▼ damage, ☠ a death, ↓ a queue card dropping in. [enter] plays, [b] rings.", | |
| "sacrifice": "mark lanes totaling the ♦ cost (most processes = 1♦, ≡priv = 3♦). marked ones die.", | |
| "lane": "summon into any empty lane — including one your sacrifice just emptied.", | |
| } | |
| class BoardScreen(Screen): | |
| CSS = """ | |
| #dialogue { height: 5; padding: 0 2; color: $text; } | |
| #table { height: auto; align-horizontal: center; } | |
| #board { width: auto; height: auto; content-align: center top; } | |
| #decks { width: 18; height: auto; margin: 5 0 0 2; } | |
| #hand { height: 9; content-align: center top; } | |
| #cardinfo { height: 3; content-align: center top; } | |
| #status { height: 1; content-align: center top; } | |
| #hint { height: 1; content-align: center top; color: $text-muted; } | |
| #prompt { height: 1; dock: bottom; background: $panel; content-align: center middle; } | |
| """ | |
| IDLE_AFTER_S = 45.0 | |
| def __init__( | |
| self, | |
| state: CombatState, | |
| intro: str | None = None, | |
| presence=None, | |
| director=None, | |
| tutorial: bool = False, | |
| intro_moment: str | None = None, | |
| track=None, | |
| ): | |
| super().__init__() | |
| self.state = state | |
| self.intro = intro or LINES["start"] | |
| self.intro_moment = intro_moment # live greeting; scripted shows first | |
| self.presence = presence # WardenPresence or None (scripted lines only) | |
| self.director = director # warden.director.Director or None | |
| self.tutorial = tutorial # show contextual hints (first fight) | |
| self.track = track # Text: where this fight sits in the run | |
| self.mode = Mode.NORMAL | |
| self.selected = 0 | |
| self.inspect: int | None = None # foe-row lane under the magnifier | |
| self.marks: set[int] = set() | |
| self._seen_events = 0 | |
| self._speech_target = "" | |
| self._speech_shown = 0 | |
| self._pause_ticks = 0 | |
| self._chars_per_tick = 2 | |
| self._last_key = 0.0 | |
| self._idled = False # one needle about hesitation per fight | |
| self._fx = BoardFX() | |
| self._animating = False | |
| self._skip_anim = False | |
| self._eye_blink = False | |
| self._eye_wide_until = 0.0 | |
| def compose(self) -> ComposeResult: | |
| with Vertical(): | |
| yield Static(id="dialogue") | |
| with Horizontal(id="table"): | |
| yield Static(id="board") | |
| yield Static(id="decks") | |
| yield Static(id="hand") | |
| yield Static(id="cardinfo") | |
| yield Static(id="status") | |
| yield Static(id="hint") | |
| yield Static(id="prompt") | |
| def on_mount(self) -> None: | |
| self.say(self.intro) # the scripted line opens; a live one may replace it | |
| self.set_interval(0.025, self._typewriter_tick) | |
| self._last_key = time.monotonic() | |
| if self.presence is not None: | |
| self.presence.attach(self._on_warden_line) | |
| if self.intro_moment: | |
| # fallback="" — the scripted intro is already on screen. | |
| self.presence.submit(self.intro_moment, key="intro", priority=REACTION) | |
| self.set_interval(5.0, self._check_idle) | |
| if not fxmod.reduced_motion(): | |
| self.set_interval(4.5, self._blink) | |
| self.refresh_all() | |
| def _blink(self) -> None: | |
| self._eye_blink = True | |
| self.set_timer(0.18, self._unblink) | |
| def _unblink(self) -> None: | |
| self._eye_blink = False | |
| def _check_idle(self) -> None: | |
| if self._idled or self.state.phase is Phase.OVER: | |
| return | |
| idle = time.monotonic() - self._last_key | |
| if idle >= self.IDLE_AFTER_S: | |
| self._idled = True | |
| self.presence.submit( | |
| moments.board_idle(int(idle)), | |
| fallback="The scale is patient. I am less so.", | |
| priority=AMBIENT, | |
| digest=combat_digest(self.state), | |
| ) | |
| def on_unmount(self) -> None: | |
| if self.presence is not None: | |
| self.presence.detach(self._on_warden_line) | |
| def _on_warden_line(self, line: str, priority: int) -> None: | |
| # Ambient menace creeps out slowly; reactions snap. | |
| self.say(line, pace=1 if priority == AMBIENT else 2) | |
| # ----------------------------------------------------------- dialogue | |
| def say(self, line: str, pace: int = 2) -> None: | |
| self._speech_target = line | |
| self._speech_shown = 0 | |
| self._pause_ticks = 0 | |
| self._chars_per_tick = pace | |
| def _eye_str(self) -> Text: | |
| wide = ( | |
| time.monotonic() < self._eye_wide_until | |
| or abs(self.state.scale) >= 4 | |
| ) | |
| glyphs = fxmod.eye( | |
| self.selected, max(1, len(self.state.hand)), | |
| blink=self._eye_blink, wide=wide, | |
| ) | |
| return Text(glyphs, style=f"bold {pal.DANGER}" if wide else pal.MUTED) | |
| def _speech_text(self, shown: str, cursor: str) -> Text: | |
| """A leaked reasoning block ('# ...' lines before the final one) | |
| renders as a dim terminal comment; only the spoken line is quoted.""" | |
| nl = self._speech_target.rfind("\n") | |
| comment_len = nl + 1 if nl != -1 else 0 | |
| t = Text() | |
| if comment_len: | |
| t.append(shown[:comment_len], style=pal.GHOST) | |
| if len(shown) >= comment_len: | |
| t.append(f'"{shown[comment_len:]}"{cursor}', style=f"italic {pal.WARDEN}") | |
| else: | |
| t.append(cursor, style=pal.GHOST) | |
| return t | |
| def _typewriter_tick(self) -> None: | |
| if self._pause_ticks > 0: | |
| self._pause_ticks -= 1 | |
| elif self._speech_shown < len(self._speech_target): | |
| start = self._speech_shown | |
| self._speech_shown += self._chars_per_tick | |
| just_revealed = self._speech_target[start: self._speech_shown] | |
| if not fxmod.reduced_motion(): | |
| for ch, hold in fxmod.PUNCT_PAUSE.items(): | |
| if ch in just_revealed: | |
| self._pause_ticks = hold | |
| break | |
| shown = self._speech_target[: self._speech_shown] | |
| cursor = "▌" if self._speech_shown < len(self._speech_target) else "" | |
| self.query_one("#dialogue", Static).update( | |
| Panel( | |
| self._speech_text(shown, cursor), | |
| box=box.HORIZONTALS, | |
| border_style=pal.BORDER_BRIGHT, | |
| title=Text("⟪ the warden ⟫", style=f"bold {pal.WARDEN}"), | |
| title_align="left", | |
| subtitle=self._eye_str(), | |
| subtitle_align="right", | |
| height=5, | |
| ) | |
| ) | |
| # The exact win/loss moments, shared by reaction and prefetch so the | |
| # speculative cache key always matches. | |
| OUTCOME_MOMENTS = { | |
| "player_win": "the player just won the fight", | |
| "player_loss": "the player just lost the fight; you reaped them", | |
| } | |
| def _react_to_events(self) -> None: | |
| new = self.state.events[self._seen_events :] | |
| self._seen_events = len(self.state.events) | |
| line = moment = key = None | |
| for e in new: | |
| if e.kind == "sacrificed": | |
| line = LINES["sacrificed"] | |
| moment = f"the player just sacrificed their {e.data['card']} to pay a summoning cost" | |
| elif e.kind == "self_replicating": | |
| line = LINES["self_replicating"] | |
| moment = f"the player's {e.data['card']} just copied itself into their hand" | |
| elif e.kind == "face_damage" and e.data.get("player") and e.data["amount"] >= 3: | |
| line = LINES["big_hit"] | |
| moment = f"the player just hit you for {e.data['amount']} damage" | |
| elif e.kind == "combat_over": | |
| line = LINES[e.data["result"]] | |
| moment = self.OUTCOME_MOMENTS[e.data["result"]] | |
| key = e.data["result"] | |
| if line: | |
| self._deliver(line, moment, key=key) | |
| def _deliver(self, fallback: str, moment: str | None, key: str | None = None) -> None: | |
| """Live LLM line through the presence queue; scripted otherwise.""" | |
| if self.presence is None or moment is None: | |
| self.say(fallback) | |
| return | |
| self.presence.submit( | |
| moment, | |
| fallback=fallback, | |
| priority=REACTION, | |
| digest=combat_digest(self.state), | |
| key=key, | |
| ) | |
| def _prefetch_outcomes(self) -> None: | |
| """The kill line should land the instant the scale tips: while the | |
| player is thinking near the threshold, pregenerate both endings.""" | |
| if self.presence is None or abs(self.state.scale) < 3: | |
| return | |
| digest = combat_digest(self.state) | |
| which = "player_win" if self.state.scale > 0 else "player_loss" | |
| self.presence.prefetch(which, self.OUTCOME_MOMENTS[which], digest=digest) | |
| async def _let_the_director_look(self) -> None: | |
| intervention = await self.director.consider(self.state) | |
| if intervention is not None and intervention.action in DIRECTOR_LINES: | |
| self._seen_events = len(self.state.events) # taunt covers these | |
| self._deliver(DIRECTOR_LINES[intervention.action], intervention.taunt_moment) | |
| self._eye_wide_until = time.monotonic() + 2.5 # it touched the game | |
| if not fxmod.reduced_motion(): | |
| await self._glitch() | |
| self.refresh_all() | |
| async def _glitch(self) -> None: | |
| """The director moved something: the board shows the seams.""" | |
| board_widget = self.query_one("#board", Static) | |
| for _ in range(3): | |
| board_widget.styles.opacity = 0.55 | |
| await asyncio.sleep(0.06) | |
| board_widget.styles.opacity = 1.0 | |
| await asyncio.sleep(0.05) | |
| # ------------------------------------------------------------ theater | |
| async def _bell_theater(self) -> None: | |
| """Ring the bell, then stage the engine's event log: strikes flash, | |
| damage floats, the scale tips a pip at a time, the dead dissolve. | |
| Any key skips; input is otherwise swallowed until the dust settles.""" | |
| if self._animating or self.state.phase is Phase.OVER: | |
| return | |
| state = self.state | |
| self._animating = True | |
| self._skip_anim = False | |
| self.inspect = None # the board is about to stop holding still | |
| pre_scale = state.scale | |
| before = len(state.events) | |
| state.ring_bell() | |
| self.selected = 0 | |
| try: | |
| await self._play_events(state.events[before:], pre_scale) | |
| finally: | |
| self._fx = BoardFX() | |
| self._animating = False | |
| self._react_to_events() | |
| self._prefetch_outcomes() | |
| self.refresh_all() | |
| if self.director is not None and state.phase is not Phase.OVER: | |
| self.run_worker(self._let_the_director_look()) | |
| async def _frame(self, dt: float) -> None: | |
| self.refresh_all() | |
| if not self._skip_anim: | |
| await asyncio.sleep(dt) | |
| async def _play_events(self, events, pre_scale: int) -> None: | |
| fx = self._fx = BoardFX(scale=pre_scale) | |
| board_widget = self.query_one("#board", Static) | |
| for e in events: | |
| fx.flash.clear() | |
| fx.floats.clear() | |
| if e.kind == "strike": | |
| lane = e.data["lane"] | |
| fx.flash |= {("foe", lane), ("player", lane)} | |
| await self._frame(0.12) | |
| elif e.kind == "damaged": | |
| lane = e.data["lane"] | |
| fx.floats[lane] = (f"-{e.data['amount']}", f"bold {pal.DANGER}") | |
| await self._frame(0.18) | |
| elif e.kind == "face_damage": | |
| player_dealt = e.data.get("player") | |
| amount = e.data["amount"] | |
| fx.scale = e.data.get("scale", fx.scale) | |
| lane = 1 if player_dealt else 2 | |
| arrow = "▲" if player_dealt else "▼" | |
| style = f"bold {pal.JADE_GLOW}" if player_dealt else f"bold {pal.DANGER}" | |
| fx.floats[lane] = (f"{arrow} {amount}", style) | |
| if amount >= 3: # the table takes the hit too | |
| board_widget.styles.offset = (1, 0) | |
| await self._frame(0.07) | |
| board_widget.styles.offset = (-1, 0) | |
| await self._frame(0.07) | |
| board_widget.styles.offset = (0, 0) | |
| await self._frame(0.2) | |
| elif e.kind == "died": | |
| side = "player" if e.data.get("player") else "foe" | |
| lane = e.data["lane"] | |
| for frame_n in range(3): | |
| fx.dissolve[(side, lane)] = frame_n | |
| await self._frame(0.09) | |
| fx.dissolve.pop((side, lane), None) | |
| elif e.kind == "honeypot_recoil": | |
| await self._frame(0.08) | |
| elif e.kind in ("advanced", "queued"): | |
| side = "foe" if e.kind == "advanced" else "queue" | |
| fx.flash.add((side, e.data["lane"])) | |
| await self._frame(0.07) | |
| elif e.kind == "turn": | |
| await self._frame(0.12) | |
| fx.scale = None # hand the meter back to the real state | |
| board_widget.styles.offset = (0, 0) | |
| # ------------------------------------------------------------- render | |
| def refresh_all(self) -> None: | |
| marks = self.marks if self.mode is Mode.SACRIFICE else set() | |
| sel = self.selected if self.state.hand and self.state.phase is Phase.MAIN else None | |
| preview = None if self._animating else preview_bell(self.state) | |
| self.query_one("#board", Static).update( | |
| render.board( | |
| self.state, sacrifice_marks=marks, fx=self._fx, | |
| inspect=self.inspect, preview=preview, | |
| ) | |
| ) | |
| self.query_one("#decks", Static).update(render.deck_stacks(self.state)) | |
| self.query_one("#hand", Static).update(render.hand_row(self.state, sel)) | |
| self.query_one("#cardinfo", Static).update(self._card_info()) | |
| status = render.status_line(self.state) | |
| if self.track is not None: | |
| status.append(" ") | |
| status.append_text(self.track) | |
| self.query_one("#status", Static).update(status) | |
| self.query_one("#hint", Static).update(Text(self._hint(), style="italic dim")) | |
| self.query_one("#prompt", Static).update(Text(self._prompt())) | |
| def _card_info(self) -> Text: | |
| """Exact rules text for whatever the player is looking at.""" | |
| state = self.state | |
| if self.inspect is not None and state.foe_row[self.inspect] is not None: | |
| return render.card_info(state.foe_row[self.inspect], foe=True) | |
| if state.phase is Phase.DRAW: | |
| return Text( | |
| "deck holds your authored cards · a bit is a free 0/1 process born to be sacrificed", | |
| style=pal.MUTED, justify="center", | |
| ) | |
| if state.phase is Phase.MAIN and state.hand: | |
| return render.card_info(state.hand[self.selected]) | |
| return Text("") | |
| def _hint(self) -> str: | |
| if not self.tutorial or self.state.phase is Phase.OVER: | |
| return "" | |
| if self.state.phase is Phase.DRAW: | |
| return HINTS["draw"] | |
| return HINTS[self.mode.value] | |
| def _prompt(self) -> str: | |
| if self.state.phase is Phase.OVER: | |
| won = self.state.result is Result.PLAYER_WIN | |
| cycles = f" +{self.state.overkill_cycles} cycles" if won else "" | |
| return f"the fight is over{cycles} [any key] continue" | |
| if self.state.phase is Phase.DRAW: | |
| opts = [] | |
| if self.state.can_draw_main: | |
| opts.append(f"[d] draw from your deck ({len(self.state._draw_pile)} left)") | |
| if self.state.can_draw_side: | |
| opts.append(f"[s] take a bit ({len(self.state._side_pile)} left)") | |
| return " ".join(opts) | |
| if self.mode is Mode.SACRIFICE: | |
| need = self.state.hand[self.selected].spec.cost.amount | |
| have = sum( | |
| mem_value(self.state.player_row[i]) | |
| for i in self.marks | |
| if self.state.player_row[i] is not None | |
| ) | |
| return f"[1-{LANES}] mark processes to kill ({have}/{need}♦) [enter] kill them [esc] cancel" | |
| if self.mode is Mode.LANE: | |
| return f"[1-{LANES}] choose a lane [esc] cancel" | |
| return ( | |
| "[←/→] card [enter] play it [tab] inspect foe [b] ring the bell" | |
| " [v] deck [g] sigils [?] help [q] abandon" | |
| ) | |
| # -------------------------------------------------------------- input | |
| def on_key(self, event: events.Key) -> None: | |
| self._last_key = time.monotonic() | |
| if self._animating: | |
| self._skip_anim = True # impatience is allowed; chaos is not | |
| return | |
| key = event.key | |
| state = self.state | |
| try: | |
| if state.phase is Phase.OVER: | |
| self.dismiss(state.result) | |
| return | |
| if key == "q": | |
| self.dismiss(None) # abandoned | |
| return | |
| if key == "question_mark": | |
| from scrypt.ui.menu import HowToPlayScreen | |
| self.app.push_screen(HowToPlayScreen()) | |
| return | |
| if key == "g": | |
| from scrypt.ui.glossary import SigilGlossaryScreen | |
| self.app.push_screen(SigilGlossaryScreen()) | |
| return | |
| if key == "tab": | |
| self._cycle_inspect() | |
| self.refresh_all() | |
| return | |
| if state.phase is Phase.DRAW: | |
| if key == "d" and state.can_draw_main: | |
| state.draw("main") | |
| elif key == "s" and state.can_draw_side: | |
| state.draw("side") | |
| elif self.mode is Mode.SACRIFICE: | |
| self._key_sacrifice(key) | |
| elif self.mode is Mode.LANE: | |
| self._key_lane(key) | |
| else: | |
| self._key_normal(key) | |
| except IllegalMove as err: | |
| self.say(f"({err})") | |
| self._react_to_events() | |
| self._prefetch_outcomes() | |
| self.refresh_all() | |
| def _cycle_inspect(self) -> None: | |
| """[tab] walks the magnifier across the foe's row, then puts it down.""" | |
| occupied = [i for i, c in enumerate(self.state.foe_row) if c is not None] | |
| if not occupied: | |
| self.inspect = None | |
| return | |
| later = [i for i in occupied if self.inspect is None or i > self.inspect] | |
| self.inspect = later[0] if later else None | |
| def _key_normal(self, key: str) -> None: | |
| state = self.state | |
| if key == "left" and state.hand: | |
| self.selected = (self.selected - 1) % len(state.hand) | |
| elif key == "right" and state.hand: | |
| self.selected = (self.selected + 1) % len(state.hand) | |
| elif key == "enter" and state.hand: | |
| card = state.hand[self.selected] | |
| if card.spec.cost.type is CostType.MEM: | |
| self.marks = set() | |
| self.mode = Mode.SACRIFICE | |
| else: | |
| self.mode = Mode.LANE | |
| elif key == "v": | |
| from scrypt.ui.deckview import DeckViewScreen | |
| self.app.push_screen(DeckViewScreen(state.main_deck)) | |
| elif key == "b": | |
| if fxmod.reduced_motion(): | |
| state.ring_bell() | |
| self.selected = 0 | |
| self.inspect = None | |
| if self.director is not None and state.phase is not Phase.OVER: | |
| self.run_worker(self._let_the_director_look()) | |
| else: | |
| self.run_worker(self._bell_theater()) | |
| def _key_sacrifice(self, key: str) -> None: | |
| if key == "escape": | |
| self.mode = Mode.NORMAL | |
| elif key in tuple("1234"): | |
| lane = int(key) - 1 | |
| if self.state.player_row[lane] is not None: | |
| self.marks ^= {lane} | |
| elif key == "enter": | |
| self.mode = Mode.LANE | |
| def _key_lane(self, key: str) -> None: | |
| if key == "escape": | |
| self.mode = Mode.NORMAL | |
| return | |
| if key not in tuple("1234"): | |
| return | |
| lane = int(key) - 1 | |
| card = self.state.hand[self.selected] | |
| sacrifices = tuple(sorted(self.marks)) if card.spec.cost.type is CostType.MEM else () | |
| self.state.play(self.selected, lane, sacrifices=sacrifices) | |
| self.mode = Mode.NORMAL | |
| self.marks = set() | |
| self.selected = min(self.selected, max(0, len(self.state.hand) - 1)) | |