File size: 8,363 Bytes
9fca766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d2b52f
 
 
 
 
 
 
 
 
9fca766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""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()