"""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"))