"""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))