"""The title screen: pick a deck, learn the rules, or feed the machine. MenuScreen dismisses with a starter-deck id, or None to quit. """ from __future__ import annotations from rich.columns import Columns from rich.text import Text from textual import events from textual.app import ComposeResult from textual.containers import Vertical from textual.screen import Screen from textual.widgets import Static from scrypt.engine.cards import CardInstance from scrypt.ui.render import card_panel TITLE_ART = r""" ███████ ██████ ██████ ██ ██ ██████ ████████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███████ ██ ██████ ████ ██████ ██ ██ ██ ██ ██ ██ ██ ██ ███████ ██████ ██ ██ ██ ██ ██ """ HOW_TO_PLAY = """\ THE TABLE Tip the balance 5 points your way to win a fight. Lose 5 and you are reaped. Your creatures hit the lane across from them — or the Warden's face when that lane is empty. DRAWING Each turn, [d] draws from your deck or [s] takes a bit. A bit is a free 0/1 process whose entire purpose is to be killed. When in doubt, take the bit — sacrifices are how anything gets summoned. Your two piles stand at the right of the table; when one says EMPTY, it means it. THE PATH The status line tracks your run: ⚔ fight $ shell † altar + draft ⑂ a fork in the road. The bracketed glyph is where you stand. Win the last fight and you are out. PAYING COSTS ♦ cards cost mem: playing one asks you to mark your own board processes to kill (most are worth 1♦; ≡priv is worth 3♦). ⊙ cards cost core dumps — you bank one each time a process of yours dies. READING CARDS Select any card in your hand (or at a draft, or on the altar) and its exact rules appear beneath it — what it costs, what every sigil does. [tab] walks the same magnifier across the Warden's cards. [g] opens a glossary of every sigil at once. In a fight, [?] reopens this page. READING THE BELL The thin rows around the scale preview exactly what ringing the bell will do: ▲N hits the Warden's face, ▼N hits yours, ⚔N strikes a card, ☠ marks a death, and ↓ shows which queued cards drop onto the board. If a bell would end the fight either way, the table says so. BETWEEN FIGHTS You get a shell. Explore it. Some files are worth reading, some are worth deleting, and one is a schedule you really should do something about. The Warden will offer you power at its altar. The price is always a command you love. THE WARDEN It watches how you play. It tilts the game when you are comfortable. It is not cheating. It owns the machine; you are the anomaly. """ class MenuScreen(Screen): CSS = """ #title { height: 8; content-align: center middle; color: $text; } #subtitle { height: 3; content-align: center middle; } #decks { height: 12; content-align: center middle; } #deck-blurb { height: 2; content-align: center middle; } #menu-prompt { height: 1; dock: bottom; background: $panel; content-align: center middle; } """ def __init__( self, starter_decks: dict[str, dict], backend_mode: str, backend_error: str | None = None, waking: bool = False, carvable: bool = False, ): super().__init__() self.decks = starter_decks self.deck_ids = list(starter_decks) self.selected = 0 self.backend_mode = backend_mode self.backend_error = backend_error self.waking = waking self.carvable = carvable def compose(self) -> ComposeResult: with Vertical(): yield Static(id="title") yield Static(id="subtitle") yield Static(id="decks") yield Static(id="deck-blurb") yield Static(id="menu-prompt") def on_mount(self) -> None: from scrypt.ui import palette as pal self.query_one("#title", Static).update(Text(TITLE_ART, style=f"bold {pal.JADE_GLOW}")) self._refresh_subtitle() self._refresh() from scrypt.ui import fx as fxmod if not fxmod.reduced_motion(): self.set_interval(0.7, self._flicker) def _flicker(self) -> None: """A bad sign in good lighting: the logo loses a few cells.""" import random from scrypt.ui import palette as pal t = Text(TITLE_ART, style=f"bold {pal.JADE_GLOW}") cells = [i for i, ch in enumerate(TITLE_ART) if ch == "█"] for i in random.sample(cells, k=min(4, len(cells))): t.stylize(f"dim {pal.GREEN}", i, i + 1) self.query_one("#title", Static).update(t) def set_brain( self, mode: str, error: str | None, waking: bool, carvable: bool = False ) -> None: self.backend_mode = mode self.backend_error = error self.waking = waking self.carvable = carvable if self.is_mounted: self._refresh_subtitle() self._refresh() def _refresh_subtitle(self) -> None: if self.waking: brain = "the Warden stirs… (loading the model)" else: brain = { "local": "the Warden is awake (local model)", "api": "the Warden is awake (api)", "scripted": "the Warden talks in its sleep (scripted lines)", }[self.backend_mode] # On the hosted Space the model runs in-process and the game reaches # it over a loopback API — that's the real finetuned Warden, not a # remote service, so don't make it read like a third-party "api". if self.backend_mode == "api": import os base = os.environ.get("SCRYPT_API_BASE", "") if "127.0.0.1" in base or "localhost" in base: brain = "the Warden is awake (running on this machine)" if self.backend_error: reason = self.backend_error.splitlines()[0][:90] brain += f"\nwhy: {reason}" from scrypt.ui import palette as pal self.query_one("#subtitle", Static).update( Text(f"a deck-builder escape room\n{brain}", style=pal.MUTED, justify="center") ) def _refresh(self) -> None: deck_id = self.deck_ids[self.selected] deck = self.decks[deck_id] panels = [card_panel(CardInstance(spec=c), show_cost=True, art=True) for c in deck["cards"][:3]] self.query_one("#decks", Static).update(Columns(panels, padding=(0, 1))) self.query_one("#deck-blurb", Static).update( Text( f"deck: {deck['name']} ({len(deck['cards'])} cards)\n{deck['description']}", style="yellow3", justify="center", ) ) carve = " [c] carve the totem (get the local model)" if self.carvable else "" self.query_one("#menu-prompt", Static).update( Text(f"[enter] begin [←/→] choose deck [h] how to play{carve} [q] quit") ) def on_key(self, event: events.Key) -> None: if event.key == "enter": self.dismiss(self.deck_ids[self.selected]) elif event.key == "left": self.selected = (self.selected - 1) % len(self.deck_ids) self._refresh() elif event.key == "right": self.selected = (self.selected + 1) % len(self.deck_ids) self._refresh() elif event.key == "h": self.app.push_screen(HowToPlayScreen()) elif event.key == "c" and self.carvable: self.dismiss("::carve::") elif event.key == "q": self.dismiss(None) class HowToPlayScreen(Screen): CSS = """ #howto { height: 1fr; padding: 1 4; overflow-y: auto; } #howto-prompt { height: 1; dock: bottom; background: $panel; content-align: center middle; } """ def compose(self) -> ComposeResult: yield Static(HOW_TO_PLAY, id="howto") yield Static(Text("[any key] back"), id="howto-prompt") def on_key(self, event: events.Key) -> None: self.app.pop_screen()