Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [16.7.0] - unreleased

### Added
- Games can be materialised directly from OpenSpiel games if `pyspiel` is installed. (#917)

### Fixed
- Corrected resizing of row and column index labels in strategic form so pivoting works correctly. (#844)
- Corrected incorrect output of strategic game tables to .nfg files if strategies have previously been
Expand Down
24 changes: 24 additions & 0 deletions doc/catalog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,29 @@ Catalog of games

Below is a complete list of games included in Gambit's catalog.
Check out the :ref:`pygambit API reference <pygambit-catalog>` for instructions on how to search and load these games in Python, and the :ref:`Updating the games catalog <updating-catalog>` guide for instructions on how to contribute new games to the catalog.
Games from the OpenSpiel library are also available; see :ref:`Loading OpenSpiel games <catalog-openspiel>`.

.. include:: catalog_table.rst

.. _catalog-openspiel:

.. rubric:: Loading OpenSpiel games

Games from the `OpenSpiel <https://openspiel.readthedocs.io/en/latest/games.html>`_ library
can be loaded using :func:`pygambit.catalog.load_openspiel`:

.. code-block:: python

pygambit.catalog.load_openspiel("matrix_rps")
pygambit.catalog.load_openspiel("tiny_hanabi")
pygambit.catalog.load_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2})

The ``params`` argument is forwarded directly to ``pyspiel.load_game``; see the
`OpenSpiel game list <https://openspiel.readthedocs.io/en/latest/games.html>`_ for
available parameters per game.

This requires ``open_spiel`` to be installed (``pip install open_spiel``;
not available on Windows). The game is exported to EFG or NFG format on the fly
and loaded into Gambit. Not all OpenSpiel games can be exported; a
:class:`ValueError` is raised for games that are incompatible with either format.
See the :doc:`OpenSpiel interoperability tutorial <tutorials/interoperability_tutorials/openspiel>` for worked examples.
1 change: 1 addition & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -338,4 +338,5 @@ Catalog of games
:toctree: api/

load
load_openspiel
games
47 changes: 6 additions & 41 deletions doc/tutorials/interoperability_tutorials/openspiel.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,11 @@
"metadata": {},
"outputs": [],
"source": [
"from io import StringIO\n",
"\n",
"import matplotlib.pyplot as plt\n",
"import numpy as np\n",
"import pyspiel\n",
"from open_spiel.python import rl_environment\n",
"from open_spiel.python.algorithms import tabular_qlearner\n",
"from open_spiel.python.algorithms.gambit import export_gambit\n",
"from open_spiel.python.egt import dynamics\n",
"from open_spiel.python.egt.utils import game_payoffs_array\n",
"\n",
Expand Down Expand Up @@ -150,27 +147,7 @@
"id": "045cf8dd",
"metadata": {},
"source": [
"OpenSpiel can generate an NFG representation of the game loadable in Gambit:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f5fa4e42",
"metadata": {},
"outputs": [],
"source": [
"nfg_matrix_rps_game = pyspiel.game_to_nfg_string(ops_matrix_rps_game)\n",
"nfg_matrix_rps_game"
]
},
{
"cell_type": "markdown",
"id": "70d1df64",
"metadata": {},
"source": [
"Now let's load the NFG in Gambit. Since Gambit's `read_nfg` function expects a file like object, we'll convert the string with `io.StringIO`.\n",
"We can also add labels for the actions to make the output more interpretable:"
"Gambit's catalog module can load games from the OpenSpiel library using `gbt.catalog.load_openspiel`:\n"
]
},
{
Expand All @@ -180,7 +157,7 @@
"metadata": {},
"outputs": [],
"source": [
"gbt_matrix_rps_game = gbt.read_nfg(StringIO(nfg_matrix_rps_game))\n",
"gbt_matrix_rps_game = gbt.catalog.load_openspiel(\"matrix_rps\")\n",
"\n",
"gbt_matrix_rps_game.title = \"Rock-Paper-Scissors\"\n",
"\n",
Expand Down Expand Up @@ -462,27 +439,15 @@
"source": [
"## Extensive-form games from the OpenSpiel library\n",
"\n",
"For extensive-form games, OpenSpiel can export to the EFG format used by Gambit. Here we demonstrate this with **Tiny Hanabi**, loaded from the OpenSpiel [game library](https://openspiel.readthedocs.io/en/latest/games.html):"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "02a42600",
"metadata": {},
"outputs": [],
"source": [
"ops_hanabi_game = pyspiel.load_game(\"tiny_hanabi\")\n",
"efg_hanabi_game = export_gambit(ops_hanabi_game)\n",
"efg_hanabi_game"
"We can also load extensive-form games via Gambit's catalog module.\n",
"Here we demonstrate this with **Tiny Hanabi**, from the OpenSpiel [game library](https://openspiel.readthedocs.io/en/latest/games.html):\n"
]
},
{
"cell_type": "markdown",
"id": "fa354c9f",
"metadata": {},
"source": [
"Now let's load the EFG in Gambit.\n",
"We can then compute equilibria strategies for the players as usual:"
]
},
Expand All @@ -493,7 +458,7 @@
"metadata": {},
"outputs": [],
"source": [
"gbt_hanabi_game = gbt.read_efg(StringIO(efg_hanabi_game))\n",
"gbt_hanabi_game = gbt.catalog.load_openspiel(\"tiny_hanabi\")\n",
"eqm = gbt.nash.lcp_solve(gbt_hanabi_game).equilibria[0]"
]
},
Expand Down Expand Up @@ -880,7 +845,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
"version": "3.13.13"
}
},
"nbformat": 4,
Expand Down
75 changes: 75 additions & 0 deletions src/pygambit/catalog.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import io
from importlib.resources import as_file, files
from pathlib import Path
from typing import Any
Expand All @@ -20,6 +21,80 @@
}


def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game:
"""
Load a game from the OpenSpiel library.

Parameters
----------
game_name : str
The short name of the OpenSpiel game (e.g. ``"matrix_rps"``,
``"tiny_hanabi"``). Passed directly to ``pyspiel.load_game``.
params : dict, optional
Game parameters forwarded to ``pyspiel.load_game``
(e.g. ``{"players": 2, "coins": 3, "fields": 2}`` for ``"blotto"``).
See the `OpenSpiel game list
<https://openspiel.readthedocs.io/en/latest/games.html>`_ for
available parameters per game.

Returns
-------
gbt.Game
The loaded game.

Raises
------
ImportError
If ``open_spiel`` is not installed.
ValueError
If the game's dynamics type is not supported for export, or if the
format exporter raises an error for this specific game.
Other exceptions from ``pyspiel.load_game`` propagate directly.
For example, ``pyspiel.SpielError`` is raised for unknown game names
or invalid/missing parameters.
"""
try:
import pyspiel
from open_spiel.python.algorithms.gambit import export_gambit
except ImportError as exc:
raise ImportError(
"open_spiel is required to load OpenSpiel games. "
"Install it with: pip install open_spiel"
) from exc

# Let pyspiel's own exceptions propagate unchanged — they already carry
# informative messages ("Unknown game '...'", "Unknown parameter '...'", etc.)
game = pyspiel.load_game(game_name, params or {})

dynamics = game.get_type().dynamics

# OpenSpiel's SEQUENTIAL corresponds to extensive-form (tree) games in Gambit;
# SIMULTANEOUS corresponds to normal-form (strategic-form) games.
if dynamics == pyspiel.GameType.Dynamics.SEQUENTIAL:
try:
efg_str = export_gambit(game)
except Exception as exc:
raise ValueError(
f"OpenSpiel game '{game_name}' could not be exported to EFG format: {exc}"
) from exc
return gbt.read_efg(io.StringIO(efg_str))

elif dynamics == pyspiel.GameType.Dynamics.SIMULTANEOUS:
try:
nfg_str = pyspiel.game_to_nfg_string(game)
except Exception as exc:
raise ValueError(
f"OpenSpiel game '{game_name}' could not be exported to NFG format: {exc}"
) from exc
return gbt.read_nfg(io.StringIO(nfg_str))

else:
raise ValueError(
f"OpenSpiel game '{game_name}' has unsupported dynamics type "
f"'{dynamics}' and cannot be exported to Gambit format."
)


def load(slug: str) -> gbt.Game:
"""
Load a game from the package catalog.
Expand Down
135 changes: 135 additions & 0 deletions tests/test_catalog.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import sys
from unittest.mock import MagicMock

import pandas as pd
import pytest

Expand Down Expand Up @@ -172,3 +175,135 @@ def test_catalog_games_include_descriptions():
games_with_desc = gbt.catalog.games(include_descriptions=True)
assert "Description" in games_with_desc.columns
assert "Download" in games_with_desc.columns


# ---------------------------------------------------------------------------
# OpenSpiel dynamic loading tests (all mocked; open_spiel need not be installed)
# ---------------------------------------------------------------------------

_MOCK_NFG = gbt.Game.new_table([2, 2]).to_nfg()
_MOCK_EFG = gbt.catalog.load("bagwell1995").to_efg()


def _setup_pyspiel_mock(
monkeypatch,
*,
nfg_str=None,
nfg_raises=None,
efg_str=None,
efg_raises=None,
load_raises=None,
dynamics="sequential",
):
"""Inject a fake pyspiel + open_spiel.python.algorithms.gambit into sys.modules.

``dynamics`` controls which export path the code takes:
- ``"sequential"`` → game.get_type().dynamics == pyspiel.GameType.Dynamics.SEQUENTIAL
(OpenSpiel's term for extensive-form / tree games in Gambit)
- ``"simultaneous"`` → SIMULTANEOUS (normal-form / strategic-form games in Gambit)
- ``"other"`` → any other value, triggers the unsupported-dynamics ValueError
"""
mock_ps = MagicMock()
mock_export_fn = MagicMock()
mock_game = MagicMock()

# Wire the dynamics attribute so the == comparison in load_openspiel resolves correctly.
# MagicMock attribute access is idempotent: mock_ps.GameType.Dynamics.SEQUENTIAL always
# returns the same object, so the equality check passes.
if dynamics == "sequential":
mock_game.get_type.return_value.dynamics = mock_ps.GameType.Dynamics.SEQUENTIAL
elif dynamics == "simultaneous":
mock_game.get_type.return_value.dynamics = mock_ps.GameType.Dynamics.SIMULTANEOUS
else:
mock_game.get_type.return_value.dynamics = object() # matches neither branch

if load_raises is not None:
mock_ps.load_game.side_effect = load_raises
else:
mock_ps.load_game.return_value = mock_game

if nfg_raises is not None:
mock_ps.game_to_nfg_string.side_effect = nfg_raises
else:
mock_ps.game_to_nfg_string.return_value = nfg_str

if efg_raises is not None:
mock_export_fn.side_effect = efg_raises
else:
mock_export_fn.return_value = efg_str

mock_gambit_module = MagicMock()
mock_gambit_module.export_gambit = mock_export_fn

monkeypatch.setitem(sys.modules, "pyspiel", mock_ps)
monkeypatch.setitem(sys.modules, "open_spiel", MagicMock())
monkeypatch.setitem(sys.modules, "open_spiel.python", MagicMock())
monkeypatch.setitem(sys.modules, "open_spiel.python.algorithms", MagicMock())
monkeypatch.setitem(sys.modules, "open_spiel.python.algorithms.gambit", mock_gambit_module)
return mock_ps, mock_export_fn


def test_openspiel_load_efg_success(monkeypatch):
"""Sequential (extensive-form) game: EFG export is used and returns a valid Game."""
_setup_pyspiel_mock(monkeypatch, dynamics="sequential", efg_str=_MOCK_EFG)
game = gbt.catalog.load_openspiel("tiny_hanabi")
assert isinstance(game, gbt.Game)


def test_openspiel_load_nfg_success(monkeypatch):
"""Simultaneous (normal-form) game: NFG export is used and returns a valid Game."""
_setup_pyspiel_mock(monkeypatch, dynamics="simultaneous", nfg_str=_MOCK_NFG)
game = gbt.catalog.load_openspiel("matrix_rps")
assert isinstance(game, gbt.Game)


def test_openspiel_load_import_error(monkeypatch):
"""Missing open_spiel raises ImportError with a helpful message."""
monkeypatch.setitem(sys.modules, "pyspiel", None)
with pytest.raises(ImportError, match="open_spiel"):
gbt.catalog.load_openspiel("matrix_rps")


def test_openspiel_load_game_not_found(monkeypatch):
"""pyspiel.load_game errors propagate directly without wrapping."""
_setup_pyspiel_mock(monkeypatch, load_raises=RuntimeError("Unknown game 'bogus_game'"))
with pytest.raises(RuntimeError, match="Unknown game"):
gbt.catalog.load_openspiel("bogus_game")


def test_openspiel_load_efg_export_failure(monkeypatch):
"""EFG export failure on a sequential game raises ValueError with format context."""
_setup_pyspiel_mock(
monkeypatch,
dynamics="sequential",
efg_raises=RuntimeError("export error"),
)
with pytest.raises(ValueError, match="EFG format"):
gbt.catalog.load_openspiel("tiny_hanabi")


def test_openspiel_load_nfg_export_failure(monkeypatch):
"""NFG export failure on a simultaneous game raises ValueError with format context."""
_setup_pyspiel_mock(
monkeypatch,
dynamics="simultaneous",
nfg_raises=RuntimeError("export error"),
)
with pytest.raises(ValueError, match="NFG format"):
gbt.catalog.load_openspiel("matrix_rps")


def test_openspiel_load_unsupported_dynamics(monkeypatch):
"""A game with unsupported dynamics (e.g. MEAN_FIELD) raises ValueError."""
_setup_pyspiel_mock(monkeypatch, dynamics="other")
with pytest.raises(ValueError, match="unsupported dynamics"):
gbt.catalog.load_openspiel("some_mfg_game")


def test_openspiel_load_with_params(monkeypatch):
"""params dict is forwarded verbatim to pyspiel.load_game."""
mock_ps, _ = _setup_pyspiel_mock(
monkeypatch, dynamics="simultaneous", nfg_str=_MOCK_NFG
)
gbt.catalog.load_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2})
mock_ps.load_game.assert_called_once_with("blotto", {"players": 2, "coins": 3, "fields": 2})
Loading