Scrypt / scrypt /ui /altar.py
IMJONEZZ's picture
SCRYPT: initial commit — game, sandbox, Warden, Space web layer
9fca766
Raw
History Blame Contribute Delete
8.41 kB
"""The altar: the Warden offers power for your most-used command.
If you walked in carrying contraband (a card smuggled out of a won run),
the Warden offers to take THAT instead — it knows. Refusal has a price
too: the Warden deletes something personal from the sandbox. (Duplicates.
Always duplicates. The player doesn't know that.)
Dismisses with {"card": offered_id | None, "paid": "command" | "contraband" | None}.
"""
from __future__ import annotations
import random
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 Card, CardInstance
from scrypt.sandbox.shell import Shell
from scrypt.sandbox.vfs import DirNode
from scrypt.ui import fx as fxmod
from scrypt.ui import palette as pal
from scrypt.ui.render import card_info, card_panel
from scrypt.warden import moments
from scrypt.warden.presence import REACTION
EPITAPHS = {
"grep": "no more needles. enjoy the haystacks.",
"ls": "you will walk your directories blind.",
"cat": "the files keep their secrets now.",
"find": "lost things stay lost.",
"tree": "the forest, missed for the trees.",
"cd": "you live here now.",
}
class AltarScreen(Screen):
CSS = """
#altar-dialogue { height: 4; padding: 1 2 0 2; text-style: italic; }
#altar-offer { height: 12; content-align: center middle; }
#altar-info { height: 3; content-align: center top; }
#altar-prompt { height: 1; dock: bottom; background: $panel; content-align: center middle; }
"""
def __init__(
self,
shell: Shell,
offered: Card,
rng: random.Random,
contraband: Card | None = None,
presence=None,
):
super().__init__()
self.shell = shell
self.offered = offered
self.rng = rng
self.contraband = contraband
self.presence = presence
self.demand = shell.most_used() or "echo"
self.resolved = False
def compose(self) -> ComposeResult:
with Vertical():
yield Static(id="altar-dialogue")
yield Static(id="altar-offer")
yield Static(id="altar-info")
yield Static(id="altar-prompt")
def on_mount(self) -> None:
opener = (
f'"You lean on `{self.demand}`. I have counted. {self.shell.usage[self.demand]} times.\n'
f' Give it to me — for good — and take this."'
)
keys = f"[a] surrender `{self.demand}` [r] refuse"
if self.contraband is not None:
opener = (
f'"You brought {self.contraband.name} in here. Stolen property. I want it back —\n'
f' or, if you are attached, `{self.demand}` will do. I have counted your uses."'
)
keys = f"[t] hand over {self.contraband.name} [a] surrender `{self.demand}` [r] refuse"
self.query_one("#altar-dialogue", Static).update(Text(opener, style="italic"))
self.query_one("#altar-offer", Static).update(
card_panel(CardInstance(spec=self.offered), show_cost=True, selected=True, art=True)
)
self.query_one("#altar-info", Static).update(card_info(CardInstance(spec=self.offered)))
self.query_one("#altar-prompt", Static).update(Text(keys))
if self.presence is not None:
self.presence.attach(self._on_warden_line)
# The player is deciding: cook every ending while they hesitate.
self.presence.prefetch(
"altar_accept", moments.altar_accept(self.demand, self.offered.name)
)
self.presence.prefetch("altar_refuse", moments.altar_refuse(self.demand))
if self.contraband is not None:
self.presence.prefetch(
"altar_contraband",
moments.altar_contraband(self.contraband.name, self.demand),
)
def on_unmount(self) -> None:
if self.presence is not None:
self.presence.detach(self._on_warden_line)
def _on_warden_line(self, line: str, priority: int) -> None:
self.query_one("#altar-dialogue", Static).update(Text(f'"{line}"', style="italic"))
def _speak(self, moment: str, key: str) -> None:
if self.presence is not None:
# fallback="" — the scripted farewell is already on screen.
self.presence.submit(moment, key=key, priority=REACTION)
def _personal_target(self) -> str | None:
"""Pick a personal directory worth grieving over."""
home = self.shell.vfs.resolve("/home/drifter")
if not isinstance(home, DirNode):
return None
dirs = [
name for name, node in home.children.items()
if isinstance(node, DirNode) and not name.startswith(".") and node.children
]
return self.rng.choice(sorted(dirs)) if dirs else None
def on_key(self, event: events.Key) -> None:
if self.resolved:
self.dismiss(self._outcome)
return
if event.key == "a":
self.shell.revoke(self.demand, EPITAPHS.get(self.demand, ""))
self._outcome = {"card": self.offered.id, "paid": "command"}
self._farewell(
f'"Done. `{self.demand}` is mine now. Try not to think about '
f'how often your fingers will reach for it."',
detail=f"`{self.demand}` revoked for the rest of the run",
)
self._speak(moments.altar_accept(self.demand, self.offered.name), "altar_accept")
elif event.key == "t" and self.contraband is not None:
self._outcome = {"card": self.offered.id, "paid": "contraband"}
self._farewell(
f'"Recovered. {self.contraband.name} goes back in evidence. '
f'Keep your `{self.demand}` — and take the offer. We are square."',
detail=f"{self.contraband.name} removed from your deck",
)
self._speak(
moments.altar_contraband(self.contraband.name, self.demand),
"altar_contraband",
)
elif event.key == "r":
self._outcome = {"card": None, "paid": None}
target = self._personal_target()
if target:
node = self.shell.vfs.resolve(f"/home/drifter/{target}")
doomed = [
path for path, _ in self.shell.vfs.iter_files(node, prefix=f"~/{target}")
]
count = self.shell.vfs.remove(f"/home/drifter/{target}", recursive=True)
self._farewell(
'"Refusal has a price. Refuse me again sometime."',
detail=f"rm -rf ~/{target}{count} of your files, unlinked",
)
if not fxmod.reduced_motion():
self.run_worker(self._unlink_scroll(doomed))
else:
self._farewell('"Refusal noted. There is so little left of yours to take."')
self._speak(moments.altar_refuse(self.demand), "altar_refuse")
async def _unlink_scroll(self, paths: list[str]) -> None:
"""Watch them go. Each name appears already struck through."""
import asyncio
offer = self.query_one("#altar-offer", Static)
shown: list[str] = []
for path in paths[:8]:
shown.append(path)
t = Text(justify="center")
for s in shown:
t.append(s + "\n", style=f"{pal.DANGER} strike")
if len(paths) > 8 and len(shown) == 8:
t.append(f"…and {len(paths) - 8} more", style=f"{pal.DANGER} dim")
offer.update(t)
await asyncio.sleep(0.13)
def _farewell(self, line: str, detail: str = "") -> None:
"""The scripted goodbye, with the deal's mechanical terms kept
deterministic and visible even if a live line replaces the dialogue."""
self.resolved = True
self.query_one("#altar-dialogue", Static).update(Text(line, style="italic"))
self.query_one("#altar-offer", Static).update(Text(""))
self.query_one("#altar-info", Static).update(Text(detail, style=pal.MUTED, justify="center"))
self.query_one("#altar-prompt", Static).update(Text("[any key] step away"))