Scrypt / scrypt /ui /tutorial.py
IMJONEZZ's picture
SCRYPT: initial commit β€” game, sandbox, Warden, Space web layer
9fca766
Raw
History Blame Contribute Delete
6.45 kB
"""Orientation: shown once per installation, the first time a game begins.
Five pages: who the Warden is, the way out, the table, the economy of
death, and the world between fights. Skippable, never repeated (the
`initiated` flag in legacy.json), and everything it teaches stays
reachable afterwards β€” [?] in a fight, [h] on the menu.
"""
from __future__ import annotations
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 Vertical
from textual.screen import Screen
from textual.widgets import Static
TABLE_DIAGRAM = """\
β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”
β”‚queuedβ”‚ β”‚ β”‚ β”‚queuedβ”‚ β”‚ β”‚ arriving next turn
β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”
β”‚ foe β”‚ β”‚ β”‚ β”‚ foe β”‚ β”‚ β”‚ the Warden's row
β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜
───── you β–ˆβ–ˆ Β·Β· Β·Β· Β·Β· Β·Β· βš– Β·Β· Β·Β· Β·Β· Β·Β· Β·Β· foe ─────
β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”
β”‚yours β”‚ β”‚ β”‚ β”‚yours β”‚ β”‚ β”‚ your row
β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜
[1] [2] [3] [4]
"""
PAGES: list[tuple[str, str]] = [
(
"WHERE YOU ARE",
"""\
You are a process. A small one, freshly spawned, inside a machine
that belongs to something called the WARDEN.
The Warden is not a script wearing a villain mask. It is a language
model running on this computer. It watches what you type, remembers
you between runs, rewrites encounters while you explore, and keeps
files on everyone who has died here.
It has noticed you. That is rarely good news.""",
),
(
"THE WAY OUT",
"""\
Objective: escape the machine.
A run is a short gauntlet β€” the path reads left to right:
βš” fight $ shell † altar + draft
Win every fight and the last door opens: you walk out with root.
You have 2 ttys. Lose a fight and one burns β€” you retry the same
fight. Lose both and you are reaped: the run ends, the Warden files
a report on your corpse, and your next run inherits the wreckage.""",
),
(
"THE TABLE",
TABLE_DIAGRAM
+ """
Your processes hit the lane across from them β€” or the Warden's face
when that lane is empty. Face damage tips THE SCALE. Tip it +5 your
way and you win the fight; let it reach βˆ’5 and you are reaped.
[b] rings the bell to end your turn. Then everything attacks.""",
),
(
"THE ECONOMY OF DEATH",
"""\
Each turn you draw: [d] from your deck, or [s] a bit.
A bit is a free 0/1 process whose entire purpose is to die.
♦ cards are paid in blood β€” mark processes you already control and
they are killed to summon it. βŠ™ cards cost core dumps: you bank one
each time a process of yours dies. Death is your economy. Spend it.
Your two piles stand at the right of the table. When a pile says
EMPTY, there is no more drawing from it. Watch them.
Select any card and its exact rules appear under your hand.
[tab] does the same for the Warden's cards. No mystery mechanics.""",
),
(
"BETWEEN FIGHTS",
"""\
After a fight you get a shell. It is small, it is fake, and it is
the most honest part of this machine. `ls`, `cat`, `cd`, `grep`
your way around β€” [tab] completes, [↑] recalls β€” some files pay
cycles, some hide cards, and one is a schedule you really should
do something about.
The altar sells power. The price is always one of YOUR commands β€”
whichever you lean on most. Sold means gone for the rest of the
run, and the Warden notices every time you reach for it anyway.
Hints will follow you through your first fight. [?] reopens the
rules any time. The Warden is listening β€” `say` something if you
must. It will not be kind about it.""",
),
]
class OrientationScreen(Screen):
"""Dismisses with True when the player has seen (or skipped) it all."""
CSS = """
#orient { height: 1fr; align: center middle; }
#orient-page { width: 76; height: auto; }
#orient-prompt { height: 1; dock: bottom; background: $panel; content-align: center middle; }
"""
def __init__(self) -> None:
super().__init__()
self.page = 0
def compose(self) -> ComposeResult:
with Vertical(id="orient"):
yield Static(id="orient-page")
yield Static(id="orient-prompt")
def on_mount(self) -> None:
self._show()
def _dots(self) -> Text:
from scrypt.ui import palette as pal
t = Text()
for i in range(len(PAGES)):
t.append("● " if i <= self.page else "β—‹ ",
style=pal.WARDEN if i == self.page else pal.GHOST)
return t
def _show(self) -> None:
from scrypt.ui import palette as pal
title, body = PAGES[self.page]
self.query_one("#orient-page", Static).update(
Panel(
Text(body, style=pal.FG),
box=box.HEAVY,
border_style=pal.BORDER_BRIGHT,
title=Text(f"βŸͺ orientation β€” {title} ⟫", style=f"bold {pal.WARDEN}"),
subtitle=self._dots(),
subtitle_align="center",
padding=(1, 3),
)
)
last = self.page == len(PAGES) - 1
prompt = (
"[enter] the Warden is waiting"
if last
else "[enter] next [backspace] back [s] skip orientation"
)
self.query_one("#orient-prompt", Static).update(Text(prompt))
def on_key(self, event: events.Key) -> None:
if event.key == "s":
self.dismiss(True)
elif event.key == "backspace" and self.page > 0:
self.page -= 1
self._show()
elif event.key in ("enter", "space", "right"):
if self.page == len(PAGES) - 1:
self.dismiss(True)
else:
self.page += 1
self._show()