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.
@@ -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.
@@ -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)