Scrypt / scrypt /ui /board.py
IMJONEZZ's picture
SCRYPT: initial commit — game, sandbox, Warden, Space web layer
9fca766
Raw
History Blame Contribute Delete
23 kB
"""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))