diff --git a/doc/catalog.rst b/doc/catalog.rst index dfb0875e2..118a991f5 100644 --- a/doc/catalog.rst +++ b/doc/catalog.rst @@ -6,6 +6,7 @@ Catalog of games Below is a complete list of games included in Gambit's catalog. Check out the :ref:`pygambit API reference ` for instructions on how to search and load these games in Python, and the :ref:`Updating the games 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 `. +Games can also be generated on the fly from the GAMUT suite; see :ref:`Generating GAMUT games `. .. include:: catalog_table.rst @@ -14,13 +15,13 @@ Games from the OpenSpiel library are also available; see :ref:`Loading OpenSpiel .. rubric:: Loading OpenSpiel games Games from the `OpenSpiel `_ library -can be loaded using :func:`pygambit.catalog.load_openspiel`: +can be generated using :func:`pygambit.catalog.generate_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}) + pygambit.catalog.generate_openspiel("matrix_rps") + pygambit.catalog.generate_openspiel("tiny_hanabi") + pygambit.catalog.generate_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2}) The ``params`` argument is forwarded directly to ``pyspiel.load_game``; see the `OpenSpiel game list `_ for @@ -31,3 +32,41 @@ 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 ` for worked examples. + +.. _catalog-gamut: + +.. rubric:: Generating GAMUT games + +Games from the `GAMUT `_ generator suite can be created +using :func:`pygambit.catalog.generate_gamut`: + +.. code-block:: python + + pygambit.catalog.generate_gamut("RandomGame", params={"players": 2, "actions": 3}, gamut_jar="/path/to/gamut.jar") + pygambit.catalog.generate_gamut("CovariantGame", params={"players": 2, "actions": [3, 3]}, gamut_jar="/path/to/gamut.jar") + pygambit.catalog.generate_gamut( + "RandomGame", + params={"players": 3, "actions": 4, "normalize": True, "min_payoff": 0, "max_payoff": 100}, + gamut_jar="/path/to/gamut.jar", + ) + +The ``params`` argument maps directly to GAMUT command-line flags. Boolean flags such as +``-normalize`` and ``-int_payoffs`` are passed as ``True``; list values for ``-actions`` +expand to space-separated tokens. See the +`GAMUT documentation `_ for the full list +of game classes and parameters. + +GAMUT requires Java (install from `java.com `_) and +``gamut.jar`` to be installed. Provide the path to ``gamut.jar`` via the ``gamut_jar`` +argument or the ``GAMUT_JAR`` environment variable. Download GAMUT from +http://gamut.stanford.edu/. + +.. dropdown:: All 35 GAMUT game classes + + .. jupyter-execute:: + :hide-code: + + import pygambit as gbt + gbt.catalog.gamut_games() + +See the :doc:`GAMUT interoperability tutorial ` for worked examples. diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index b9fe15e1a..e475a689a 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -357,5 +357,7 @@ Catalog of games :toctree: api/ load - load_openspiel + generate_openspiel + generate_gamut + gamut_games games diff --git a/doc/pygambit.rst b/doc/pygambit.rst index d2c8006cb..ce251cb5b 100644 --- a/doc/pygambit.rst +++ b/doc/pygambit.rst @@ -56,6 +56,7 @@ These tutorials assume you have read the new user tutorials and are familiar wit :maxdepth: 2 tutorials/interoperability_tutorials/openspiel + tutorials/interoperability_tutorials/gamut API documentation ---------------- diff --git a/doc/tutorials/interoperability_tutorials/gamut.ipynb b/doc/tutorials/interoperability_tutorials/gamut.ipynb new file mode 100644 index 000000000..31fcb60e3 --- /dev/null +++ b/doc/tutorials/interoperability_tutorials/gamut.ipynb @@ -0,0 +1,809 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "gamut-intro", + "metadata": {}, + "source": [ + "# Generating games with GAMUT\n", + "\n", + "[GAMUT](http://gamut.stanford.edu/) is a suite of parameterised game generators covering a wide range of game families studied in the game theory literature. Written in Java, GAMUT can generate instances of 35 game classes, including random games, coordination games, covariant games, voting games, and many more.\n", + "\n", + "PyGambit's `generate_gamut` function calls GAMUT as an external subprocess and returns the resulting game as a `Game` object, ready for analysis with PyGambit's full suite of tools. Before running this tutorial, you will need Java and `gamut.jar` installed; see the [catalog documentation](../../catalog.html#catalog-gamut) for full installation instructions.\n", + "\n", + "> **Note:** The cell outputs in this notebook were generated locally. To reproduce them, update the `gamut_jar` argument in each cell to the path of your local `gamut.jar`.\n", + "\n", + "This tutorial covers:\n", + "- Generating classic two-player games with no additional parameters\n", + "- Generating parameterised random normal-form games\n", + "- Exploring how the covariance parameter in `CovariantGame` affects equilibrium structure\n", + "- Generating multi-player games\n", + "- Controlling payoff normalisation and obtaining integer payoffs\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "gamut-imports", + "metadata": {}, + "outputs": [], + "source": [ + "import pygambit as gbt" + ] + }, + { + "cell_type": "markdown", + "id": "a08eb52d", + "metadata": {}, + "source": [ + "## Discovering available game classes\n", + "\n", + "`gbt.catalog.gamut_games()` returns a DataFrame listing all 35 GAMUT game classes, their descriptions, and whether they support more than two players. Use this as a quick reference when choosing a game to generate:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1a5dde4f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ClassDescriptionPlayers
0ArmsRaceArms race with cost and demand functions chose...n
1BattleOfTheSexesCoordination game where players prefer to meet...2
2BertrandOligopolyBertrand oligopoly with arbitrary cost and dem...n
3BidirectionalLEGBidirectional local-effect game on a specified...n
4ChickenClassic 2x2 Chicken game.2
5CollaborationGameGame where all players choosing the same actio...n
6CongestionGameCongestion game where payoffs depend on how ma...n
7CoordinationGamePure coordination game rewarding matching acti...n
8CournotDuopolyCournot duopoly with arbitrary cost and invers...2
9CovariantGameGame with payoffs whose cross-player correlati...n
10DispersionGameGame where dispersed action choices are rewarded.n
11GrabTheDollarSimultaneous competition to claim a prize first.2
12GreedyGamePlayers each choose a subset from a set; payof...2
13GuessThirdsAvePlayers guess a number trying to reach 2/3 of ...n
14HawkAndDoveClassic 2x2 Hawk and Dove game.2
15LocationGameHotelling-style two-player location game on a ...2
16MajorityVotingMajority voting game with arbitrary candidate ...n
17MatchingPenniesClassic 2x2 Matching Pennies game.2
18MinimumEffortGamePayoffs depend on the minimum effort exerted a...n
19NPlayerChickenN-player Chicken game with cooperation costs a...n
20NPlayerPrisonersDilemmaN-player Prisoner's Dilemma with parameterised...n
21PolymatrixGamePolymatrix game formed by two-player edge game...n
22PrisonersDilemmaClassic 2x2 Prisoner's Dilemma.2
23RandomCompoundGameCompound game whose payoffs are sums of random...n
24RandomGameGame with payoffs drawn uniformly at random.n
25RandomGraphicalGameRandom graphical game on a specified graph.n
26RandomLEGRandom local-effect game on a specified graph.n
27RandomZeroSumTwo-player zero-sum game with random payoffs.2
28RockPaperScissorsClassic Rock, Paper, Scissors game.2
29ShapleyGameShapley's original game; no stable equilibrium...2
30SimpleInspectionGameInspection game where players choose from diff...2
31TravelersDilemmaPlayers claim a value; the lowest claim wins a...n
32TwoByTwoGame2x2 game of a specified type per Rapoport's cl...2
33UniformLEGLocal-effect game where all edges share the sa...n
34WarOfAttritionPlayers choose concession times; payoffs depen...2
\n", + "
" + ], + "text/plain": [ + " Class \\\n", + "0 ArmsRace \n", + "1 BattleOfTheSexes \n", + "2 BertrandOligopoly \n", + "3 BidirectionalLEG \n", + "4 Chicken \n", + "5 CollaborationGame \n", + "6 CongestionGame \n", + "7 CoordinationGame \n", + "8 CournotDuopoly \n", + "9 CovariantGame \n", + "10 DispersionGame \n", + "11 GrabTheDollar \n", + "12 GreedyGame \n", + "13 GuessThirdsAve \n", + "14 HawkAndDove \n", + "15 LocationGame \n", + "16 MajorityVoting \n", + "17 MatchingPennies \n", + "18 MinimumEffortGame \n", + "19 NPlayerChicken \n", + "20 NPlayerPrisonersDilemma \n", + "21 PolymatrixGame \n", + "22 PrisonersDilemma \n", + "23 RandomCompoundGame \n", + "24 RandomGame \n", + "25 RandomGraphicalGame \n", + "26 RandomLEG \n", + "27 RandomZeroSum \n", + "28 RockPaperScissors \n", + "29 ShapleyGame \n", + "30 SimpleInspectionGame \n", + "31 TravelersDilemma \n", + "32 TwoByTwoGame \n", + "33 UniformLEG \n", + "34 WarOfAttrition \n", + "\n", + " Description Players \n", + "0 Arms race with cost and demand functions chose... n \n", + "1 Coordination game where players prefer to meet... 2 \n", + "2 Bertrand oligopoly with arbitrary cost and dem... n \n", + "3 Bidirectional local-effect game on a specified... n \n", + "4 Classic 2x2 Chicken game. 2 \n", + "5 Game where all players choosing the same actio... n \n", + "6 Congestion game where payoffs depend on how ma... n \n", + "7 Pure coordination game rewarding matching acti... n \n", + "8 Cournot duopoly with arbitrary cost and invers... 2 \n", + "9 Game with payoffs whose cross-player correlati... n \n", + "10 Game where dispersed action choices are rewarded. n \n", + "11 Simultaneous competition to claim a prize first. 2 \n", + "12 Players each choose a subset from a set; payof... 2 \n", + "13 Players guess a number trying to reach 2/3 of ... n \n", + "14 Classic 2x2 Hawk and Dove game. 2 \n", + "15 Hotelling-style two-player location game on a ... 2 \n", + "16 Majority voting game with arbitrary candidate ... n \n", + "17 Classic 2x2 Matching Pennies game. 2 \n", + "18 Payoffs depend on the minimum effort exerted a... n \n", + "19 N-player Chicken game with cooperation costs a... n \n", + "20 N-player Prisoner's Dilemma with parameterised... n \n", + "21 Polymatrix game formed by two-player edge game... n \n", + "22 Classic 2x2 Prisoner's Dilemma. 2 \n", + "23 Compound game whose payoffs are sums of random... n \n", + "24 Game with payoffs drawn uniformly at random. n \n", + "25 Random graphical game on a specified graph. n \n", + "26 Random local-effect game on a specified graph. n \n", + "27 Two-player zero-sum game with random payoffs. 2 \n", + "28 Classic Rock, Paper, Scissors game. 2 \n", + "29 Shapley's original game; no stable equilibrium... 2 \n", + "30 Inspection game where players choose from diff... 2 \n", + "31 Players claim a value; the lowest claim wins a... n \n", + "32 2x2 game of a specified type per Rapoport's cl... 2 \n", + "33 Local-effect game where all edges share the sa... n \n", + "34 Players choose concession times; payoffs depen... 2 " + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.catalog.gamut_games()\n" + ] + }, + { + "cell_type": "markdown", + "id": "gamut-bos-intro", + "metadata": {}, + "source": [ + "## Classic two-player games\n", + "\n", + "Many of GAMUT's game classes correspond directly to well-known games from the game theory literature and require no additional parameters to generate. Here we generate Battle of the Sexes, a canonical 2×2 coordination game.\n", + "\n", + "In Battle of the Sexes, two players must independently decide whether to go to the Opera or a Football match. Both prefer to attend the same event, but player 1 prefers the Opera while player 2 prefers Football. The game has two pure-strategy Nash equilibria — both go to the Opera, or both go to Football — and one mixed-strategy equilibrium.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "gamut-bos-gen", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

Battle of the Sexes

\n", + "
Player2
OperaFootball
Player1Opera3,30,0
Football0,03,3
\n" + ], + "text/plain": [ + "Game(title='Battle of the Sexes')" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g_bos = gbt.catalog.generate_gamut(\n", + " \"BattleOfTheSexes\",\n", + " params={\n", + " \"int_payoffs\": True,\n", + " \"int_mult\": 1,\n", + " \"normalize\": True,\n", + " \"min_payoff\": 0,\n", + " \"max_payoff\": 3\n", + " },\n", + " gamut_jar=\"~/Downloads/gamut.jar\",\n", + ")\n", + "g_bos.title = \"Battle of the Sexes\"\n", + "for player in g_bos.players:\n", + " for strategy, label in zip(player.strategies, [\"Opera\", \"Football\"], strict=True):\n", + " strategy.label = label\n", + "g_bos\n" + ] + }, + { + "cell_type": "markdown", + "id": "gamut-bos-eqm-intro", + "metadata": {}, + "source": [ + "Now let's compute all Nash equilibria using the linear complementarity method, which is well-suited to two-player games:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "gamut-bos-eqm", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "3" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result_bos = gbt.nash.lcp_solve(g_bos)\n", + "len(result_bos.equilibria)\n" + ] + }, + { + "cell_type": "markdown", + "id": "gamut-bos-interp", + "metadata": {}, + "source": [ + "As expected, the solver finds three equilibria: two pure-strategy equilibria (both play Opera, or both play Football) and one mixed-strategy equilibrium in which each player randomises between the two options:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "37294505", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[1,0\\right],\\left[1,0\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(1, 1), Rational(0, 1)], [Rational(1, 1), Rational(0, 1)]]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result_bos.equilibria[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "293a5436", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[\\frac{1}{2},\\frac{1}{2}\\right],\\left[\\frac{1}{2},\\frac{1}{2}\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(1, 2), Rational(1, 2)], [Rational(1, 2), Rational(1, 2)]]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result_bos.equilibria[1]" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d2e7a8e8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[0,1\\right],\\left[0,1\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(0, 1), Rational(1, 1)], [Rational(0, 1), Rational(1, 1)]]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result_bos.equilibria[2]" + ] + }, + { + "cell_type": "markdown", + "id": "gamut-norm-intro", + "metadata": {}, + "source": [ + "## Payoff normalisation and integer payoffs\n", + "\n", + "By default, GAMUT draws payoffs uniformly from the range [-100, 100]. These global parameters let you rescale and discretise payoffs:\n", + "\n", + "| Parameter | Type | Effect |\n", + "|---|---|---|\n", + "| `normalize` | boolean flag | Enable rescaling to [`min_payoff`, `max_payoff`] |\n", + "| `min_payoff` | number | Lower bound after normalisation |\n", + "| `max_payoff` | number | Upper bound after normalisation |\n", + "| `int_payoffs` | boolean flag | Round all payoffs to integers |\n", + "| `int_mult` | boolean flag | Multiplier used before rounding when converting from double to integer payoffs |\n", + "\n", + "See the [GAMUT documentation](http://gamut.stanford.edu/) for more info on the available Global parameters.\n" + ] + }, + { + "cell_type": "markdown", + "id": "gamut-random-intro", + "metadata": {}, + "source": [ + "## Parameterised random normal-form games\n", + "\n", + "Most GAMUT game classes accept parameters to control the number of players, the number of actions, and the structure of payoffs. These are passed as a dictionary to the `params` argument; each key maps to a GAMUT command-line flag.\n", + "\n", + "`RandomGame` is the most general generator: payoffs are drawn independently and uniformly at random from a fixed range. It serves as a useful baseline for testing algorithms. The `players` parameter controls the number of players, and `actions` controls the number of actions per player. When `actions` is a list, each element gives the action count for the corresponding player, allowing asymmetric games:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "gamut-random-gen", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

Random Game (2 players, 3x4)

\n", + "
Player2
123
Player11-24.90896560296312,-41.46093893377729-97.04833183504611,-23.56380990697073250.0831929786506,26.051862305230316
2-65.96410417734546,44.09018185830413530.77026176277343,-17.52125395472777-10.707266835174579,26.28680487237078
3-65.52630679123462,-87.74228077603547-3.9604024034780565,-61.10491491476051-94.29034279305093,-45.34942611324284
\n" + ], + "text/plain": [ + "Game(title='Random Game (2 players, 3x4)')" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g_rand = gbt.catalog.generate_gamut(\n", + " \"RandomGame\",\n", + " params={\"players\": 2, \"actions\": [3, 3]},\n", + " gamut_jar=\"~/Downloads/gamut.jar\",\n", + ")\n", + "g_rand.title = \"Random Game (2 players, 3x4)\"\n", + "g_rand\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "gamut-random-eqm", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[1,0,0\\right],\\left[0,0,1\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(1, 1), Rational(0, 1), Rational(0, 1)], [Rational(0, 1), Rational(0, 1), Rational(1, 1)]]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.nash.lcp_solve(g_rand).equilibria[0]\n" + ] + }, + { + "cell_type": "markdown", + "id": "gamut-covariant-intro", + "metadata": {}, + "source": [ + "## Covariant games\n", + "\n", + "`CovariantGame` generates two-player games in which the degree of alignment between players' interests is controlled by a covariance parameter `r`.\n", + "\n", + "- When `r = 1` the game is a common-payoff game: both players receive identical payoffs.\n", + "- When `r = 0` payoffs are independent — equivalent to `RandomGame`.\n", + "- As `r` approaches `-1` (the minimum for a two-player game) the game approaches a zero-sum game.\n", + "\n", + "Let's compare an equilibrium under high positive covariance (nearly a coordination game) with one under negative covariance (a more adversarial setting):\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "gamut-cov-pos", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[1,0,0\\right],\\left[1,0,0\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(1, 1), Rational(0, 1), Rational(0, 1)], [Rational(1, 1), Rational(0, 1), Rational(0, 1)]]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g_cov_pos = gbt.catalog.generate_gamut(\n", + " \"CovariantGame\",\n", + " params={\"players\": 2, \"actions\": [3, 3], \"r\": 0.8},\n", + " gamut_jar=\"~/Downloads/gamut.jar\",\n", + ")\n", + "g_cov_pos.title = \"Covariant Game (r=0.8)\"\n", + "eqm_pos = gbt.nash.lcp_solve(g_cov_pos).equilibria[0]\n", + "eqm_pos\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "gamut-cov-neg", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[1,0,0\\right],\\left[0,0,1\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(1, 1), Rational(0, 1), Rational(0, 1)], [Rational(0, 1), Rational(0, 1), Rational(1, 1)]]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g_cov_neg = gbt.catalog.generate_gamut(\n", + " \"CovariantGame\",\n", + " params={\"players\": 2, \"actions\": [3, 3], \"r\": -0.5},\n", + " gamut_jar=\"~/Downloads/gamut.jar\",\n", + ")\n", + "g_cov_neg.title = \"Covariant Game (r=-0.5)\"\n", + "eqm_neg = gbt.nash.lcp_solve(g_cov_neg).equilibria[0]\n", + "eqm_neg\n" + ] + }, + { + "cell_type": "markdown", + "id": "gamut-cov-interp", + "metadata": {}, + "source": [ + "With high positive covariance (`r = 0.8`) the game is close to a coordination game, so an equilibrium in pure or near-pure strategies is typical. With negative covariance (`r = -0.5`) the game is more adversarial, making genuinely mixed equilibria more likely.\n" + ] + }, + { + "cell_type": "markdown", + "id": "gamut-multi-intro", + "metadata": {}, + "source": [ + "## Multi-player games\n", + "\n", + "GAMUT includes several n-player game families. Here we use `MajorityVoting`, a model of a committee in which each player votes for one of several candidates. The candidate who receives the most votes wins, and each player has a privately known preferred candidate.\n", + "\n", + "Voting games naturally lend themselves to pure-strategy analysis — each player simply votes for their preferred candidate. `gbt.nash.enumpure_solve` searches exhaustively for pure-strategy Nash equilibria and works for games with any number of players:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "gamut-majority-gen", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

Majority Voting (3 players, 3 candidates)

\n", + "
Subtable with strategies:
Player 3 Strategy 1
Player2
123
Player1139,54,9739,54,9739,54,97
239,54,97-42,-25,8739,54,97
339,54,9739,54,9732,-98,-29
Subtable with strategies:
Player 3 Strategy 2
Player2
123
Player1139,54,97-42,-25,8739,54,97
2-42,-25,87-42,-25,87-42,-25,87
339,54,97-42,-25,8732,-98,-29
Subtable with strategies:
Player 3 Strategy 3
Player2
123
Player1139,54,9739,54,9732,-98,-29
239,54,97-42,-25,8732,-98,-29
332,-98,-2932,-98,-2932,-98,-29
\n" + ], + "text/plain": [ + "Game(title='Majority Voting (3 players, 3 candidates)')" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g_mv = gbt.catalog.generate_gamut(\n", + " \"MajorityVoting\",\n", + " params={\n", + " \"int_payoffs\": True,\n", + " \"int_mult\": 1,\n", + " \"players\": 3,\n", + " \"actions\": 3\n", + " },\n", + " gamut_jar=\"~/Downloads/gamut.jar\",\n", + ")\n", + "g_mv.title = \"Majority Voting (3 players, 3 candidates)\"\n", + "g_mv\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "gamut-majority-eqm", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[1,0,0\\right],\\left[1,0,0\\right],\\left[1,0,0\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(1, 1), Rational(0, 1), Rational(0, 1)], [Rational(1, 1), Rational(0, 1), Rational(0, 1)], [Rational(1, 1), Rational(0, 1), Rational(0, 1)]]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result_mv = gbt.nash.enumpure_solve(g_mv)\n", + "result_mv.equilibria[0]\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "dtpure", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.13" + }, + "nbsphinx": { + "execute": "never" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index c3abb6aee..d11dac55b 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -147,7 +147,7 @@ "id": "045cf8dd", "metadata": {}, "source": [ - "Gambit's catalog module can load games from the OpenSpiel library using `gbt.catalog.load_openspiel`:\n" + "Gambit's catalog module can generate games from the OpenSpiel library using `gbt.catalog.generate_openspiel`:\n" ] }, { @@ -157,13 +157,13 @@ "metadata": {}, "outputs": [], "source": [ - "gbt_matrix_rps_game = gbt.catalog.load_openspiel(\"matrix_rps\")\n", + "gbt_matrix_rps_game = gbt.catalog.generate_openspiel(\"matrix_rps\")\n", "\n", "gbt_matrix_rps_game.title = \"Rock-Paper-Scissors\"\n", "\n", "for player in gbt_matrix_rps_game.players:\n", - " for strategy, label in zip(player.strategies, [\"Rock\", \"Paper\", \"Scissors\"], strict=True):\n", - " strategy.label = label\n", + " for strategy, name in zip(player.strategies, [\"Rock\", \"Paper\", \"Scissors\"], strict=True):\n", + " strategy.label = name\n", "\n", "gbt_matrix_rps_game" ] @@ -458,7 +458,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt_hanabi_game = gbt.catalog.load_openspiel(\"tiny_hanabi\")\n", + "gbt_hanabi_game = gbt.catalog.generate_openspiel(\"tiny_hanabi\")\n", "eqm = gbt.nash.lcp_solve(gbt_hanabi_game).equilibria[0]" ] }, diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 2b6aadaeb..dfbac9775 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -1,4 +1,8 @@ import io +import os +import shutil +import subprocess +import tempfile from importlib.resources import as_file, files from pathlib import Path from typing import Any @@ -21,9 +25,9 @@ } -def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: +def generate_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: """ - Load a game from the OpenSpiel library. + Generate a game using the OpenSpiel library. Parameters ---------- @@ -40,7 +44,7 @@ def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: Returns ------- gbt.Game - The loaded game. + The generated game. Raises ------ @@ -95,6 +99,232 @@ def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: ) +def generate_gamut( + game_class: str, + params: dict | None = None, + gamut_jar: Path | str | None = None, +) -> gbt.Game: + """ + Generate a game using the GAMUT game generator. + + GAMUT (http://gamut.stanford.edu) is a Java-based suite of parameterised + game generators. This function calls GAMUT as an external process and + returns the resulting game as a :class:`~pygambit.Game` object. + + Parameters + ---------- + game_class : str + The GAMUT game class name (e.g. ``"RandomGame"``, ``"CovariantGame"``). + See the `GAMUT documentation `_ + for available game classes and their parameters. + params : dict, optional + Parameters forwarded to GAMUT as command-line flags. Each key becomes + ``-key``. Values are handled as follows: + + - ``True`` → flag with no value token + (e.g. ``{"normalize": True}`` → ``-normalize``) + - list or tuple → space-separated tokens + (e.g. ``{"actions": [3, 3]}`` → ``-actions 3 3``) + - anything else → single token + (e.g. ``{"players": 2}`` → ``-players 2``) + + Multi-word GAMUT flags use underscores: + ``{"min_payoff": 0, "max_payoff": 100}``. + gamut_jar : pathlib.Path or str, optional + Path to ``gamut.jar``. If omitted, the ``GAMUT_JAR`` environment + variable is used. Raises :class:`FileNotFoundError` if neither is + supplied. + + Returns + ------- + Game + The generated game. + + Raises + ------ + RuntimeError + If ``java`` is not found on the system PATH. + FileNotFoundError + If ``gamut.jar`` cannot be located. + ValueError + If GAMUT exits with a non-zero return code (e.g. invalid game class + or parameters). + """ + if shutil.which("java") is None: + raise RuntimeError( + "Java is required to run GAMUT. " + "Install Java and ensure 'java' is on your PATH." + ) + if gamut_jar is None: + env_jar = os.environ.get("GAMUT_JAR") + if env_jar is None: + raise FileNotFoundError( + "gamut.jar not found. Provide the path via the 'gamut_jar' argument " + "or set the GAMUT_JAR environment variable. " + "Download GAMUT from http://gamut.stanford.edu/." + ) + gamut_jar = Path(env_jar).expanduser() + else: + gamut_jar = Path(gamut_jar).expanduser() + if not gamut_jar.is_file(): + raise FileNotFoundError(f"gamut.jar not found at {gamut_jar}") + + cmd = ["java", "-jar", str(gamut_jar), "-g", game_class, "-output", "GambitOutput"] + if params: + for key, value in params.items(): + cmd.append(f"-{key}") + if value is True: + pass # boolean flags like -normalize, -int_payoffs take no value token + elif isinstance(value, (list, tuple)): + cmd.extend(str(v) for v in value) + else: + cmd.append(str(value)) + + with tempfile.NamedTemporaryFile(suffix=".nfg", delete=False) as tmp: + out_path = Path(tmp.name) + try: + cmd.extend(["-f", str(out_path)]) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise ValueError( + f"GAMUT failed for game class '{game_class}': " + f"{result.stderr.strip() or result.stdout.strip()}" + ) + return gbt.read_nfg(str(out_path)) + finally: + out_path.unlink(missing_ok=True) + + +_GAMUT_GAMES = [ + {"Class": "ArmsRace", + "Description": "Arms race with cost and demand functions chosen by players.", + "Players": "n"}, + {"Class": "BattleOfTheSexes", + "Description": "Coordination game where players prefer to meet but disagree on the venue.", + "Players": "2"}, + {"Class": "BertrandOligopoly", + "Description": "Bertrand oligopoly with arbitrary cost and demand functions.", + "Players": "n"}, + {"Class": "BidirectionalLEG", + "Description": "Bidirectional local-effect game on a specified graph.", + "Players": "n"}, + {"Class": "Chicken", + "Description": "Classic 2x2 Chicken game.", + "Players": "2"}, + {"Class": "CollaborationGame", + "Description": "Game where all players choosing the same action yields the highest payoffs.", + "Players": "n"}, + {"Class": "CongestionGame", + "Description": "Congestion game where payoffs depend on how many players share facilities.", + "Players": "n"}, + {"Class": "CoordinationGame", + "Description": "Pure coordination game rewarding matching action choices.", + "Players": "n"}, + {"Class": "CournotDuopoly", + "Description": "Cournot duopoly with arbitrary cost and inverse demand functions.", + "Players": "2"}, + {"Class": "CovariantGame", + "Description": "Game with payoffs whose cross-player correlation is set by a parameter r.", + "Players": "n"}, + {"Class": "DispersionGame", + "Description": "Game where dispersed action choices are rewarded.", + "Players": "n"}, + {"Class": "GrabTheDollar", + "Description": "Simultaneous competition to claim a prize first.", + "Players": "2"}, + {"Class": "GreedyGame", + "Description": "Players each choose a subset from a set; payoffs depend on overlap.", + "Players": "2"}, + {"Class": "GuessThirdsAve", + "Description": "Players guess a number trying to reach 2/3 of the average.", + "Players": "n"}, + {"Class": "HawkAndDove", + "Description": "Classic 2x2 Hawk and Dove game.", + "Players": "2"}, + {"Class": "LocationGame", + "Description": "Hotelling-style two-player location game on a street.", + "Players": "2"}, + {"Class": "MajorityVoting", + "Description": "Majority voting game with arbitrary candidate utilities.", + "Players": "n"}, + {"Class": "MatchingPennies", + "Description": "Classic 2x2 Matching Pennies game.", + "Players": "2"}, + {"Class": "MinimumEffortGame", + "Description": "Payoffs depend on the minimum effort exerted across all players.", + "Players": "n"}, + {"Class": "NPlayerChicken", + "Description": "N-player Chicken game with cooperation costs and collective rewards.", + "Players": "n"}, + {"Class": "NPlayerPrisonersDilemma", + "Description": "N-player Prisoner's Dilemma with parameterised payoff functions.", + "Players": "n"}, + {"Class": "PolymatrixGame", + "Description": "Polymatrix game formed by two-player edge games on a graph.", + "Players": "n"}, + {"Class": "PrisonersDilemma", + "Description": "Classic 2x2 Prisoner's Dilemma.", + "Players": "2"}, + {"Class": "RandomCompoundGame", + "Description": "Compound game whose payoffs are sums of random 2x2 sub-games.", + "Players": "n"}, + {"Class": "RandomGame", + "Description": "Game with payoffs drawn uniformly at random.", + "Players": "n"}, + {"Class": "RandomGraphicalGame", + "Description": "Random graphical game on a specified graph.", + "Players": "n"}, + {"Class": "RandomLEG", + "Description": "Random local-effect game on a specified graph.", + "Players": "n"}, + {"Class": "RandomZeroSum", + "Description": "Two-player zero-sum game with random payoffs.", + "Players": "2"}, + {"Class": "RockPaperScissors", + "Description": "Classic Rock, Paper, Scissors game.", + "Players": "2"}, + {"Class": "ShapleyGame", + "Description": "Shapley's original game; no stable equilibrium under replicator dynamics.", + "Players": "2"}, + {"Class": "SimpleInspectionGame", + "Description": "Inspection game where players choose from different-sized subsets.", + "Players": "2"}, + {"Class": "TravelersDilemma", + "Description": "Players claim a value; the lowest claim " + "wins a reward, others receive a penalty.", + "Players": "n"}, + {"Class": "TwoByTwoGame", + "Description": "2x2 game of a specified type per Rapoport's classification (type in 1-85).", + "Players": "2"}, + {"Class": "UniformLEG", + "Description": "Local-effect game where all edges share the same local-effect function.", + "Players": "n"}, + {"Class": "WarOfAttrition", + "Description": "Players choose concession times; payoffs " + "depend on valuations and decrements.", + "Players": "2"}, +] + + +def gamut_games() -> pd.DataFrame: + """ + Return a DataFrame listing all 35 GAMUT game classes. + + Each row describes one game class that can be passed to + :func:`generate_gamut` as the ``game_class`` argument. + + Returns + ------- + pd.DataFrame + DataFrame with columns: + + - ``"Class"``: the game class name to pass to :func:`generate_gamut` + - ``"Description"``: a short description of the game family + - ``"Players"``: ``"2"`` for two-player only, ``"n"`` for n-player + """ + return pd.DataFrame.from_records(_GAMUT_GAMES, columns=["Class", "Description", "Players"]) + + def load(slug: str) -> gbt.Game: """ Load a game from the package catalog. diff --git a/tests/test_catalog.py b/tests/test_catalog.py index f807fdde2..3d854f0e2 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -1,4 +1,5 @@ import sys +from pathlib import Path from unittest.mock import MagicMock import pandas as pd @@ -207,7 +208,7 @@ def _setup_pyspiel_mock( mock_export_fn = MagicMock() mock_game = MagicMock() - # Wire the dynamics attribute so the == comparison in load_openspiel resolves correctly. + # Wire the dynamics attribute so the == comparison in generate_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": @@ -243,35 +244,35 @@ def _setup_pyspiel_mock( return mock_ps, mock_export_fn -def test_openspiel_load_efg_success(monkeypatch): +def test_generate_openspiel_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") + game = gbt.catalog.generate_openspiel("tiny_hanabi") assert isinstance(game, gbt.Game) -def test_openspiel_load_nfg_success(monkeypatch): +def test_generate_openspiel_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") + game = gbt.catalog.generate_openspiel("matrix_rps") assert isinstance(game, gbt.Game) -def test_openspiel_load_import_error(monkeypatch): +def test_generate_openspiel_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") + gbt.catalog.generate_openspiel("matrix_rps") -def test_openspiel_load_game_not_found(monkeypatch): +def test_generate_openspiel_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") + gbt.catalog.generate_openspiel("bogus_game") -def test_openspiel_load_efg_export_failure(monkeypatch): +def test_generate_openspiel_efg_export_failure(monkeypatch): """EFG export failure on a sequential game raises ValueError with format context.""" _setup_pyspiel_mock( monkeypatch, @@ -279,10 +280,10 @@ def test_openspiel_load_efg_export_failure(monkeypatch): efg_raises=RuntimeError("export error"), ) with pytest.raises(ValueError, match="EFG format"): - gbt.catalog.load_openspiel("tiny_hanabi") + gbt.catalog.generate_openspiel("tiny_hanabi") -def test_openspiel_load_nfg_export_failure(monkeypatch): +def test_generate_openspiel_nfg_export_failure(monkeypatch): """NFG export failure on a simultaneous game raises ValueError with format context.""" _setup_pyspiel_mock( monkeypatch, @@ -290,20 +291,140 @@ def test_openspiel_load_nfg_export_failure(monkeypatch): nfg_raises=RuntimeError("export error"), ) with pytest.raises(ValueError, match="NFG format"): - gbt.catalog.load_openspiel("matrix_rps") + gbt.catalog.generate_openspiel("matrix_rps") -def test_openspiel_load_unsupported_dynamics(monkeypatch): +def test_generate_openspiel_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") + gbt.catalog.generate_openspiel("some_mfg_game") -def test_openspiel_load_with_params(monkeypatch): +def test_generate_openspiel_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}) + gbt.catalog.generate_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2}) mock_ps.load_game.assert_called_once_with("blotto", {"players": 2, "coins": 3, "fields": 2}) + + +# --------------------------------------------------------------------------- +# GAMUT game generation tests (all mocked; Java and gamut.jar not required) +# --------------------------------------------------------------------------- + + +def _fake_gamut_run(nfg_content, returncode=0, stderr=""): + """Return a fake subprocess.run callable that writes nfg_content to the -f path.""" + def _run(cmd, **kwargs): + if returncode == 0: + f_idx = cmd.index("-f") + Path(cmd[f_idx + 1]).write_text(nfg_content) + result = MagicMock() + result.returncode = returncode + result.stderr = stderr + result.stdout = "" + return result + return _run + + +def test_generate_gamut_success(monkeypatch, tmp_path): + """Happy path: GAMUT writes an NFG file and generate_gamut returns a Game.""" + fake_jar = tmp_path / "gamut.jar" + fake_jar.touch() + monkeypatch.setattr("pygambit.catalog.shutil.which", lambda _: "/usr/bin/java") + monkeypatch.setattr("pygambit.catalog.subprocess.run", _fake_gamut_run(_MOCK_NFG)) + game = gbt.catalog.generate_gamut("RandomGame", gamut_jar=fake_jar) + assert isinstance(game, gbt.Game) + + +def test_generate_gamut_with_params(monkeypatch, tmp_path): + """params dict is translated correctly to GAMUT command-line flags.""" + fake_jar = tmp_path / "gamut.jar" + fake_jar.touch() + captured = {} + + def _run(cmd, **kwargs): + captured["cmd"] = cmd + Path(cmd[cmd.index("-f") + 1]).write_text(_MOCK_NFG) + result = MagicMock() + result.returncode = 0 + return result + + monkeypatch.setattr("pygambit.catalog.shutil.which", lambda _: "/usr/bin/java") + monkeypatch.setattr("pygambit.catalog.subprocess.run", _run) + gbt.catalog.generate_gamut( + "RandomGame", + params={"players": 2, "actions": [3, 3], "normalize": True, "min_payoff": 0}, + gamut_jar=fake_jar, + ) + cmd = captured["cmd"] + assert "-players" in cmd and cmd[cmd.index("-players") + 1] == "2" + assert "-actions" in cmd + actions_idx = cmd.index("-actions") + assert cmd[actions_idx + 1] == "3" and cmd[actions_idx + 2] == "3" + assert "-normalize" in cmd + normalize_idx = cmd.index("-normalize") + assert cmd[normalize_idx + 1] == "-min_payoff" # no value token after -normalize + assert "-min_payoff" in cmd and cmd[cmd.index("-min_payoff") + 1] == "0" + + +def test_generate_gamut_no_java(monkeypatch, tmp_path): + """Missing java on PATH raises RuntimeError.""" + monkeypatch.setattr("pygambit.catalog.shutil.which", lambda _: None) + with pytest.raises(RuntimeError, match="Java is required"): + gbt.catalog.generate_gamut("RandomGame", gamut_jar=tmp_path / "gamut.jar") + + +def test_generate_gamut_jar_not_found(monkeypatch, tmp_path): + """Non-existent gamut_jar path raises FileNotFoundError.""" + monkeypatch.setattr("pygambit.catalog.shutil.which", lambda _: "/usr/bin/java") + with pytest.raises(FileNotFoundError, match="gamut.jar not found at"): + gbt.catalog.generate_gamut("RandomGame", gamut_jar=tmp_path / "missing.jar") + + +def test_generate_gamut_no_jar_env(monkeypatch): + """With gamut_jar=None and GAMUT_JAR unset, raises FileNotFoundError.""" + monkeypatch.setattr("pygambit.catalog.shutil.which", lambda _: "/usr/bin/java") + monkeypatch.delenv("GAMUT_JAR", raising=False) + with pytest.raises(FileNotFoundError, match="GAMUT_JAR"): + gbt.catalog.generate_gamut("RandomGame") + + +def test_generate_gamut_gamut_fails(monkeypatch, tmp_path): + """Non-zero returncode from GAMUT raises ValueError naming the game class.""" + fake_jar = tmp_path / "gamut.jar" + fake_jar.touch() + monkeypatch.setattr("pygambit.catalog.shutil.which", lambda _: "/usr/bin/java") + monkeypatch.setattr( + "pygambit.catalog.subprocess.run", + _fake_gamut_run("", returncode=1, stderr="Unknown game class 'Bogus'"), + ) + with pytest.raises(ValueError, match="Bogus"): + gbt.catalog.generate_gamut("Bogus", gamut_jar=fake_jar) + + +def test_gamut_games_returns_dataframe(): + assert isinstance(gbt.catalog.gamut_games(), pd.DataFrame) + + +def test_gamut_games_columns(): + assert list(gbt.catalog.gamut_games().columns) == ["Class", "Description", "Players"] + + +def test_gamut_games_count(): + assert len(gbt.catalog.gamut_games()) == 35 + + +def test_gamut_games_known_classes(): + classes = set(gbt.catalog.gamut_games()["Class"]) + for name in [ + "RandomGame", "BattleOfTheSexes", "CovariantGame", + "MajorityVoting", "PrisonersDilemma", + ]: + assert name in classes + + +def test_gamut_games_players_values(): + assert set(gbt.catalog.gamut_games()["Players"]) == {"2", "n"} diff --git a/tests/test_tutorials.py b/tests/test_tutorials.py index e15171ebe..6d6a4bb9e 100644 --- a/tests/test_tutorials.py +++ b/tests/test_tutorials.py @@ -53,6 +53,10 @@ def test_execute_notebook(nb_path): if sys.platform == "win32" and "openspiel" in nb_path.name.lower(): pytest.skip("OpenSpiel notebook requires OpenSpiel, which is not available on Windows") + # GAMUT notebook requires Java and gamut.jar; outputs are pre-saved for docs builds + if "gamut" in nb_path.name.lower(): + pytest.skip("GAMUT notebook requires Java and gamut.jar (see catalog documentation)") + nb = nbformat.read(str(nb_path), as_version=4) # Prefer the notebook's kernelspec if provided, otherwise let nbclient pick the default.