Skip to content

Commit 0d1ac99

Browse files
authored
Merge pull request #66 from CurtisTechSolutions/feat/python-pettingzoo-aec
feat(python): add PettingZoo AEC self-play
2 parents e91095a + 41fcd8b commit 0d1ac99

7 files changed

Lines changed: 234 additions & 2 deletions

File tree

‎crates/apps/gfa-py/src/lib.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ impl NativeEnv {
111111
Ok(dict)
112112
}
113113

114+
fn public_text(&self, py: Python<'_>) -> PyResult<String> {
115+
py.detach(|| {
116+
self.inner
117+
.observe(Viewer::Spectator)
118+
.map(|observation| observation.text)
119+
})
120+
.map_err(error)
121+
}
122+
114123
fn clone(&self) -> Self {
115124
Self {
116125
inner: self.inner.clone(),

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ version = "0.1.0"
88
description = "Native game environments for model training and evaluation"
99
requires-python = ">=3.10"
1010
license = "GPL-3.0-only"
11-
dependencies = ["numpy>=1.24,<3", "gymnasium>=1.2,<2"]
11+
dependencies = ["numpy>=1.24,<3", "gymnasium>=1.2,<2", "pettingzoo>=1.25,<2"]
1212

1313
[project.optional-dependencies]
1414
test = ["pytest>=8,<9"]

‎python/README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,22 @@ The default opponent samples legal actions from the environment's seeded random
4242
Masks are required: a fixed action space includes actions that are illegal in a particular position. Invalid actions raise `ValueError` without committing a learner/opponent exchange. Call `reset` after termination or truncation. Position curricula can use `reset(options={"position": notation})`.
4343

4444
Wrapper checkpoints include native state and the environment RNG, so random-opponent continuations repeat exactly. A cloned wrapper owns independent game, RNG, and action-space state. A custom policy callable and its model state remain caller-owned; checkpoint those separately for reproducible self-play.
45+
46+
## PettingZoo self-play
47+
48+
```python
49+
import gamesforai
50+
51+
env = gamesforai.aec("connect4")
52+
env.reset(seed=42)
53+
for agent in env.agent_iter():
54+
obs, reward, terminated, truncated, info = env.last()
55+
action = None if terminated or truncated else policies[agent](
56+
obs["observation"], obs["action_mask"]
57+
)
58+
env.step(action)
59+
```
60+
61+
Agents are named `player_0`, `player_1`, and so on. Each policy sees only its seat's observation. The AEC interface preserves accumulated rewards and gives every agent its final transition before `step(None)` removes it. Numeric spaces and masks stay fixed through an episode. `clone()` copies native and AEC bookkeeping for independent search; rendering uses the public spectator view.
62+
63+
CI runs [PettingZoo's API test](https://pettingzoo.farama.org/content/environment_tests/) against every installed game in addition to full-game reward and truncation tests.

‎python/gamesforai/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Native GamesForAI environments for training and evaluation."""
22
from ._native import NativeEnv, games
33
from .gym_env import GameEnv, make
4+
from .aec_env import AECGameEnv, aec
45

5-
__all__ = ["GameEnv", "NativeEnv", "games", "make"]
6+
__all__ = ["AECGameEnv", "GameEnv", "NativeEnv", "aec", "games", "make"]

‎python/gamesforai/_native.pyi‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class NativeEnv:
1414
def flags(self) -> tuple[bool, bool]: ...
1515
def returns(self) -> list[float]: ...
1616
def frame(self, seat: int) -> dict[str, Any]: ...
17+
def public_text(self) -> str: ...
1718
def clone(self) -> NativeEnv: ...
1819
def get_state(self) -> str: ...
1920
def set_state(self, snapshot: str) -> None: ...

‎python/gamesforai/aec_env.py‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""PettingZoo AEC interface for independent policies and self-play."""
2+
from __future__ import annotations
3+
4+
import copy
5+
import json
6+
from typing import Any
7+
8+
import numpy as np
9+
from gymnasium import spaces
10+
from gymnasium.utils import seeding
11+
from pettingzoo import AECEnv
12+
13+
from ._native import NativeEnv
14+
15+
16+
class AECGameEnv(AECEnv):
17+
"""Sequential game turns with a separate information set for each agent."""
18+
19+
metadata = {"render_modes": ["ansi"], "name": "gamesforai_v0", "is_parallelizable": False}
20+
21+
def __init__(
22+
self, game: str, *, config: dict[str, Any] | None = None, render_mode: str | None = None
23+
) -> None:
24+
if render_mode not in (None, "ansi"):
25+
raise ValueError("render_mode must be None or 'ansi'")
26+
self.native = NativeEnv(game, json.dumps(config or {}))
27+
self.render_mode = render_mode
28+
self.possible_agents = [f"player_{seat}" for seat in range(self.native.num_players)]
29+
self._seats = {agent: seat for seat, agent in enumerate(self.possible_agents)}
30+
self._actions = {agent: spaces.Discrete(self.native.action_space_size) for agent in self.possible_agents}
31+
self._observations = {}
32+
for agent, seat in self._seats.items():
33+
shape = self.native.frame(seat)["observation"].shape
34+
self._observations[agent] = spaces.Dict({
35+
"observation": spaces.Box(-np.inf, np.inf, shape=shape, dtype=np.float32),
36+
"action_mask": spaces.MultiBinary(self.native.action_space_size),
37+
})
38+
self.agents = []
39+
self._rng, _ = seeding.np_random(None)
40+
41+
def observation_space(self, agent):
42+
return self._observations[agent]
43+
44+
def action_space(self, agent):
45+
return self._actions[agent]
46+
47+
def observe(self, agent):
48+
frame = self.native.frame(self._seats[agent])
49+
return {"observation": frame["observation"], "action_mask": frame["action_mask"]}
50+
51+
def _refresh_infos(self):
52+
self.infos = {
53+
agent: {"seat": self._seats[agent], "text": self.native.frame(self._seats[agent])["text"]}
54+
for agent in self.agents
55+
}
56+
57+
def reset(self, seed=None, options=None):
58+
options = options or {}
59+
# PettingZoo callers may pass options intended for other wrappers.
60+
position = options.get("position")
61+
if position is not None and not isinstance(position, str):
62+
raise ValueError("position must be a notation string")
63+
if seed is not None:
64+
self._rng, _ = seeding.np_random(seed)
65+
native_seed = int(self._rng.integers(0, 2**64, dtype=np.uint64)) if seed is None else seed
66+
self.native.reset(native_seed, position)
67+
actors = self.native.current_players()
68+
if len(actors) > 1:
69+
raise ValueError("AEC adapter currently requires sequential turns")
70+
self.agents = self.possible_agents[:]
71+
self.rewards = {agent: 0.0 for agent in self.agents}
72+
self._cumulative_rewards = self.rewards.copy()
73+
terminated, truncated = self.native.flags()
74+
self.terminations = {agent: terminated for agent in self.agents}
75+
self.truncations = {agent: truncated for agent in self.agents}
76+
self.agent_selection = self.possible_agents[actors[0]] if actors else self.agents[0]
77+
self._skip_agent_selection = None
78+
self._refresh_infos()
79+
80+
def step(self, action):
81+
if not self.agents:
82+
raise ValueError("reset before stepping an empty AEC episode")
83+
agent = self.agent_selection
84+
if self.terminations[agent] or self.truncations[agent]:
85+
self._was_dead_step(action)
86+
return
87+
if isinstance(action, (bool, np.bool_)) or not isinstance(action, (int, np.integer)):
88+
raise ValueError("a live agent must choose an integer action index")
89+
# Do not change bookkeeping until the native engine accepts the action.
90+
rewards, terminated, truncated = self.native.step(self._seats[agent], int(action))
91+
self._cumulative_rewards[agent] = 0.0
92+
self.rewards = {name: float(rewards[self._seats[name]]) for name in self.agents}
93+
self.terminations = {name: terminated for name in self.agents}
94+
self.truncations = {name: truncated for name in self.agents}
95+
actors = self.native.current_players()
96+
if actors:
97+
self.agent_selection = self.possible_agents[actors[0]]
98+
self._refresh_infos()
99+
self._accumulate_rewards()
100+
self._deads_step_first()
101+
102+
def render(self):
103+
return self.native.public_text()
104+
105+
def close(self):
106+
pass
107+
108+
def clone(self):
109+
"""Independent native and AEC bookkeeping state for trusted local search."""
110+
result = copy.copy(self)
111+
result.native = self.native.clone()
112+
for name in (
113+
"agents", "rewards", "_cumulative_rewards", "terminations", "truncations",
114+
"infos", "_actions", "_observations", "_rng",
115+
):
116+
if hasattr(self, name):
117+
setattr(result, name, copy.deepcopy(getattr(self, name)))
118+
return result
119+
120+
121+
def aec(game: str, **kwargs: Any) -> AECGameEnv:
122+
"""Create a PettingZoo AEC environment with one policy per game seat."""
123+
return AECGameEnv(game, **kwargs)

‎python/tests/test_aec.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import numpy as np
2+
import pytest
3+
from pettingzoo.test import api_test
4+
5+
from gamesforai import aec
6+
7+
8+
@pytest.mark.parametrize("game,config", [
9+
("tictactoe", {}),
10+
("connect4", {}),
11+
("chess", {"max_plies": 12}),
12+
("sudoku", {"size": 4, "max_moves": 8, "allow_notes": False}),
13+
])
14+
def test_pettingzoo_contract(game, config):
15+
api_test(aec(game, config=config), num_cycles=100)
16+
17+
18+
def test_final_rewards_are_delivered_to_both_agents_before_removal():
19+
env = aec("tictactoe")
20+
env.reset(seed=0)
21+
for action in [0, 3, 1, 4, 2]:
22+
env.step(action)
23+
assert env.agents == ["player_0", "player_1"]
24+
rewards = {}
25+
for agent in env.agent_iter():
26+
observation, reward, terminated, truncated, info = env.last()
27+
assert terminated and not truncated
28+
assert not observation["action_mask"].any()
29+
rewards[agent] = reward
30+
env.step(None)
31+
assert env.agents == []
32+
assert rewards == {"player_0": 1.0, "player_1": -1.0}
33+
34+
35+
def test_aec_clone_and_invalid_steps_preserve_turn_bookkeeping():
36+
env = aec("connect4")
37+
env.reset(seed=10)
38+
clone = env.clone()
39+
before = env.native.get_state()
40+
with pytest.raises(ValueError):
41+
env.step(99)
42+
assert env.native.get_state() == before
43+
assert env.agent_selection == "player_0"
44+
assert env.rewards == {"player_0": 0.0, "player_1": 0.0}
45+
env.step(3)
46+
assert clone.native.get_state() == before
47+
assert clone.agent_selection == "player_0"
48+
assert env.agent_selection == "player_1"
49+
assert not env.observe("player_0")["action_mask"].any()
50+
assert env.observe("player_1")["action_mask"].any()
51+
52+
53+
def test_seeded_self_play_and_cap_endings():
54+
first = aec("chess", config={"max_plies": 8})
55+
second = aec("chess", config={"max_plies": 8})
56+
first.reset(seed=41)
57+
second.reset(seed=41)
58+
for _ in range(8):
59+
assert first.agent_selection == second.agent_selection
60+
observation, _, terminated, truncated, _ = first.last()
61+
assert not terminated and not truncated
62+
other = second.last()[0]
63+
np.testing.assert_array_equal(observation["observation"], other["observation"])
64+
action = int(np.flatnonzero(observation["action_mask"])[0])
65+
first.step(action)
66+
second.step(action)
67+
assert all(first.truncations.values())
68+
assert not any(first.terminations.values())
69+
assert first.native.get_state() == second.native.get_state()
70+
assert first.render() == first.native.public_text()
71+
72+
73+
def test_reset_accepts_framework_options_and_validates_position():
74+
env = aec("tictactoe")
75+
env.reset(seed=4, options={"options": 1})
76+
before = env.native.get_state()
77+
with pytest.raises(ValueError, match="notation string"):
78+
env.reset(seed=4, options={"position": 12})
79+
assert env.native.get_state() == before

0 commit comments

Comments
 (0)