Scrypt / scrypt /ui /menu.py
IMJONEZZ's picture
menu: loopback api backend reads 'running on this machine', not generic 'api' (the hosted Space serves the real Warden over localhost)
8d2b52f
Raw
History Blame Contribute Delete
8.36 kB
"""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()