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.
- magmacrunch_threes-0.1.0/.gitignore +7 -0
- magmacrunch_threes-0.1.0/LICENSE +75 -0
- magmacrunch_threes-0.1.0/NOTICE +36 -0
- magmacrunch_threes-0.1.0/PKG-INFO +75 -0
- magmacrunch_threes-0.1.0/README.md +58 -0
- magmacrunch_threes-0.1.0/pyproject.toml +50 -0
- magmacrunch_threes-0.1.0/tests/conftest.py +56 -0
- magmacrunch_threes-0.1.0/tests/test_app.py +280 -0
- magmacrunch_threes-0.1.0/tests/test_oracle.py +68 -0
- magmacrunch_threes-0.1.0/tests/test_rules.py +167 -0
- magmacrunch_threes-0.1.0/threes/__init__.py +0 -0
- magmacrunch_threes-0.1.0/threes/__main__.py +29 -0
- magmacrunch_threes-0.1.0/threes/app.py +104 -0
- magmacrunch_threes-0.1.0/threes/arcade.py +46 -0
- magmacrunch_threes-0.1.0/threes/rules.py +238 -0
- magmacrunch_threes-0.1.0/threes/scenes.py +524 -0
- magmacrunch_threes-0.1.0/threes/theme.py +82 -0
- magmacrunch_threes-0.1.0/tools/js_oracle.mjs +141 -0
|
@@ -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())
|