game-room 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.
- game_room-0.1.0/LICENSE +21 -0
- game_room-0.1.0/PKG-INFO +116 -0
- game_room-0.1.0/README.md +95 -0
- game_room-0.1.0/docs/publishing.md +74 -0
- game_room-0.1.0/docs/rules-flip7.md +108 -0
- game_room-0.1.0/pyproject.toml +53 -0
- game_room-0.1.0/pyproject.toml.orig +37 -0
- game_room-0.1.0/src/game_room/__init__.py +1 -0
- game_room-0.1.0/src/game_room/flip7_v0.py +413 -0
- game_room-0.1.0/src/game_room/py.typed +0 -0
- game_room-0.1.0/tests/test_flip7.py +326 -0
game_room-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Austin Poor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
game_room-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: game-room
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PettingZoo environments for tabletop games
|
|
5
|
+
Keywords: pettingzoo,reinforcement-learning,multi-agent,card-games
|
|
6
|
+
Author: Austin Poor
|
|
7
|
+
Author-email: Austin Poor <45295232+a-poor@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Dist: pettingzoo>=1.24.3,<2
|
|
17
|
+
Requires-Dist: gymnasium>=1.0,<2
|
|
18
|
+
Requires-Dist: numpy>=2.1
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# game-room
|
|
23
|
+
|
|
24
|
+
A collection of PettingZoo environments for a few different games.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Requires Python 3.14+. Once the first release is published to PyPI, install it
|
|
29
|
+
with pip:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
python -m pip install game-room
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or add it to a [uv](https://docs.astral.sh/uv/) project with `uv add game-room`.
|
|
36
|
+
The first release is being prepared; until it is published, install from this
|
|
37
|
+
repository with `python -m pip install .`.
|
|
38
|
+
|
|
39
|
+
## Getting Started
|
|
40
|
+
|
|
41
|
+
Save the following as `play.py`, then run `python play.py` (or
|
|
42
|
+
`uv run python play.py` in a uv project) to simulate a
|
|
43
|
+
three-player Flip 7 game with random legal actions and print the final scores:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from game_room import flip7_v0
|
|
47
|
+
|
|
48
|
+
env = flip7_v0.env(num_players=3, render_mode="ansi")
|
|
49
|
+
env.reset(seed=42)
|
|
50
|
+
|
|
51
|
+
for agent in env.agent_iter():
|
|
52
|
+
observation, reward, terminated, truncated, info = env.last()
|
|
53
|
+
if terminated or truncated:
|
|
54
|
+
action = None
|
|
55
|
+
else:
|
|
56
|
+
action = env.action_space(agent).sample(observation["action_mask"])
|
|
57
|
+
env.step(action)
|
|
58
|
+
|
|
59
|
+
print(env.render())
|
|
60
|
+
env.close()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Flip 7
|
|
64
|
+
|
|
65
|
+
`game_room.flip7_v0` implements the 94-card game for 3-18 players using
|
|
66
|
+
PettingZoo's turn-based AEC API. It includes the opening deal, all special cards,
|
|
67
|
+
chained Flip Threes, deck recycling, and complete games across multiple rounds.
|
|
68
|
+
|
|
69
|
+
Actions are `0` (Hit), `1` (Stay), and `2 + seat_index` (choose the recipient
|
|
70
|
+
of an action card). Always use the mask: target selection is a separate decision,
|
|
71
|
+
and the same player can act multiple times in succession. Invalid actions raise
|
|
72
|
+
`ValueError` without changing game state.
|
|
73
|
+
|
|
74
|
+
Rewards are points earned at each round's end, so each player's episode return
|
|
75
|
+
equals their final score. Winners are reported in `info["winners"]` at game end;
|
|
76
|
+
the reward is not a win/loss reward. By default, a game ends after a completed
|
|
77
|
+
round brings someone to 200 points. Set `target_score` to change that threshold,
|
|
78
|
+
or `max_rounds` to truncate training episodes at a round boundary (default: no
|
|
79
|
+
limit). Tied leaders share the win. Scores and the last completed round's scores
|
|
80
|
+
are available in `info` after the first round.
|
|
81
|
+
|
|
82
|
+
The environment supports `ansi` and `human` text rendering. It has no parallel
|
|
83
|
+
API because each decision can immediately affect other players.
|
|
84
|
+
|
|
85
|
+
See [rules and observation reference](docs/rules-flip7.md) for rule conventions,
|
|
86
|
+
card IDs, and the public observation layout.
|
|
87
|
+
|
|
88
|
+
The `_v0` suffix identifies version 0 of the environment's rules and interface.
|
|
89
|
+
It is independent of the package version (`0.1.0`). Changes that invalidate
|
|
90
|
+
existing policies or experiment comparisons should introduce a new environment
|
|
91
|
+
version, such as `flip7_v1`, rather than silently replacing `flip7_v0`.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
From a checkout, install the package and development tools:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
uv sync --group dev
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`dev` is the dependency group defined in `pyproject.toml` for pytest and Ruff.
|
|
102
|
+
uv includes this group by default, so `uv sync` also works; `--group dev` makes
|
|
103
|
+
the intent explicit. These tools are not runtime dependencies and are not
|
|
104
|
+
installed when users install the published package.
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
uv run pytest
|
|
108
|
+
uv run ruff check src tests
|
|
109
|
+
uv run ruff format --check src tests
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Tests include PettingZoo API and seed checks, controlled special-card scenarios,
|
|
113
|
+
and full randomized games checking card conservation and cumulative rewards.
|
|
114
|
+
|
|
115
|
+
See [publishing instructions](docs/publishing.md) for building and releasing
|
|
116
|
+
the package. The implementation is distributed under the [MIT license](LICENSE).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# game-room
|
|
2
|
+
|
|
3
|
+
A collection of PettingZoo environments for a few different games.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Requires Python 3.14+. Once the first release is published to PyPI, install it
|
|
8
|
+
with pip:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
python -m pip install game-room
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or add it to a [uv](https://docs.astral.sh/uv/) project with `uv add game-room`.
|
|
15
|
+
The first release is being prepared; until it is published, install from this
|
|
16
|
+
repository with `python -m pip install .`.
|
|
17
|
+
|
|
18
|
+
## Getting Started
|
|
19
|
+
|
|
20
|
+
Save the following as `play.py`, then run `python play.py` (or
|
|
21
|
+
`uv run python play.py` in a uv project) to simulate a
|
|
22
|
+
three-player Flip 7 game with random legal actions and print the final scores:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from game_room import flip7_v0
|
|
26
|
+
|
|
27
|
+
env = flip7_v0.env(num_players=3, render_mode="ansi")
|
|
28
|
+
env.reset(seed=42)
|
|
29
|
+
|
|
30
|
+
for agent in env.agent_iter():
|
|
31
|
+
observation, reward, terminated, truncated, info = env.last()
|
|
32
|
+
if terminated or truncated:
|
|
33
|
+
action = None
|
|
34
|
+
else:
|
|
35
|
+
action = env.action_space(agent).sample(observation["action_mask"])
|
|
36
|
+
env.step(action)
|
|
37
|
+
|
|
38
|
+
print(env.render())
|
|
39
|
+
env.close()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Flip 7
|
|
43
|
+
|
|
44
|
+
`game_room.flip7_v0` implements the 94-card game for 3-18 players using
|
|
45
|
+
PettingZoo's turn-based AEC API. It includes the opening deal, all special cards,
|
|
46
|
+
chained Flip Threes, deck recycling, and complete games across multiple rounds.
|
|
47
|
+
|
|
48
|
+
Actions are `0` (Hit), `1` (Stay), and `2 + seat_index` (choose the recipient
|
|
49
|
+
of an action card). Always use the mask: target selection is a separate decision,
|
|
50
|
+
and the same player can act multiple times in succession. Invalid actions raise
|
|
51
|
+
`ValueError` without changing game state.
|
|
52
|
+
|
|
53
|
+
Rewards are points earned at each round's end, so each player's episode return
|
|
54
|
+
equals their final score. Winners are reported in `info["winners"]` at game end;
|
|
55
|
+
the reward is not a win/loss reward. By default, a game ends after a completed
|
|
56
|
+
round brings someone to 200 points. Set `target_score` to change that threshold,
|
|
57
|
+
or `max_rounds` to truncate training episodes at a round boundary (default: no
|
|
58
|
+
limit). Tied leaders share the win. Scores and the last completed round's scores
|
|
59
|
+
are available in `info` after the first round.
|
|
60
|
+
|
|
61
|
+
The environment supports `ansi` and `human` text rendering. It has no parallel
|
|
62
|
+
API because each decision can immediately affect other players.
|
|
63
|
+
|
|
64
|
+
See [rules and observation reference](docs/rules-flip7.md) for rule conventions,
|
|
65
|
+
card IDs, and the public observation layout.
|
|
66
|
+
|
|
67
|
+
The `_v0` suffix identifies version 0 of the environment's rules and interface.
|
|
68
|
+
It is independent of the package version (`0.1.0`). Changes that invalidate
|
|
69
|
+
existing policies or experiment comparisons should introduce a new environment
|
|
70
|
+
version, such as `flip7_v1`, rather than silently replacing `flip7_v0`.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
From a checkout, install the package and development tools:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
uv sync --group dev
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`dev` is the dependency group defined in `pyproject.toml` for pytest and Ruff.
|
|
81
|
+
uv includes this group by default, so `uv sync` also works; `--group dev` makes
|
|
82
|
+
the intent explicit. These tools are not runtime dependencies and are not
|
|
83
|
+
installed when users install the published package.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
uv run pytest
|
|
87
|
+
uv run ruff check src tests
|
|
88
|
+
uv run ruff format --check src tests
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Tests include PettingZoo API and seed checks, controlled special-card scenarios,
|
|
92
|
+
and full randomized games checking card conservation and cumulative rewards.
|
|
93
|
+
|
|
94
|
+
See [publishing instructions](docs/publishing.md) for building and releasing
|
|
95
|
+
the package. The implementation is distributed under the [MIT license](LICENSE).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Publishing game-room
|
|
2
|
+
|
|
3
|
+
The initial distribution name is `game-room`, the Python import is `game_room`,
|
|
4
|
+
and the first planned version is `0.1.0`. Python 3.14+ is currently required.
|
|
5
|
+
The package uses the MIT license and uv's build backend.
|
|
6
|
+
|
|
7
|
+
## Validate and build
|
|
8
|
+
|
|
9
|
+
Run from the repository root:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
uv sync --locked --group dev
|
|
13
|
+
uv run pytest
|
|
14
|
+
uv run ruff check src tests
|
|
15
|
+
uv run ruff format --check src tests
|
|
16
|
+
uv build --no-sources
|
|
17
|
+
uvx --from twine twine check --strict dist/game_room-0.1.0*
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The build produces a source archive (`.tar.gz`) and a wheel (`.whl`) in `dist/`.
|
|
21
|
+
uv builds the wheel from the source archive by default, checking that the source
|
|
22
|
+
distribution is sufficient to build the package. The source archive includes
|
|
23
|
+
tests and documentation; the wheel includes the importable package, typing
|
|
24
|
+
marker, metadata, and license. Development dependencies are not wheel runtime
|
|
25
|
+
requirements.
|
|
26
|
+
|
|
27
|
+
Before releasing, install the wheel into a fresh environment and run the README
|
|
28
|
+
example from outside the checkout. Confirm the imported package comes from that
|
|
29
|
+
environment's site-packages.
|
|
30
|
+
|
|
31
|
+
## Registry setup
|
|
32
|
+
|
|
33
|
+
1. Create or select your PyPI and TestPyPI accounts. They are separate services.
|
|
34
|
+
2. Confirm the distribution name is available or owned by your account on each
|
|
35
|
+
service. A missing project page does not guarantee the name can be registered.
|
|
36
|
+
3. Configure authentication locally. For manual uploads, use a registry API token
|
|
37
|
+
through `UV_PUBLISH_TOKEN` or uv's credential prompt. Never commit tokens.
|
|
38
|
+
4. When a repository is hosted, add its actual URL to `[project.urls]` and configure
|
|
39
|
+
a Trusted Publisher if you want releases through CI. No remote repository or
|
|
40
|
+
publisher is configured by this checkout's initial packaging setup.
|
|
41
|
+
|
|
42
|
+
## Test release
|
|
43
|
+
|
|
44
|
+
With TestPyPI credentials configured:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
uv publish --publish-url https://test.pypi.org/legacy/ dist/game_room-0.1.0.tar.gz dist/game_room-0.1.0-py3-none-any.whl
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
To test that artifact, create a fresh environment, install the runtime
|
|
51
|
+
dependencies from regular PyPI, then install this package from TestPyPI without
|
|
52
|
+
dependency resolution:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
uv venv /tmp/game-room-test-release
|
|
56
|
+
uv pip install --python /tmp/game-room-test-release/bin/python 'pettingzoo>=1.24.3,<2' 'gymnasium>=1.0,<2' 'numpy>=2.1'
|
|
57
|
+
uv pip install --python /tmp/game-room-test-release/bin/python --index-url https://test.pypi.org/simple/ --no-deps game-room==0.1.0
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Production release
|
|
61
|
+
|
|
62
|
+
After verifying the test release, use production PyPI credentials:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
uv publish dist/game_room-0.1.0.tar.gz dist/game_room-0.1.0-py3-none-any.whl
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
PyPI release files cannot be overwritten. For subsequent releases, update
|
|
69
|
+
`project.version`, refresh `uv.lock`, rebuild, and publish only the intended
|
|
70
|
+
version's artifacts. Update these versioned commands accordingly. After the
|
|
71
|
+
first upload succeeds, remove the README's pre-release installation notice.
|
|
72
|
+
|
|
73
|
+
References: [uv packaging guide](https://docs.astral.sh/uv/guides/package/),
|
|
74
|
+
[Python packaging tutorial](https://packaging.python.org/en/latest/tutorials/packaging-projects/).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Flip 7 environment reference
|
|
2
|
+
|
|
3
|
+
Implementation: `game_room.flip7_v0`, based on The Op's
|
|
4
|
+
[official rules, edition 3.1](https://cdn.shopify.com/s/files/1/0611/3958/3198/files/25_FLIP_7_TB_RULES_C_Rev_9_2_25_ND.pdf?v=1756935535).
|
|
5
|
+
The game name belongs to its respective owner; this repository supplies an
|
|
6
|
+
independent implementation and does not include card artwork.
|
|
7
|
+
|
|
8
|
+
## Implemented rules
|
|
9
|
+
|
|
10
|
+
The deck contains 94 cards: one zero, n copies of each number n from 1 through
|
|
11
|
+
12, one each of +2/+4/+6/+8/+10/x2, and three each of Freeze, Flip Three, and
|
|
12
|
+
Second Chance. Each round starts with one deal per active seat. Special cards
|
|
13
|
+
interrupt that deal. Players then choose Hit or Stay in seat order; staying
|
|
14
|
+
requires at least one card in front of the player.
|
|
15
|
+
|
|
16
|
+
A duplicate number busts its recipient unless they have Second Chance, in which
|
|
17
|
+
case the duplicate and Second Chance are discarded immediately. A second Second
|
|
18
|
+
Chance must go to another active player without one, or be discarded if there
|
|
19
|
+
is no eligible recipient. Freeze banks a target's current hand. Flip Three
|
|
20
|
+
forces up to three draws, stopping on a bust or seven distinct numbers. Every
|
|
21
|
+
card counts toward those three draws. Second Chance works immediately; Freeze
|
|
22
|
+
and further Flip Three cards wait until the three draws finish and are canceled
|
|
23
|
+
if their recipient busts.
|
|
24
|
+
|
|
25
|
+
Seven number cards end the round for everyone. Otherwise the round ends when
|
|
26
|
+
everyone stays, freezes, or busts. Non-busted players score their numbers,
|
|
27
|
+
doubled if they hold x2, then add modifiers and the 15-point Flip 7 bonus.
|
|
28
|
+
The highest total wins once a completed round has brought someone to the target
|
|
29
|
+
score. The unused deck carries forward between rounds. Only discards are
|
|
30
|
+
reshuffled when it runs out; cards on the table remain out of the draw pile.
|
|
31
|
+
|
|
32
|
+
## Explicit conventions
|
|
33
|
+
|
|
34
|
+
- Seats are fixed as `player_0` through `player_N-1`. The first dealer is seat 0;
|
|
35
|
+
dealing and turns start on the dealer's left, and the dealer rotates each round.
|
|
36
|
+
- Seats already frozen or busted during the opening deal are skipped.
|
|
37
|
+
- Multiple deferred actions resolve in draw order. Each Flip Three chain resolves
|
|
38
|
+
before the next deferred action. The player who drew a special card chooses its
|
|
39
|
+
target, including when forced to draw it by someone else. A player frozen while
|
|
40
|
+
resolving deferred actions still chooses targets for their remaining actions;
|
|
41
|
+
a bust cancels their remaining actions. The printed rules do not specify all
|
|
42
|
+
of these ordering cases.
|
|
43
|
+
- When the only eligible target is oneself, target selection remains an explicit
|
|
44
|
+
decision with a single legal action.
|
|
45
|
+
- Tied leaders share victory; the rules do not prescribe a tiebreaker.
|
|
46
|
+
- If every card is on the table and both draw and discard piles are empty when
|
|
47
|
+
a draw is needed, bank the round and proceed normally. This rare large-table
|
|
48
|
+
case has no printed ruling; this convention avoids an impossible draw.
|
|
49
|
+
- Observations retain publicly revealed cards even after players turn them face
|
|
50
|
+
down. This gives policies perfect recall of public card counts, never deck
|
|
51
|
+
order. Deferred action order and scheduled events are also public.
|
|
52
|
+
- This version uses a single deck and the standard 3-18 player competitive game.
|
|
53
|
+
The one/two-player round-count challenge and multiple decks are not implemented.
|
|
54
|
+
|
|
55
|
+
## API and rewards
|
|
56
|
+
|
|
57
|
+
`env(num_players=3, target_score=200, max_rounds=None, render_mode=None)` returns a
|
|
58
|
+
wrapped PettingZoo AEC environment. `raw_env(...)` exposes the underlying class.
|
|
59
|
+
`reset(seed=...)` seeds shuffling and action-space sampling; reset options are
|
|
60
|
+
accepted but unused. `render_mode` accepts `None`, `"ansi"`, or `"human"`.
|
|
61
|
+
|
|
62
|
+
Each round awards its scored points as rewards to all players together. Stayed,
|
|
63
|
+
frozen, and busted players remain episode agents but are skipped for ordinary
|
|
64
|
+
turns. On episode termination/truncation, drain remaining agents with `step(None)`
|
|
65
|
+
as required by the AEC API. Natural termination takes precedence over a round
|
|
66
|
+
limit. `info` includes `scores`, `round_scores`, `completed_round`, and `winners`
|
|
67
|
+
after a round ends, and retains that information until the next completed round.
|
|
68
|
+
No future draw information is exposed through observations, rendering, or infos.
|
|
69
|
+
|
|
70
|
+
## Observation layout
|
|
71
|
+
|
|
72
|
+
`observe(agent)` returns a dictionary:
|
|
73
|
+
|
|
74
|
+
- `action_mask`: an `int8` vector of length `2 + N`. Legal decisions have value 1;
|
|
75
|
+
all other entries are zero. Non-selected and finished agents have all-zero masks.
|
|
76
|
+
- `observation`: a flat `float64` vector of length `419 + 24*N`, with the following
|
|
77
|
+
consecutive blocks. All values represent nonnegative integers. Seats are in
|
|
78
|
+
absolute order, not rotated relative to the observer.
|
|
79
|
+
|
|
80
|
+
| Block | Size | Contents |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| Header | 10 | Observer seat, acting seat, dealer seat, round number (1-based), draw-pile size, target score, max rounds (0 means unlimited), target-decision flag, pending card ID (0 when absent), Flip 7 seat plus one (0 when absent) |
|
|
83
|
+
| Player rows | N * 24 | For each seat: banked total, round status, counts for each of 22 card IDs |
|
|
84
|
+
| Discard counts | 22 | Counts in the current discard pile by card ID |
|
|
85
|
+
| Scheduled events | 128 * 3 | Ordered triples `(kind, seat, card)`, padded with zeros |
|
|
86
|
+
| Deferred actions | 3 | Card ID plus one, in draw order, padded with zeros |
|
|
87
|
+
|
|
88
|
+
Round status codes: `0` active, `1` stayed, `2` busted, `3` frozen. Banked totals
|
|
89
|
+
update together at round end. Final observations clear hands after moving cards
|
|
90
|
+
to discard; `round_scores` in infos preserves the final scoring result.
|
|
91
|
+
|
|
92
|
+
Scheduled event kinds: `1` opening deal, `2` forced draw, `3` end of a Flip Three,
|
|
93
|
+
`4` resolve a deferred action, `5` next ordinary turn. Only kind 4 uses the card
|
|
94
|
+
field. Draw events contain no card identities. The fixed padding exceeds the
|
|
95
|
+
maximum scheduler size for the supported player counts and single deck.
|
|
96
|
+
|
|
97
|
+
| Card IDs | Meaning |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| 0-12 | Number cards |
|
|
100
|
+
| 13-17 | +2, +4, +6, +8, +10 |
|
|
101
|
+
| 18 | x2 |
|
|
102
|
+
| 19 | Freeze |
|
|
103
|
+
| 20 | Flip Three |
|
|
104
|
+
| 21 | Second Chance |
|
|
105
|
+
|
|
106
|
+
Actions: `HIT=0`, `STAY=1`, and `TARGET_OFFSET + seat` with `TARGET_OFFSET=2`.
|
|
107
|
+
The `Card` enum exports symbolic names for special cards. The vector layout and
|
|
108
|
+
action IDs are part of the versioned `flip7_v0` interface.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "game-room"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "PettingZoo environments for tabletop games"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = [
|
|
9
|
+
"pettingzoo",
|
|
10
|
+
"reinforcement-learning",
|
|
11
|
+
"multi-agent",
|
|
12
|
+
"card-games",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
requires-python = ">=3.14"
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pettingzoo>=1.24.3,<2",
|
|
25
|
+
"gymnasium>=1.0,<2",
|
|
26
|
+
"numpy>=2.1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "Austin Poor"
|
|
31
|
+
email = "45295232+a-poor@users.noreply.github.com"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8",
|
|
36
|
+
"ruff>=0.11",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
target-version = "py314"
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.uv.build-backend]
|
|
46
|
+
source-include = [
|
|
47
|
+
"docs/**",
|
|
48
|
+
"tests/**",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["uv_build>=0.13.6,<0.14.0"]
|
|
53
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "game-room"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "PettingZoo environments for tabletop games"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = ["pettingzoo", "reinforcement-learning", "multi-agent", "card-games"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.14",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
16
|
+
]
|
|
17
|
+
authors = [
|
|
18
|
+
{ name = "Austin Poor", email = "45295232+a-poor@users.noreply.github.com" }
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.14"
|
|
21
|
+
dependencies = ["pettingzoo>=1.24.3,<2", "gymnasium>=1.0,<2", "numpy>=2.1"]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = ["pytest>=8", "ruff>=0.11"]
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
target-version = "py314"
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.13.6,<0.14.0"]
|
|
34
|
+
build-backend = "uv_build"
|
|
35
|
+
|
|
36
|
+
[tool.uv.build-backend]
|
|
37
|
+
source-include = ["docs/**", "tests/**"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tabletop games exposed as PettingZoo environments."""
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Flip 7 (rules edition 3.1), a sequential, imperfect-information game.
|
|
2
|
+
|
|
3
|
+
See docs/rules-flip7.md for the observation layout and rule conventions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections import Counter, deque
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from gymnasium import spaces
|
|
12
|
+
from gymnasium.utils import seeding
|
|
13
|
+
from pettingzoo import AECEnv
|
|
14
|
+
from pettingzoo.utils import wrappers
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Card(IntEnum):
|
|
18
|
+
"""Numbers use IDs 0..12; the remaining IDs describe special cards."""
|
|
19
|
+
|
|
20
|
+
PLUS_2 = 13
|
|
21
|
+
PLUS_4 = 14
|
|
22
|
+
PLUS_6 = 15
|
|
23
|
+
PLUS_8 = 16
|
|
24
|
+
PLUS_10 = 17
|
|
25
|
+
TIMES_2 = 18
|
|
26
|
+
FREEZE = 19
|
|
27
|
+
FLIP_THREE = 20
|
|
28
|
+
SECOND_CHANCE = 21
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
HIT = 0
|
|
32
|
+
STAY = 1
|
|
33
|
+
TARGET_OFFSET = 2
|
|
34
|
+
CARD_COUNT = 22
|
|
35
|
+
# Scheduler instructions are public; no future card identities are stored here.
|
|
36
|
+
DEAL, FORCED_DRAW, END_THREE, RESOLVE, TURN = range(1, 6)
|
|
37
|
+
ACTIVE, STAYED, BUSTED, FROZEN = range(4)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_deck() -> list[int]:
|
|
41
|
+
"""Return one unshuffled 94-card deck."""
|
|
42
|
+
return (
|
|
43
|
+
[0]
|
|
44
|
+
+ [number for number in range(1, 13) for _ in range(number)]
|
|
45
|
+
+ list(range(Card.PLUS_2, Card.TIMES_2 + 1))
|
|
46
|
+
+ [
|
|
47
|
+
card
|
|
48
|
+
for card in range(Card.FREEZE, Card.SECOND_CHANCE + 1)
|
|
49
|
+
for _ in range(3)
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def env(**kwargs):
|
|
55
|
+
"""Create a wrapped AEC environment; illegal actions raise an exception."""
|
|
56
|
+
return wrappers.OrderEnforcingWrapper(
|
|
57
|
+
wrappers.AssertOutOfBoundsWrapper(raw_env(**kwargs))
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class raw_env(AECEnv):
|
|
62
|
+
metadata: ClassVar[dict] = {
|
|
63
|
+
"name": "flip7_v0",
|
|
64
|
+
"render_modes": ["human", "ansi"],
|
|
65
|
+
"is_parallelizable": False,
|
|
66
|
+
"render_fps": 1,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
num_players: int = 3,
|
|
72
|
+
target_score: int = 200,
|
|
73
|
+
max_rounds: int | None = None,
|
|
74
|
+
render_mode: str | None = None,
|
|
75
|
+
):
|
|
76
|
+
super().__init__()
|
|
77
|
+
if (
|
|
78
|
+
isinstance(num_players, bool)
|
|
79
|
+
or not isinstance(num_players, int)
|
|
80
|
+
or not 3 <= num_players <= 18
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("num_players must be an integer from 3 to 18")
|
|
83
|
+
if (
|
|
84
|
+
isinstance(target_score, bool)
|
|
85
|
+
or not isinstance(target_score, int)
|
|
86
|
+
or target_score < 1
|
|
87
|
+
):
|
|
88
|
+
raise ValueError("target_score must be a positive integer")
|
|
89
|
+
if max_rounds is not None and (
|
|
90
|
+
isinstance(max_rounds, bool)
|
|
91
|
+
or not isinstance(max_rounds, int)
|
|
92
|
+
or max_rounds < 1
|
|
93
|
+
):
|
|
94
|
+
raise ValueError("max_rounds must be a positive integer or None")
|
|
95
|
+
if render_mode not in (None, "ansi", "human"):
|
|
96
|
+
raise ValueError("render_mode must be None, 'ansi', or 'human'")
|
|
97
|
+
self.num_players = num_players
|
|
98
|
+
self.target_score = target_score
|
|
99
|
+
self.max_rounds = max_rounds
|
|
100
|
+
self.render_mode = render_mode
|
|
101
|
+
self.possible_agents = [f"player_{i}" for i in range(num_players)]
|
|
102
|
+
self._indices = {agent: i for i, agent in enumerate(self.possible_agents)}
|
|
103
|
+
self.action_spaces = {
|
|
104
|
+
agent: spaces.Discrete(TARGET_OFFSET + num_players)
|
|
105
|
+
for agent in self.possible_agents
|
|
106
|
+
}
|
|
107
|
+
# Header + per-seat scores/status/cards + discard + scheduler + deferred.
|
|
108
|
+
self._observation_size = 10 + num_players * 24 + CARD_COUNT + 128 * 3 + 3
|
|
109
|
+
self.observation_spaces = {
|
|
110
|
+
agent: spaces.Dict(
|
|
111
|
+
{
|
|
112
|
+
"observation": spaces.Box(
|
|
113
|
+
0, np.inf, (self._observation_size,), dtype=np.float64
|
|
114
|
+
),
|
|
115
|
+
"action_mask": spaces.MultiBinary(TARGET_OFFSET + num_players),
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
for agent in self.possible_agents
|
|
119
|
+
}
|
|
120
|
+
self.np_random, _ = seeding.np_random()
|
|
121
|
+
|
|
122
|
+
def observation_space(self, agent):
|
|
123
|
+
return self.observation_spaces[agent]
|
|
124
|
+
|
|
125
|
+
def action_space(self, agent):
|
|
126
|
+
return self.action_spaces[agent]
|
|
127
|
+
|
|
128
|
+
def reset(self, seed=None, options=None):
|
|
129
|
+
# Accept unused options for compatibility with PettingZoo's reset API.
|
|
130
|
+
if seed is not None:
|
|
131
|
+
self.np_random, _ = seeding.np_random(seed)
|
|
132
|
+
for i, agent in enumerate(self.possible_agents):
|
|
133
|
+
self.action_space(agent).seed(seed + i)
|
|
134
|
+
self.agents = self.possible_agents[:]
|
|
135
|
+
self.rewards = dict.fromkeys(self.agents, 0.0)
|
|
136
|
+
self._cumulative_rewards = dict.fromkeys(self.agents, 0.0)
|
|
137
|
+
self.terminations = dict.fromkeys(self.agents, False)
|
|
138
|
+
self.truncations = dict.fromkeys(self.agents, False)
|
|
139
|
+
self.infos = {agent: {} for agent in self.agents}
|
|
140
|
+
self._skip_agent_selection = None
|
|
141
|
+
self.scores = [0] * self.num_players
|
|
142
|
+
self.deck = make_deck()
|
|
143
|
+
self.np_random.shuffle(self.deck)
|
|
144
|
+
self.discard = []
|
|
145
|
+
self.round_number = 0
|
|
146
|
+
self.dealer = 0
|
|
147
|
+
self.winners = []
|
|
148
|
+
self._start_round()
|
|
149
|
+
self._advance()
|
|
150
|
+
self._accumulate_rewards()
|
|
151
|
+
|
|
152
|
+
def _start_round(self):
|
|
153
|
+
self.round_number += 1
|
|
154
|
+
self.hands = [[] for _ in self.possible_agents]
|
|
155
|
+
self.status = [ACTIVE] * self.num_players
|
|
156
|
+
self.flip7_player = None
|
|
157
|
+
self._exhausted = False
|
|
158
|
+
self.pending_card = None
|
|
159
|
+
self.deferred = []
|
|
160
|
+
# The player to the dealer's left acts first; dealer acts last.
|
|
161
|
+
first = (self.dealer + 1) % self.num_players
|
|
162
|
+
self.tasks = deque(
|
|
163
|
+
(DEAL, (first + i) % self.num_players, 0) for i in range(self.num_players)
|
|
164
|
+
)
|
|
165
|
+
self.tasks.append((TURN, first, 0))
|
|
166
|
+
|
|
167
|
+
def _round_score(self, player):
|
|
168
|
+
if self.status[player] == BUSTED:
|
|
169
|
+
return 0
|
|
170
|
+
hand = self.hands[player]
|
|
171
|
+
total = sum(card for card in hand if card <= 12)
|
|
172
|
+
total *= 2 if Card.TIMES_2 in hand else 1
|
|
173
|
+
total += sum(2 * (card - 12) for card in hand if 13 <= card <= 17)
|
|
174
|
+
return total + (15 if self.flip7_player == player else 0)
|
|
175
|
+
|
|
176
|
+
def _draw(self, player, forced=False):
|
|
177
|
+
if not self.deck:
|
|
178
|
+
self.deck, self.discard = self.discard, []
|
|
179
|
+
self.np_random.shuffle(self.deck)
|
|
180
|
+
if not self.deck:
|
|
181
|
+
# Large tables can hold the entire deck without a Flip 7. The
|
|
182
|
+
# rulebook has no adjudication for this; bank the round (see docs).
|
|
183
|
+
self._exhausted = True
|
|
184
|
+
return
|
|
185
|
+
card = self.deck.pop()
|
|
186
|
+
hand = self.hands[player]
|
|
187
|
+
if card <= 12:
|
|
188
|
+
if card in hand:
|
|
189
|
+
if Card.SECOND_CHANCE in hand:
|
|
190
|
+
hand.remove(Card.SECOND_CHANCE)
|
|
191
|
+
self.discard.extend([int(Card.SECOND_CHANCE), card])
|
|
192
|
+
return
|
|
193
|
+
self.status[player] = BUSTED
|
|
194
|
+
hand.append(card)
|
|
195
|
+
if self.status[player] != BUSTED and sum(c <= 12 for c in hand) == 7:
|
|
196
|
+
self.flip7_player = player
|
|
197
|
+
elif card <= Card.TIMES_2:
|
|
198
|
+
hand.append(card)
|
|
199
|
+
elif card == Card.SECOND_CHANCE:
|
|
200
|
+
if card not in hand:
|
|
201
|
+
hand.append(card)
|
|
202
|
+
elif self._targets(player, card):
|
|
203
|
+
self._ask_target(player, card)
|
|
204
|
+
else:
|
|
205
|
+
self.discard.append(card)
|
|
206
|
+
elif forced:
|
|
207
|
+
self.deferred.append(card)
|
|
208
|
+
else:
|
|
209
|
+
self._ask_target(player, card)
|
|
210
|
+
|
|
211
|
+
def _targets(self, player, card):
|
|
212
|
+
return [
|
|
213
|
+
i
|
|
214
|
+
for i in range(self.num_players)
|
|
215
|
+
if self.status[i] == ACTIVE
|
|
216
|
+
and (
|
|
217
|
+
card != Card.SECOND_CHANCE
|
|
218
|
+
or (i != player and Card.SECOND_CHANCE not in self.hands[i])
|
|
219
|
+
)
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
def _ask_target(self, player, card):
|
|
223
|
+
self.pending_card = int(card)
|
|
224
|
+
self.agent_selection = self.possible_agents[player]
|
|
225
|
+
|
|
226
|
+
def _advance(self):
|
|
227
|
+
"""Run chance events until the next player decision or game end."""
|
|
228
|
+
while True:
|
|
229
|
+
if (
|
|
230
|
+
self.flip7_player is not None
|
|
231
|
+
or ACTIVE not in self.status
|
|
232
|
+
or self._exhausted
|
|
233
|
+
) and self._finish_round():
|
|
234
|
+
return
|
|
235
|
+
if self.pending_card is not None:
|
|
236
|
+
return
|
|
237
|
+
kind, player, card = self.tasks.popleft()
|
|
238
|
+
if kind == TURN:
|
|
239
|
+
self.tasks.append((TURN, (player + 1) % self.num_players, 0))
|
|
240
|
+
if self.status[player] == ACTIVE:
|
|
241
|
+
self.agent_selection = self.possible_agents[player]
|
|
242
|
+
return
|
|
243
|
+
elif kind in (DEAL, FORCED_DRAW):
|
|
244
|
+
if self.status[player] == ACTIVE:
|
|
245
|
+
self._draw(player, forced=kind == FORCED_DRAW)
|
|
246
|
+
elif kind == END_THREE:
|
|
247
|
+
if self.status[player] == BUSTED:
|
|
248
|
+
self.hands[player].extend(self.deferred)
|
|
249
|
+
else:
|
|
250
|
+
for action_card in reversed(self.deferred):
|
|
251
|
+
self.tasks.appendleft((RESOLVE, player, action_card))
|
|
252
|
+
self.deferred = []
|
|
253
|
+
elif kind == RESOLVE:
|
|
254
|
+
# Busting in a chained Flip Three cancels outstanding actions.
|
|
255
|
+
if self.status[player] == BUSTED:
|
|
256
|
+
self.hands[player].append(card)
|
|
257
|
+
else:
|
|
258
|
+
self._ask_target(player, card)
|
|
259
|
+
|
|
260
|
+
def _finish_round(self):
|
|
261
|
+
round_scores = [self._round_score(i) for i in range(self.num_players)]
|
|
262
|
+
self.scores = [a + b for a, b in zip(self.scores, round_scores)]
|
|
263
|
+
for hand in self.hands:
|
|
264
|
+
self.discard.extend(hand)
|
|
265
|
+
self.discard.extend(self.deferred)
|
|
266
|
+
self.discard.extend(card for kind, _, card in self.tasks if kind == RESOLVE)
|
|
267
|
+
if self.pending_card is not None:
|
|
268
|
+
self.discard.append(self.pending_card)
|
|
269
|
+
self.tasks.clear()
|
|
270
|
+
self.deferred = []
|
|
271
|
+
self.pending_card = None
|
|
272
|
+
finished = max(self.scores) >= self.target_score
|
|
273
|
+
truncated = (
|
|
274
|
+
not finished
|
|
275
|
+
and self.max_rounds is not None
|
|
276
|
+
and self.round_number >= self.max_rounds
|
|
277
|
+
)
|
|
278
|
+
if finished:
|
|
279
|
+
best = max(self.scores)
|
|
280
|
+
self.winners = [
|
|
281
|
+
a for a, s in zip(self.possible_agents, self.scores) if s == best
|
|
282
|
+
]
|
|
283
|
+
for i, agent in enumerate(self.possible_agents):
|
|
284
|
+
self.rewards[agent] += float(round_scores[i])
|
|
285
|
+
self.infos[agent] = {
|
|
286
|
+
"scores": dict(zip(self.possible_agents, self.scores)),
|
|
287
|
+
"round_scores": dict(zip(self.possible_agents, round_scores)),
|
|
288
|
+
"completed_round": self.round_number,
|
|
289
|
+
"winners": self.winners[:],
|
|
290
|
+
}
|
|
291
|
+
self.terminations[agent] = finished
|
|
292
|
+
self.truncations[agent] = truncated
|
|
293
|
+
if finished or truncated:
|
|
294
|
+
# Clear hands now that their cards have moved to the discard pile.
|
|
295
|
+
self.hands = [[] for _ in self.possible_agents]
|
|
296
|
+
self.agent_selection = self.possible_agents[0]
|
|
297
|
+
return True
|
|
298
|
+
self.dealer = (self.dealer + 1) % self.num_players
|
|
299
|
+
self._start_round()
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
def _mask(self, agent):
|
|
303
|
+
mask = np.zeros(TARGET_OFFSET + self.num_players, dtype=np.int8)
|
|
304
|
+
if (
|
|
305
|
+
agent not in self.agents
|
|
306
|
+
or self.terminations[agent]
|
|
307
|
+
or self.truncations[agent]
|
|
308
|
+
or agent != self.agent_selection
|
|
309
|
+
):
|
|
310
|
+
return mask
|
|
311
|
+
player = self._indices[agent]
|
|
312
|
+
if self.pending_card is None:
|
|
313
|
+
mask[HIT] = 1
|
|
314
|
+
mask[STAY] = bool(self.hands[player])
|
|
315
|
+
else:
|
|
316
|
+
for target in self._targets(player, self.pending_card):
|
|
317
|
+
mask[TARGET_OFFSET + target] = 1
|
|
318
|
+
return mask
|
|
319
|
+
|
|
320
|
+
def observe(self, agent):
|
|
321
|
+
header = [
|
|
322
|
+
self._indices[agent],
|
|
323
|
+
self._indices[self.agent_selection],
|
|
324
|
+
self.dealer,
|
|
325
|
+
self.round_number,
|
|
326
|
+
len(self.deck),
|
|
327
|
+
self.target_score,
|
|
328
|
+
self.max_rounds or 0,
|
|
329
|
+
int(self.pending_card is not None),
|
|
330
|
+
0 if self.pending_card is None else self.pending_card,
|
|
331
|
+
0 if self.flip7_player is None else self.flip7_player + 1,
|
|
332
|
+
]
|
|
333
|
+
rows = []
|
|
334
|
+
for i, hand in enumerate(self.hands):
|
|
335
|
+
counts = Counter(hand)
|
|
336
|
+
rows.extend([self.scores[i], self.status[i]])
|
|
337
|
+
rows.extend(counts[c] for c in range(CARD_COUNT))
|
|
338
|
+
discard = Counter(self.discard)
|
|
339
|
+
deferred = [card + 1 for card in self.deferred]
|
|
340
|
+
deferred += [0] * (3 - len(deferred))
|
|
341
|
+
queue = np.zeros((128, 3), dtype=np.float64)
|
|
342
|
+
for i, task in enumerate(self.tasks):
|
|
343
|
+
queue[i] = task
|
|
344
|
+
observation = np.concatenate(
|
|
345
|
+
[
|
|
346
|
+
header,
|
|
347
|
+
rows,
|
|
348
|
+
[discard[c] for c in range(CARD_COUNT)],
|
|
349
|
+
queue.ravel(),
|
|
350
|
+
deferred,
|
|
351
|
+
]
|
|
352
|
+
).astype(np.float64)
|
|
353
|
+
return {"observation": observation, "action_mask": self._mask(agent)}
|
|
354
|
+
|
|
355
|
+
def step(self, action):
|
|
356
|
+
agent = self.agent_selection
|
|
357
|
+
if self.terminations[agent] or self.truncations[agent]:
|
|
358
|
+
self._was_dead_step(action)
|
|
359
|
+
return
|
|
360
|
+
if (
|
|
361
|
+
not self.action_space(agent).contains(action)
|
|
362
|
+
or not self._mask(agent)[action]
|
|
363
|
+
):
|
|
364
|
+
raise ValueError(f"Illegal action {action!r} for {agent}")
|
|
365
|
+
self._cumulative_rewards[agent] = 0
|
|
366
|
+
self._clear_rewards()
|
|
367
|
+
player = self._indices[agent]
|
|
368
|
+
if self.pending_card is not None:
|
|
369
|
+
target = int(action) - TARGET_OFFSET
|
|
370
|
+
card, self.pending_card = self.pending_card, None
|
|
371
|
+
self.hands[target].append(card)
|
|
372
|
+
if card == Card.FREEZE:
|
|
373
|
+
self.status[target] = FROZEN
|
|
374
|
+
elif card == Card.FLIP_THREE:
|
|
375
|
+
self.tasks.appendleft((END_THREE, target, 0))
|
|
376
|
+
for _ in range(3):
|
|
377
|
+
self.tasks.appendleft((FORCED_DRAW, target, 0))
|
|
378
|
+
elif action == STAY:
|
|
379
|
+
self.status[player] = STAYED
|
|
380
|
+
else:
|
|
381
|
+
self._draw(player)
|
|
382
|
+
self._advance()
|
|
383
|
+
self._accumulate_rewards()
|
|
384
|
+
if self.render_mode == "human":
|
|
385
|
+
self.render()
|
|
386
|
+
|
|
387
|
+
def render(self):
|
|
388
|
+
if self.render_mode is None:
|
|
389
|
+
return None
|
|
390
|
+
lines = [f"Flip 7 | round {self.round_number} | deck {len(self.deck)}"]
|
|
391
|
+
names = ["active", "stayed", "busted", "frozen"]
|
|
392
|
+
for i, agent in enumerate(self.possible_agents):
|
|
393
|
+
cards = [str(c) if c <= 12 else Card(c).name for c in self.hands[i]]
|
|
394
|
+
lines.append(
|
|
395
|
+
f"{agent}: {self.scores[i]} points | {names[self.status[i]]} | {', '.join(cards)}"
|
|
396
|
+
)
|
|
397
|
+
if self.winners:
|
|
398
|
+
lines.append(f"Winners: {', '.join(self.winners)}")
|
|
399
|
+
elif self.agents:
|
|
400
|
+
decision = (
|
|
401
|
+
"Hit / Stay"
|
|
402
|
+
if self.pending_card is None
|
|
403
|
+
else f"target for {Card(self.pending_card).name}"
|
|
404
|
+
)
|
|
405
|
+
lines.append(f"{self.agent_selection}: {decision}")
|
|
406
|
+
text = "\n".join(lines)
|
|
407
|
+
if self.render_mode == "human":
|
|
408
|
+
print(text)
|
|
409
|
+
return None
|
|
410
|
+
return text
|
|
411
|
+
|
|
412
|
+
def close(self):
|
|
413
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
from collections import Counter, deque
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from pettingzoo.test import api_test, seed_test
|
|
6
|
+
|
|
7
|
+
from game_room import flip7_v0 as game
|
|
8
|
+
from game_room.flip7_v0 import Card as C
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def position(hands=None, draws=(), **kwargs):
|
|
12
|
+
"""Build a controlled public position with a physically consistent deck."""
|
|
13
|
+
env = game.raw_env(**kwargs)
|
|
14
|
+
env.reset(seed=1)
|
|
15
|
+
env.scores = [0] * env.num_players
|
|
16
|
+
env.hands = hands or [[] for _ in env.possible_agents]
|
|
17
|
+
env.status = [game.ACTIVE] * env.num_players
|
|
18
|
+
env.discard = []
|
|
19
|
+
env.pending_card = None
|
|
20
|
+
env.deferred = []
|
|
21
|
+
env.flip7_player = None
|
|
22
|
+
env.round_number = 1
|
|
23
|
+
env.tasks = deque([(game.TURN, 1, 0)])
|
|
24
|
+
env.agent_selection = "player_0"
|
|
25
|
+
deck = game.make_deck()
|
|
26
|
+
for card in [c for hand in env.hands for c in hand] + list(draws):
|
|
27
|
+
deck.remove(card)
|
|
28
|
+
env.deck = deck + list(reversed(draws))
|
|
29
|
+
return env
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_deck():
|
|
33
|
+
counts = Counter(game.make_deck())
|
|
34
|
+
assert sum(counts.values()) == 94
|
|
35
|
+
assert counts[0] == 1
|
|
36
|
+
assert all(counts[i] == i for i in range(1, 13))
|
|
37
|
+
assert all(counts[i] == 1 for i in range(13, 19))
|
|
38
|
+
assert all(counts[i] == 3 for i in range(19, 22))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_api():
|
|
42
|
+
api_test(game.env(max_rounds=10), num_cycles=2000)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_seed():
|
|
46
|
+
seed_test(lambda: game.env(max_rounds=10), num_cycles=1000)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_duplicate_bust_and_retained_cards():
|
|
50
|
+
env = position([[12], [], []], [12])
|
|
51
|
+
env.step(game.HIT)
|
|
52
|
+
assert env.status[0] == game.BUSTED
|
|
53
|
+
assert env.hands[0] == [12, 12]
|
|
54
|
+
assert env._round_score(0) == 0
|
|
55
|
+
assert env.agent_selection == "player_1"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_second_chance_consumed():
|
|
59
|
+
env = position([[12, C.SECOND_CHANCE], [], []], [12])
|
|
60
|
+
env.step(game.HIT)
|
|
61
|
+
assert env.status[0] == game.ACTIVE
|
|
62
|
+
assert env.hands[0] == [12]
|
|
63
|
+
assert Counter(env.discard) == Counter([12, C.SECOND_CHANCE])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_second_chance_gift_and_ineligible_players():
|
|
67
|
+
env = position([[C.SECOND_CHANCE], [C.SECOND_CHANCE], []], [C.SECOND_CHANCE])
|
|
68
|
+
env.step(game.HIT)
|
|
69
|
+
assert env.observe("player_0")["action_mask"].tolist() == [0, 0, 0, 0, 1]
|
|
70
|
+
env.step(4)
|
|
71
|
+
assert C.SECOND_CHANCE in env.hands[2]
|
|
72
|
+
assert env.agent_selection == "player_1"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_second_chance_discard_without_recipient():
|
|
76
|
+
env = position([[C.SECOND_CHANCE], [], []], [C.SECOND_CHANCE])
|
|
77
|
+
env.status[1:] = [game.STAYED, game.STAYED]
|
|
78
|
+
env.step(game.HIT)
|
|
79
|
+
assert env.discard == [C.SECOND_CHANCE]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_stay_requires_any_card_and_invalid_action_is_atomic():
|
|
83
|
+
env = position()
|
|
84
|
+
before = env.observe("player_0")["observation"].copy()
|
|
85
|
+
assert env.observe("player_0")["action_mask"].tolist() == [1, 0, 0, 0, 0]
|
|
86
|
+
with pytest.raises(ValueError):
|
|
87
|
+
env.step(game.STAY)
|
|
88
|
+
np.testing.assert_array_equal(before, env.observe("player_0")["observation"])
|
|
89
|
+
env.hands[0] = [C.PLUS_2]
|
|
90
|
+
assert env.observe("player_0")["action_mask"][game.STAY]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_freeze_and_turn_resume():
|
|
94
|
+
env = position([[10], [11], []], [C.FREEZE])
|
|
95
|
+
env.step(game.HIT)
|
|
96
|
+
env.step(3)
|
|
97
|
+
assert env.status[1] == game.FROZEN
|
|
98
|
+
assert env._round_score(1) == 11
|
|
99
|
+
assert env.agent_selection == "player_2"
|
|
100
|
+
assert env.observe("player_1")["action_mask"].sum() == 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_flip_three_deferred_freeze():
|
|
104
|
+
env = position([[1], [], []], [C.FLIP_THREE, C.FREEZE, 2, 3])
|
|
105
|
+
env.step(game.HIT)
|
|
106
|
+
env.step(2)
|
|
107
|
+
assert env.pending_card == C.FREEZE
|
|
108
|
+
assert env.hands[0] == [1, C.FLIP_THREE, 2, 3]
|
|
109
|
+
env.step(2)
|
|
110
|
+
assert env.status[0] == game.FROZEN
|
|
111
|
+
assert env._round_score(0) == 6
|
|
112
|
+
assert env.agent_selection == "player_1"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_flip_three_bust_cancels_deferred_and_remaining_draws():
|
|
116
|
+
env = position([[12], [], []], [C.FLIP_THREE, C.FREEZE, 12, 5])
|
|
117
|
+
env.step(game.HIT)
|
|
118
|
+
env.step(2)
|
|
119
|
+
assert env.status[0] == game.BUSTED
|
|
120
|
+
assert env.pending_card is None
|
|
121
|
+
assert C.FREEZE in env.hands[0]
|
|
122
|
+
assert env.deck[-1] == 5
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_second_chance_is_usable_during_flip_three():
|
|
126
|
+
env = position([[12], [], []], [C.FLIP_THREE, C.SECOND_CHANCE, 12, 2])
|
|
127
|
+
env.step(game.HIT)
|
|
128
|
+
env.step(2)
|
|
129
|
+
assert env.status[0] == game.ACTIVE
|
|
130
|
+
assert env.hands[0] == [12, C.FLIP_THREE, 2]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_chained_flip_three():
|
|
134
|
+
env = position([[0], [], []], [C.FLIP_THREE, C.FLIP_THREE, 1, 2, 3, 4, 5])
|
|
135
|
+
env.step(game.HIT)
|
|
136
|
+
env.step(2)
|
|
137
|
+
assert env.hands[0] == [0, C.FLIP_THREE, 1, 2]
|
|
138
|
+
assert env.pending_card == C.FLIP_THREE
|
|
139
|
+
env.step(3)
|
|
140
|
+
assert env.hands[1] == [C.FLIP_THREE, 3, 4, 5]
|
|
141
|
+
assert env.agent_selection == "player_1"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_flip7_ends_round_and_bonus_not_doubled():
|
|
145
|
+
env = position(
|
|
146
|
+
[[0, 1, 2, 3, 4, 5, C.TIMES_2, C.PLUS_10], [12], [11]], [6], target_score=1
|
|
147
|
+
)
|
|
148
|
+
env.step(game.HIT)
|
|
149
|
+
assert env.scores == [67, 12, 11]
|
|
150
|
+
assert env.rewards == {"player_0": 67, "player_1": 12, "player_2": 11}
|
|
151
|
+
assert all(env.terminations.values())
|
|
152
|
+
assert env.winners == ["player_0"]
|
|
153
|
+
for agent in env.agent_iter():
|
|
154
|
+
assert env.last()[2]
|
|
155
|
+
env.step(None)
|
|
156
|
+
assert not env.agents
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_flip7_interrupts_forced_draws():
|
|
160
|
+
env = position(
|
|
161
|
+
[[0, 1, 2, 3, 4, 5], [], []], [C.FLIP_THREE, 6, 7, 8], target_score=1
|
|
162
|
+
)
|
|
163
|
+
env.step(game.HIT)
|
|
164
|
+
env.step(2)
|
|
165
|
+
assert env.scores[0] == 36
|
|
166
|
+
assert env.deck[-1] == 7
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_winner_only_at_round_end_and_shared_tie():
|
|
170
|
+
env = position([[12], [12], []], target_score=12)
|
|
171
|
+
env.step(game.STAY)
|
|
172
|
+
assert not any(env.terminations.values())
|
|
173
|
+
env.step(game.STAY)
|
|
174
|
+
env.hands[2] = [C.TIMES_2]
|
|
175
|
+
env.step(game.STAY)
|
|
176
|
+
assert env.winners == ["player_0", "player_1"]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_modifier_only_score():
|
|
180
|
+
env = position([[C.PLUS_10, C.TIMES_2], [], []])
|
|
181
|
+
assert env._round_score(0) == 10
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_next_round_keeps_deck_and_rotates_dealer():
|
|
185
|
+
env = position([[1], [2], [3]], [4, 5, 6])
|
|
186
|
+
before = len(env.deck)
|
|
187
|
+
for _ in range(3):
|
|
188
|
+
env.step(game.STAY)
|
|
189
|
+
assert env.round_number == 2
|
|
190
|
+
assert env.dealer == 1
|
|
191
|
+
assert env.agent_selection == "player_2"
|
|
192
|
+
assert env.hands == [[5], [6], [4]]
|
|
193
|
+
assert len(env.deck) == before - 3
|
|
194
|
+
assert env.discard == [1, 2, 3]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_reshuffle_excludes_table():
|
|
198
|
+
env = position([[12], [], []])
|
|
199
|
+
env.deck = []
|
|
200
|
+
env.discard = [3]
|
|
201
|
+
env.step(game.HIT)
|
|
202
|
+
assert env.hands[0] == [12, 3]
|
|
203
|
+
assert env.discard == []
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_exhausted_deck_banks_round():
|
|
207
|
+
env = position([[1], [2], [3]], max_rounds=1)
|
|
208
|
+
env.deck = []
|
|
209
|
+
env.discard = []
|
|
210
|
+
env.step(game.HIT)
|
|
211
|
+
assert env.scores == [1, 2, 3]
|
|
212
|
+
assert all(env.truncations.values())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_second_chance_gift_resumes_remaining_forced_draws():
|
|
216
|
+
env = position(
|
|
217
|
+
[[12, C.SECOND_CHANCE], [], []],
|
|
218
|
+
[C.FLIP_THREE, C.SECOND_CHANCE, 12, 3],
|
|
219
|
+
)
|
|
220
|
+
env.step(game.HIT)
|
|
221
|
+
env.step(2)
|
|
222
|
+
assert env.pending_card == C.SECOND_CHANCE
|
|
223
|
+
env.step(3)
|
|
224
|
+
assert C.SECOND_CHANCE in env.hands[1]
|
|
225
|
+
assert env.hands[0] == [12, C.FLIP_THREE, 3]
|
|
226
|
+
assert env.agent_selection == "player_1"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_deferred_actions_keep_draw_order_after_self_freeze():
|
|
230
|
+
env = position([[1], [], []], [C.FLIP_THREE, C.FREEZE, C.FLIP_THREE, 2, 3, 4, 5])
|
|
231
|
+
env.step(game.HIT)
|
|
232
|
+
env.step(2)
|
|
233
|
+
assert env.pending_card == C.FREEZE
|
|
234
|
+
env.step(2)
|
|
235
|
+
assert env.status[0] == game.FROZEN
|
|
236
|
+
assert env.agent_selection == "player_0"
|
|
237
|
+
assert env.pending_card == C.FLIP_THREE
|
|
238
|
+
assert env.observe("player_0")["action_mask"].tolist() == [0, 0, 0, 1, 1]
|
|
239
|
+
env.step(3)
|
|
240
|
+
assert env.hands[1] == [C.FLIP_THREE, 3, 4, 5]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_opening_deal_pauses_for_actions(monkeypatch):
|
|
244
|
+
# A no-op shuffle makes the rigged deck deterministic.
|
|
245
|
+
class RNG:
|
|
246
|
+
def shuffle(self, deck):
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
monkeypatch.setattr(game.seeding, "np_random", lambda seed=None: (RNG(), 1))
|
|
250
|
+
monkeypatch.setattr(game, "make_deck", lambda: [7, 6, 5, C.FREEZE])
|
|
251
|
+
env = game.raw_env()
|
|
252
|
+
env.reset()
|
|
253
|
+
assert env.agent_selection == "player_1"
|
|
254
|
+
assert env.pending_card == C.FREEZE
|
|
255
|
+
env.step(4) # Freeze player 2 before their opening deal; skip them.
|
|
256
|
+
assert env.hands[0] == [5]
|
|
257
|
+
assert env.hands[1] == []
|
|
258
|
+
assert env.hands[2] == [C.FREEZE]
|
|
259
|
+
assert env.agent_selection == "player_1"
|
|
260
|
+
assert not env.observe("player_1")["action_mask"][game.STAY]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_truncation():
|
|
264
|
+
env = position([[1], [2], [3]], max_rounds=1)
|
|
265
|
+
for _ in range(3):
|
|
266
|
+
env.step(game.STAY)
|
|
267
|
+
assert all(env.truncations.values())
|
|
268
|
+
assert not any(env.terminations.values())
|
|
269
|
+
assert env.scores == [1, 2, 3]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@pytest.mark.parametrize("num_players", [3, 6, 18])
|
|
273
|
+
def test_random_games_conserve_cards_and_rewards(num_players):
|
|
274
|
+
for seed in range(10):
|
|
275
|
+
env = game.raw_env(num_players=num_players, max_rounds=100)
|
|
276
|
+
env.reset(seed=seed)
|
|
277
|
+
totals = dict.fromkeys(env.possible_agents, 0)
|
|
278
|
+
for agent in env.agent_iter(20000):
|
|
279
|
+
observation, reward, terminated, truncated, _ = env.last()
|
|
280
|
+
totals[agent] += reward
|
|
281
|
+
assert env.observation_space(agent).contains(observation)
|
|
282
|
+
cards = env.deck + env.discard + [c for h in env.hands for c in h]
|
|
283
|
+
cards += env.deferred
|
|
284
|
+
cards += [c for k, _, c in env.tasks if k == game.RESOLVE]
|
|
285
|
+
if env.pending_card is not None:
|
|
286
|
+
cards.append(env.pending_card)
|
|
287
|
+
assert Counter(cards) == Counter(game.make_deck())
|
|
288
|
+
action = (
|
|
289
|
+
None
|
|
290
|
+
if terminated or truncated
|
|
291
|
+
else env.action_space(agent).sample(observation["action_mask"])
|
|
292
|
+
)
|
|
293
|
+
env.step(action)
|
|
294
|
+
assert not env.agents
|
|
295
|
+
assert list(totals.values()) == env.scores
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_hidden_deck_order_not_observed_and_observations_are_copies():
|
|
299
|
+
env = position([[1], [2], [3]])
|
|
300
|
+
before = env.observe("player_0")["observation"]
|
|
301
|
+
env.deck.reverse()
|
|
302
|
+
np.testing.assert_array_equal(before, env.observe("player_0")["observation"])
|
|
303
|
+
before[:] = 99
|
|
304
|
+
assert not np.all(env.observe("player_0")["observation"] == 99)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_render():
|
|
308
|
+
env = game.env(render_mode="ansi")
|
|
309
|
+
env.reset(seed=5)
|
|
310
|
+
assert "Flip 7 | round 1" in env.render()
|
|
311
|
+
env.close()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@pytest.mark.parametrize(
|
|
315
|
+
"kwargs",
|
|
316
|
+
[
|
|
317
|
+
{"num_players": 2},
|
|
318
|
+
{"num_players": 19},
|
|
319
|
+
{"target_score": 0},
|
|
320
|
+
{"max_rounds": 0},
|
|
321
|
+
{"render_mode": "rgb_array"},
|
|
322
|
+
],
|
|
323
|
+
)
|
|
324
|
+
def test_invalid_config(kwargs):
|
|
325
|
+
with pytest.raises(ValueError):
|
|
326
|
+
game.raw_env(**kwargs)
|