magmacrunch-threes 0.1.0__tar.gz

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,7 @@
1
+ # The link tools/link-shared.mjs makes to the website's arcade/shared/.
2
+ /shared
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ node_modules/
7
+ .DS_Store
@@ -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,36 @@
1
+ threes
2
+ Copyright 2026 magmacrunch media
3
+
4
+
5
+ THE LICENCE
6
+
7
+ Everything in this repository -- the game in web/, its terminal version in
8
+ tui/, its tests, the tools and the
9
+ documentation -- is licensed under the PolyForm Noncommercial License 1.0.0, in the
10
+ LICENSE file beside this one.
11
+
12
+ Read it, learn from it, build on it, play with it. Any noncommercial purpose is
13
+ permitted, and that includes personal projects, teaching and research. What is not
14
+ permitted is commercial use: selling the game or a derivative of it, shipping it inside
15
+ a product, or running it as part of a business.
16
+
17
+
18
+ THE NAME IS NOT OURS
19
+
20
+ Threes! is Sirvo's game (Asher Vollmer, Greg Wohlwend and Jimmy Hinson, 2014), and
21
+ its name is theirs. This is an unofficial fan implementation, played for free in the
22
+ magmacrunch arcade, and is not affiliated with or endorsed by Sirvo. Nothing here
23
+ claims the name, and the licence above grants no rights in it. What is licensed is
24
+ this implementation only: the code and the arcade's presentation.
25
+
26
+ What is reserved: the magmacrunch name and marks and the visual design of the arcade.
27
+ If you want to use any of it, ask.
28
+
29
+
30
+ THIRD-PARTY COMPONENTS
31
+
32
+ Fonts are loaded from Google Fonts under the SIL Open Font License 1.1 rather than
33
+ shipped here as files.
34
+
35
+ The page loads adenosine-puzzle.js, adenosine-score-client.js, arcade-base.css, puzzle/puzzle-base.css, puzzle/puzzle-grid.css, puzzle/puzzle-modals.css, puzzle/puzzle-responsive.css, score-server.js from the website's arcade/shared/.
36
+ Those are part of the magmacrunch.com repository and adenosine, and are not copied here.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: magmacrunch-threes
3
+ Version: 0.1.0
4
+ Summary: THREES - slide, merge, survive. Terminal version.
5
+ Project-URL: Homepage, https://magmacrunch.com/arcade/threes/
6
+ Author: magmacrunch media
7
+ License-Expression: PolyForm-Noncommercial-1.0.0
8
+ License-File: LICENSE
9
+ License-File: NOTICE
10
+ Keywords: arcade,games,puzzle,terminal,tiles,tui
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: magmacrunch>=0.7.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == 'dev'
15
+ Requires-Dist: ruff>=0.6; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # THREES (terminal)
19
+
20
+ Slide, merge, survive, in a terminal. The second version of the game beside
21
+ `web/`, on the [magmacrunch](https://github.com/magmacrunch-media/magmacrunch)
22
+ engine's terminal backend: a cabinet in the magmacrunch arcade and its own
23
+ command, the same either way.
24
+
25
+ ```
26
+ pip install -e ".[dev]"
27
+ python -m threes
28
+ ```
29
+
30
+ ## Keys
31
+
32
+ | key | |
33
+ |---|---|
34
+ | arrows (or WASD, hjkl) | slide every tile one step |
35
+ | `R` | new game |
36
+ | `Esc` | the title (from the title, back to the arcade) |
37
+ | `Q` | quit |
38
+
39
+ 1 + 2 makes 3, and from there same plus same; 1 + 1 and 2 + 2 do not merge.
40
+ The new tile comes in on the edge you slid away from, and lights up for a
41
+ moment so you can see where it landed.
42
+
43
+ ## It is the browser's game
44
+
45
+ `threes/rules.py` is `web/js/game.js` plus the part of adenosine-puzzle's
46
+ `createGame` it relies on: a move is the left slide with the board rotated
47
+ round it, and only a move that changed something brings a tile and counts.
48
+ `tests/test_oracle.py` checks it: `tools/js_oracle.mjs` loads the website's
49
+ `adenosine-puzzle.js` and `web/js/game.js` unmodified with `Math.random`
50
+ seeded, plays 300 games through the game's own `handleMove()`, and the test
51
+ replays every move through the port and requires the same board, score and
52
+ move count after each one, and the same answer to whether it moved at all.
53
+
54
+ The oracle needs the website, for the bundle. It finds it the way
55
+ `tools/link-shared.mjs` does: `../shared`, `$WEBSITE`, `../../website`,
56
+ `../../../web/website`. CI checks the website out beside the game.
57
+
58
+ The browser's choices are copied rather than improved, because both versions
59
+ share a scoreboard and a rule changed in one would make its scores mean
60
+ something else:
61
+
62
+ - **Three new tiles in four are 1s** (75% 1, 15% 2, 10% 3), and a 1 merges only
63
+ with a 2, so the board fills with 1s and a game lasts a few dozen moves.
64
+ The original Threes! deals 1s and 2s about evenly.
65
+ - **The score is the sum of every merge**, not the original's powers of three.
66
+
67
+ Change either in `web/js/game.js` and here together, and the oracle will hold
68
+ the two to each other.
69
+
70
+ Needs a 56x21 terminal.
71
+
72
+ ## Licence
73
+
74
+ [PolyForm Noncommercial 1.0.0](LICENSE), as the rest of this repository. See
75
+ [NOTICE](NOTICE), which also says whose the name is.
@@ -0,0 +1,58 @@
1
+ # THREES (terminal)
2
+
3
+ Slide, merge, survive, in a terminal. The second version of the game beside
4
+ `web/`, on the [magmacrunch](https://github.com/magmacrunch-media/magmacrunch)
5
+ engine's terminal backend: a cabinet in the magmacrunch arcade and its own
6
+ command, the same either way.
7
+
8
+ ```
9
+ pip install -e ".[dev]"
10
+ python -m threes
11
+ ```
12
+
13
+ ## Keys
14
+
15
+ | key | |
16
+ |---|---|
17
+ | arrows (or WASD, hjkl) | slide every tile one step |
18
+ | `R` | new game |
19
+ | `Esc` | the title (from the title, back to the arcade) |
20
+ | `Q` | quit |
21
+
22
+ 1 + 2 makes 3, and from there same plus same; 1 + 1 and 2 + 2 do not merge.
23
+ The new tile comes in on the edge you slid away from, and lights up for a
24
+ moment so you can see where it landed.
25
+
26
+ ## It is the browser's game
27
+
28
+ `threes/rules.py` is `web/js/game.js` plus the part of adenosine-puzzle's
29
+ `createGame` it relies on: a move is the left slide with the board rotated
30
+ round it, and only a move that changed something brings a tile and counts.
31
+ `tests/test_oracle.py` checks it: `tools/js_oracle.mjs` loads the website's
32
+ `adenosine-puzzle.js` and `web/js/game.js` unmodified with `Math.random`
33
+ seeded, plays 300 games through the game's own `handleMove()`, and the test
34
+ replays every move through the port and requires the same board, score and
35
+ move count after each one, and the same answer to whether it moved at all.
36
+
37
+ The oracle needs the website, for the bundle. It finds it the way
38
+ `tools/link-shared.mjs` does: `../shared`, `$WEBSITE`, `../../website`,
39
+ `../../../web/website`. CI checks the website out beside the game.
40
+
41
+ The browser's choices are copied rather than improved, because both versions
42
+ share a scoreboard and a rule changed in one would make its scores mean
43
+ something else:
44
+
45
+ - **Three new tiles in four are 1s** (75% 1, 15% 2, 10% 3), and a 1 merges only
46
+ with a 2, so the board fills with 1s and a game lasts a few dozen moves.
47
+ The original Threes! deals 1s and 2s about evenly.
48
+ - **The score is the sum of every merge**, not the original's powers of three.
49
+
50
+ Change either in `web/js/game.js` and here together, and the oracle will hold
51
+ the two to each other.
52
+
53
+ Needs a 56x21 terminal.
54
+
55
+ ## Licence
56
+
57
+ [PolyForm Noncommercial 1.0.0](LICENSE), as the rest of this repository. See
58
+ [NOTICE](NOTICE), which also says whose the name is.
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # Prefixed like every other cabinet: PyPI cannot scope a family, so the prefix
7
+ # claims it. The import package stays plain `threes`, and the command a player
8
+ # types is `threes`.
9
+ name = "magmacrunch-threes"
10
+ version = "0.1.0"
11
+ description = "THREES - slide, merge, survive. Terminal version."
12
+ readme = "README.md"
13
+ license = "PolyForm-Noncommercial-1.0.0"
14
+ license-files = ["LICENSE", "NOTICE"]
15
+ requires-python = ">=3.10"
16
+ authors = [{ name = "magmacrunch media" }]
17
+ keywords = ["arcade", "terminal", "tui", "games", "puzzle", "tiles"]
18
+
19
+ # The engine, with its terminal backend. threes/rules.py imports neither and
20
+ # runs on a bare interpreter, which tests/test_rules.py and
21
+ # tests/test_oracle.py rely on. 0.7.0 is where magmacrunch.engine.ui.glyphs
22
+ # arrives, which run() imports.
23
+ dependencies = ["magmacrunch>=0.7.0"]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=7.0", "ruff>=0.6"]
27
+
28
+ [project.scripts]
29
+ threes = "threes.__main__:main"
30
+
31
+ # Found by a launcher enumerating installed games. This line is the whole of
32
+ # what puts THREES on the arcade floor - see magmacrunch.engine.arcade.
33
+ [project.entry-points."magmacrunch.games"]
34
+ threes = "threes.arcade:GAME"
35
+
36
+ [project.urls]
37
+ Homepage = "https://magmacrunch.com/arcade/threes/"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["threes"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+
45
+ [tool.ruff]
46
+ target-version = "py310"
47
+ line-length = 100
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,56 @@
1
+ """Shared fixtures.
2
+
3
+ The browser's own answers, generated once per session rather than committed,
4
+ so the trace cannot go stale against a web build that has moved on. A
5
+ committed trace agrees with itself forever; this one is regenerated from
6
+ whatever ``web/js/game.js`` says today.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ TUI = Path(__file__).resolve().parents[1]
20
+ ORACLE = TUI / "tools" / "js_oracle.mjs"
21
+ WEB = TUI.parent / "web"
22
+
23
+
24
+ def node_available() -> bool:
25
+ return shutil.which("node") is not None and (WEB / "js" / "game.js").exists()
26
+
27
+
28
+ @pytest.fixture(scope="session")
29
+ def trace(tmp_path_factory):
30
+ """Everything the browser did, as JSON. Skips when node or web/ is absent.
31
+
32
+ web/ is absent from an installed wheel, which is the case that matters:
33
+ these tests are a development guarantee, not something a player's machine
34
+ should have to satisfy. A web build that is present but will not load is a
35
+ failure, not a skip, so a broken oracle cannot pass by comparing nothing.
36
+ """
37
+ if not node_available():
38
+ # CI sets this, so a runner that lost node fails rather than passing
39
+ # with the one test that matters skipped.
40
+ if os.environ.get("THREES_REQUIRE_ORACLE"):
41
+ pytest.fail("THREES_REQUIRE_ORACLE is set and node or web/js/game.js is missing")
42
+ pytest.skip("needs node and web/js/game.js (absent from an installed wheel)")
43
+
44
+ node = shutil.which("node")
45
+ probe = subprocess.run([node, str(ORACLE), "--check"], capture_output=True,
46
+ text=True, cwd=TUI)
47
+ if probe.returncode != 0:
48
+ pytest.fail("js_oracle.mjs cannot load the game (is the website beside it?):\n"
49
+ + probe.stderr)
50
+
51
+ out = tmp_path_factory.mktemp("oracle") / "oracle.json"
52
+ proc = subprocess.run([node, str(ORACLE), str(out)], capture_output=True,
53
+ text=True, cwd=TUI)
54
+ if proc.returncode != 0:
55
+ pytest.fail(f"js_oracle.mjs failed:\n{proc.stderr}")
56
+ return json.loads(out.read_text(encoding="utf-8"))
@@ -0,0 +1,280 @@
1
+ """The screens, driven on a real host. Skipped without Textual."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ pytest.importorskip("textual", reason='needs: pip install -e ".[dev]"')
11
+
12
+ from magmacrunch.engine import scores as score_mod # noqa: E402
13
+ from magmacrunch.engine.arcade import ArcadeGame # noqa: E402
14
+ from magmacrunch.engine.core.tui_host import TuiHost # noqa: E402
15
+ from magmacrunch.engine.ui.glyphs import FALLBACKS # noqa: E402
16
+
17
+ from threes import rules, scenes # noqa: E402
18
+ from threes.app import ThreesApp # noqa: E402
19
+ from threes.arcade import GAME # noqa: E402
20
+ from threes.scenes import GameScene, InitialsScene, TitleScene # noqa: E402
21
+
22
+ TUI = Path(__file__).resolve().parents[1]
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def isolated_scores(tmp_path, monkeypatch):
27
+ monkeypatch.setenv(score_mod.DATA_DIR_ENV, str(tmp_path))
28
+
29
+
30
+ def settle(app):
31
+ app.host.stack.update(0.0)
32
+
33
+
34
+ def hosted(seed=3, size=(80, 24)) -> ThreesApp:
35
+ host = TuiHost(title=GAME.info.title, fps=GAME.info.fps, hold_ms=GAME.info.hold_ms)
36
+ host.renderer.resize(*size)
37
+ app = ThreesApp(host, seed=seed)
38
+ host.push_scene(app.root_scene)
39
+ settle(app)
40
+ return app
41
+
42
+
43
+ def playing(seed=3, size=(80, 24)):
44
+ app = hosted(seed, size)
45
+ app.start_game()
46
+ settle(app)
47
+ return app, app.host.scene
48
+
49
+
50
+ def tap(app, key):
51
+ app.host.input.press(key)
52
+ for pressed in app.host.input.drain():
53
+ app.host.stack.dispatch_key(pressed)
54
+
55
+
56
+ def frames(app, n, fps=GAME.info.fps):
57
+ for _ in range(n):
58
+ app.host.stack.update(1 / fps)
59
+
60
+
61
+ def text(app) -> str:
62
+ app.host.stack.render()
63
+ return app.host.game.surface.buffer.to_text()
64
+
65
+
66
+ def play_out(app, scene):
67
+ """Slide round in a circle until the board locks. Games here last a few
68
+ dozen moves, so this always ends."""
69
+ for i in range(2000):
70
+ if scene.over:
71
+ break
72
+ tap(app, ("left", "up", "right", "down")[i % 4])
73
+ assert scene.over
74
+ settle(app)
75
+
76
+
77
+ def tiles(scene) -> int:
78
+ return sum(v != 0 for row in scene.board.grid for v in row)
79
+
80
+
81
+ def test_the_game_is_a_valid_arcade_cabinet():
82
+ assert isinstance(GAME, ArcadeGame)
83
+ assert GAME.info.key == "threes"
84
+
85
+
86
+ def test_scores_go_to_the_board_the_browser_already_has():
87
+ """main.js keeps its table as createScoring('threes')."""
88
+ assert GAME.info.scoreboard == "threes"
89
+ app = hosted()
90
+ assert app.scores.path.name.startswith("threes")
91
+
92
+
93
+ def test_the_entry_point_names_the_object_the_arcade_will_load():
94
+ tomllib = pytest.importorskip("tomllib", reason="Python 3.11+")
95
+ meta = tomllib.loads((TUI / "pyproject.toml").read_text(encoding="utf-8"))
96
+ assert meta["project"]["entry-points"]["magmacrunch.games"] == {
97
+ "threes": "threes.arcade:GAME"}
98
+ assert meta["project"]["scripts"]["threes"] == "threes.__main__:main"
99
+
100
+
101
+ def test_the_game_draws_at_exactly_its_declared_minimum():
102
+ app, _ = playing(size=(GAME.info.min_cols, GAME.info.min_rows))
103
+ assert "TERMINAL TOO SMALL" not in text(app)
104
+ app, _ = playing(size=(GAME.info.min_cols, GAME.info.min_rows - 1))
105
+ assert "TERMINAL TOO SMALL" in text(app)
106
+ app, _ = playing(size=(GAME.info.min_cols - 1, GAME.info.min_rows))
107
+ assert "TERMINAL TOO SMALL" in text(app)
108
+
109
+
110
+ def test_a_game_starts_with_nine_tiles_on_the_board():
111
+ app, scene = playing()
112
+ assert tiles(scene) == rules.INITIAL_TILE_COUNT
113
+ shown = text(app)
114
+ for row in scene.board.grid:
115
+ for v in row:
116
+ if v:
117
+ assert str(v) in shown
118
+
119
+
120
+ def test_an_arrow_slides_the_board_and_lights_the_tile_it_brings():
121
+ app, scene = playing()
122
+ scene.board.grid = [[0, 3, 0, 0], [0] * 4, [0] * 4, [0] * 4]
123
+ tap(app, "left")
124
+ b = scene.board
125
+ assert b.moves == 1 and b.grid[0][0] == 3
126
+ # Two tiles now: the 3 that slid, and the new one on the right edge.
127
+ assert tiles(scene) == 2
128
+ row, col = scene.flash
129
+ assert col == rules.SIZE - 1 and b.grid[row][col] in (1, 2, 3)
130
+ assert scene.flash_left > 0
131
+ frames(app, GAME.info.fps)
132
+ assert scene.flash_left == 0
133
+
134
+
135
+ def test_wasd_and_hjkl_are_arrows_too():
136
+ for key, direction in (("a", "left"), ("h", "left"), ("w", "up"), ("k", "up"),
137
+ ("d", "right"), ("l", "right"), ("s", "down"), ("j", "down")):
138
+ assert scenes.MOVE_KEYS[key] == direction
139
+
140
+
141
+ def test_a_move_that_changes_nothing_brings_no_tile():
142
+ app, scene = playing()
143
+ scene.board.grid = [[3, 6, 3, 6], [6, 3, 6, 3], [3, 6, 3, 6], [6, 3, 6, 0]]
144
+ tap(app, "left")
145
+ assert scene.board.moves == 0
146
+ assert scene.board.grid[3][3] == 0
147
+
148
+
149
+ def test_the_clock_runs_while_playing_and_stops_at_the_end():
150
+ app, scene = playing()
151
+ # One frame past three seconds: twenty steps of 1/20 sum to a hair under 1.
152
+ frames(app, GAME.info.fps * 3 + 1)
153
+ assert 3.0 < scene.elapsed < 3.2
154
+ assert "0:03" in text(app)
155
+ play_out(app, scene)
156
+ stopped = scene.elapsed
157
+ frames(app, GAME.info.fps * 2)
158
+ assert scene.elapsed == stopped
159
+
160
+
161
+ def test_the_game_does_not_inherit_the_enter_that_started_it():
162
+ app = hosted()
163
+ tap(app, "enter")
164
+ settle(app)
165
+ scene = app.host.scene
166
+ assert isinstance(scene, GameScene)
167
+ assert scene.board.moves == 0
168
+
169
+
170
+ def test_a_locked_board_asks_for_initials_and_files_the_browsers_extras():
171
+ app, scene = playing()
172
+ play_out(app, scene)
173
+ assert isinstance(app.host.scene, InitialsScene)
174
+ for key in ("j", "a", "m", "enter"):
175
+ tap(app, key)
176
+ settle(app)
177
+ entry = app.scores.load()[0]
178
+ assert (entry.initials, entry.score) == ("JAM", scene.board.score)
179
+ assert entry.extra == {"time": int(scene.elapsed), "highestTile": scene.board.high_tile}
180
+ assert "GAME OVER" in text(app)
181
+
182
+
183
+ def test_mashing_arrows_at_the_end_does_not_restart_unseen():
184
+ app, scene = playing()
185
+ play_out(app, scene)
186
+ tap(app, "enter") # confirm initials
187
+ settle(app)
188
+ finished = scene.board
189
+ tap(app, "enter")
190
+ assert scene.board is finished
191
+ frames(app, int(GameScene.OVER_GRACE * GAME.info.fps) + 1)
192
+ tap(app, "enter")
193
+ assert scene.board is not finished and not scene.over
194
+
195
+
196
+ def test_r_starts_a_new_game():
197
+ app, scene = playing()
198
+ for key in ("left", "up", "right", "down"):
199
+ tap(app, key)
200
+ old = scene.board
201
+ tap(app, "r")
202
+ assert scene.board is not old and scene.board.moves == 0
203
+
204
+
205
+ def test_escape_returns_to_the_title_and_seated_to_the_arcade():
206
+ app, scene = playing()
207
+ tap(app, "escape")
208
+ settle(app)
209
+ assert isinstance(app.host.scene, TitleScene)
210
+ assert scenes.ARCADE_HELP not in text(app)
211
+
212
+ host = TuiHost(title="arcade", fps=20)
213
+
214
+ class Floor:
215
+ def update(self, dt): pass
216
+ def render(self): pass
217
+ def handle_key(self, key): return False
218
+
219
+ floor = Floor()
220
+ host.push_scene(floor)
221
+ host.seat(GAME)
222
+ host.stack.update(0)
223
+ host.renderer.resize(80, 24)
224
+ host.stack.render()
225
+ assert scenes.ARCADE_HELP in host.game.surface.buffer.to_text()
226
+ host.input.press("escape")
227
+ for key in host.input.drain():
228
+ host.stack.dispatch_key(key)
229
+ host.stack.update(0)
230
+ assert host.scene is floor
231
+
232
+
233
+ def test_every_glyph_drawn_is_one_the_engine_can_degrade():
234
+ seen = set()
235
+
236
+ def sweep(app):
237
+ seen.update(ch for ch in text(app) if ord(ch) > 127)
238
+
239
+ app = hosted(size=(100, 30))
240
+ sweep(app)
241
+ for opener in (app.show_rules, app.show_scores):
242
+ opener()
243
+ settle(app)
244
+ sweep(app)
245
+ tap(app, "escape")
246
+ settle(app)
247
+ app.start_game()
248
+ settle(app)
249
+ scene = app.host.scene
250
+ for i in range(20):
251
+ tap(app, ("left", "up", "right", "down")[i % 4])
252
+ frames(app, 2)
253
+ sweep(app)
254
+ play_out(app, scene)
255
+ sweep(app)
256
+ unknown = {ch for ch in seen if ch not in FALLBACKS}
257
+ assert not unknown, f"not in magmacrunch.engine.ui.glyphs.GROUPS: {unknown}"
258
+
259
+
260
+ def test_a_real_terminal_session_runs():
261
+ from magmacrunch.engine.core.tui_game import _GameApp
262
+
263
+ app = hosted()
264
+
265
+ async def go():
266
+ textual_app = _GameApp(app.host.game, app.host.game.surface)
267
+ app.host.game._app = textual_app
268
+ async with textual_app.run_test(size=(80, 24)) as pilot:
269
+ await pilot.pause()
270
+ await asyncio.sleep(0.2)
271
+ await pilot.press("enter")
272
+ await asyncio.sleep(0.2)
273
+ await pilot.press("left", "up", "right", "down")
274
+ await asyncio.sleep(0.5)
275
+ scene = app.host.scene
276
+ assert isinstance(scene, GameScene)
277
+ assert scene.board.moves >= 1
278
+ app.host.quit()
279
+
280
+ asyncio.run(go())