diff --git a/.gitignore b/.gitignore index d2f3fe579..9d37e0d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ doc/tutorials/*.png Gambit.app/* *.ipynb_checkpoints *.ef +build_support/msw/gambit.wxs +build_support/osx/Info.plist diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index ae029bae7..8a83e8b8d 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -325,3 +325,158 @@ Computation of quantal response equilibria logit_estimate LogitQREMixedStrategyFitResult LogitQREMixedBehaviorFitResult + + +Game catalog +~~~~~~~~~~~~ + +.. currentmodule:: pygambit.catalog + +.. autosummary:: + :toctree: api/ + + games + Game2s2x2x2 + Game2smp + Game2x2 + Game2x2a + Game2x2const + Game2x2x2_nau + Game2x2x2 + Game2x2x2NFG + Game2x2x2x2 + Game2x2x2x2x2 + Game3x3x3 + Game4cards + Game5x4x3 + Game8x2x2 + Game8x8 + Artist1 + Artist2 + Badgame1 + Badgame2 + Bagwell + Bayes1a + Bayes2a + Bcp2 + Bcp3 + Bcp4 + Bhg1 + Bhg2 + Bhg3 + Bhg4 + Bhg5 + Caro2 + Cent2 + Cent2NFG + Cent3 + Cent4 + Cent6 + Centcs10 + Centcs6 + Condjury + Coord2 + Coord2NFG + Coord2ts + Coord3 + Coord3NFG + Coord333 + Coord4 + Coord4NFG + Cross + Cs + Csg1 + Csg2 + Csg3 + Csg4 + Deg1 + Deg2 + E01 + E01NFG + E02 + E02NFG + E03 + E04 + E04NFG + E05 + E06 + E07 + E07NFG + E08 + E09 + E10 + E10a + E13 + E16 + E17 + E18 + G1 + G1NFG + G2 + G2NFG + G3 + G3NFG + Holdout + Hs1 + Jury_mr + Jury_un + Km1 + Km2 + Km3 + Km6 + Loopback + Mixdom + Mixdom2 + Montyhal + My_2_1 + My_2_4 + My_2_8 + My_3_3a + My_3_3b + My_3_3c + My_3_3d + My_3_3e + My_3_4 + Myerson + Myerson_fig_4_2 + Nim + Nim7 + Oneill + Palf + Palf2 + Palf3 + Pbride + PrisonersDilemma + Perfect1 + Perfect2 + Perfect3 + Poker + PokerNFG + Poker2 + Pvw + Pvw2 + Sh3 + Sh3NFG + Spence + Stengel + Sww1 + Sww1NFG + Sww2 + Sww3 + Tim + Todd1 + Todd2 + Todd3 + Ttt + Vd + VdNFG + W_ex1 + W_ex2 + Wilson1 + Wink3 + Winkels + Work1 + Work2 + Work3 + Yamamoto + Zero diff --git a/doc/tutorials/01_quickstart.ipynb b/doc/tutorials/01_quickstart.ipynb index 06ec6f91e..354218160 100644 --- a/doc/tutorials/01_quickstart.ipynb +++ b/doc/tutorials/01_quickstart.ipynb @@ -38,7 +38,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "c58d382d", "metadata": {}, "outputs": [], @@ -50,7 +50,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "2060c1ed", "metadata": {}, "outputs": [ @@ -60,7 +60,7 @@ "pygambit.gambit.Game" ] }, - "execution_count": 1, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -83,7 +83,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "9d8203e8", "metadata": {}, "outputs": [], @@ -111,7 +111,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "id": "61030607", "metadata": {}, "outputs": [], @@ -135,7 +135,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "caecc334", "metadata": {}, "outputs": [ @@ -149,7 +149,7 @@ "Game(title='Prisoner's Dilemma')" ] }, - "execution_count": 4, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -189,7 +189,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "843ba7f3", "metadata": {}, "outputs": [ @@ -203,7 +203,7 @@ "Game(title='Another Prisoner's Dilemma')" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -233,7 +233,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "5ee752c4", "metadata": {}, "outputs": [ @@ -270,7 +270,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "id": "a81c06c7", "metadata": {}, "outputs": [ @@ -280,7 +280,7 @@ "pygambit.nash.NashComputationResult" ] }, - "execution_count": 7, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -300,7 +300,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "id": "bd395180", "metadata": {}, "outputs": [ @@ -310,7 +310,7 @@ "1" ] }, - "execution_count": 8, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -329,7 +329,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 10, "id": "76570ebc", "metadata": {}, "outputs": [ @@ -342,7 +342,7 @@ "[[Rational(0, 1), Rational(1, 1)], [Rational(0, 1), Rational(1, 1)]]" ] }, - "execution_count": 9, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -354,7 +354,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "id": "6e8cfcde", "metadata": {}, "outputs": [ @@ -364,7 +364,7 @@ "pygambit.gambit.MixedStrategyProfileRational" ] }, - "execution_count": 10, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -385,7 +385,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 12, "id": "980bf6b1", "metadata": {}, "outputs": [ @@ -417,11 +417,163 @@ }, { "cell_type": "markdown", - "id": "24f36b0d", + "id": "15ab8d84", "metadata": {}, "source": [ "The equilibrium shows that both players are playing their dominant strategy, which is to defect. This is because defecting is the best response to the other player's strategy, regardless of what that strategy is.\n", "\n", + "Loading games from the catalog\n", + "------------------------------\n", + "\n", + "Gambit includes a catalog of standard games that can be loaded directly by name.\n", + "You can list all the available games and filtering on the game type and number of players in the catalog like so:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "701aa52a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['Game2x2',\n", + " 'Game2x2a',\n", + " 'Game2x2const',\n", + " 'Game8x8',\n", + " 'Cent2NFG',\n", + " 'Coord2NFG',\n", + " 'Coord3NFG',\n", + " 'Coord4NFG',\n", + " 'Csg1',\n", + " 'Csg2',\n", + " 'Csg3',\n", + " 'Csg4',\n", + " 'Deg1',\n", + " 'Deg2',\n", + " 'E02NFG',\n", + " 'E04NFG',\n", + " 'E07NFG',\n", + " 'Loopback',\n", + " 'Mixdom',\n", + " 'Mixdom2',\n", + " 'Oneill',\n", + " 'PrisonersDilemma',\n", + " 'Perfect1',\n", + " 'Perfect2',\n", + " 'PokerNFG',\n", + " 'Sh3NFG',\n", + " 'Stengel',\n", + " 'Sww1NFG',\n", + " 'Todd1',\n", + " 'Todd2',\n", + " 'Todd3',\n", + " 'VdNFG',\n", + " 'Wink3',\n", + " 'Winkels',\n", + " 'Yamamoto',\n", + " 'Zero']" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.catalog.games(game_type=\"nfg\", num_players=2)" + ] + }, + { + "cell_type": "markdown", + "id": "fd3f5829-c268-451a-8bc7-28e766e7d655", + "metadata": {}, + "source": [ + "The catalog can also be searched with custom metadata filters:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "2e680610-e2c9-45bc-84bd-67006aca5174", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['PrisonersDilemma']" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.catalog.games(tutorial=1)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "ff307b74-16bc-4889-b57c-fef3124ce5ca", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "''" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.catalog.PrisonersDilemma.description" + ] + }, + { + "cell_type": "markdown", + "id": "a919ddf7", + "metadata": {}, + "source": [ + "You can then load a specific game by its name. For example, to load the \"Prisoner's Dilemma\" game from the catalog, you would do the following:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "6db7a29a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

Two person Prisoner's Dilemma game

\n", + "
12
19,90,10
210,01,1
\n" + ], + "text/plain": [ + "Game(title='Two person Prisoner's Dilemma game')" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g = gbt.catalog.PrisonersDilemma()\n", + "g" + ] + }, + { + "cell_type": "markdown", + "id": "24f36b0d", + "metadata": {}, + "source": [ "Saving and reading strategic form games to and from file\n", "--------------------\n", "\n", @@ -433,7 +585,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "id": "f58eaa77", "metadata": {}, "outputs": [], @@ -451,23 +603,12 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "id": "4119a2ac", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "pygambit.gambit.Game" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "# gbt.read_nfg(\"test_games/prisoners_dilemma.nfg\")" + "# gbt.read_nfg(\"prisoners_dilemma.nfg\")" ] } ], diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 528970c61..4fed4cfbb 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -79,20 +79,20 @@ { "data": { "image/svg+xml": [ - "\n", + "\n", "\n", "\n", - "\n", + "\n", "\n", "\n", - "\n", + "\n", "\n", "\n", "\n", - "\n", + "\n", "\n", "\n", - "\n", + "\n", "\n", "" ], @@ -142,119 +142,119 @@ { "data": { "image/svg+xml": [ - "\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", "" ], @@ -302,181 +302,171 @@ { "data": { "image/svg+xml": [ - "\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", + "\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", "" ], "text/plain": [ @@ -529,157 +519,147 @@ { "data": { "image/svg+xml": [ - "\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", "" ], "text/plain": [ @@ -728,171 +708,161 @@ { "data": { "image/svg+xml": [ - "\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", - "\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", "" ], "text/plain": [ @@ -941,179 +911,169 @@ { "data": { "image/svg+xml": [ - "\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", + "\n", + "\n", "\n", "\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", + "\n", + "\n", "" ], "text/plain": [ @@ -1136,7 +1096,357 @@ "source": [ "Nodes without an outcome attached are assumed to have payoffs of zero for all players.\n", "\n", - "Therefore, adding the outcome to this latter terminal node is not strictly necessary in Gambit, but it is useful to be explicit for readability." + "Therefore, adding the outcome to this latter terminal node is not strictly necessary in Gambit, but it is useful to be explicit for readability.\n", + "\n", + "Loading games from the catalog\n", + "------------------------------\n", + "\n", + "Gambit includes a catalog of standard games that can be loaded directly by name.\n", + "You can list all the available games and filtering on the game type and number of players in the catalog like so:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "3207441f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['Game2smp',\n", + " 'Game4cards',\n", + " 'Artist1',\n", + " 'Artist2',\n", + " 'Badgame1',\n", + " 'Badgame2',\n", + " 'Bagwell',\n", + " 'Bayes1a',\n", + " 'Bayes2a',\n", + " 'Bcp2',\n", + " 'Bcp3',\n", + " 'Bcp4',\n", + " 'Bhg1',\n", + " 'Bhg2',\n", + " 'Bhg3',\n", + " 'Bhg4',\n", + " 'Bhg5',\n", + " 'Caro2',\n", + " 'Cent2',\n", + " 'Cent3',\n", + " 'Cent4',\n", + " 'Cent6',\n", + " 'Centcs10',\n", + " 'Centcs6',\n", + " 'Coord2',\n", + " 'Coord2ts',\n", + " 'Coord3',\n", + " 'Coord4',\n", + " 'Cross',\n", + " 'E02',\n", + " 'E04',\n", + " 'E07',\n", + " 'E10',\n", + " 'E10a',\n", + " 'E13',\n", + " 'E16',\n", + " 'E17',\n", + " 'E18',\n", + " 'Holdout',\n", + " 'Hs1',\n", + " 'Km1',\n", + " 'Km2',\n", + " 'Km3',\n", + " 'Km6',\n", + " 'Montyhal',\n", + " 'My_2_1',\n", + " 'My_2_4',\n", + " 'My_3_3a',\n", + " 'My_3_3b',\n", + " 'My_3_3c',\n", + " 'My_3_3d',\n", + " 'My_3_3e',\n", + " 'Myerson',\n", + " 'Myerson_fig_4_2',\n", + " 'Nim',\n", + " 'Nim7',\n", + " 'Palf',\n", + " 'Palf2',\n", + " 'Palf3',\n", + " 'Pbride',\n", + " 'Poker',\n", + " 'Poker2',\n", + " 'Pvw',\n", + " 'Pvw2',\n", + " 'Sh3',\n", + " 'Spence',\n", + " 'Sww1',\n", + " 'Sww2',\n", + " 'Sww3',\n", + " 'Tim',\n", + " 'Ttt',\n", + " 'Vd',\n", + " 'W_ex1',\n", + " 'W_ex2',\n", + " 'Wilson1',\n", + " 'Work1',\n", + " 'Work2',\n", + " 'Work3',\n", + " 'OneShotTrust']" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.catalog.games(game_type=\"efg\", num_players=2)" + ] + }, + { + "cell_type": "markdown", + "id": "af175f99", + "metadata": {}, + "source": [ + "Some games in the catalog have configurable parameters.\n", + "For example, the `OneShotTrust` game has the `unique_NE_variant` parameter, which is explained in the game's description:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "2933061a-a2eb-4ca7-8ce3-f350bc0678ce", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The unique_NE_variant makes Trust a dominant strategy, replacing the\n", + "non-singleton equilibrium component from the standard version of the game\n", + "where the Buyer plays \"Not Trust\" and the seller can play any mixture with\n", + "< 0.5 probability on Honor with a unique NE where the Buyer plays Trust and\n", + "the Seller plays Abuse.\n" + ] + } + ], + "source": [ + "print(gbt.catalog.OneShotTrust.description)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "bd8b291b-6cc6-4f9a-b5c3-0d1f4c05252b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Game(title='One-shot trust game, after Kreps (1990)')" + ], + "text/plain": [ + "Game(title='One-shot trust game, after Kreps (1990)')" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.catalog.OneShotTrust(unique_NE_variant=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "4556c0bf", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\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", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "draw_tree(gbt.catalog.OneShotTrust(unique_NE_variant=True))" ] }, { @@ -1156,7 +1466,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 18, "id": "37c51152", "metadata": {}, "outputs": [], @@ -1174,7 +1484,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 19, "id": "0d86a750", "metadata": {}, "outputs": [], @@ -1193,7 +1503,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 20, "id": "1bab777f-8a0b-4f1e-9c0c-270690288243", "metadata": {}, "outputs": [], @@ -1205,7 +1515,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 21, "id": "2b715221-e427-4092-ad2f-9f4f2b548fa4", "metadata": {}, "outputs": [], diff --git a/src/pygambit/__init__.py b/src/pygambit/__init__.py index 1d6f730bf..5f9fb08cc 100644 --- a/src/pygambit/__init__.py +++ b/src/pygambit/__init__.py @@ -26,6 +26,7 @@ nash, # noqa: F401 qre, # noqa: F401 supports, # noqa: F401 + catalog, # noqa: F401 ) import importlib.metadata diff --git a/src/pygambit/catalog/__init__.py b/src/pygambit/catalog/__init__.py new file mode 100644 index 000000000..a26c0f3af --- /dev/null +++ b/src/pygambit/catalog/__init__.py @@ -0,0 +1,22 @@ +from . import catalog +from .catalog import games + +# Ensure catalog module is fully imported including all YAML-generated classes +_all_catalog_classes = games() +_all_catalog_classes.append("CatalogGame") # Ensure base class is included + +_game_classes = {} + +for game_name in _all_catalog_classes: + try: + _game_classes[game_name] = getattr(catalog, game_name) + except AttributeError as e: + raise ImportError( + f"Catalog game '{game_name}' listed but not found in catalog module" + ) from e + +# Add to module namespace +globals().update(_game_classes) + +# Build __all__ dynamically +__all__ = ["games", *list(_all_catalog_classes)] diff --git a/src/pygambit/catalog/catalog.py b/src/pygambit/catalog/catalog.py new file mode 100644 index 000000000..fff2c1ae3 --- /dev/null +++ b/src/pygambit/catalog/catalog.py @@ -0,0 +1,263 @@ +import inspect +import sys +from pathlib import Path +from typing import Literal + +import yaml + +# import numpy as np +from pygambit.gambit import Game, read_efg, read_nfg + +_GAMEFILES_DIR = Path(__file__).parent.parent.parent.parent / "contrib/games" + + +class CatalogGame: + """ + Base class for catalog games. + This class serves as a template for specific games in the catalog. + Calling any subclass will return an instance of the corresponding game. + """ + + title: str + num_players: int + game_type: Literal["nfg", "efg"] + description: str + _cached_game: Game | None = None + + def __new__(cls, *args, **kwargs) -> Game: + """Create a game instance by calling the _game() method.""" + if cls._cached_game is None: + cls._cached_game = cls._game(*args, **kwargs) + return cls._cached_game + + @staticmethod + def _game() -> Game: + """Override this method in subclasses to define the game.""" + raise NotImplementedError("Subclasses must implement _game() method") + + @classmethod + def _extract_metadata_from_game(cls, game: Game) -> None: + """Extract metadata from the game and set as class attributes.""" + cls.title = game.title + cls.num_players = len(game.players) + if cls.__doc__: + cls.description = inspect.cleandoc(cls.__doc__) + else: + cls.description = game.comment + + def __init_subclass__(cls, **kwargs): + """Extract metadata when subclass is defined (if not a file-based game).""" + super().__init_subclass__(**kwargs) + + # Skip if this is CatalogGameFromContrib or its subclasses + if cls.__name__ == "CatalogGameFromContrib" or issubclass(cls, CatalogGameFromContrib): + return + + # Load game and extract metadata immediately when class is defined + cls._cached_game = cls._game() + cls._extract_metadata_from_game(cls._cached_game) + + +class CatalogGameFromContrib(CatalogGame): + """ + Base class for catalog games loaded from files. + This class serves as a template for specific games in the catalog. + Calling any subclass will return an instance of the corresponding game. + """ + + game_file: str + + def __new__(cls) -> Game: + if cls._cached_game is None: + cls._cached_game = cls._load_game() + return cls._cached_game + + @classmethod + def _load_game(cls) -> Game: + """Load the game from file.""" + if not hasattr(cls, "game_file") or cls.game_file is None: + raise TypeError(f"{cls.__name__} must define 'game_file' class attribute") + + game_type = cls.game_file.split(".")[-1] + file_path = _GAMEFILES_DIR / cls.game_file + + if game_type == "nfg": + cls.game_type = "nfg" + return read_nfg(str(file_path)) + elif game_type == "efg": + cls.game_type = "efg" + return read_efg(str(file_path)) + else: + raise ValueError(f"Game file extension must be 'nfg' or 'efg', got '{game_type}'") + + def __init_subclass__(cls, **kwargs): + """Validate and extract metadata when subclass is defined.""" + super().__init_subclass__(**kwargs) + + # Load game and extract metadata immediately when class is defined + cls._cached_game = cls._load_game() + cls._extract_metadata_from_game(cls._cached_game) + + +def games( + game_type: Literal["all", "nfg", "efg"] = "all", + num_players: int | None = None, + **metadata_filters, +) -> list[str]: + """ + Return a list of catalog game class names. + + Parameters + ---------- + game_type : {"all", "nfg", "efg"}, default "all" + Filter games by type: + - "all": return all games + - "nfg": return only normal-form (strategic) games + - "efg": return only extensive-form games + num_players : int | None, default None + If specified, only return games with the given number of players. + **metadata_filters + Additional keyword arguments to filter by catalog.yml metadata fields. + For example, `x=1` filters for games with `x: 1` in metadata. + + Returns + ------- + list[str] + List of game class names matching the specified filters. + + Examples + -------- + >>> games(x=1) # Games with a custom metadata field 'x' equal to 1 + >>> games(game_type="efg", num_players=2) # 2-player extensive-form games + """ + + def get_all_subclasses(cls): + """Recursively get all subclasses.""" + all_subclasses = [] + for subclass in cls.__subclasses__(): + # Check standard filters + if subclass.__name__ in ["CatalogGameFromContrib"]: + all_subclasses.extend(get_all_subclasses(subclass)) + continue + + if game_type != "all" and not hasattr(subclass, "game_type"): + all_subclasses.extend(get_all_subclasses(subclass)) + continue + + if game_type != "all" and subclass.game_type != game_type: + all_subclasses.extend(get_all_subclasses(subclass)) + continue + + if num_players is not None and not hasattr(subclass, "num_players"): + all_subclasses.extend(get_all_subclasses(subclass)) + continue + + if num_players is not None and subclass.num_players != num_players: + all_subclasses.extend(get_all_subclasses(subclass)) + continue + + # Check metadata filters + metadata_match = True + for key, value in metadata_filters.items(): + if not hasattr(subclass, key) or getattr(subclass, key) != value: + metadata_match = False + break + + if metadata_match: + all_subclasses.append(subclass.__name__) + + all_subclasses.extend(get_all_subclasses(subclass)) + return all_subclasses + + return get_all_subclasses(CatalogGame) + + +############################ +# Catalog games from files # +############################ + +_CATALOG_YAML = Path(__file__).parent / "catalog.yml" + + +def _load_catalog_from_yaml(path: Path) -> dict[str, dict]: + if not path.exists(): + raise FileNotFoundError(f"Catalog YAML not found: {path}") + with path.open("r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def _generate_contrib_game_classes(catalog: dict[str, dict]) -> None: + """ + Dynamically generate CatalogGameFromContrib subclasses from YAML + and attach them to this module's namespace. + """ + module = sys.modules[__name__] + + for class_name, entry in catalog.items(): + if "file" not in entry: + raise ValueError(f"Missing 'file' for catalog entry '{class_name}'") + + game_file = entry["file"] + metadata = entry.get("metadata", {}) + + # Build class attributes dict + class_attrs = { + "game_file": game_file, + "__module__": __name__, + } + + # Add metadata fields as class attributes + if metadata and "valid_game" in metadata and metadata["valid_game"] is False: + pass # Marked as invalid game, do not create class + else: + if metadata: + for key, value in metadata.items(): + class_attrs[key] = value + + cls = type( + class_name, + (CatalogGameFromContrib,), + class_attrs, + ) + + setattr(module, class_name, cls) + + +# Generate classes at import time +_catalog_data = _load_catalog_from_yaml(_CATALOG_YAML) +_generate_contrib_game_classes(_catalog_data) + +########################################## +# Catalog games defined programmatically # +########################################## + + +class OneShotTrust(CatalogGame): + """ + The unique_NE_variant makes Trust a dominant strategy, replacing the + non-singleton equilibrium component from the standard version of the game + where the Buyer plays "Not Trust" and the seller can play any mixture with + < 0.5 probability on Honor with a unique NE where the Buyer plays Trust and + the Seller plays Abuse. + """ + game_type = "efg" + # test_suite = True + + @staticmethod + def _game(unique_NE_variant: bool = False): + g = Game.new_tree( + players=["Buyer", "Seller"], title="One-shot trust game, after Kreps (1990)" + ) + g.append_move(g.root, "Buyer", ["Trust", "Not trust"]) + g.append_move(g.root.children[0], "Seller", ["Honor", "Abuse"]) + g.set_outcome(g.root.children[0].children[0], g.add_outcome([1, 1], label="Trustworthy")) + if unique_NE_variant: + g.set_outcome( + g.root.children[0].children[1], g.add_outcome(["1/2", 2], label="Untrustworthy") + ) + else: + g.set_outcome( + g.root.children[0].children[1], g.add_outcome([-1, 2], label="Untrustworthy") + ) + g.set_outcome(g.root.children[1], g.add_outcome([0, 0], label="Opt-out")) + return g diff --git a/src/pygambit/catalog/catalog.yml b/src/pygambit/catalog/catalog.yml new file mode 100644 index 000000000..277cfae7e --- /dev/null +++ b/src/pygambit/catalog/catalog.yml @@ -0,0 +1,437 @@ +Game2s2x2x2: + file: 2s2x2x2.efg + metadata: +Game2smp: + file: 2smp.efg + metadata: +Game2x2: + file: 2x2.nfg + metadata: +Game2x2a: + file: 2x2a.nfg + metadata: +Game2x2const: + file: 2x2const.nfg + metadata: +Game2x2x2_nau: + file: 2x2x2-nau.nfg + metadata: +Game2x2x2: + file: 2x2x2.efg + metadata: +Game2x2x2NFG: + file: 2x2x2.nfg + metadata: +Game2x2x2x2: + file: 2x2x2x2.nfg + metadata: +Game2x2x2x2x2: + file: 2x2x2x2x2.nfg + metadata: +Game3x3x3: + file: 3x3x3.nfg + metadata: +Game4cards: + file: 4cards.efg + metadata: +Game5x4x3: + file: 5x4x3.nfg + metadata: +Game8x2x2: + file: 8x2x2.nfg + metadata: +Game8x8: + file: 8x8.nfg + metadata: +Artist1: + file: artist1.efg + metadata: +Artist2: + file: artist2.efg + metadata: +Badgame1: + file: badgame1.efg + metadata: +Badgame2: + file: badgame2.efg + metadata: +Bagwell: + file: bagwell.efg + metadata: +Bayes1a: + file: bayes1a.efg + metadata: +Bayes2a: + file: bayes2a.efg + metadata: +Bcp2: + file: bcp2.efg + metadata: +Bcp3: + file: bcp3.efg + metadata: +Bcp4: + file: bcp4.efg + metadata: +Bhg1: + file: bhg1.efg + metadata: +Bhg2: + file: bhg2.efg + metadata: +Bhg3: + file: bhg3.efg + metadata: +Bhg4: + file: bhg4.efg + metadata: +Bhg5: + file: bhg5.efg + metadata: +Caro2: + file: caro2.efg + metadata: +Cent2: + file: cent2.efg + metadata: +Cent2NFG: + file: cent2.nfg + metadata: +Cent3: + file: cent3.efg + metadata: +Cent4: + file: cent4.efg + metadata: +Cent6: + file: cent6.efg + metadata: +Centcs10: + file: centcs10.efg + metadata: +Centcs6: + file: centcs6.efg + metadata: +Condjury: + file: condjury.efg + metadata: +Coord2: + file: coord2.efg + metadata: +Coord2NFG: + file: coord2.nfg + metadata: +Coord2ts: + file: coord2ts.efg + metadata: +Coord3: + file: coord3.efg + metadata: +Coord3NFG: + file: coord3.nfg + metadata: +Coord333: + file: coord333.nfg + metadata: +Coord4: + file: coord4.efg + metadata: +Coord4NFG: + file: coord4.nfg + metadata: +Cross: + file: cross.efg + metadata: +Cs: + file: cs.efg + metadata: +Csg1: + file: csg1.nfg + metadata: +Csg2: + file: csg2.nfg + metadata: +Csg3: + file: csg3.nfg + metadata: +Csg4: + file: csg4.nfg + metadata: +Deg1: + file: deg1.nfg + metadata: +Deg2: + file: deg2.nfg + metadata: +E01: + file: e01.efg + metadata: +E01NFG: + file: e01.nfg + metadata: +E02: + file: e02.efg + metadata: +E02NFG: + file: e02.nfg + metadata: +E03: + file: e03.efg + metadata: +E04: + file: e04.efg + metadata: +E04NFG: + file: e04.nfg + metadata: +E05: + file: e05.efg + metadata: +E06: + file: e06.efg + metadata: +E07: + file: e07.efg + metadata: +E07NFG: + file: e07.nfg + metadata: +E08: + file: e08.efg + metadata: +E09: + file: e09.efg + metadata: +E10: + file: e10.efg + metadata: +E10a: + file: e10a.efg + metadata: +E13: + file: e13.efg + metadata: +E16: + file: e16.efg + metadata: +E17: + file: e17.efg + metadata: +E18: + file: e18.efg + metadata: +G1: + file: g1.efg + metadata: +G1NFG: + file: g1.nfg + metadata: +G2: + file: g2.efg + metadata: +G2NFG: + file: g2.nfg + metadata: +G3: + file: g3.efg + metadata: +G3NFG: + file: g3.nfg + metadata: +Holdout: + file: holdout.efg + metadata: +Holdout7: + file: holdout7.efg + metadata: + valid_game: false # This game does not work (ValueError: Parse error in game file: Probabilities must sum to exactly one) +Hs1: + file: hs1.efg + metadata: +Jury_mr: + file: jury_mr.efg + metadata: +Jury_un: + file: jury_un.efg + metadata: +Km1: + file: km1.efg + metadata: +Km2: + file: km2.efg + metadata: +Km3: + file: km3.efg + metadata: +Km6: + file: km6.efg + metadata: +Loopback: + file: loopback.nfg + metadata: +Mixdom: + file: mixdom.nfg + metadata: +Mixdom2: + file: mixdom2.nfg + metadata: +Montyhal: + file: montyhal.efg + metadata: +My_2_1: + file: my_2-1.efg + metadata: +My_2_4: + file: my_2-4.efg + metadata: +My_2_8: + file: my_2-8.efg + metadata: +My_3_3a: + file: my_3-3a.efg + metadata: +My_3_3b: + file: my_3-3b.efg + metadata: +My_3_3c: + file: my_3-3c.efg + metadata: +My_3_3d: + file: my_3-3d.efg + metadata: +My_3_3e: + file: my_3-3e.efg + metadata: +My_3_4: + file: my_3-4.efg + metadata: +Myerson: + file: myerson.efg + metadata: +Myerson_fig_4_2: + file: myerson_fig_4_2.efg + metadata: +Nim: + file: nim.efg + metadata: +Nim7: + file: nim7.efg + metadata: +Oneill: + file: oneill.nfg + metadata: +Palf: + file: palf.efg + metadata: +Palf2: + file: palf2.efg + metadata: +Palf3: + file: palf3.efg + metadata: +Pbride: + file: pbride.efg + metadata: +PrisonersDilemma: + file: pd.nfg + metadata: + test_suite: true +Perfect1: + file: perfect1.nfg + metadata: +Perfect2: + file: perfect2.nfg + metadata: +Perfect3: + file: perfect3.nfg + metadata: +Poker: + file: poker.efg + metadata: +PokerNFG: + file: poker.nfg + metadata: +Poker2: + file: poker2.efg + metadata: +Pvw: + file: pvw.efg + metadata: +Pvw2: + file: pvw2.efg + metadata: +Sh3: + file: sh3.efg + metadata: +Sh3NFG: + file: sh3.nfg + metadata: +Spence: + file: spence.efg + metadata: +Stengel: + file: stengel.nfg + metadata: +Sww1: + file: sww1.efg + metadata: +Sww1NFG: + file: sww1.nfg + metadata: +Sww2: + file: sww2.efg + metadata: +Sww3: + file: sww3.efg + metadata: +Tim: + file: tim.efg + metadata: +Todd1: + file: todd1.nfg + metadata: +Todd2: + file: todd2.nfg + metadata: +Todd3: + file: todd3.nfg + metadata: +Ttt: + file: ttt.efg + metadata: +Vd: + file: vd.efg + metadata: +VdNFG: + file: vd.nfg + metadata: +W_ex1: + file: w_ex1.efg + metadata: +W_ex2: + file: w_ex2.efg + metadata: +Wilson1: + file: wilson1.efg + metadata: +Wink3: + file: wink3.nfg + metadata: +Winkels: + file: winkels.nfg + metadata: +Work1: + file: work1.efg + metadata: +Work2: + file: work2.efg + metadata: +Work3: + file: work3.efg + metadata: +Yamamoto: + file: yamamoto.nfg + metadata: +Zero: + file: zero.nfg + metadata: diff --git a/src/pygambit/catalog/update.py b/src/pygambit/catalog/update.py new file mode 100644 index 000000000..cf05c4eec --- /dev/null +++ b/src/pygambit/catalog/update.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +from pathlib import Path + +from ruamel.yaml import YAML + +_CATALOG_YAML = Path(__file__).parent / "catalog.yml" +_GAMEFILES_DIR = Path(__file__).parent.parent.parent.parent / "contrib/games" +_API_RST = Path(__file__).parent.parent.parent.parent / "doc/pygambit.api.rst" + + +def make_class_name(filename: str) -> str: + """ + Convert a filename (without extension) into a class name. + - Replace hyphens with underscores + - Capitalise + - Prepend 'Game' if it starts with a digit + """ + name = filename.replace("-", "_") + + # Capitalise in a simple, predictable way + name = name[0].upper() + name[1:] if name else name + + if name and name[0].isdigit(): + name = f"Game{name}" + + return name + + +def update_api_rst(catalog: dict[str, dict]) -> None: + """Update the Game catalog section in pygambit.api.rst with all class names.""" + with open(_API_RST, encoding="utf-8") as f: + content = f.read() + + # Find the Game catalog section + game_catalog_start = content.find("Game catalog\n~~~~~~~~~~~~") + if game_catalog_start == -1: + print("Warning: 'Game catalog' section not found in pygambit.api.rst") + return + + # Find the autosummary block + autosummary_start = content.find(".. autosummary::", game_catalog_start) + toctree_start = content.find(":toctree: api/", autosummary_start) + + # Find the next section (starts with ~~) + next_section = content.find("\n~~", toctree_start) + if next_section == -1: + next_section = len(content) + + # Build the new toctree content + new_toctree = ".. autosummary::\n :toctree: api/\n\n games\n" + for class_name, entry in catalog.items(): + metadata = entry.get("metadata", {}) + if metadata and "valid_game" in metadata and metadata["valid_game"] is False: + pass # Marked as invalid game, do not add class + else: + new_toctree += f" {class_name}\n" + + # Replace the old toctree with the new one + old_toctree_start = content.rfind(".. autosummary::", game_catalog_start, toctree_start + 100) + old_toctree_end = next_section + + new_content = ( + content[:old_toctree_start] + + new_toctree + + content[old_toctree_end:] + ) + + with open(_API_RST, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated {_API_RST} with new catalog game names") + + +if __name__ == "__main__": + # Use ruamel.yaml to preserve comments + yaml = YAML() + yaml.preserve_quotes = True + yaml.default_flow_style = False + + efg_files = list(_GAMEFILES_DIR.rglob("*.efg")) + nfg_files = list(_GAMEFILES_DIR.rglob("*.nfg")) + + print(f"Found {len(efg_files)} .efg files in contrib/games") + print(f"Found {len(nfg_files)} .nfg files in contrib/games") + + all_files = sorted(efg_files + nfg_files) + + # Get the current class names from the catalog + with open(_CATALOG_YAML, encoding="utf-8") as f: + catalog = yaml.load(f) or {} + file_names = [entry["file"] for entry in catalog.values() if "file" in entry] + + # Iterate through contrib/games and update the catalog + # with new/missing entries + new_entries_counter = 0 + new_entries = {} + for path in all_files: + stem = path.stem + class_name = make_class_name(stem) + + # Avoid duplicates by appending EFG or NFG + if class_name in new_entries: + class_name += path.suffix.split(".")[-1].upper() + + if path.name not in file_names: + new_entries[class_name] = { + "file": path.name, + "metadata": {}, + } + new_entries_counter += 1 + + # Update the catalog + catalog.update(new_entries) + with _CATALOG_YAML.open("w", encoding="utf-8") as f: + yaml.dump(catalog, f) + + # Update the RST documentation + update_api_rst(catalog) + + print(f"Added {new_entries_counter} new entries to the catalog") + print(f"Output written to: {_CATALOG_YAML}") + print("Done.") diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 000000000..40cd53fa7 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,89 @@ +import pytest + +from pygambit import Game, catalog +from pygambit.catalog import CatalogGame, PrisonersDilemma + + +class ExampleGame(CatalogGame): + """ + Test game description. + """ + + game_type = "efg" + + @staticmethod + def _game(some_param: bool = False): + if some_param: + return Game.new_tree( + players=["A", "B"], title="Test game T" + ) + return Game.new_tree( + players=["A", "B"], title="Test game F" + ) + + +class TestCatalogGame: + """Tests for CatalogGame base class and subclassing.""" + + def test_catalog_game_not_instantiable(self): + """CatalogGame should not be directly instantiable.""" + with pytest.raises(NotImplementedError): + CatalogGame() + + def test_custom_game_subclass_extracts_metadata(self): + """Custom CatalogGame subclasses should extract metadata from _game().""" + assert ExampleGame.num_players == 2 + assert ExampleGame.game_type == "efg" + assert ExampleGame.title == "Test game F" + assert ExampleGame.description == "Test game description." + + def test_catalog_py_game_with_parameters(self): + """ + Custom CatalogGame subclass should return Game + and support parameters. + """ + assert isinstance(ExampleGame(some_param=False), Game) + assert isinstance(ExampleGame(some_param=True), Game) + + def test_catalog_yml_game_instantiation(self): + """Custom CatalogGame subclasses reading from catalog.yml should return Game instances.""" + assert isinstance(PrisonersDilemma(), Game) + + +class TestGamesFunction: + """Tests for the games() query function.""" + + def test_games_returns_list_of_strings(self): + """games() should return a list of class name strings.""" + result = catalog.games() + assert isinstance(result, list) + assert all(isinstance(name, str) for name in result) + + def test_games_filter_by_game_type(self): + """Filtering should split games into NFG/EFG.""" + nfg_games = catalog.games(game_type="nfg") + efg_games = catalog.games(game_type="efg") + all_games = catalog.games(game_type="all") + assert len(all_games) == len(set(nfg_games + efg_games)) + + def test_games_filter_by_num_players(self): + """games(num_players=n) should return only n-player games.""" + two_player_games = catalog.games(num_players=3) + for game_name in two_player_games: + game_class = getattr(catalog, game_name) + assert game_class.num_players == 3 + + def test_games_filter_by_custom_metadata(self): + """games() should filter by custom metadata fields.""" + custom_filter_games = catalog.games(test_suite=True) + assert isinstance(custom_filter_games, list) + for game_name in custom_filter_games: + game_class = getattr(catalog, game_name) + assert hasattr(game_class, "test_suite") + assert game_class.test_suite is True + + def test_games_excludes_base_classes(self): + """games() should not include base classes like CatalogGameFromContrib.""" + result = catalog.games() + assert "CatalogGame" not in result + assert "CatalogGameFromContrib" not in result