magmacrunch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ """The magmacrunch terminal arcade.
2
+
3
+ A menu of every installed cabinet, and the wiring that seats one on the
4
+ terminal it is already holding.
5
+
6
+ Games are found by enumerating the ``magmacrunch.games`` entry point group -
7
+ see :mod:`texastoast.arcade` for the contract. **Nothing in this package
8
+ imports a game.** Installing one makes it appear here; uninstalling makes it
9
+ vanish; neither needs a release of the arcade.
10
+
11
+ Imports are kept out of this module so that ``import magmacrunch`` costs
12
+ nothing: the launcher's own screens pull in the engine's terminal backend, and
13
+ ``magmacrunch --help`` should not pay for it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = ["__version__"]
@@ -0,0 +1,101 @@
1
+ """``magmacrunch`` - open the arcade.
2
+
3
+ magmacrunch play
4
+ magmacrunch --list print the installed cabinets and exit
5
+
6
+ Discovery happens here, before the terminal is taken, and that placement is
7
+ load-bearing. :func:`texastoast.arcade.discover` reports a game it could not
8
+ load with :func:`warnings.warn`, and a warning written onto a live Textual
9
+ screen corrupts it. Enumerating first puts anything it has to say on an
10
+ ordinary terminal, where it can be read.
11
+
12
+ ``--list`` is the debugging surface for the same thing: it answers "why is my
13
+ game not in the menu" without taking the screen over at all.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+
20
+
21
+ def main() -> None:
22
+ parser = argparse.ArgumentParser(
23
+ prog="magmacrunch",
24
+ description="The magmacrunch terminal arcade.",
25
+ )
26
+ parser.add_argument(
27
+ "--list", action="store_true", dest="list_only",
28
+ help="print the installed cabinets and exit, without opening the arcade",
29
+ )
30
+ args = parser.parse_args()
31
+
32
+ # Imported here, not at module scope, so --help works without the engine
33
+ # or its terminal extra installed.
34
+ from texastoast.arcade import discover
35
+
36
+ games = discover()
37
+
38
+ if args.list_only:
39
+ _print(games)
40
+ return
41
+
42
+ from texastoast.core.tui_host import TuiHost
43
+
44
+ from magmacrunch.app import ARCADE_INFO, ArcadeApp
45
+
46
+ host = TuiHost(title=ARCADE_INFO.title, fps=ARCADE_INFO.fps,
47
+ hold_ms=ARCADE_INFO.hold_ms)
48
+ host.push_scene(ArcadeApp(host, games).root_scene)
49
+ host.run()
50
+
51
+
52
+ def _print(games: list) -> None:
53
+ """What ``--list`` shows: what was found, and what it wants.
54
+
55
+ Titles and blurbs are written by whoever wrote the game, so they can hold
56
+ anything Unicode holds - a suit glyph, a card, an emoji. Redirected to a
57
+ file on a legacy Windows codepage that is a ``UnicodeEncodeError``, and a
58
+ diagnostic that dies on the thing it is diagnosing is worse than useless.
59
+ Degrade the characters instead.
60
+ """
61
+ _degrade_gracefully()
62
+
63
+ if not games:
64
+ print("No cabinets installed.")
65
+ print()
66
+ print(" pip install magmacrunch-george-boole")
67
+ print(" pip install magmacrunch-thld")
68
+ print()
69
+ print("Any package declaring a magmacrunch.games entry point appears")
70
+ print("here. Nothing above this line is a hardcoded list.")
71
+ return
72
+
73
+ print(f"{len(games)} cabinet{'s' if len(games) != 1 else ''} installed:")
74
+ print()
75
+ for game in games:
76
+ info = game.info
77
+ print(f" {info.key}")
78
+ print(f" {info.title}")
79
+ print(f" {info.blurb}")
80
+ print(f" wants {info.min_cols}x{info.min_rows}, "
81
+ f"{info.fps} fps, hold {info.hold_ms} ms")
82
+ print()
83
+
84
+
85
+ def _degrade_gracefully() -> None:
86
+ """Let stdout substitute characters it cannot encode rather than raising."""
87
+ import sys
88
+
89
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
90
+ if reconfigure is None:
91
+ return
92
+ try:
93
+ reconfigure(errors="replace")
94
+ except (ValueError, OSError):
95
+ # Already detached, or not a stream that can be reconfigured. Printing
96
+ # is still worth attempting; this was only ever insurance.
97
+ pass
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
magmacrunch/app.py ADDED
@@ -0,0 +1,100 @@
1
+ """Wiring - what outlives one visit to a cabinet, and how the screen reaches it.
2
+
3
+ Everything that draws lives in :mod:`magmacrunch.scenes`. This holds the host,
4
+ the cabinets that were found, and the one decision the launcher actually makes:
5
+ seating a game.
6
+
7
+ **The terminal is not owned here.** It belongs to a
8
+ :class:`~texastoast.core.tui_host.TuiHost`, which this is handed - the same
9
+ arrangement the games use, for the same reason. The launcher is not a special
10
+ case of the host; it is another thing seated on one, which is why the menu can
11
+ sit underneath a game and come back when the game leaves.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from texastoast.arcade import ArcadeGame, GameInfo
19
+
20
+ from magmacrunch import theme
21
+ from magmacrunch.scenes import CabinetScene
22
+
23
+ #: The launcher, described with the same dataclass it reads from cabinets.
24
+ #:
25
+ #: This is not decoration. ``TuiHost.seat()`` applies a seated game's ``fps``
26
+ #: and ``hold_ms`` to the terminal and nothing puts them back, so the menu has
27
+ #: to retune on the way out of a game - and having its own ``GameInfo`` makes
28
+ #: that ``host.apply(ARCADE_INFO)`` rather than a pair of remembered numbers.
29
+ ARCADE_INFO = GameInfo(
30
+ key="arcade",
31
+ title="magmacrunch arcade",
32
+ blurb="Every cabinet installed on this machine.",
33
+ # The menu changes only when a key is pressed, and edge input is what keeps
34
+ # one arrow press from running the selection down the whole list.
35
+ fps=20,
36
+ hold_ms=0,
37
+ min_cols=theme.MIN_COLS,
38
+ min_rows=theme.MIN_ROWS,
39
+ )
40
+
41
+
42
+ class ArcadeApp:
43
+ """A visit to the arcade, drawing on somebody else's terminal."""
44
+
45
+ #: What this asks of a terminal, in the same shape a cabinet declares it.
46
+ #: The menu reads it back through here when it retunes on the way out of a
47
+ #: game, which is why the scene needs no import from this module.
48
+ info = ARCADE_INFO
49
+
50
+ def __init__(self, host: Any, games: list[ArcadeGame] | None = None):
51
+ self.host = host
52
+ #: Sorted by title, the way :func:`texastoast.arcade.discover` returns
53
+ #: them. Discovery happens before the terminal is taken - see
54
+ #: ``__main__`` - so this is handed in rather than looked up.
55
+ self.games: list[ArcadeGame] = list(games or [])
56
+ #: What went wrong last time somebody chose a cabinet, if anything.
57
+ self.error: str = ""
58
+
59
+ #: The menu. The caller pushes it, so that this class does not decide
60
+ #: what the bottom of somebody else's scene stack is.
61
+ self.root_scene = CabinetScene(self)
62
+
63
+ # -- What the scene reaches for ----------------------------------
64
+
65
+ @property
66
+ def renderer(self):
67
+ return self.host.renderer
68
+
69
+ # -- The one decision --------------------------------------------
70
+
71
+ def play(self, game: ArcadeGame) -> bool:
72
+ """Seat a cabinet. Returns whether it started.
73
+
74
+ ``TuiHost.seat()`` calls ``game.start(host)``, and that is where a
75
+ cabinet's real imports finally happen - its rules, its screens, its
76
+ widgets. It is therefore the first moment a bad install can raise, and
77
+ it raises with the terminal live and a menu underneath.
78
+
79
+ :func:`texastoast.arcade.discover` already refuses to let one broken
80
+ game take down the whole menu. This keeps that promise past the point
81
+ where discovery stops looking: the arcade stays up and says which
82
+ cabinet is out of order.
83
+ """
84
+ self.error = ""
85
+ try:
86
+ self.host.seat(game)
87
+ except Exception as exc: # noqa: BLE001 - one bad cabinet is not fatal
88
+ self.error = f"{game.info.title} would not start: {exc}"
89
+ return False
90
+ return True
91
+
92
+ # -- Introspection, for tests ------------------------------------
93
+
94
+ @property
95
+ def scene(self):
96
+ return self.host.scene
97
+
98
+ @property
99
+ def in_menu(self) -> bool:
100
+ return self.host.scene is self.root_scene
magmacrunch/banner.py ADDED
@@ -0,0 +1,172 @@
1
+ """The title art, and picking the biggest one that fits.
2
+
3
+ ``arcade.css`` sets ``Press Start 2P`` on nearly every string on the arcade
4
+ page. None of that can come across: a terminal program does not choose its
5
+ font. What carries the same look instead is art drawn *out of characters*,
6
+ which reads the same in whatever monospace font the terminal is set to - and
7
+ the web page already does exactly this for its own title, in Courier Prime
8
+ rather than Press Start 2P, for the same reason.
9
+
10
+ A terminal is small and resizable, so one piece of art is not enough. The
11
+ variants below are ordered **best first** and :func:`best_fit` returns the
12
+ first the window can actually hold, down to a single line of spaced capitals
13
+ that fits anything. Best rather than widest, because two variants can differ
14
+ on both axes: :data:`ARCADE` is narrower than :data:`WELCOME` and wants twice
15
+ the rows, and on an ordinary 80x24 terminal it is the one worth showing.
16
+
17
+ That ladder is what keeps the floor low: the banner gives up room before the
18
+ cabinets have to, so the smallest window the arcade draws in is set by one
19
+ card and not by the art.
20
+
21
+ **Adding art:** put it in :data:`VARIANTS` at the rung it deserves and nothing
22
+ else changes. Art is measured, not declared - a variant too big for the window
23
+ is simply never chosen, so a wrong guess costs a rung rather than breaking a
24
+ screen. That is why :data:`FULL` can sit at the top at 119 columns without
25
+ hurting anyone running a normal terminal.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from texastoast.ui import bigtext
31
+
32
+ #: The arcade page's complete title art, from ``website/arcade/index.html``.
33
+ #:
34
+ #: 119x19 - it needs about a 121x34 terminal, which is a maximised window on a
35
+ #: large monitor and nothing smaller. It is here because it costs nothing to
36
+ #: offer and looks like the website when there is room for it.
37
+ FULL = r""" /$$
38
+ | $$
39
+ /$$$$$$/$$$$ /$$$$$$ /$$$$$$ /$$$$$$/$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ /$$ /$$ /$$$$$$$ /$$$$$$$| $$$$$$$
40
+ | $$_ $$_ $$ |____ $$ /$$__ $$| $$_ $$_ $$ |____ $$ /$$_____/ /$$__ $$| $$ | $$| $$__ $$ /$$_____/| $$__ $$
41
+ | $$ \ $$ \ $$ /$$$$$$$| $$ \ $$| $$ \ $$ \ $$ /$$$$$$$| $$ | $$ \__/| $$ | $$| $$ \ $$| $$ | $$ \ $$
42
+ | $$ | $$ | $$ /$$__ $$| $$ | $$| $$ | $$ | $$ /$$__ $$| $$ | $$ | $$ | $$| $$ | $$| $$ | $$ | $$
43
+ | $$ | $$ | $$| $$$$$$$| $$$$$$$| $$ | $$ | $$| $$$$$$$| $$$$$$$| $$ | $$$$$$/| $$ | $$| $$$$$$$| $$ | $$
44
+ |__/ |__/ |__/ \_______/ \____ $$|__/ |__/ |__/ \_______/ \_______/|__/ \______/ |__/ |__/ \_______/|__/ |__/
45
+ /$$ \ $$
46
+ | $$$$$$/
47
+ \______/
48
+ /$$
49
+ | $$
50
+ /$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$
51
+ |____ $$ /$$__ $$ /$$_____/ |____ $$ /$$__ $$ /$$__ $$
52
+ /$$$$$$$| $$ \__/| $$ /$$$$$$$| $$ | $$| $$$$$$$$
53
+ /$$__ $$| $$ | $$ /$$__ $$| $$ | $$| $$_____/
54
+ | $$$$$$$| $$ | $$$$$$$| $$$$$$$| $$$$$$$| $$$$$$$
55
+ \_______/|__/ \_______/ \_______/ \_______/ \_______/ """
56
+
57
+ #: MAGMACRUNCH at 71x5 - **the rung a standard 80x24 terminal gets**, and the
58
+ #: only wordmark that fits one. :data:`FULL` says the same word in 119 columns
59
+ #: and eleven rows, which no ordinary window has; this says it in five.
60
+ CHUNKY = """\
61
+ __
62
+ .--------.---.-.-----.--------.---.-.----.----.--.--.-----.----.| |--.
63
+ | | _ | _ | | _ | __| _| | | | __|| |
64
+ |__|__|__|___._|___ |__|__|__|___._|____|__| |_____|__|__|____||__|__|
65
+ |_____|"""
66
+
67
+ #: The ARCADE half of :data:`FULL` on its own, 60x8. Narrower than
68
+ #: :data:`CHUNKY`, so it is what a window between about 62 and 72 columns gets
69
+ #: - and it is the half of the big art that was always going to survive the
70
+ #: trip, since the MAGMACRUNCH word above it cannot.
71
+ ARCADE = r""" /$$
72
+ | $$
73
+ /$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$
74
+ |____ $$ /$$__ $$ /$$_____/ |____ $$ /$$__ $$ /$$__ $$
75
+ /$$$$$$$| $$ \__/| $$ /$$$$$$$| $$ | $$| $$$$$$$$
76
+ /$$__ $$| $$ | $$ /$$__ $$| $$ | $$| $$_____/
77
+ | $$$$$$$| $$ | $$$$$$$| $$$$$$$| $$$$$$$| $$$$$$$
78
+ \_______/|__/ \_______/ \_______/ \_______/ \_______/"""
79
+
80
+ #: The thin strapline alone, 69x4. Wider than :data:`ARCADE` but a quarter of
81
+ #: the height, which is what makes it the right answer for a short window.
82
+ WELCOME = r""" | | | |
83
+ . . .,---.| ,---.,---.,-.-.,---. |--- ,---. |--- |---.,---.
84
+ | | ||---'| | | || | ||---' | | | | | ||---'
85
+ `-'-'`---'`---'`---'`---'` ' '`---' `---'`---' `---'` '`---'"""
86
+
87
+ #: The name in the engine's block face, 54x3 - the same lettering the two
88
+ #: cabinets set their own titles in, so the family reads as one thing rather
89
+ #: than as three hand-drawn alphabets.
90
+ BLOCK = bigtext.block("MAGMACRUNCH")
91
+
92
+ #: A last piece of art before the plain line: two rows rather than three, and
93
+ #: six columns narrower than :data:`BLOCK`, which is the whole of why it is
94
+ #: still here. Hand-drawn, because a two-row face cannot tell B from D and is
95
+ #: therefore no use as a general alphabet - it only works because this one
96
+ #: word happens not to contain the letters that collide.
97
+ WORDMARK = """█▀▄▀█ ▄▀█ █▀▀ █▀▄▀█ ▄▀█ █▀▀ █▀█ █ █ █▄ █ █▀▀ █ █
98
+ █ ▀ █ █▀█ █▄█ █ ▀ █ █▀█ █▄▄ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"""
99
+
100
+ #: The bottom rung, and the reason the floor is as low as it is. Spaced
101
+ #: capitals are not art, but they fit a 36-column window and still read as a
102
+ #: title rather than a sentence.
103
+ PLAIN = "M A G M A C R U N C H A R C A D E"
104
+
105
+
106
+ def lines(art: str) -> list[str]:
107
+ return art.split("\n")
108
+
109
+
110
+ def size(art: str) -> tuple[int, int]:
111
+ """``(cols, rows)`` the art occupies."""
112
+ rows = lines(art)
113
+ return max((len(line) for line in rows), default=0), len(rows)
114
+
115
+
116
+ def over(top: str, bottom: str) -> str:
117
+ """One block above another, each centred on the wider of the two.
118
+
119
+ Composed rather than written out, so that a stacked variant cannot drift
120
+ from the pieces it is made of. Each block is indented as a whole; centring
121
+ the *lines* would ragged a block apart, since art lines are not all the
122
+ same length before padding.
123
+ """
124
+ width = max(size(top)[0], size(bottom)[0])
125
+
126
+ def block(art: str) -> list[str]:
127
+ indent = " " * ((width - size(art)[0]) // 2)
128
+ return [(indent + line).ljust(width) for line in lines(art)]
129
+
130
+ return "\n".join(block(top) + [" " * width] + block(bottom))
131
+
132
+
133
+ #: MAGMACRUNCH over ARCADE at 71x14: everything :data:`FULL` says, in the
134
+ #: width an ordinary terminal has. It needs a tall window rather than a wide
135
+ #: one, which is the easier of the two to come by.
136
+ STACK = over(CHUNKY, ARCADE)
137
+
138
+ #: The strapline over the ARCADE wordmark, 69x13.
139
+ HERO = over(WELCOME, ARCADE)
140
+
141
+ #: Best first. :func:`best_fit` walks this in order and takes the first that
142
+ #: fits, so the ordering is the editorial judgement and the measuring is not.
143
+ #:
144
+ #: The order is not by width. :data:`ARCADE` is narrower than :data:`CHUNKY`
145
+ #: and twice as tall, and which of them a window can take depends on both.
146
+ VARIANTS: tuple[str, ...] = (
147
+ FULL, STACK, HERO, CHUNKY, ARCADE, WELCOME, BLOCK, WORDMARK, PLAIN,
148
+ )
149
+
150
+
151
+ def best_fit(cols: int, rows: int) -> list[str]:
152
+ """The best variant fitting ``cols`` x ``rows``, as lines to draw.
153
+
154
+ Lines come back padded to the art's own width. Art is a block, and a
155
+ caller centring each line by its own length would ragged the block apart -
156
+ the top line of :data:`WELCOME` is mostly leading space, so it would sit a
157
+ dozen cells right of the line beneath it.
158
+
159
+ Returns an empty list when even :data:`PLAIN` will not fit, which the
160
+ caller should treat as "no banner this frame" rather than an error: a
161
+ window that short has better uses for the row, and the cabinets are the
162
+ part worth keeping.
163
+ """
164
+ for art in VARIANTS:
165
+ w, h = size(art)
166
+ if w <= cols and h <= rows:
167
+ return [line.ljust(w) for line in lines(art)]
168
+ return []
169
+
170
+
171
+ __all__ = ["ARCADE", "BLOCK", "CHUNKY", "FULL", "HERO", "PLAIN", "STACK", "VARIANTS",
172
+ "WELCOME", "WORDMARK", "best_fit", "lines", "over", "size"]
magmacrunch/cards.py ADDED
@@ -0,0 +1,174 @@
1
+ """The cabinet cards, and where they go.
2
+
3
+ ``.game-grid`` on magmacrunch.com is a two-column grid of bordered cards, each
4
+ carrying a title, a description, a ``▶ ENTER`` line in that card's own accent
5
+ colour, and a border that lights up in the same colour under the cursor. This
6
+ is that, in cells.
7
+
8
+ Layout is separated from drawing because layout is the part with arithmetic in
9
+ it: how many columns a window takes, how many rows of cards fit under the
10
+ banner, and which page of cards the selected one is on. All of it is pure and
11
+ none of it needs a terminal, so it is tested directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import textwrap
17
+ from dataclasses import dataclass
18
+
19
+ from magmacrunch import theme
20
+
21
+ # Box drawing for the card border. Light rather than heavy: the border is
22
+ # chrome, and the accent colour is what makes the selected card loud.
23
+ TL, TR, BL, BR, H, V = "┌", "┐", "└", "┘", "─", "│"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Slot:
28
+ """Where one card goes, and which cabinet is in it."""
29
+
30
+ index: int
31
+ x: int
32
+ y: int
33
+
34
+
35
+ def columns(width: int) -> int:
36
+ """How many cards fit across ``width``.
37
+
38
+ Two at most - ``.game-grid`` is ``repeat(2, 1fr)`` until 1100px and a
39
+ terminal is never the wide case.
40
+ """
41
+ usable = width - 2 * theme.MARGIN_X
42
+ if usable >= theme.MAX_COLS * theme.CARD_W + theme.GAP_X:
43
+ return theme.MAX_COLS
44
+ return 1
45
+
46
+
47
+ def rows(height: int) -> int:
48
+ """How many rows of cards fit in ``height`` cells."""
49
+ if height < theme.CARD_H:
50
+ return 0
51
+ return 1 + (height - theme.CARD_H) // (theme.CARD_H + theme.GAP_Y)
52
+
53
+
54
+ def page_start(selected: int, capacity: int) -> int:
55
+ """Index of the first card on the page holding ``selected``.
56
+
57
+ Paged rather than scrolled by one: a grid that slides a row at a time
58
+ makes the cards move under the cursor on almost every keypress, where a
59
+ page turns rarely and lands somewhere predictable.
60
+ """
61
+ if capacity <= 0:
62
+ return 0
63
+ return (selected // capacity) * capacity
64
+
65
+
66
+ def layout(count: int, selected: int, width: int, height: int) -> list[Slot]:
67
+ """Slots for the cards visible in a ``width`` x ``height`` region.
68
+
69
+ The region is what is left after the banner and the chrome have taken
70
+ theirs. Cards that do not fit on the selected card's page are simply not
71
+ in the result - see :func:`page_start`.
72
+ """
73
+ cols = columns(width)
74
+ row_count = rows(height)
75
+ capacity = cols * row_count
76
+ if capacity <= 0 or count <= 0:
77
+ return []
78
+
79
+ first = page_start(selected, capacity)
80
+ shown = min(capacity, count - first)
81
+
82
+ used_rows = -(-shown // cols) # ceil
83
+ grid_w = cols * theme.CARD_W + (cols - 1) * theme.GAP_X
84
+ grid_h = used_rows * theme.CARD_H + (used_rows - 1) * theme.GAP_Y
85
+ origin_x = max(theme.MARGIN_X, (width - grid_w) // 2)
86
+ # Centred vertically as well as horizontally: a part-full last page left
87
+ # hard against the banner leaves a block of dead rows under it, which
88
+ # reads as a screen that failed to finish drawing.
89
+ origin_y = max(0, (height - grid_h) // 2)
90
+
91
+ slots = []
92
+ for offset in range(shown):
93
+ row, col = divmod(offset, cols)
94
+ slots.append(Slot(
95
+ index=first + offset,
96
+ x=origin_x + col * (theme.CARD_W + theme.GAP_X),
97
+ y=origin_y + row * (theme.CARD_H + theme.GAP_Y),
98
+ ))
99
+ return slots
100
+
101
+
102
+ def pages(count: int, width: int, height: int) -> int:
103
+ """How many pages ``count`` cabinets take. One when they all fit."""
104
+ capacity = columns(width) * rows(height)
105
+ if capacity <= 0:
106
+ return 0
107
+ return (count + capacity - 1) // capacity
108
+
109
+
110
+ def _wrap(text: str, width: int, limit: int) -> list[str]:
111
+ """``text`` over at most ``limit`` lines, the last one elided if it runs on."""
112
+ if width <= 0 or limit <= 0:
113
+ return []
114
+ out = textwrap.wrap(text, width) or [""]
115
+ if len(out) <= limit:
116
+ return out
117
+ kept = out[:limit]
118
+ kept[-1] = kept[-1][:max(0, width - 1)].rstrip() + "…"
119
+ return kept
120
+
121
+
122
+ def draw(renderer, slot: Slot, info, *, accent: str, selected: bool,
123
+ enabled: bool, cursor: bool) -> None:
124
+ """One card.
125
+
126
+ ``cursor`` is the blinking underscore after ENTER, which the web page
127
+ shows only under the pointer (``.card-arrow`` is ``opacity: 0`` until
128
+ ``:hover``). Here that is the selected card, blinking on the frame clock.
129
+ """
130
+ x, y, w, h = slot.x, slot.y, theme.CARD_W, theme.CARD_H
131
+ border = accent if selected else theme.CARD_BORDER
132
+ if not enabled:
133
+ border = theme.CARD_BORDER
134
+
135
+ renderer.draw_rect(x, y, w, h, theme.CARD_BG)
136
+ renderer.ui_text(x, y, TL + H * (w - 2) + TR, fill=border)
137
+ renderer.ui_text(x, y + h - 1, BL + H * (w - 2) + BR, fill=border)
138
+ for row in range(1, h - 1):
139
+ renderer.ui_text(x, y + row, V, fill=border)
140
+ renderer.ui_text(x + w - 1, y + row, V, fill=border)
141
+
142
+ left = x + 1 + theme.CARD_PAD
143
+ inner = theme.CARD_INNER
144
+ title_fill = theme.CARD_TITLE if enabled else theme.MUTED
145
+
146
+ line = y + 1
147
+ for text in _wrap(info.title, inner, 2):
148
+ renderer.ui_text(left, line, text, fill=title_fill)
149
+ line += 1
150
+
151
+ line += 1
152
+ if enabled:
153
+ for text in _wrap(info.blurb, inner, 3):
154
+ renderer.ui_text(left, line, text, fill=theme.MUTED)
155
+ line += 1
156
+ else:
157
+ # A greyed card with no explanation is a bug report waiting to happen.
158
+ for text in _wrap(f"needs {info.min_cols}x{info.min_rows}", inner, 2):
159
+ renderer.ui_text(left, line, text, fill=theme.MUTED)
160
+ line += 1
161
+
162
+ play_y = y + h - 2
163
+ if enabled:
164
+ # The cursor belongs to the selected card alone - ``.card-arrow`` is
165
+ # ``opacity: 0`` until ``:hover``, and a grid where every card blinks
166
+ # says nothing about which one Enter would start.
167
+ play = theme.PLAY + ("_" if cursor and selected else "")
168
+ renderer.ui_text(left, play_y, play,
169
+ fill=accent if selected else theme.MUTED)
170
+ else:
171
+ renderer.ui_text(left, play_y, "TOO BIG", fill=theme.CARD_BORDER)
172
+
173
+
174
+ __all__ = ["Slot", "columns", "draw", "layout", "page_start", "pages", "rows"]
magmacrunch/scenes.py ADDED
@@ -0,0 +1,284 @@
1
+ """The arcade floor, as a scene.
2
+
3
+ One screen: the title art, a grid of every cabinet that was found, and a way
4
+ into one. It draws through the engine's ``Renderer``/``UISurface`` protocols
5
+ and does not know what Textual is.
6
+
7
+ The layout follows magmacrunch.com's arcade page - banner, tagline, a blinking
8
+ INSERT COIN, then the card grid - and gives up its parts in that order as the
9
+ window shrinks, so that the cabinets are the last thing to go. See
10
+ :mod:`magmacrunch.banner` and :mod:`magmacrunch.cards`.
11
+
12
+ Modality is the stack, not a flag - the engine's own rule. This sits at the
13
+ bottom; seating a cabinet pushes its scenes on top, and the cabinet leaving
14
+ pops back down to here. Nothing needs an ``in_game`` boolean, and the floor
15
+ cannot be updated while a game is over it because the stack will not call it.
16
+
17
+ The engine's ``Menu`` widget is deliberately not used. It draws a centred list
18
+ of single-line rows, which is the wrong shape for a grid of bordered cards -
19
+ so selection state lives here instead, in :attr:`CabinetScene.selected`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from magmacrunch import banner, cards, theme
25
+
26
+ MENU_HELP = "↑↓←→ CHOOSE ENTER PLAY Q QUIT"
27
+
28
+ EMPTY_FLOOR = (
29
+ "NO CABINETS INSTALLED",
30
+ "",
31
+ "pip install magmacrunch-george-boole",
32
+ "pip install magmacrunch-thld",
33
+ "",
34
+ "Any package declaring a magmacrunch.games entry point",
35
+ "appears here - no release of the arcade needed.",
36
+ )
37
+
38
+
39
+ def _fit(text: str, width: int) -> str:
40
+ """Trim to ``width``, marking the cut so a clipped line reads as clipped."""
41
+ if width <= 1 or len(text) <= width:
42
+ return text
43
+ return text[:width - 1] + "…"
44
+
45
+
46
+ def _too_small(renderer, cols: int, rows: int) -> bool:
47
+ """Say so rather than drawing a clipped screen.
48
+
49
+ Two short lines rather than one long one, because the message is being
50
+ shown precisely when the terminal is narrow: a single line stating both
51
+ sizes runs past 40 columns and gets cut mid-number, which reads as a bug in
52
+ the thing reporting the problem.
53
+ """
54
+ if renderer.width >= cols and renderer.height >= rows:
55
+ return False
56
+ renderer.ui_text(1, 1, "TERMINAL TOO SMALL", fill=theme.ERROR)
57
+ renderer.ui_text(
58
+ 1, 2,
59
+ f"need {cols}x{rows}, have {renderer.width}x{renderer.height}",
60
+ fill=theme.MUTED,
61
+ )
62
+ return True
63
+
64
+
65
+ class CabinetScene:
66
+ """The grid of installed games.
67
+
68
+ ``render_below`` is deliberately *not* set: a cabinet seated on top covers
69
+ this completely, so drawing underneath it would be wasted work.
70
+ """
71
+
72
+ def __init__(self, app):
73
+ self.app = app
74
+ #: Which cabinet the cursor is on. Kept here rather than in a widget
75
+ #: because the grid is drawn here - see the module docstring.
76
+ self.selected = 0
77
+ #: Seconds since the scene started, for the blinking cursor and coin.
78
+ #: A frame clock rather than a wall clock so the blink is the engine's
79
+ #: business and stays put when the loop is stopped in a test.
80
+ self.elapsed = 0.0
81
+
82
+ # -- The grid ----------------------------------------------------
83
+
84
+ @property
85
+ def highlighted(self):
86
+ if not self.app.games:
87
+ return None
88
+ return self.app.games[self.selected]
89
+
90
+ def _enabled(self, game) -> bool:
91
+ """Whether this window is big enough to seat ``game``.
92
+
93
+ ``GameInfo.fits`` exists for exactly this. Asked at the moment it is
94
+ needed rather than cached, so a resize is reflected in the same frame
95
+ that draws it.
96
+ """
97
+ r = self.app.renderer
98
+ return game.info.fits(r.width, r.height)
99
+
100
+ def _grid_region(self) -> tuple[int, int]:
101
+ """``(width, height)`` left for cards after the chrome takes its share."""
102
+ r = self.app.renderer
103
+ used = self._banner_rows() + theme.HEADER_ROWS + theme.FOOTER_ROWS + 2
104
+ return r.width, max(0, r.height - used)
105
+
106
+ def _banner_rows(self) -> int:
107
+ r = self.app.renderer
108
+ return len(banner.best_fit(r.width - 2, self._banner_budget()))
109
+
110
+ def _banner_budget(self) -> int:
111
+ """Rows the banner may have.
112
+
113
+ It is given whatever is left once one row of cards and the chrome are
114
+ accounted for, so the art stands down before the cabinets do. That is
115
+ what keeps the floor at one card rather than at the widest banner.
116
+ """
117
+ r = self.app.renderer
118
+ spare = r.height - (theme.HEADER_ROWS + theme.CARD_H + theme.FOOTER_ROWS + 2)
119
+ return max(0, spare)
120
+
121
+ def _move(self, delta: int) -> None:
122
+ if not self.app.games:
123
+ return
124
+ self.selected = (self.selected + delta) % len(self.app.games)
125
+
126
+ def _move_row(self, delta: int) -> None:
127
+ """Up/down moves by a whole row of the grid, not by one card."""
128
+ if not self.app.games:
129
+ return
130
+ width, height = self._grid_region()
131
+ self._move(delta * cards.columns(width))
132
+
133
+ def choose(self) -> None:
134
+ game = self.highlighted
135
+ if game is None or not self._enabled(game):
136
+ return
137
+ if not self.app.play(game):
138
+ # Nothing was pushed, so nothing will pop and no on_resume will
139
+ # fire. The error is drawn on the floor instead.
140
+ pass
141
+
142
+ # -- Stack hooks -------------------------------------------------
143
+
144
+ def on_resume(self) -> None:
145
+ """Take the terminal back from a cabinet that has just left.
146
+
147
+ ``TuiHost.seat()`` applies the seated game's declared ``fps`` and
148
+ ``hold_ms``, and nothing puts them back - so a real-time cabinet
149
+ handing control back would leave the floor running at its frame rate
150
+ with its held-key decay, and arrows would skate across the grid on
151
+ auto-repeat instead of stepping.
152
+
153
+ Retuning here rather than in the host is deliberate: the host cannot
154
+ know what to revert *to*. Only whatever is underneath does, and that
155
+ is this.
156
+ """
157
+ self.app.host.apply(self.app.info)
158
+
159
+ # -- Frame -------------------------------------------------------
160
+
161
+ def update(self, dt: float) -> None:
162
+ self.elapsed += dt
163
+
164
+ def handle_key(self, key: str) -> bool:
165
+ if key in ("left", "a", "h"):
166
+ self._move(-1)
167
+ elif key in ("right", "d", "l"):
168
+ self._move(1)
169
+ elif key in ("up", "w", "k"):
170
+ self._move_row(-1)
171
+ elif key in ("down", "s", "j"):
172
+ self._move_row(1)
173
+ elif key in ("enter", "space"):
174
+ self.choose()
175
+ elif key == "q":
176
+ self.app.host.quit()
177
+ elif key == "escape":
178
+ # The same call a cabinet makes to leave. This is the bottom of the
179
+ # stack, so it ends the session - see TuiHost.pop_scene.
180
+ self.app.host.pop_scene()
181
+ else:
182
+ return False
183
+ return True
184
+
185
+ def render(self) -> None:
186
+ r = self.app.renderer
187
+ r.clear()
188
+ r.draw_rect(0, 0, r.width, r.height, theme.BG)
189
+
190
+ if _too_small(r, theme.MIN_COLS, theme.MIN_ROWS):
191
+ r.present()
192
+ return
193
+
194
+ cx = r.width // 2
195
+ y = self._render_header(r, cx)
196
+
197
+ if self.app.games:
198
+ self._render_grid(r, y)
199
+ else:
200
+ self._render_empty_floor(r, cx, y)
201
+
202
+ self._render_footer(r, cx)
203
+ r.present()
204
+
205
+ def _render_header(self, r, cx: int) -> int:
206
+ """Banner, tagline and coin. Returns the first row below them."""
207
+ y = 0
208
+ art = banner.best_fit(r.width - 2, self._banner_budget())
209
+ for line in art:
210
+ r.ui_text(cx, y, line, fill=theme.CYAN, anchor="n")
211
+ y += 1
212
+
213
+ r.ui_text(cx, y, theme.TAGLINE, fill=theme.PINK, anchor="n")
214
+ y += 1
215
+ if self._blink(theme.COIN_BLINK_SECONDS):
216
+ r.ui_text(cx, y, theme.INSERT_COIN, fill=theme.YELLOW, anchor="n")
217
+ y += 2
218
+ return y
219
+
220
+ def _render_grid(self, r, top: int) -> None:
221
+ width, height = self._grid_region()
222
+ slots = cards.layout(len(self.app.games), self.selected, width, height)
223
+ cursor = self._blink(theme.BLINK_SECONDS)
224
+
225
+ for slot in slots:
226
+ game = self.app.games[slot.index]
227
+ cards.draw(
228
+ r, cards.Slot(slot.index, slot.x, slot.y + top), game.info,
229
+ accent=theme.accent(slot.index),
230
+ selected=slot.index == self.selected,
231
+ enabled=self._enabled(game),
232
+ cursor=cursor,
233
+ )
234
+
235
+ total = cards.pages(len(self.app.games), width, height)
236
+ if total > 1 and slots:
237
+ capacity = cards.columns(width) * cards.rows(height)
238
+ here = self.selected // capacity + 1
239
+ # Directly under the grid rather than at a fixed row near the
240
+ # bottom, where it would land on the error line.
241
+ below = top + max(s.y for s in slots) + theme.CARD_H
242
+ r.ui_text(r.width // 2, below, f"PAGE {here}/{total}",
243
+ fill=theme.MUTED, anchor="n")
244
+
245
+ def _render_footer(self, r, cx: int) -> None:
246
+ if self.app.error:
247
+ r.ui_text(cx, r.height - 4, _fit(self.app.error, r.width - 2),
248
+ fill=theme.ERROR, anchor="n")
249
+ r.ui_text(cx, r.height - 3, _fit(MENU_HELP, r.width - 2),
250
+ fill=theme.MUTED, anchor="n")
251
+ self._render_credits(r, cx)
252
+
253
+ def _render_credits(self, r, cx: int) -> None:
254
+ """The signature, with the domain in the colour the web page links in.
255
+
256
+ Drawn as two runs at computed positions rather than one centred
257
+ string, because ``.credits-row a`` is cyan against muted text and a
258
+ single ``ui_text`` gets one colour. Falls back to the copyright alone
259
+ when the window is too narrow to carry both.
260
+ """
261
+ y = r.height - 2
262
+ full = theme.COPYRIGHT + theme.CREDITS_SEP + theme.DOMAIN
263
+ if len(full) + 2 > r.width:
264
+ r.ui_text(cx, y, _fit(theme.COPYRIGHT, r.width - 2),
265
+ fill=theme.CARD_BORDER, anchor="n")
266
+ return
267
+ x = cx - len(full) // 2
268
+ r.ui_text(x, y, theme.COPYRIGHT + theme.CREDITS_SEP,
269
+ fill=theme.CARD_BORDER)
270
+ r.ui_text(x + len(theme.COPYRIGHT) + len(theme.CREDITS_SEP), y,
271
+ theme.DOMAIN, fill=theme.CYAN)
272
+
273
+ def _render_empty_floor(self, r, cx: int, top: int) -> None:
274
+ for i, line in enumerate(EMPTY_FLOOR):
275
+ fill = theme.YELLOW if i == 0 else theme.MUTED
276
+ r.ui_text(cx, top + i + 1, _fit(line, r.width - 2),
277
+ fill=fill, anchor="n")
278
+
279
+ def _blink(self, period: float) -> bool:
280
+ """On for the first half of each period, off for the second.
281
+
282
+ ``animation: blink 0.6s step-end infinite`` in ``arcade.css``.
283
+ """
284
+ return (self.elapsed % period) < period / 2
magmacrunch/theme.py ADDED
@@ -0,0 +1,96 @@
1
+ """Palette and layout for the arcade floor, in character cells.
2
+
3
+ The colours are magmacrunch.com's arcade page, lifted from ``arcade.css`` so
4
+ the terminal and the browser are recognisably the same place. What could not
5
+ come with them is the typography: ``arcade.css`` sets ``Press Start 2P`` on
6
+ almost everything, and **a terminal program does not choose its font** - the
7
+ font is whatever the person running it has their emulator set to. The web page
8
+ already runs into this itself, which is why its own title art is Courier Prime
9
+ rather than Press Start 2P: pixel lettering in a terminal has to be drawn out
10
+ of block glyphs, not asked for. See :mod:`magmacrunch.banner`.
11
+
12
+ Split out so :mod:`magmacrunch.scenes` and :mod:`magmacrunch.app` can share it
13
+ without importing each other. Nothing here imports the engine.
14
+
15
+ Every measurement is **cells**, not pixels.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ TAGLINE = "EVERY CABINET ON THIS MACHINE"
21
+ INSERT_COIN = "INSERT COIN"
22
+
23
+ # ── Palette ─────────────────────────────────────────────────────────
24
+ # arcade.css, near enough verbatim. The names are the web page's roles.
25
+
26
+ #: ``body { background: #0a0612 }``
27
+ BG = "#0a0612"
28
+ #: ``.game-card { background: #150b29 }``
29
+ CARD_BG = "#150b29"
30
+ #: ``.game-card { border: 2px solid #3a2d5c }`` - the resting border.
31
+ CARD_BORDER = "#3a2d5c"
32
+ #: ``.card-title { color: #f0f8ff }``
33
+ CARD_TITLE = "#f0f8ff"
34
+ #: ``.card-desc`` and ``.breadcrumb`` - the muted body colour.
35
+ MUTED = "#8a7fa8"
36
+
37
+ CYAN = "#00f0ff" # .arcade-title-ascii, links
38
+ PINK = "#ff2e9c" # .arcade-subtitle, --nav-accent, the default card colour
39
+ YELLOW = "#fff733" # .insert-coin, the scoreboard header
40
+ GREEN = "#39ff6e" # card-games card
41
+ AMBER = "#ffe03a" # puzzles card
42
+
43
+ ERROR = PINK
44
+
45
+ #: One accent per cabinet, cycled by position, in the order the four category
46
+ #: cards use them on the web page. ``.game-card:hover`` puts this on the
47
+ #: border and ``.card-play`` puts it on the ENTER line, which is exactly what
48
+ #: a selected card does here.
49
+ ACCENTS = (CYAN, GREEN, AMBER, PINK)
50
+
51
+
52
+ def accent(index: int) -> str:
53
+ return ACCENTS[index % len(ACCENTS)]
54
+
55
+
56
+ # ── Cards ───────────────────────────────────────────────────────────
57
+
58
+ #: A card is 32x9 with a two-cell gutter, and the grid is at most two across
59
+ #: - ``.game-grid`` is ``repeat(2, 1fr)`` until 1100px, and a terminal is
60
+ #: never the wide case.
61
+ CARD_W = 32
62
+ CARD_H = 9
63
+ GAP_X = 2
64
+ GAP_Y = 1
65
+ MAX_COLS = 2
66
+
67
+ #: Border on both sides plus a cell of padding inside it.
68
+ CARD_PAD = 1
69
+ #: What a card has left for text.
70
+ CARD_INNER = CARD_W - 2 * (1 + CARD_PAD)
71
+
72
+ MARGIN_X = 2
73
+ #: Rows above the grid that are not the banner: the tagline and INSERT COIN.
74
+ HEADER_ROWS = 2
75
+ #: Rows below it: the error line, the key help and the credits.
76
+ FOOTER_ROWS = 3
77
+
78
+ #: The footer signature, in two parts so the domain can carry the link colour
79
+ #: the web page gives it (``.credits-row a { color: #00f0ff }``).
80
+ COPYRIGHT = "© 2026 magmacrunch media"
81
+ DOMAIN = "magmacrunch.com"
82
+ CREDITS_SEP = " · "
83
+
84
+ PLAY = "▶ ENTER"
85
+ #: ``.card-arrow { animation: blink 0.6s step-end infinite }`` - the cursor
86
+ #: after ENTER on the selected card, on for one half-period and off the next.
87
+ BLINK_SECONDS = 0.6
88
+ #: ``.insert-coin { animation: blink 1s step-end infinite }``
89
+ COIN_BLINK_SECONDS = 1.0
90
+
91
+ #: Smallest terminal the floor is drawn in: one card, one column, plus the
92
+ #: chrome that never goes away. Derived so the floor cannot drift from the
93
+ #: layout. The banner is not in it - it stands down to whatever fits, and at
94
+ #: the bottom rung it is one line. See :func:`magmacrunch.banner.best_fit`.
95
+ MIN_COLS = CARD_W + 2 * MARGIN_X
96
+ MIN_ROWS = 1 + HEADER_ROWS + CARD_H + FOOTER_ROWS + 2
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.5
2
+ Name: magmacrunch
3
+ Version: 0.1.0
4
+ Summary: The magmacrunch terminal arcade - a menu of every installed cabinet
5
+ Project-URL: Homepage, https://magmacrunch.com/arcade/
6
+ Author: magmacrunch media
7
+ License-Expression: PolyForm-Noncommercial-1.0.0
8
+ License-File: LICENSE
9
+ License-File: NOTICE
10
+ Keywords: arcade,games,launcher,terminal,tui
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: magmacrunch-george-boole>=0.1.0
13
+ Requires-Dist: magmacrunch-moonlight-drift>=0.1.0
14
+ Requires-Dist: magmacrunch-thld>=0.1.0
15
+ Requires-Dist: texastoast[tui]>=0.10.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7.0; extra == 'dev'
18
+ Requires-Dist: ruff>=0.6; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # magmacrunch
22
+
23
+ The terminal arcade. One command, a menu of every cabinet installed on the
24
+ machine, and a way into one.
25
+
26
+ ```
27
+ pip install magmacrunch
28
+ magmacrunch
29
+ ```
30
+
31
+ ```
32
+ __
33
+ .--------.---.-.-----.--------.---.-.----.----.--.--.-----.----.| |--.
34
+ | | _ | _ | | _ | __| _| | | | __|| |
35
+ |__|__|__|___._|___ |__|__|__|___._|____|__| |_____|__|__|____||__|__|
36
+ |_____|
37
+ EVERY CABINET ON THIS MACHINE
38
+ INSERT COIN
39
+
40
+
41
+ ┌──────────────────────────────┐ ┌──────────────────────────────┐
42
+ │ George Boole Has Entered The │ │ Texas Hold'Em Lava Dome │
43
+ │ Chat │ │ │
44
+ │ │ │ Solo hold'em against a │
45
+ │ 2048 played with logic gates │ │ threshold that climbs every │
46
+ │ — merge, invert, overflow. │ │ round. │
47
+ │ │ │ │
48
+ │ ▶ ENTER_ │ │ ▶ ENTER │
49
+ └──────────────────────────────┘ └──────────────────────────────┘
50
+
51
+ ↑↓←→ CHOOSE ENTER PLAY Q QUIT
52
+ © 2026 magmacrunch media · magmacrunch.com
53
+ ```
54
+
55
+ It is `magmacrunch.com/arcade` in a shell — the same palette, the same card
56
+ grid, the same blinking cursor on the card you are about to start. The one
57
+ thing that could not come across is the typography: `arcade.css` sets
58
+ `Press Start 2P` on nearly every string, and a terminal program does not
59
+ choose its font. Pixel lettering has to be *drawn*, out of block glyphs, which
60
+ is what the narrow title variant does — and what the web page's own title art
61
+ does too, in Courier Prime rather than Press Start 2P, for the same reason.
62
+
63
+ The title art stands down as the window grows or shrinks, and always before
64
+ the cabinets have to — which is what keeps the floor as low as it is:
65
+
66
+ | window | banner |
67
+ |---|---|
68
+ | 121×34 and up | the full site wordmark, MAGMACRUNCH over ARCADE, 119×19 |
69
+ | ~73×30 | the same two words in 71 columns instead of 119 |
70
+ | **80×24** | **MAGMACRUNCH alone, 71×5** |
71
+ | ~62–72 cols | the ARCADE wordmark alone, 60×8 |
72
+ | ~50 cols | a block-glyph MAGMACRUNCH, 48×2 |
73
+ | 36 cols | spaced capitals |
74
+
75
+ Art is measured rather than declared, so the 119-column piece can sit at the
76
+ top of the list without ever bothering someone on an ordinary terminal — it
77
+ is simply never the one that fits. Adding your own is one entry in `VARIANTS`
78
+ in [`banner.py`](magmacrunch/banner.py) and nothing else.
79
+
80
+ Choosing a cabinet starts it on the terminal the arcade is already holding.
81
+ Leaving the game brings you back here. Every game is also its own command
82
+ (`george-boole`, `lava-dome`, `moonlight-drift`) and plays identically either way — the arcade is
83
+ not a wrapper around them, it is the same host they run on.
84
+
85
+ ## What is installed
86
+
87
+ ```
88
+ magmacrunch --list
89
+ ```
90
+
91
+ Prints every cabinet found, what terminal size it wants, and how it wants
92
+ input. If a game you installed is not in the menu, this says why — a game that
93
+ fails to load is reported rather than silently skipped.
94
+
95
+ ## How games get here
96
+
97
+ Games are **not** bundled. Each one lives in its own repo alongside its browser
98
+ and Wii versions, publishes its own wheel, and declares an entry point:
99
+
100
+ ```toml
101
+ [project.entry-points."magmacrunch.games"]
102
+ george-boole = "boole.arcade:GAME"
103
+ ```
104
+
105
+ pointing at an object with `info: GameInfo` and `start(host) -> scene`. The
106
+ launcher enumerates that group at startup. **Installing a game makes it appear
107
+ here; uninstalling makes it vanish; neither needs a release of the arcade.**
108
+
109
+ Nothing in this package imports a game, so the arcade runs with none installed
110
+ and tells you how to get one. The contract lives in
111
+ [`texastoast.arcade`](https://pypi.org/project/texastoast/); the two cabinets
112
+ above are worked examples of it.
113
+
114
+ ## Cabinets
115
+
116
+ | | |
117
+ |---|---|
118
+ | `magmacrunch-george-boole` | 2048 played with logic gates |
119
+ | `magmacrunch-thld` | Solo hold'em against a climbing threshold |
120
+ | `magmacrunch-moonlight-drift` | Thread the columns; hold to climb |
121
+
122
+ Installing `magmacrunch` installs all three.
123
+
124
+ ## Requires
125
+
126
+ Python 3.10+, and a terminal at least 36×17. Individual cabinets ask for more
127
+ (around 59×22); below that their card greys out, says the size it wants, and
128
+ cannot be started. The arcade's own floor is deliberately lower than any
129
+ game's, so getting to the floor is never the thing that fails.
130
+
131
+ Two cards sit side by side from 70 columns and stack into one below that. More
132
+ cabinets than fit are paged, not scrolled.
133
+
134
+ Truecolor helps but is not required.
135
+
136
+ ## Licence
137
+
138
+ PolyForm Noncommercial 1.0.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
139
+ Noncommercial because the games it seats are. The engine underneath,
140
+ [texastoast](https://pypi.org/project/texastoast/), stays Apache-2.0.
@@ -0,0 +1,13 @@
1
+ magmacrunch/__init__.py,sha256=a4EEYSnLnwyaBHKgxnHvCImPwTLg1b7TzhWDxIlk6Co,705
2
+ magmacrunch/__main__.py,sha256=SAJjcnMmuQbiQA3rE2UY8wmqNJ_08wtILIkNhcsSplE,3290
3
+ magmacrunch/app.py,sha256=F8ky-2yZXGQ5-q5c8mMSX9WO66EkT-KLf_EtO2jH3dk,3944
4
+ magmacrunch/banner.py,sha256=UjtHesK9_R4z7-3r6jV_4UxQN81S1WWvTsirV19HfcA,9597
5
+ magmacrunch/cards.py,sha256=zDKL9UjDoFEV9MCgIMq1-tbD1jLG1mFjTsLfxv7RNKE,6185
6
+ magmacrunch/scenes.py,sha256=aJnuPZuesbT08c0YxwxdbGThtkgoe8Px3dCfQf7nSAo,10871
7
+ magmacrunch/theme.py,sha256=kAx6KOLLLeNdj6BnFCCcQ-DLuwxN03ZbrtDX2Tf1G8A,3904
8
+ magmacrunch-0.1.0.dist-info/METADATA,sha256=aynF4E12HXiPgjxJDEkUlxxj1X0Ls3qGUIVJ3C2a4kA,5915
9
+ magmacrunch-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ magmacrunch-0.1.0.dist-info/entry_points.txt,sha256=0JqIHnkppyYXU3HVxCTiZWUdA19NYzy1UDPaxSQUrFo,58
11
+ magmacrunch-0.1.0.dist-info/licenses/LICENSE,sha256=dQ7s_huoOpXIV8Gtdt3ZVm4N2UofO4MvxlappsL14Q8,4640
12
+ magmacrunch-0.1.0.dist-info/licenses/NOTICE,sha256=4NAfifkrNa6CCMl7Q8ibvd3y4Xhvykwf-muPIg6LB8c,1330
13
+ magmacrunch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ magmacrunch = magmacrunch.__main__:main
@@ -0,0 +1,75 @@
1
+ Required Notice: Copyright 2026 magmacrunch media (https://magmacrunch.com)
2
+
3
+ # PolyForm Noncommercial License 1.0.0
4
+
5
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
14
+
15
+ ## Distribution License
16
+
17
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
18
+
19
+ ## Notices
20
+
21
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
22
+
23
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
24
+
25
+ ## Changes and New Works License
26
+
27
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
28
+
29
+ ## Patent License
30
+
31
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
32
+
33
+ ## Noncommercial Purposes
34
+
35
+ Any noncommercial purpose is a permitted purpose.
36
+
37
+ ## Personal Uses
38
+
39
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
40
+
41
+ ## Noncommercial Organizations
42
+
43
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
44
+
45
+ ## Fair Use
46
+
47
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
48
+
49
+ ## No Other Rights
50
+
51
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
52
+
53
+ ## Patent Defense
54
+
55
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
56
+
57
+ ## Violations
58
+
59
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
60
+
61
+ ## No Liability
62
+
63
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
64
+
65
+ ## Definitions
66
+
67
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
68
+
69
+ **You** refers to the individual or entity agreeing to these terms.
70
+
71
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
72
+
73
+ **Your licenses** are all the licenses granted to you for the software under these terms.
74
+
75
+ **Use** means anything you do with the software requiring one of your licenses.
@@ -0,0 +1,33 @@
1
+ magmacrunch — the terminal arcade
2
+ Copyright 2026 magmacrunch media
3
+
4
+
5
+ THE LICENCE
6
+
7
+ Everything in this repository -- the launcher, the tests, the build files and
8
+ the documentation -- is licensed under the PolyForm Noncommercial License
9
+ 1.0.0, in the LICENSE file beside this one.
10
+
11
+ Read it, learn from it, build on it, play with it. Any noncommercial purpose is
12
+ permitted, and that includes personal projects, teaching and research. What is
13
+ not permitted is commercial use: selling it or a derivative of it, shipping it
14
+ inside a product, or running it as part of a business.
15
+
16
+ It is Noncommercial because the games it seats are. This package contains no
17
+ game code -- it finds installed games by entry point and never imports one --
18
+ but it depends on them, and a package cannot be more permissive than what it
19
+ depends on.
20
+
21
+
22
+ WHAT THIS DEPENDS ON
23
+
24
+ texastoast, the engine, with its terminal backend. Apache License 2.0,
25
+ copyright 2026 magmacrunch media. https://pypi.org/project/texastoast/
26
+
27
+ texastoast's terminal backend depends in turn on Textual, and through it on
28
+ Rich (MIT, copyright Will McGugan). Both carry their own licences in the
29
+ installed distributions.
30
+
31
+ The cabinets -- magmacrunch-george-boole, magmacrunch-thld -- are each
32
+ PolyForm Noncommercial 1.0.0, copyright 2026 magmacrunch media, and carry
33
+ their own NOTICE files.