Spaces:
Running on Zero
Running on Zero
| """Rich renderables for the board: card frames, lanes, the scale. | |
| Pure functions from engine state to renderables; no Textual imports so | |
| these can be golden-tested without an app. | |
| """ | |
| from __future__ import annotations | |
| import random as _random | |
| from rich import box | |
| from rich.align import Align | |
| from rich.columns import Columns | |
| from rich.console import Group, RenderableType | |
| from rich.panel import Panel | |
| from rich.table import Table | |
| from rich.text import Text | |
| from scrypt.engine.cards import CardInstance, CostType | |
| from scrypt.engine.combat import LANES, CombatState, Phase | |
| from scrypt.engine.run import Node, NodeKind | |
| from scrypt.ui import palette as pal | |
| from scrypt.ui.fx import NOISE_CHARS, BoardFX | |
| CARD_W = 16 | |
| SIGIL_GLYPHS = { | |
| "tunneling": "↑tun", | |
| "packet_filter": "#filt", | |
| "forked": "⑂fork", | |
| "null_pointer": "☠null", | |
| "honeypot": "✱trap", | |
| "privileged": "≡priv", | |
| "auto_restart": "∞rst", | |
| "scavenger_loop": "⛏scav", | |
| "self_replicating": "²repl", | |
| } | |
| # Exact rules text, shown for the selected card. Every sigil the engine | |
| # implements must be explained here (test-enforced) — no mystery mechanics. | |
| SIGIL_EXPLAIN = { | |
| "tunneling": "attacks the Warden directly, flying over the lane across — blocked only by #filt", | |
| "packet_filter": "blocks ↑tun attackers: they must hit this card instead of flying over it", | |
| "forked": "attacks the two lanes BESIDE the one across, not the card directly opposite", | |
| "null_pointer": "any damage it deals kills the target outright, no matter its ♥", | |
| "honeypot": "anything that attacks this card takes 1 damage back", | |
| "privileged": "worth 3♦ when sacrificed, instead of the usual 1♦", | |
| "auto_restart": "survives being sacrificed: pays the cost and stays on the board", | |
| "scavenger_loop": "banks you 1⊙ at the end of each of your turns", | |
| "self_replicating": "when played, a copy of it is added to your hand", | |
| } | |
| def cost_explain(card: CardInstance) -> str: | |
| cost = card.spec.cost | |
| if cost.type is CostType.FREE: | |
| return "free: costs nothing to play" | |
| if cost.type is CostType.MEM: | |
| return ( | |
| f"costs {'♦' * cost.amount}: mark your own processes to kill, " | |
| f"totaling {cost.amount}♦ (most are 1♦, ≡priv is 3♦)" | |
| ) | |
| return f"costs {cost.amount}⊙: core dumps, banked each time a process of yours dies" | |
| def card_info(card: CardInstance, *, foe: bool = False) -> Text: | |
| """What the selected card does, exactly — stats, cost, every sigil.""" | |
| t = Text(justify="center") | |
| if foe: | |
| t.append("the warden's ", style=pal.WARDEN) | |
| t.append(f"{card.name}: ", style=f"bold {pal.WARDEN}" if foe else "bold") | |
| t.append(f"hits for {card.power}⚔, dies after {card.health}♥ damage", style=pal.MUTED) | |
| if not foe: | |
| t.append(" · ", style=pal.MUTED) | |
| t.append(cost_explain(card), style=pal.MUTED) | |
| for sigil in card.sigils: | |
| t.append("\n") | |
| t.append(f"{SIGIL_GLYPHS.get(sigil, sigil)} ", style=pal.TREASURE) | |
| t.append(SIGIL_EXPLAIN.get(sigil, "(unwritten sigil)"), style=pal.MUTED) | |
| return t | |
| def card_panel( | |
| card: CardInstance | None, | |
| *, | |
| show_cost: bool = False, | |
| dim: bool = False, | |
| selected: bool = False, | |
| art: bool = False, | |
| ) -> RenderableType: | |
| # borders + art lines + cost line + sigil line + stats line | |
| height = 2 + (3 if art else 1) + (1 if show_cost else 0) + 2 | |
| if card is None: | |
| return Panel(Text("\n", justify="center"), width=CARD_W, height=height, style=pal.GHOST) | |
| body = Text(justify="center") | |
| sigils = " ".join(SIGIL_GLYPHS.get(s, s) for s in card.sigils) | |
| if show_cost: | |
| cost = card.spec.cost | |
| cost_str = { | |
| CostType.FREE: "free", | |
| CostType.MEM: "♦" * cost.amount, | |
| CostType.DUMPS: f"{cost.amount}⊙", | |
| }[cost.type] | |
| body.append(f"{cost_str}\n", style=pal.DANGER if cost.type is CostType.MEM else pal.MUTED) | |
| if art: | |
| raw = card.spec.art.splitlines() if card.spec.art else [] | |
| indent = min((len(l) - len(l.lstrip(" ")) for l in raw if l.strip()), default=0) | |
| # Equal-length lines keep their relative alignment under center-justify. | |
| width = max((len(l) - indent for l in raw), default=0) | |
| lines = [l[indent:].ljust(width) for l in raw][:3] | |
| lines += [" " * width] * (3 - len(lines)) | |
| body.append("\n".join(lines) + "\n", style=pal.PLAYER if not dim else pal.GHOST) | |
| body.append(sigils + "\n" if sigils else "\n", style=pal.TREASURE) | |
| body.append(f"{card.power}⚔ {card.health}♥", style="bold") | |
| border = pal.WARDEN if selected else (pal.GHOST if dim else pal.BORDER_BRIGHT) | |
| title = card.name[: CARD_W - 4] | |
| return Panel(body, title=title, width=CARD_W, height=height, border_style=border, style="dim" if dim else "") | |
| def dissolve_panel(intensity: int, height: int = 7) -> RenderableType: | |
| """A dying card: art collapses into static, then nothing.""" | |
| rng = _random.Random(intensity) # stable within a frame, varies across | |
| density = max(0, 2 - intensity) | |
| body = Text(justify="center") | |
| for _ in range(height - 2): | |
| line = "".join( | |
| rng.choice(NOISE_CHARS[: 3 + density]) if rng.random() < 0.5 + 0.2 * density else " " | |
| for _ in range(CARD_W - 4) | |
| ) | |
| body.append(line + "\n", style=pal.BRIGHT_BLACK) | |
| return Panel(body, width=CARD_W, height=height, border_style=pal.RED_DIM, style="dim") | |
| def row_panels( | |
| row: list[CardInstance | None], | |
| *, | |
| dim: bool = False, | |
| marked: set[int] = frozenset(), | |
| flash: set[int] = frozenset(), | |
| dissolve: dict[int, int] | None = None, | |
| art: bool = False, | |
| ) -> RenderableType: | |
| grid = Table.grid(padding=(0, 1)) | |
| cells = [] | |
| for i, card in enumerate(row): | |
| if dissolve and i in dissolve: | |
| cells.append(dissolve_panel(dissolve[i], height=7 if art else 5)) | |
| elif i in flash: | |
| cells.append(card_panel(card, dim=False, selected=True, art=art)) | |
| else: | |
| cells.append(card_panel(card, dim=dim, selected=(i in marked), art=art)) | |
| grid.add_row(*cells) | |
| return grid | |
| def preview_marks(events) -> tuple[dict, dict, dict, str | None]: | |
| """Digest a preview_bell() event log into per-lane markers. | |
| Returns (advancing, outgoing, incoming, verdict): queue lanes that will | |
| drop in, what the player's attacks land per lane, what the foe's land, | |
| and the fight-ending result if the bell would decide it. | |
| """ | |
| adv: dict[int, Text] = {} | |
| out: dict[int, Text] = {} | |
| inc: dict[int, Text] = {} | |
| verdict: str | None = None | |
| def add(cells: dict[int, Text], lane: int, piece: str, style: str) -> None: | |
| cell = cells.setdefault(lane, Text()) | |
| if cell.plain: | |
| cell.append(" ") | |
| cell.append(piece, style=style) | |
| for e in events: | |
| d = e.data | |
| if e.kind == "strike": | |
| mine = bool(d.get("player")) | |
| add(out if mine else inc, d["lane"], | |
| f"⚔{d['amount']}", pal.TREASURE if mine else pal.DANGER) | |
| elif e.kind == "face_damage": | |
| if d.get("player"): | |
| add(out, d["lane"], f"▲{d['amount']}", f"bold {pal.JADE_GLOW}") | |
| else: | |
| add(inc, d["lane"], f"▼{d['amount']}", f"bold {pal.DANGER}") | |
| elif e.kind == "died": | |
| theirs_mine = bool(d.get("player")) | |
| add(inc if theirs_mine else out, d["lane"], | |
| "☠", f"bold {pal.DANGER}" if theirs_mine else pal.MUTED) | |
| elif e.kind == "advanced": | |
| adv[d["lane"]] = Text("↓", style=f"bold {pal.DANGER}") | |
| elif e.kind == "combat_over": | |
| verdict = d["result"] | |
| return adv, out, inc, verdict | |
| def marker_row(cells: dict[int, Text]) -> RenderableType: | |
| """One thin line of per-lane glyphs (attack previews, queue arrows). | |
| Cells are padded to lane width so the grid centers identically to the | |
| card rows — anything wider would skew every lane off its column.""" | |
| grid = Table.grid(padding=(0, 1)) | |
| row = [] | |
| for i in range(LANES): | |
| cell = cells.get(i) | |
| if cell is None: | |
| row.append(Text(" " * CARD_W)) | |
| continue | |
| pad = max(0, CARD_W - cell.cell_len) | |
| padded = Text(" " * (pad // 2)) | |
| padded.append_text(cell) | |
| padded.append(" " * (pad - pad // 2)) | |
| row.append(padded) | |
| grid.add_row(*row) | |
| return grid | |
| def float_row(floats: dict[int, tuple[str, str]]) -> RenderableType: | |
| """Damage numbers drifting in the space between the rows.""" | |
| grid = Table.grid(padding=(0, 1)) | |
| grid.add_row(*[ | |
| Text(f"{floats[i][0]:^{CARD_W}}" if i in floats else " " * CARD_W, | |
| style=floats[i][1] if i in floats else "") | |
| for i in range(LANES) | |
| ]) | |
| return grid | |
| def lane_strip(marks: set[int] = frozenset()) -> RenderableType: | |
| """Lane numbers under the player row, mirroring the [1-4] keys.""" | |
| grid = Table.grid(padding=(0, 1)) | |
| grid.add_row(*[ | |
| Text( | |
| f"{f'[{i + 1}]':^{CARD_W}}", | |
| style=f"bold {pal.DANGER}" if i in marks else pal.GHOST, | |
| ) | |
| for i in range(LANES) | |
| ]) | |
| return grid | |
| def scale_meter(scale: int) -> Text: | |
| """The Warden's scale: five fat pips a side, net damage tips it.""" | |
| you = min(max(scale, 0), 5) | |
| foe = min(max(-scale, 0), 5) | |
| you_style = f"bold {pal.JADE_GLOW}" if you >= 4 else pal.PLAYER | |
| foe_style = f"bold {pal.DANGER}" if foe >= 4 else pal.RED_DIM | |
| t = Text(justify="center") | |
| t.append("you ", style=f"bold {pal.PLAYER}") | |
| t.append("██ " * you, style=you_style) | |
| t.append("·· " * (5 - you), style=pal.GHOST) | |
| t.append(" ⚖ ", style="bold") | |
| t.append(" ··" * (5 - foe), style=pal.GHOST) | |
| t.append(" ██" * foe, style=foe_style) | |
| t.append(" foe", style=f"bold {pal.DANGER}") | |
| if scale != 0: | |
| t.append( | |
| f" {scale:+d}", | |
| style=you_style if scale > 0 else foe_style, | |
| ) | |
| else: | |
| t.append(" 0", style=pal.GHOST) | |
| return t | |
| def scoreboard(scale: int, verdict: str | None = None) -> RenderableType: | |
| """The scale as the table's centerpiece, win condition carved into it. | |
| When the bell preview already knows the ending, the carving says so.""" | |
| if verdict == "player_win": | |
| subtitle = Text("⚠ this bell wins it ⚠", style=f"bold {pal.JADE_GLOW}") | |
| border = pal.JADE_GLOW | |
| elif verdict == "player_loss": | |
| subtitle = Text("⚠ this bell would end you ⚠", style=f"bold {pal.DANGER}") | |
| border = pal.DANGER | |
| else: | |
| subtitle = Text("+5 you escape ── −5 you are reaped", style=f"{pal.MUTED} italic") | |
| border = pal.BORDER_BRIGHT | |
| return Panel( | |
| scale_meter(scale), | |
| box=box.HORIZONTALS, | |
| border_style=border, | |
| height=3, | |
| subtitle=subtitle, | |
| subtitle_align="center", | |
| ) | |
| TRACK_GLYPHS = { | |
| NodeKind.BATTLE: "⚔", | |
| NodeKind.SHELL: "$", | |
| NodeKind.ALTAR: "†", | |
| NodeKind.CARD_CHOICE: "+", | |
| NodeKind.FORK: "⑂", | |
| } | |
| def run_track(nodes: list[Node], position: int) -> Text: | |
| """Where you are in the run: ⚔ fight $ shell † altar + draft ⑂ fork. | |
| The past is ash, the present burns, the future is dim.""" | |
| t = Text() | |
| t.append("path ", style=pal.MUTED) | |
| for i, node in enumerate(nodes): | |
| glyph = TRACK_GLYPHS[node.kind] | |
| if i < position: | |
| t.append(glyph, style=pal.GHOST) | |
| elif i == position: | |
| t.append(f"[{glyph}]", style=f"bold {pal.WARDEN}") | |
| else: | |
| t.append(glyph, style=pal.FG_DIM) | |
| if i < len(nodes) - 1: | |
| t.append("─", style=pal.GHOST) | |
| t.append(" exit", style=pal.MUTED) | |
| return t | |
| def status_line(state: CombatState) -> Text: | |
| t = Text() | |
| t.append(f"dumps {state.dumps}⊙ ", style=pal.MUTED) | |
| t.append(f"turn {state.turn + 1} ", style=pal.MUTED) | |
| t.append(f"[{state.phase.value}]", style=f"bold {pal.WARDEN}") | |
| return t | |
| def _pile_gauge(remaining: int, total: int, width: int = 10) -> Text: | |
| filled = 0 | |
| if remaining > 0 and total > 0: | |
| filled = max(1, round(width * min(remaining, total) / total)) | |
| t = Text(justify="center") | |
| style = pal.MUTED if remaining > 3 else (pal.TREASURE if remaining else pal.GHOST) | |
| t.append("▮" * filled, style=style) | |
| t.append("▯" * (width - filled), style=pal.GHOST) | |
| return t | |
| def _pile_panel( | |
| title: str, remaining: int, total: int, key: str, drawable: bool | |
| ) -> RenderableType: | |
| body = Text(justify="center") | |
| body.append_text(_pile_gauge(remaining, total)) | |
| body.append("\n") | |
| if remaining == 0: | |
| body.append("EMPTY", style=f"bold {pal.DANGER}") | |
| border = pal.DANGER | |
| else: | |
| body.append(f"{remaining} left ", style=pal.MUTED if remaining > 3 else f"bold {pal.TREASURE}") | |
| body.append(f"[{key}]", style=f"bold {pal.WARDEN}" if drawable else pal.GHOST) | |
| border = pal.BORDER_BRIGHT | |
| return Panel(body, title=title, width=CARD_W, height=4, border_style=border) | |
| def deck_stacks(state: CombatState) -> RenderableType: | |
| """The player's two piles, standing at the table's right hand. | |
| They thin as the fight runs long; an empty pile says so loudly.""" | |
| drawing = state.phase is Phase.DRAW | |
| return Group( | |
| _pile_panel( | |
| "your deck", | |
| len(state._draw_pile), max(1, len(state.main_deck)), | |
| "d", drawing and state.can_draw_main, | |
| ), | |
| _pile_panel( | |
| "bits", | |
| len(state._side_pile), max(1, len(state.side_deck)), | |
| "s", drawing and state.can_draw_side, | |
| ), | |
| ) | |
| def hand_row(state: CombatState, selected: int | None) -> RenderableType: | |
| if not state.hand: | |
| return Text("(empty hand)", style=pal.GHOST, justify="center") | |
| return Align.center( | |
| Columns( | |
| [ | |
| card_panel(card, show_cost=True, selected=(i == selected), art=True) | |
| for i, card in enumerate(state.hand) | |
| ], | |
| padding=(0, 1), | |
| ) | |
| ) | |
| def board( | |
| state: CombatState, | |
| *, | |
| sacrifice_marks: set[int] = frozenset(), | |
| fx: BoardFX | None = None, | |
| inspect: int | None = None, | |
| preview: list | None = None, | |
| ) -> RenderableType: | |
| """The table, centered as one column: the scoreboard stretches to the | |
| full width, so every row must be re-centered onto the same axis or the | |
| card grids drift to the left edge. fx carries one frame of theater; | |
| inspect highlights the foe-row card under the player's magnifier; | |
| preview is a preview_bell() event log rendered as marker rows (the | |
| three thin rows are always present so the table never changes height).""" | |
| fx = fx or BoardFX() | |
| adv, out, inc, verdict = preview_marks(preview or []) | |
| def lanes(side: str, kind: str): | |
| if kind == "flash": | |
| return {l for s, l in fx.flash if s == side} | |
| return {l: n for (s, l), n in fx.dissolve.items() if s == side} | |
| parts = [ | |
| Align.center(row_panels( | |
| state.foe_queue, dim=True, | |
| flash=lanes("queue", "flash"), dissolve=lanes("queue", "d"), | |
| )), | |
| Align.center(marker_row(adv)), | |
| Align.center(row_panels( | |
| state.foe_row, art=True, | |
| marked={inspect} if inspect is not None else frozenset(), | |
| flash=lanes("foe", "flash"), dissolve=lanes("foe", "d"), | |
| )), | |
| # The strip between foe row and scale: theater floats while the | |
| # bell resolves, the player's outgoing preview while they think. | |
| Align.center( | |
| float_row(fx.floats) if fx.floats else marker_row(out) | |
| ), | |
| scoreboard(state.scale if fx.scale is None else fx.scale, verdict), | |
| Align.center(marker_row(inc)), | |
| Align.center(row_panels( | |
| state.player_row, art=True, marked=sacrifice_marks, | |
| flash=lanes("player", "flash"), dissolve=lanes("player", "d"), | |
| )), | |
| Align.center(lane_strip(sacrifice_marks)), | |
| ] | |
| return Group(*parts) | |