Skip to content

Commit

Permalink
Add "bounce" game
Browse files Browse the repository at this point in the history
  • Loading branch information
jojolebarjos committed May 11, 2024
1 parent 52cfa4e commit 12b7ebb
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 28 deletions.
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ include(FetchContent)
FetchContent_Declare(
game
GIT_REPOSITORY https://github.com/jojolebarjos/board-game-simulator.git
GIT_TAG 67055d0af27cf3b83864e92b7142a8405143b1bd
GIT_TAG 0b3b7113ba7819ef2c74f30a664e68561f67e8cd
GIT_SHALLOW 1
)
FetchContent_MakeAvailable(game)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ pytest -v

Available games:

* "Bounce", a homebrew game I heard of. I was unable to find anything online, including the actual name...
* [Connect 4](https://en.wikipedia.org/wiki/Connect_Four)
* [Chinese checkers](https://en.wikipedia.org/wiki/Chinese_checkers), 2 players only

Expand Down
24 changes: 13 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ cmake.build-type = "RelWithDebInfo"

[project]
name = "game"
version = "0.0.2"
version = "0.0.3"
requires-python = ">=3.10"
dependencies = [
"numpy",
Expand All @@ -18,18 +18,20 @@ extend-include = ["*.ipynb"]
target-version = "py310"
line-length = 88
extend-select = [
"W605", # pycodestyle: invalid-escape-sequence
"S102", # flake8-bandit: exec-builtin
"INP", # flake8-no-pep420
"PYI", # flake8-pyi
"PT", # flake8-pytest-style
"PGH", # pygrep-hooks
"PL", # Pylint
"NPY", # NumPy-specific rules
"RUF", # Ruff-specific rules
"W605", # pycodestyle: invalid-escape-sequence
"S102", # flake8-bandit: exec-builtin
"INP", # flake8-no-pep420
"PYI", # flake8-pyi
"PT", # flake8-pytest-style
"PGH", # pygrep-hooks
"PL", # Pylint
"NPY", # NumPy-specific rules
"RUF", # Ruff-specific rules
]
ignore = [
"NPY002", # numpy-legacy-random
"NPY002", # numpy-legacy-random
"PLR2004", # magic-value-comparison
"PLW2901", # redefined-loop-name
]

[tool.ruff.per-file-ignores]
Expand Down
6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-e .
numpy==1.26.4
pre-commit==3.7.1
pytest==8.2.0
regex==2024.5.10
ruff==0.4.4
2 changes: 2 additions & 0 deletions src/module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include "./object.hpp"
#include "./game.hpp"

#include <game/bounce.hpp>
#include <game/chinese_checkers.hpp>
#include <game/connect.hpp>

Expand All @@ -11,6 +12,7 @@ namespace {

bool define(PyObject* module) {
return
Game<game::bounce::Traits>::define(module, "Bounce") &&
Game<game::chinese_checkers::Traits>::define(module, "ChineseCheckers") &&
Game<game::connect::Traits<6, 7, 4>>::define(module, "Connect4");
}
Expand Down
230 changes: 230 additions & 0 deletions tests/test_bounce.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import regex as re

import numpy as np

from game import BounceState as State


def parse(string):
# Extract rows
PATTERN = r"\[?([\.\d\*][ \]\[])+\s*"
rows = []
for line in string.split("\n"):
line = line.strip()
if line:
match = re.fullmatch(PATTERN, line + " ")
assert match
steps = match.captures(1)
rows.append(steps)
assert len(rows) == 9
assert len(rows[0]) == 1
assert len(rows[-1]) == 1
assert all(len(row) == 6 for row in rows[1:-1])

# Parse rows
grid = np.zeros((7, 6), dtype=np.int8)
selections = []
targets = []
for y, steps in enumerate(rows[::-1], -1):
for x, (piece, marker) in enumerate(steps):
if piece.isdigit():
assert 0 <= y < 7
grid[y, x] = int(piece)
if piece == "*":
targets.append((x, y))
if marker == "]":
selections.append((x, y))

return grid, selections, targets


def assert_state(string, state=None):
grid, selections, targets = parse(string)

# Which player is meant to play (if unknown, assume 0)
player = 0
if selections:
[(_, y)] = set(selections) - set(targets)
if grid[:y].sum() > 0:
player = 1

# If state is not provided, use string as-is
if state is None:
state_dict = {
"grid": grid.tolist(),
"player": player,
"winner": -1,
}
state = State.from_json(state_dict)

# Recover game state
state_dict = state.to_json()

# First, check that all pieces are at the expected location
assert state_dict["grid"] == grid.tolist()

# Check current player
assert state_dict["player"] == player

# Then, if a piece is selected
selected_action = None
if len(selections) > 0:
piece_selection = None
target_selection = None
for selection in selections:
if selection in targets:
assert not target_selection
target_selection = selection
else:
assert grid[selection[1], selection[0]] > 0
assert not piece_selection
piece_selection = selection

# A piece single must be selected
assert piece_selection is not None

# Get associated actions
actions = {}
for action in state.actions:
action_dict = action.to_json()
from_coord = action_dict["from"]["x"], action_dict["from"]["y"]
to_coord = action_dict["to"]["x"], action_dict["to"]["y"]
if from_coord == piece_selection:
actions[to_coord] = action

# Actions must be exhaustively highlighted
assert set(targets) == set(actions.keys())

# If an action is selected, return that
if target_selection is not None:
selected_action = actions[target_selection]

return state, selected_action


def test_sequence():
state = State.sample_initial_state()

# Turn 1
_, action = assert_state(
"""
.
1 2 3 3 2 1
. . . . . .
. . . . . .
. . . . . .
.[*]. . . .
* . * . . .
[1]2 3 3 2 1
.
""",
state,
)
state = action.sample_next_state()

# Turn 2
_, action = assert_state(
"""
.
1 2[3]3 2 1
* . . . * .
. * . * . .
. .[*]. . .
. 1 . . . .
. . . . . .
. 2 3 3 2 1
.
""",
state,
)
state = action.sample_next_state()

# Turn 3
_, action = assert_state(
"""
.
1 2 . 3 2 1
. . . . . .
. . . . . .
.[*]3 . . .
* 1 * . . .
* . * . . .
.[2]3 3 2 1
.
""",
state,
)
state = action.sample_next_state()

# Turn 4
_, action = assert_state(
"""
.
[1]2 . 3 2 1
* * * * . *
. * * . * .
. 2 3[*]. .
. 1 . . . .
. . . . . .
. . 3 3 2 1
.
""",
state,
)
state = action.sample_next_state()

# Turn 5
_, action = assert_state(
"""
.
. 2 * 3 2 1
. * . * . .
[*]. * . * .
. 2 3 1 . .
* 1 * * . .
* . * . * .
. .[3]3 2 1
.
""",
state,
)
state = action.sample_next_state()

# Turn 6
_, action = assert_state(
"""
.
. 2 .[3]2 1
. * . * . *
3 . * . * .
. 2 3 1 * .
. 1 . * * .
. * .[*]. .
. . * 3 2 1
.
""",
state,
)
state = action.sample_next_state()

# Turn 7
_, action = assert_state(
"""
[*]
. 2 * * 2 1
* * * * . .
3 . * * * .
. 2 3 1 * .
* 1 * * * *
* . . 3 . *
. . . 3[2]1
.
""",
state,
)
state = action.sample_next_state()

assert state.has_ended
assert len(state.actions) == 0
assert state.winner == 0
assert state.reward.tolist() == [1, -1]
16 changes: 0 additions & 16 deletions tests/test_chinese_checkers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import regex as re

import numpy as np

from game import ChineseCheckersState as State


Expand Down Expand Up @@ -56,20 +54,6 @@ def parse(string):
return pieces, selections, targets


def encode(state_or_action):
if isinstance(state_or_action, State):
state = state_or_action
action = None
elif isinstance(state_or_action, Action):
action = state_or_action
state = action.state
else:
raise TypeError

# TODO render state as string, for easier debugging
raise NotImplementedError


def assert_state(state, string):
state_dict = state.to_json()
pieces, selections, targets = parse(string)
Expand Down

0 comments on commit 12b7ebb

Please sign in to comment.