Spaces:
Running on Zero
Running on Zero
| """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() | |