From 1ff44948ee092749f0cb8aeaadf7ec96b07b098f Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 10 Sep 2026 08:24:41 +0100 Subject: [PATCH 1/2] Refactor catalog into separate logical project. --- .github/workflows/lint.yml | 23 + .gitignore | 14 +- .readthedocs.yml | 4 +- MANIFEST.in | 2 +- Makefile.am | 2 +- build_support/catalog/catalog.am | 53 - catalog/.readthedocs.yml | 33 + catalog/VERSION | 1 + .../catalog/update.py => catalog/build.py | 51 +- catalog/catalog.am | 53 + catalog/doc/conf.py | 45 + .../doc/developer.rst | 63 +- catalog/doc/index.rst | 25 + .../{ => games}/books/myerson1991/fig2_1.efg | 0 .../{ => games}/books/myerson1991/fig4_2.efg | 0 .../books/shohamleytonbrown2008/fig5_1.efg | 0 .../books/shohamleytonbrown2008/fig5_10.efg | 0 .../fig5_10__original_layout.ef | 0 .../books/shohamleytonbrown2008/fig5_11.efg | 0 .../fig5_11__original_layout.ef | 0 .../books/shohamleytonbrown2008/fig5_12.efg | 0 .../fig5_12__original_layout.ef | 0 .../books/shohamleytonbrown2008/fig5_15.efg | 0 .../fig5_15__original_layout.ef | 0 .../fig5_1__original_layout.ef | 0 .../books/shohamleytonbrown2008/fig5_2.efg | 0 .../fig5_2__original_layout.ef | 0 .../books/shohamleytonbrown2008/fig5_9.efg | 0 .../fig5_9__original_layout.ef | 0 .../books/shohamleytonbrown2008/fig6_2.efg | 0 .../fig6_2__original_layout.ef | 0 .../books/shohamleytonbrown2008/fig6_8.efg | 0 .../fig6_8__original_layout.ef | 0 .../books/vonstengel2022/fig10.1.efg | 0 .../books/vonstengel2022/fig10.12.efg | 0 .../books/vonstengel2022/fig10.5.efg | 0 .../books/vonstengel2022/fig10.7.efg | 0 .../books/watson2013/exercise29_6.efg | 0 .../{ => games}/books/watson2013/fig29_1.efg | 0 .../conf/itcs/jakobsen2016/fig1a.efg | 0 .../conf/itcs/jakobsen2016/fig1b.efg | 0 .../conf/itcs/jakobsen2016/fig1c.efg | 0 .../conf/itcs/jakobsen2016/fig3.efg | 0 catalog/{ => games}/img/.gitkeep | 0 .../vonstengel1999/6x6_game_with_75_eq.nfg | 2 +- .../6x6_game_with_75_eq_small_payoffs.nfg | 0 .../{ => games}/journals/geb/bagwell1995.efg | 0 .../journals/geb/gilboa1997/fig1.efg | 0 .../journals/geb/gilboa1997/fig2.efg | 0 .../{ => games}/journals/geb/wichardt2008.efg | 0 .../journals/ijgt/nau2004/sec3.nfg | 0 .../journals/ijgt/nau2004/sec4.nfg | 0 .../journals/ijgt/nau2004/sec5.nfg | 0 .../journals/ijgt/nau2004/sec6.nfg | 0 .../journals/ijgt/selten1975/fig1.efg | 0 .../journals/ijgt/selten1975/fig2.efg | 0 .../journals/ijgt/selten1975/fig3.efg | 0 .../mor/vonstengelforges2008/fig1.efg | 0 .../fig1__Original_Layout.ef | 0 .../mor/vonstengelforges2008/fig6.efg | 0 .../fig6__Original_Layout.ef | 0 .../mor/vonstengelforges2008/fig9.efg | 0 .../fig9__Original_Layout.ef | 0 .../journals/other/reiley2008/fig1.efg | 0 .../journals/other/shapley1974/fig2.nfg | 0 .../journals/other/shapley1974/fig3.nfg | 0 .../catalog => catalog}/gtdraw_settings.yaml | 0 .../hierarchy.yaml | 0 .../test_update.py => catalog/test_build.py | 227 +-- doc/_bibstyle.py | 173 ++ doc/catalog.csv | 6 + doc/{catalog.rst => catalog_generators.rst} | 16 +- doc/conf.py | 172 +- doc/developer.overview.rst | 2 +- doc/developer.rst | 2 +- doc/index.rst | 9 +- doc/tools.enummixed.rst | 4 +- doc/tools.enumpoly.rst | 2 +- doc/tools.enumpure.rst | 6 +- doc/tools.gnm.rst | 2 +- doc/tools.hp.rst | 2 +- doc/tools.ipa.rst | 2 +- doc/tools.lcp.rst | 2 +- doc/tools.liap.rst | 2 +- doc/tools.logit.rst | 2 +- doc/tools.simpdiv.rst | 2 +- doc/tutorials/#03_poker.ipynb# | 1634 +++++++++++++++++ .../interoperability_tutorials/gamut.ipynb | 17 +- pyproject.toml | 4 +- setup.py | 2 +- src/pygambit/catalog.py | 17 +- 91 files changed, 2230 insertions(+), 448 deletions(-) delete mode 100644 build_support/catalog/catalog.am create mode 100644 catalog/.readthedocs.yml create mode 100644 catalog/VERSION rename build_support/catalog/update.py => catalog/build.py (90%) create mode 100644 catalog/catalog.am create mode 100644 catalog/doc/conf.py rename doc/developer.catalog.rst => catalog/doc/developer.rst (62%) create mode 100644 catalog/doc/index.rst rename catalog/{ => games}/books/myerson1991/fig2_1.efg (100%) rename catalog/{ => games}/books/myerson1991/fig4_2.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_1.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_10.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_10__original_layout.ef (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_11.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_11__original_layout.ef (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_12.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_12__original_layout.ef (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_15.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_15__original_layout.ef (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_1__original_layout.ef (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_2.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_2__original_layout.ef (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_9.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig5_9__original_layout.ef (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig6_2.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig6_2__original_layout.ef (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig6_8.efg (100%) rename catalog/{ => games}/books/shohamleytonbrown2008/fig6_8__original_layout.ef (100%) rename catalog/{ => games}/books/vonstengel2022/fig10.1.efg (100%) rename catalog/{ => games}/books/vonstengel2022/fig10.12.efg (100%) rename catalog/{ => games}/books/vonstengel2022/fig10.5.efg (100%) rename catalog/{ => games}/books/vonstengel2022/fig10.7.efg (100%) rename catalog/{ => games}/books/watson2013/exercise29_6.efg (100%) rename catalog/{ => games}/books/watson2013/fig29_1.efg (100%) rename catalog/{ => games}/conf/itcs/jakobsen2016/fig1a.efg (100%) rename catalog/{ => games}/conf/itcs/jakobsen2016/fig1b.efg (100%) rename catalog/{ => games}/conf/itcs/jakobsen2016/fig1c.efg (100%) rename catalog/{ => games}/conf/itcs/jakobsen2016/fig3.efg (100%) rename catalog/{ => games}/img/.gitkeep (100%) rename catalog/{ => games}/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg (99%) rename catalog/{ => games}/journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs.nfg (100%) rename catalog/{ => games}/journals/geb/bagwell1995.efg (100%) rename catalog/{ => games}/journals/geb/gilboa1997/fig1.efg (100%) rename catalog/{ => games}/journals/geb/gilboa1997/fig2.efg (100%) rename catalog/{ => games}/journals/geb/wichardt2008.efg (100%) rename catalog/{ => games}/journals/ijgt/nau2004/sec3.nfg (100%) rename catalog/{ => games}/journals/ijgt/nau2004/sec4.nfg (100%) rename catalog/{ => games}/journals/ijgt/nau2004/sec5.nfg (100%) rename catalog/{ => games}/journals/ijgt/nau2004/sec6.nfg (100%) rename catalog/{ => games}/journals/ijgt/selten1975/fig1.efg (100%) rename catalog/{ => games}/journals/ijgt/selten1975/fig2.efg (100%) rename catalog/{ => games}/journals/ijgt/selten1975/fig3.efg (100%) rename catalog/{ => games}/journals/mor/vonstengelforges2008/fig1.efg (100%) rename catalog/{ => games}/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef (100%) rename catalog/{ => games}/journals/mor/vonstengelforges2008/fig6.efg (100%) rename catalog/{ => games}/journals/mor/vonstengelforges2008/fig6__Original_Layout.ef (100%) rename catalog/{ => games}/journals/mor/vonstengelforges2008/fig9.efg (100%) rename catalog/{ => games}/journals/mor/vonstengelforges2008/fig9__Original_Layout.ef (100%) rename catalog/{ => games}/journals/other/reiley2008/fig1.efg (100%) rename catalog/{ => games}/journals/other/shapley1974/fig2.nfg (100%) rename catalog/{ => games}/journals/other/shapley1974/fig3.nfg (100%) rename {build_support/catalog => catalog}/gtdraw_settings.yaml (100%) rename build_support/catalog/catalog_hierarchy.yaml => catalog/hierarchy.yaml (100%) rename build_support/catalog/test_update.py => catalog/test_build.py (80%) create mode 100644 doc/_bibstyle.py create mode 100644 doc/catalog.csv rename doc/{catalog.rst => catalog_generators.rst} (80%) create mode 100644 doc/tutorials/#03_poker.ipynb# diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5b88b5f52..48f092c06 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -63,3 +63,26 @@ jobs: run: python -m pip install cython-lint - name: cython-lint run: cython-lint . + + catalog-am-sync: + name: catalog.am is up to date + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + steps: + - uses: actions/checkout@v7 + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: "3.14" + - name: Install pygambit (editable, picks up catalog/games directly) + run: | + python -m pip install -v -e ".[doc]" + pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" + - name: Regenerate catalog/catalog.am + run: python catalog/build.py --build --skip-table + - name: Fail if catalog.am is out of sync with catalog/games + run: | + if ! git diff --exit-code -- catalog/catalog.am; then + echo "::error::catalog/catalog.am is out of sync with catalog/games. Run 'python catalog/build.py --build' and commit the result." + exit 1 + fi diff --git a/.gitignore b/.gitignore index e70893578..68c08bd1c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,12 +45,14 @@ Gambit.app/* Gambit.app/* *.ipynb_checkpoints doc/**/*.ef +catalog/doc/**/*.ef build_support/msw/gambit.wxs build_support/osx/Info.plist src/pygambit/catalog -doc/catalog_table.rst -catalog/img/**/*.pdf -catalog/img/**/*.png -catalog/img/**/*.tex -catalog/img/**/*.ef -catalog/img/**/*.svg +catalog/doc/_table.rst +catalog/doc/_build +catalog/games/img/**/*.pdf +catalog/games/img/**/*.png +catalog/games/img/**/*.tex +catalog/games/img/**/*.ef +catalog/games/img/**/*.svg diff --git a/.readthedocs.yml b/.readthedocs.yml index 13359f049..28c50773e 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -17,9 +17,9 @@ build: - pdf2svg jobs: post_install: + # Required by the tutorials (jupyter_tikz game-tree rendering), not by the catalog — + # the catalog is now built as its own separate RTD project (see catalog/doc/). - pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" - # Create RST for catalog table in docs - - $READTHEDOCS_VIRTUALENV_PATH/bin/python build_support/catalog/update.py python: install: diff --git a/MANIFEST.in b/MANIFEST.in index ff09d5992..c262fa216 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,7 @@ recursive-include src/core *.cc *.h recursive-include src/games *.cc *.h *.imp recursive-include src/solvers *.cc *.h *.imp -recursive-include catalog * +recursive-include catalog/games * include src/gambit.h include src/games.h include src/solvers.h diff --git a/Makefile.am b/Makefile.am index 232ed6e38..f64b1b628 100644 --- a/Makefile.am +++ b/Makefile.am @@ -20,7 +20,7 @@ ## Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. ## -include build_support/catalog/catalog.am +include catalog/catalog.am EXTRA_DIST = \ build_support/GAMBIT_VERSION \ diff --git a/build_support/catalog/catalog.am b/build_support/catalog/catalog.am deleted file mode 100644 index cde07fd95..000000000 --- a/build_support/catalog/catalog.am +++ /dev/null @@ -1,53 +0,0 @@ -CATALOG_FILES = \ - catalog/books/myerson1991/fig2_1.efg \ - catalog/books/myerson1991/fig4_2.efg \ - catalog/books/shohamleytonbrown2008/fig5_1.efg \ - catalog/books/shohamleytonbrown2008/fig5_10.efg \ - catalog/books/shohamleytonbrown2008/fig5_10__original_layout.ef \ - catalog/books/shohamleytonbrown2008/fig5_11.efg \ - catalog/books/shohamleytonbrown2008/fig5_11__original_layout.ef \ - catalog/books/shohamleytonbrown2008/fig5_12.efg \ - catalog/books/shohamleytonbrown2008/fig5_12__original_layout.ef \ - catalog/books/shohamleytonbrown2008/fig5_15.efg \ - catalog/books/shohamleytonbrown2008/fig5_15__original_layout.ef \ - catalog/books/shohamleytonbrown2008/fig5_1__original_layout.ef \ - catalog/books/shohamleytonbrown2008/fig5_2.efg \ - catalog/books/shohamleytonbrown2008/fig5_2__original_layout.ef \ - catalog/books/shohamleytonbrown2008/fig5_9.efg \ - catalog/books/shohamleytonbrown2008/fig5_9__original_layout.ef \ - catalog/books/shohamleytonbrown2008/fig6_2.efg \ - catalog/books/shohamleytonbrown2008/fig6_2__original_layout.ef \ - catalog/books/shohamleytonbrown2008/fig6_8.efg \ - catalog/books/shohamleytonbrown2008/fig6_8__original_layout.ef \ - catalog/books/vonstengel2022/fig10.1.efg \ - catalog/books/vonstengel2022/fig10.12.efg \ - catalog/books/vonstengel2022/fig10.5.efg \ - catalog/books/vonstengel2022/fig10.7.efg \ - catalog/books/watson2013/exercise29_6.efg \ - catalog/books/watson2013/fig29_1.efg \ - catalog/conf/itcs/jakobsen2016/fig1a.efg \ - catalog/conf/itcs/jakobsen2016/fig1b.efg \ - catalog/conf/itcs/jakobsen2016/fig1c.efg \ - catalog/conf/itcs/jakobsen2016/fig3.efg \ - catalog/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg \ - catalog/journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs.nfg \ - catalog/journals/geb/bagwell1995.efg \ - catalog/journals/geb/gilboa1997/fig1.efg \ - catalog/journals/geb/gilboa1997/fig2.efg \ - catalog/journals/geb/wichardt2008.efg \ - catalog/journals/ijgt/nau2004/sec3.nfg \ - catalog/journals/ijgt/nau2004/sec4.nfg \ - catalog/journals/ijgt/nau2004/sec5.nfg \ - catalog/journals/ijgt/nau2004/sec6.nfg \ - catalog/journals/ijgt/selten1975/fig1.efg \ - catalog/journals/ijgt/selten1975/fig2.efg \ - catalog/journals/ijgt/selten1975/fig3.efg \ - catalog/journals/mor/vonstengelforges2008/fig1.efg \ - catalog/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef \ - catalog/journals/mor/vonstengelforges2008/fig6.efg \ - catalog/journals/mor/vonstengelforges2008/fig6__Original_Layout.ef \ - catalog/journals/mor/vonstengelforges2008/fig9.efg \ - catalog/journals/mor/vonstengelforges2008/fig9__Original_Layout.ef \ - catalog/journals/other/reiley2008/fig1.efg \ - catalog/journals/other/shapley1974/fig2.nfg \ - catalog/journals/other/shapley1974/fig3.nfg diff --git a/catalog/.readthedocs.yml b/catalog/.readthedocs.yml new file mode 100644 index 000000000..7dee191a9 --- /dev/null +++ b/catalog/.readthedocs.yml @@ -0,0 +1,33 @@ +version: 2 + +# This config file is for a SEPARATE ReadTheDocs project from the main Gambit docs +# (which use the root-level .readthedocs.yml). Setting it up requires creating a second +# project in the ReadTheDocs dashboard for this same GitHub repo, configured to use this +# file instead of the root one — that dashboard step is not something a git commit can do. + +sphinx: + configuration: catalog/doc/conf.py + +formats: all + +build: + os: ubuntu-22.04 + tools: + python: "3.14" + apt_packages: + - libgmp-dev + - texlive-full + - imagemagick + - pdf2svg + jobs: + post_install: + - pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" + # Regenerate catalog/doc/_table.rst (and the game images) from the current catalog. + - $READTHEDOCS_VIRTUALENV_PATH/bin/python catalog/build.py + +python: + install: + - method: pip + path: "." + extra_requirements: + - doc diff --git a/catalog/VERSION b/catalog/VERSION new file mode 100644 index 000000000..6e8bf73aa --- /dev/null +++ b/catalog/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/build_support/catalog/update.py b/catalog/build.py similarity index 90% rename from build_support/catalog/update.py rename to catalog/build.py index 5cc15443b..2c8f22376 100644 --- a/build_support/catalog/update.py +++ b/catalog/build.py @@ -10,11 +10,11 @@ import pygambit as gbt -CATALOG_RST_TABLE = Path(__file__).parent.parent.parent / "doc" / "catalog_table.rst" -CATALOG_DIR = Path(__file__).parent.parent.parent / "catalog" -MAKEFILE_AM = Path(__file__).parent.parent.parent / "Makefile.am" +CATALOG_RST_TABLE = Path(__file__).parent / "doc" / "_table.rst" +CATALOG_DIR = Path(__file__).parent / "games" +MAKEFILE_AM = Path(__file__).parent.parent / "Makefile.am" GTDRAW_SETTINGS_CONFIG = Path(__file__).parent / "gtdraw_settings.yaml" -CATALOG_HIERARCHY_CONFIG = Path(__file__).parent / "catalog_hierarchy.yaml" +CATALOG_HIERARCHY_CONFIG = Path(__file__).parent / "hierarchy.yaml" SUPPORTED_GAME_FORMATS = {"efg", "nfg"} @@ -60,8 +60,8 @@ def catalog_ef_file_variants(slug: str, catalog_dir: Path) -> list[dict] | None: File-naming convention:: - catalog/{slug}.ef primary variant → label "Default" - catalog/{slug}__{suffix}.ef additional variant → label derived from suffix + catalog/games/{slug}.ef primary variant → label "Default" + catalog/games/{slug}__{suffix}.ef additional variant → label derived from suffix The suffix part (after ``__``) is title-cased with underscores replaced by spaces, e.g. ``fig1__very_wide.ef`` → label "Very Wide". @@ -230,10 +230,10 @@ def _write_game_entry( for variant in ef_variants: vkey = variant["variant_key"] for ext in ["ef", "tex", "png", "pdf", "svg"]: - download_links.append(f":download:`{vkey}.{ext} <../catalog/img/{vkey}.{ext}>`") + download_links.append(f":download:`{vkey}.{ext} <../games/img/{vkey}.{ext}>`") else: for ext in all_exts: - download_links.append(f":download:`{slug}.{ext} <../catalog/img/{slug}.{ext}>`") + download_links.append(f":download:`{slug}.{ext} <../games/img/{slug}.{ext}>`") f.write(f"{i1}.. dropdown:: Download game and image files\n") f.write(f"{i1} \n") f.write(f"{i2}{' '.join(download_links)}\n") @@ -257,7 +257,7 @@ def _write_game_entry( f.write(f"{i4}import pygambit\n") f.write(f"{i4}from gtdraw import draw\n") if variant["ef_path"].exists(): - f.write(f'{i4}draw("../catalog/{vkey}.ef", {settings_str})\n') + f.write(f'{i4}draw("../games/{vkey}.ef", {settings_str})\n') else: f.write(f'{i4}draw(pygambit.catalog.load("{slug}"), {settings_str})\n') f.write(f"{i2}\n") @@ -274,13 +274,13 @@ def _write_game_entry( ) curated_ef = catalog_dir / f"{slug}.ef" if curated_ef.exists(): - f.write(f'{i2}draw("../catalog/{slug}.ef", {settings_str})\n') + f.write(f'{i2}draw("../games/{slug}.ef", {settings_str})\n') else: f.write(f'{i2}draw(pygambit.catalog.load("{slug}"), {settings_str})\n') elif row["Format"] == "nfg": f.write( f'{i2}draw(pygambit.catalog.load("{slug}"), ' - f'save_to="../catalog/img/{slug}.png")\n' + f'save_to="../games/img/{slug}.png")\n' ) f.write(f"{i1}\n") @@ -376,7 +376,7 @@ def update_makefile( game_files = [] for slug in slugs: - game_files.append(f"catalog/{slug}") + game_files.append(f"catalog/games/{slug}") game_files.sort() if am_path.exists(): @@ -404,15 +404,15 @@ def update_makefile( parser = argparse.ArgumentParser( description=( "Update Gambit catalog documentation and build files. " - "Always regenerates doc/catalog_table.rst from the current catalog. " - "Run from the repo root or build_support/catalog/." + "Always regenerates catalog/doc/_table.rst from the current catalog. " + "Run from the repo root or catalog/." ) ) parser.add_argument( "--build", action="store_true", help=( - "Also update build_support/catalog/catalog.am with the current list of " + "Also update catalog/catalog.am with the current list of " "catalog game files. Required after adding or removing games." ), ) @@ -425,13 +425,24 @@ def update_makefile( "gtdraw_settings.yaml." ), ) + parser.add_argument( + "--skip-table", + action="store_true", + help=( + "Skip regenerating catalog/doc/_table.rst and any missing game images. " + "Table/image generation requires a LaTeX toolchain (gtdraw's tex/pdf/png/svg " + "functions); use this with --build in contexts (e.g. CI) that only need " + "catalog.am checked or updated." + ), + ) args = parser.parse_args() - # Create RST list-table used by doc/catalog.rst - df = _catalog_games() - _warn_missing_descriptions(df) - generate_rst_table(df, CATALOG_RST_TABLE, regenerate_images=args.regenerate_images) - print(f"Generated {CATALOG_RST_TABLE} for use in local docs build. DO NOT COMMIT.") + if not args.skip_table: + # Create RST list-table used by catalog/doc/index.rst + df = _catalog_games() + _warn_missing_descriptions(df) + generate_rst_table(df, CATALOG_RST_TABLE, regenerate_images=args.regenerate_images) + print(f"Generated {CATALOG_RST_TABLE} for use in local docs build. DO NOT COMMIT.") if args.build: # Update the Makefile.am with the current list of catalog files update_makefile() diff --git a/catalog/catalog.am b/catalog/catalog.am new file mode 100644 index 000000000..d91d45e75 --- /dev/null +++ b/catalog/catalog.am @@ -0,0 +1,53 @@ +CATALOG_FILES = \ + catalog/games/books/myerson1991/fig2_1.efg \ + catalog/games/books/myerson1991/fig4_2.efg \ + catalog/games/books/shohamleytonbrown2008/fig5_1.efg \ + catalog/games/books/shohamleytonbrown2008/fig5_10.efg \ + catalog/games/books/shohamleytonbrown2008/fig5_10__original_layout.ef \ + catalog/games/books/shohamleytonbrown2008/fig5_11.efg \ + catalog/games/books/shohamleytonbrown2008/fig5_11__original_layout.ef \ + catalog/games/books/shohamleytonbrown2008/fig5_12.efg \ + catalog/games/books/shohamleytonbrown2008/fig5_12__original_layout.ef \ + catalog/games/books/shohamleytonbrown2008/fig5_15.efg \ + catalog/games/books/shohamleytonbrown2008/fig5_15__original_layout.ef \ + catalog/games/books/shohamleytonbrown2008/fig5_1__original_layout.ef \ + catalog/games/books/shohamleytonbrown2008/fig5_2.efg \ + catalog/games/books/shohamleytonbrown2008/fig5_2__original_layout.ef \ + catalog/games/books/shohamleytonbrown2008/fig5_9.efg \ + catalog/games/books/shohamleytonbrown2008/fig5_9__original_layout.ef \ + catalog/games/books/shohamleytonbrown2008/fig6_2.efg \ + catalog/games/books/shohamleytonbrown2008/fig6_2__original_layout.ef \ + catalog/games/books/shohamleytonbrown2008/fig6_8.efg \ + catalog/games/books/shohamleytonbrown2008/fig6_8__original_layout.ef \ + catalog/games/books/vonstengel2022/fig10.1.efg \ + catalog/games/books/vonstengel2022/fig10.12.efg \ + catalog/games/books/vonstengel2022/fig10.5.efg \ + catalog/games/books/vonstengel2022/fig10.7.efg \ + catalog/games/books/watson2013/exercise29_6.efg \ + catalog/games/books/watson2013/fig29_1.efg \ + catalog/games/conf/itcs/jakobsen2016/fig1a.efg \ + catalog/games/conf/itcs/jakobsen2016/fig1b.efg \ + catalog/games/conf/itcs/jakobsen2016/fig1c.efg \ + catalog/games/conf/itcs/jakobsen2016/fig3.efg \ + catalog/games/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg \ + catalog/games/journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs.nfg \ + catalog/games/journals/geb/bagwell1995.efg \ + catalog/games/journals/geb/gilboa1997/fig1.efg \ + catalog/games/journals/geb/gilboa1997/fig2.efg \ + catalog/games/journals/geb/wichardt2008.efg \ + catalog/games/journals/ijgt/nau2004/sec3.nfg \ + catalog/games/journals/ijgt/nau2004/sec4.nfg \ + catalog/games/journals/ijgt/nau2004/sec5.nfg \ + catalog/games/journals/ijgt/nau2004/sec6.nfg \ + catalog/games/journals/ijgt/selten1975/fig1.efg \ + catalog/games/journals/ijgt/selten1975/fig2.efg \ + catalog/games/journals/ijgt/selten1975/fig3.efg \ + catalog/games/journals/mor/vonstengelforges2008/fig1.efg \ + catalog/games/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef \ + catalog/games/journals/mor/vonstengelforges2008/fig6.efg \ + catalog/games/journals/mor/vonstengelforges2008/fig6__Original_Layout.ef \ + catalog/games/journals/mor/vonstengelforges2008/fig9.efg \ + catalog/games/journals/mor/vonstengelforges2008/fig9__Original_Layout.ef \ + catalog/games/journals/other/reiley2008/fig1.efg \ + catalog/games/journals/other/shapley1974/fig2.nfg \ + catalog/games/journals/other/shapley1974/fig3.nfg diff --git a/catalog/doc/conf.py b/catalog/doc/conf.py new file mode 100644 index 000000000..a3c05b042 --- /dev/null +++ b/catalog/doc/conf.py @@ -0,0 +1,45 @@ +import pathlib +import sys + +# The shared bibtex citation style lives in the main docs project; import it from +# there rather than duplicating it (see doc/_bibstyle.py). +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent / "doc")) +import _bibstyle # noqa: E402,F401 registers the shared "keystyle" pybtex plugin + +# -- General configuration ----------------------------------------------------- + +extensions = [ + "sphinx_design", + "jupyter_sphinx", + "sphinxcontrib.bibtex", +] + +bibtex_bibfiles = [str(pathlib.Path(__file__).parent.parent.parent / "doc" / "references.bib")] + +source_suffix = {".rst": "restructuredtext"} +master_doc = "index" + +project = "Gambit Catalog" +copyright = "1994-2026, The Gambit Project" # noqa + +_version_file = pathlib.Path(__file__).parent.parent.parent / "build_support" / "GAMBIT_VERSION" +if _version_file.exists(): + _full_version = _version_file.read_text().strip() + version = ".".join(_full_version.split(".")[:2]) + release = _full_version +else: + raise FileNotFoundError("GAMBIT_VERSION file not found") + +exclude_patterns = ["_build", "_table.rst"] + +pygments_style = "sphinx" + +# -- Options for HTML output --------------------------------------------------- + +html_theme = "pydata_sphinx_theme" +html_theme_options = { + "external_links": [ + {"name": "Gambit documentation", "url": "https://gambit.readthedocs.io/"}, + {"name": "GitHub", "url": "https://github.com/gambitproject/gambit"}, + ], +} diff --git a/doc/developer.catalog.rst b/catalog/doc/developer.rst similarity index 62% rename from doc/developer.catalog.rst rename to catalog/doc/developer.rst index b69c2a519..1c9b8d61a 100644 --- a/doc/developer.catalog.rst +++ b/catalog/doc/developer.rst @@ -1,13 +1,13 @@ .. _updating-catalog: -Updating the Games Catalog -========================== +Updating the catalog of games +============================= -This page includes developer notes regarding the catalog module, and the process for contributing to and updating Gambit's :ref:`Games Catalog `. +This page includes developer notes regarding the catalog module, and the process for contributing to and updating Gambit's :ref:`catalog of games `. To do so, you will need to have the `gambit` GitHub repo cloned and be able to submit pull request via GitHub; -you may wish to first review the :ref:`contributor guidelines `. +you may wish to first review the `contributor guidelines `_. -You can add new games to the catalog, or edit existing ones that are saved in a valid representation :ref:`format `. +You can add new games to the catalog, or edit existing ones that are saved in a valid representation `format `_. Currently supported representations are: - `.efg` for extensive form games @@ -17,7 +17,7 @@ Currently supported representations are: When updating the catalog, changes can be viewed by inspecting the documentation build generated by a pull request. You can also test changes locally. It can be helpful when doing so to perform an editable install of `pygambit` available in your Python environment. - Note that for general edits to the codebase non-editable :ref:`developer install ` is better to pick up changes in the C++ code as well as Python. + Note that for general edits to the codebase non-editable `developer install `_ is better to pick up changes in the C++ code as well as Python. .. code-block:: bash @@ -26,23 +26,23 @@ Currently supported representations are: 1. **Create or edit a game file:** - Use either :ref:`pygambit `, the Gambit :ref:`CLI ` or :ref:`GUI ` to create (or edit) and save game in a valid representation :ref:`format `. - Make sure the game includes a description, with any citations referencing the :ref:`bibliography ` using the ``:cite:p:`key``` format, e.g. ``:cite:p:`Rei2008```. + Use either `pygambit `_, the Gambit `CLI `_ or `GUI `_ to create (or edit) and save game in a valid representation `format `_. + Make sure the game includes a description, with any citations referencing the `bibliography `_ using the ``:cite:p:`key``` format, e.g. ``:cite:p:`Rei2008```. .. important:: If no bibliography entry exists, you should add one. For instructions, - see :ref:`updating-bibliography`. + see `Updating the bibliography `_. 2. **Add the game(s) to the repo:** Create a new branch in the ``gambit`` repo. - Add your new game file(s) inside the ``catalog`` dir and commit them, or edit an existing game. + Add your new game file(s) inside the ``catalog/games`` dir and commit them, or edit an existing game. The catalog uses a hierarchical folder structure that groups games by publication type and venue: .. code-block:: text - catalog/ + catalog/games/ books/{author-year}/{game}.efg # games from textbooks journals/{venue}/{author-year}/{game}.efg # games from journals (venue = geb, ijgt, mor, …) conf/{venue}/{author-year}/{game}.efg # games from conferences @@ -58,7 +58,7 @@ Currently supported representations are: When adding a game from a **new** journal, conference, or other top-level category, add a human-readable label for the new hierarchy node(s) to - ``build_support/catalog/catalog_hierarchy.yaml``. + ``catalog/hierarchy.yaml``. The catalog documentation page groups and labels games based on this file. Nodes without an entry fall back to a title-cased version of the folder name. @@ -73,8 +73,8 @@ Currently supported representations are: 3. **[Optional] Add custom game layout(s):** For extensive form games, you may optionally commit a curated ``.ef`` file alongside the ``.efg`` - (e.g. ``catalog/source/game.ef``). - When present, ``update.py`` will use this file directly as input to gtdraw instead of + (e.g. ``catalog/games/source/game.ef``). + When present, ``build.py`` will use this file directly as input to gtdraw instead of auto-generating the layout from the ``.efg``, preserving any hand-tuned layout. Consult the `gtdraw docs `_ for the ``.ef`` format. @@ -92,8 +92,8 @@ Currently supported representations are: .. code-block:: text - catalog/example/game.ef # primary (tab label "Default") - catalog/example/game__original_layout.ef # additional (tab label "Original Layout") + catalog/games/example/game.ef # primary (tab label "Default") + catalog/games/example/game__original_layout.ef # additional (tab label "Original Layout") The label shown on each tab is derived automatically from the filename suffix: underscores are replaced by spaces and the result is title-cased (e.g. ``__alt_version`` → "Alt Version"). @@ -101,15 +101,15 @@ Currently supported representations are: 4. **Update the build files:** Ensure you have installed the package in editable mode to automatically pick up the new game file(s) in the ``pygambit.catalog`` module without reinstalling each time. - Then use the ``update.py`` script to update Gambit's documentation & build files, as well as generating images for the new game(s). - If you want to customise the visualisation parameters for your game(s), edit ``build_support/catalog/gtdraw_settings.yaml``. + Then use the ``build.py`` script to update Gambit's documentation & build files, as well as generating images for the new game(s). + If you want to customise the visualisation parameters for your game(s), edit ``catalog/gtdraw_settings.yaml``. Add an entry under ``overrides`` keyed by your game's exact slug, or by a shared prefix (e.g. the author-year folder name) to apply settings to all games from that source. More specific entries (longer keys) take precedence over shorter ones. Consult the `gtdraw docs `_ for available settings. .. code-block:: bash - python build_support/catalog/update.py --build + python catalog/build.py --build .. note:: @@ -117,34 +117,39 @@ Currently supported representations are: .. warning:: - - If haven't done an editable install of ``pygambit`` in your python environment, you'll need to re-install it before running the update script to include new games in the catalog module. - - Running the script with the ``--build`` flag updates ``build_support/catalog/catalog.am``, which is included in ``Makefile.am``. If you moved games that were previously in ``contrib/games`` you'll need to also manually remove those files from ``EXTRA_DIST`` in ``Makefile.am``. + - If haven't done an editable install of ``pygambit`` in your python environment, you'll need to re-install it before running the build script to include new games in the catalog module. + - Running the script with the ``--build`` flag updates ``catalog/catalog.am``, which is included in ``Makefile.am``. If you moved games that were previously in ``contrib/games`` you'll need to also manually remove those files from ``EXTRA_DIST`` in ``Makefile.am``. .. tip:: - If you encounter any issues with game files in your python environment not matching the current branch preventing the update script or docs build working (step below) try running the following first: + If you encounter any issues with game files in your python environment not matching the current branch preventing the build script or docs build working (step below) try running the following first: .. code-block:: bash - git clean -fdX catalog/img doc/catalog_table.rst + git clean -fdX catalog/games/img catalog/doc/_table.rst .. tip:: - If the game visuals for extensive form games need some work and you aren't sure which settings to change in ``build_support/catalog/gtdraw_settings.yaml``, try loading the EFG in the gtdraw GUI and adjusting the layout there. + If the game visuals for extensive form games need some work and you aren't sure which settings to change in ``catalog/gtdraw_settings.yaml``, try loading the EFG in the gtdraw GUI and adjusting the layout there. There is an option to `download settings `_ which can be used in the Gambit catalog. 5. **[Optional] Test your updates to the documentation locally:** The previous step will (re)build your local copy of the Gambit Catalog RST page used by the documentation. - You should then build the docs in the :ref:`usual way `. - Open the catalog page at ``doc/_build/html/catalog.html`` to view your changes. + The catalog is its own Sphinx project, built independently of the main Gambit docs: + + .. code-block:: bash + + sphinx-build -b html catalog/doc catalog/doc/_build/html + + Open ``catalog/doc/_build/html/index.html`` to view your changes. Iterate steps 2-4 as required. 6. **Submit a pull request to GitHub with all changes.** - Submit a PR according to the :ref:`usual workflow `. - Ensure that any additions and changes to game files, ``build_support/catalog/gtdraw_settings.yaml``, ``build_support/catalog/update.py`` and ``build_support/catalog/catalog.am`` are included. + Submit a PR according to the `usual workflow `_. + Ensure that any additions and changes to game files, ``catalog/gtdraw_settings.yaml``, ``catalog/build.py`` and ``catalog/catalog.am`` are included. .. important:: - Even if you already checked the local docs build, ensure the Catalog page on the ReadTheDocs preview build on the pull request looks right. + Even if you already checked the local docs build, ensure the catalog's own ReadTheDocs preview build on the pull request looks right. diff --git a/catalog/doc/index.rst b/catalog/doc/index.rst new file mode 100644 index 000000000..6c5013ba1 --- /dev/null +++ b/catalog/doc/index.rst @@ -0,0 +1,25 @@ +.. _catalog: + +Catalog of games +================ + +Below is a complete list of games included in Gambit's catalog. +See the main `pygambit documentation `_ for the +:mod:`pygambit.catalog` API reference (including how to load these games in Python), +for loading games from the OpenSpiel library, and for generating games on the fly +from the GAMUT suite. +See :ref:`Updating the games catalog ` below for instructions on how +to contribute new games to the catalog. + +.. include:: _table.rst + +References +---------- + +.. bibliography:: ../../doc/references.bib + :style: keystyle + +.. toctree:: + :hidden: + + developer diff --git a/catalog/books/myerson1991/fig2_1.efg b/catalog/games/books/myerson1991/fig2_1.efg similarity index 100% rename from catalog/books/myerson1991/fig2_1.efg rename to catalog/games/books/myerson1991/fig2_1.efg diff --git a/catalog/books/myerson1991/fig4_2.efg b/catalog/games/books/myerson1991/fig4_2.efg similarity index 100% rename from catalog/books/myerson1991/fig4_2.efg rename to catalog/games/books/myerson1991/fig4_2.efg diff --git a/catalog/books/shohamleytonbrown2008/fig5_1.efg b/catalog/games/books/shohamleytonbrown2008/fig5_1.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_1.efg rename to catalog/games/books/shohamleytonbrown2008/fig5_1.efg diff --git a/catalog/books/shohamleytonbrown2008/fig5_10.efg b/catalog/games/books/shohamleytonbrown2008/fig5_10.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_10.efg rename to catalog/games/books/shohamleytonbrown2008/fig5_10.efg diff --git a/catalog/books/shohamleytonbrown2008/fig5_10__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig5_10__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_10__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig5_10__original_layout.ef diff --git a/catalog/books/shohamleytonbrown2008/fig5_11.efg b/catalog/games/books/shohamleytonbrown2008/fig5_11.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_11.efg rename to catalog/games/books/shohamleytonbrown2008/fig5_11.efg diff --git a/catalog/books/shohamleytonbrown2008/fig5_11__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig5_11__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_11__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig5_11__original_layout.ef diff --git a/catalog/books/shohamleytonbrown2008/fig5_12.efg b/catalog/games/books/shohamleytonbrown2008/fig5_12.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_12.efg rename to catalog/games/books/shohamleytonbrown2008/fig5_12.efg diff --git a/catalog/books/shohamleytonbrown2008/fig5_12__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig5_12__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_12__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig5_12__original_layout.ef diff --git a/catalog/books/shohamleytonbrown2008/fig5_15.efg b/catalog/games/books/shohamleytonbrown2008/fig5_15.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_15.efg rename to catalog/games/books/shohamleytonbrown2008/fig5_15.efg diff --git a/catalog/books/shohamleytonbrown2008/fig5_15__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig5_15__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_15__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig5_15__original_layout.ef diff --git a/catalog/books/shohamleytonbrown2008/fig5_1__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig5_1__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_1__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig5_1__original_layout.ef diff --git a/catalog/books/shohamleytonbrown2008/fig5_2.efg b/catalog/games/books/shohamleytonbrown2008/fig5_2.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_2.efg rename to catalog/games/books/shohamleytonbrown2008/fig5_2.efg diff --git a/catalog/books/shohamleytonbrown2008/fig5_2__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig5_2__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_2__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig5_2__original_layout.ef diff --git a/catalog/books/shohamleytonbrown2008/fig5_9.efg b/catalog/games/books/shohamleytonbrown2008/fig5_9.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_9.efg rename to catalog/games/books/shohamleytonbrown2008/fig5_9.efg diff --git a/catalog/books/shohamleytonbrown2008/fig5_9__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig5_9__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig5_9__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig5_9__original_layout.ef diff --git a/catalog/books/shohamleytonbrown2008/fig6_2.efg b/catalog/games/books/shohamleytonbrown2008/fig6_2.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig6_2.efg rename to catalog/games/books/shohamleytonbrown2008/fig6_2.efg diff --git a/catalog/books/shohamleytonbrown2008/fig6_2__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig6_2__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig6_2__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig6_2__original_layout.ef diff --git a/catalog/books/shohamleytonbrown2008/fig6_8.efg b/catalog/games/books/shohamleytonbrown2008/fig6_8.efg similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig6_8.efg rename to catalog/games/books/shohamleytonbrown2008/fig6_8.efg diff --git a/catalog/books/shohamleytonbrown2008/fig6_8__original_layout.ef b/catalog/games/books/shohamleytonbrown2008/fig6_8__original_layout.ef similarity index 100% rename from catalog/books/shohamleytonbrown2008/fig6_8__original_layout.ef rename to catalog/games/books/shohamleytonbrown2008/fig6_8__original_layout.ef diff --git a/catalog/books/vonstengel2022/fig10.1.efg b/catalog/games/books/vonstengel2022/fig10.1.efg similarity index 100% rename from catalog/books/vonstengel2022/fig10.1.efg rename to catalog/games/books/vonstengel2022/fig10.1.efg diff --git a/catalog/books/vonstengel2022/fig10.12.efg b/catalog/games/books/vonstengel2022/fig10.12.efg similarity index 100% rename from catalog/books/vonstengel2022/fig10.12.efg rename to catalog/games/books/vonstengel2022/fig10.12.efg diff --git a/catalog/books/vonstengel2022/fig10.5.efg b/catalog/games/books/vonstengel2022/fig10.5.efg similarity index 100% rename from catalog/books/vonstengel2022/fig10.5.efg rename to catalog/games/books/vonstengel2022/fig10.5.efg diff --git a/catalog/books/vonstengel2022/fig10.7.efg b/catalog/games/books/vonstengel2022/fig10.7.efg similarity index 100% rename from catalog/books/vonstengel2022/fig10.7.efg rename to catalog/games/books/vonstengel2022/fig10.7.efg diff --git a/catalog/books/watson2013/exercise29_6.efg b/catalog/games/books/watson2013/exercise29_6.efg similarity index 100% rename from catalog/books/watson2013/exercise29_6.efg rename to catalog/games/books/watson2013/exercise29_6.efg diff --git a/catalog/books/watson2013/fig29_1.efg b/catalog/games/books/watson2013/fig29_1.efg similarity index 100% rename from catalog/books/watson2013/fig29_1.efg rename to catalog/games/books/watson2013/fig29_1.efg diff --git a/catalog/conf/itcs/jakobsen2016/fig1a.efg b/catalog/games/conf/itcs/jakobsen2016/fig1a.efg similarity index 100% rename from catalog/conf/itcs/jakobsen2016/fig1a.efg rename to catalog/games/conf/itcs/jakobsen2016/fig1a.efg diff --git a/catalog/conf/itcs/jakobsen2016/fig1b.efg b/catalog/games/conf/itcs/jakobsen2016/fig1b.efg similarity index 100% rename from catalog/conf/itcs/jakobsen2016/fig1b.efg rename to catalog/games/conf/itcs/jakobsen2016/fig1b.efg diff --git a/catalog/conf/itcs/jakobsen2016/fig1c.efg b/catalog/games/conf/itcs/jakobsen2016/fig1c.efg similarity index 100% rename from catalog/conf/itcs/jakobsen2016/fig1c.efg rename to catalog/games/conf/itcs/jakobsen2016/fig1c.efg diff --git a/catalog/conf/itcs/jakobsen2016/fig3.efg b/catalog/games/conf/itcs/jakobsen2016/fig3.efg similarity index 100% rename from catalog/conf/itcs/jakobsen2016/fig3.efg rename to catalog/games/conf/itcs/jakobsen2016/fig3.efg diff --git a/catalog/img/.gitkeep b/catalog/games/img/.gitkeep similarity index 100% rename from catalog/img/.gitkeep rename to catalog/games/img/.gitkeep diff --git a/catalog/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg b/catalog/games/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg similarity index 99% rename from catalog/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg rename to catalog/games/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg index 79d55443f..47ab59d1c 100644 --- a/catalog/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg +++ b/catalog/games/journals/dcg/vonstengel1999/6x6_game_with_75_eq.nfg @@ -3,7 +3,7 @@ NFG 1 R "von Stengel's 6x6 bimatrix game with 75 equilibria" { "1" "2" } { { "1" "2" "3" "4" "5" "6" } { "1" "2" "3" "4" "5" "6" } } -"The example from :cite:p:`vS97` as referenced in :cite:p:`vS99`. +"The example from :cite:p:`vS97` as referenced in :cite:p:`vS99`. It is a 6x6 bimatrix game with 75 equilibria, which is more than the previously conjectured bound of :math:`2^6 - 1 = 63`." diff --git a/catalog/journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs.nfg b/catalog/games/journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs.nfg similarity index 100% rename from catalog/journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs.nfg rename to catalog/games/journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs.nfg diff --git a/catalog/journals/geb/bagwell1995.efg b/catalog/games/journals/geb/bagwell1995.efg similarity index 100% rename from catalog/journals/geb/bagwell1995.efg rename to catalog/games/journals/geb/bagwell1995.efg diff --git a/catalog/journals/geb/gilboa1997/fig1.efg b/catalog/games/journals/geb/gilboa1997/fig1.efg similarity index 100% rename from catalog/journals/geb/gilboa1997/fig1.efg rename to catalog/games/journals/geb/gilboa1997/fig1.efg diff --git a/catalog/journals/geb/gilboa1997/fig2.efg b/catalog/games/journals/geb/gilboa1997/fig2.efg similarity index 100% rename from catalog/journals/geb/gilboa1997/fig2.efg rename to catalog/games/journals/geb/gilboa1997/fig2.efg diff --git a/catalog/journals/geb/wichardt2008.efg b/catalog/games/journals/geb/wichardt2008.efg similarity index 100% rename from catalog/journals/geb/wichardt2008.efg rename to catalog/games/journals/geb/wichardt2008.efg diff --git a/catalog/journals/ijgt/nau2004/sec3.nfg b/catalog/games/journals/ijgt/nau2004/sec3.nfg similarity index 100% rename from catalog/journals/ijgt/nau2004/sec3.nfg rename to catalog/games/journals/ijgt/nau2004/sec3.nfg diff --git a/catalog/journals/ijgt/nau2004/sec4.nfg b/catalog/games/journals/ijgt/nau2004/sec4.nfg similarity index 100% rename from catalog/journals/ijgt/nau2004/sec4.nfg rename to catalog/games/journals/ijgt/nau2004/sec4.nfg diff --git a/catalog/journals/ijgt/nau2004/sec5.nfg b/catalog/games/journals/ijgt/nau2004/sec5.nfg similarity index 100% rename from catalog/journals/ijgt/nau2004/sec5.nfg rename to catalog/games/journals/ijgt/nau2004/sec5.nfg diff --git a/catalog/journals/ijgt/nau2004/sec6.nfg b/catalog/games/journals/ijgt/nau2004/sec6.nfg similarity index 100% rename from catalog/journals/ijgt/nau2004/sec6.nfg rename to catalog/games/journals/ijgt/nau2004/sec6.nfg diff --git a/catalog/journals/ijgt/selten1975/fig1.efg b/catalog/games/journals/ijgt/selten1975/fig1.efg similarity index 100% rename from catalog/journals/ijgt/selten1975/fig1.efg rename to catalog/games/journals/ijgt/selten1975/fig1.efg diff --git a/catalog/journals/ijgt/selten1975/fig2.efg b/catalog/games/journals/ijgt/selten1975/fig2.efg similarity index 100% rename from catalog/journals/ijgt/selten1975/fig2.efg rename to catalog/games/journals/ijgt/selten1975/fig2.efg diff --git a/catalog/journals/ijgt/selten1975/fig3.efg b/catalog/games/journals/ijgt/selten1975/fig3.efg similarity index 100% rename from catalog/journals/ijgt/selten1975/fig3.efg rename to catalog/games/journals/ijgt/selten1975/fig3.efg diff --git a/catalog/journals/mor/vonstengelforges2008/fig1.efg b/catalog/games/journals/mor/vonstengelforges2008/fig1.efg similarity index 100% rename from catalog/journals/mor/vonstengelforges2008/fig1.efg rename to catalog/games/journals/mor/vonstengelforges2008/fig1.efg diff --git a/catalog/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef b/catalog/games/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef similarity index 100% rename from catalog/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef rename to catalog/games/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef diff --git a/catalog/journals/mor/vonstengelforges2008/fig6.efg b/catalog/games/journals/mor/vonstengelforges2008/fig6.efg similarity index 100% rename from catalog/journals/mor/vonstengelforges2008/fig6.efg rename to catalog/games/journals/mor/vonstengelforges2008/fig6.efg diff --git a/catalog/journals/mor/vonstengelforges2008/fig6__Original_Layout.ef b/catalog/games/journals/mor/vonstengelforges2008/fig6__Original_Layout.ef similarity index 100% rename from catalog/journals/mor/vonstengelforges2008/fig6__Original_Layout.ef rename to catalog/games/journals/mor/vonstengelforges2008/fig6__Original_Layout.ef diff --git a/catalog/journals/mor/vonstengelforges2008/fig9.efg b/catalog/games/journals/mor/vonstengelforges2008/fig9.efg similarity index 100% rename from catalog/journals/mor/vonstengelforges2008/fig9.efg rename to catalog/games/journals/mor/vonstengelforges2008/fig9.efg diff --git a/catalog/journals/mor/vonstengelforges2008/fig9__Original_Layout.ef b/catalog/games/journals/mor/vonstengelforges2008/fig9__Original_Layout.ef similarity index 100% rename from catalog/journals/mor/vonstengelforges2008/fig9__Original_Layout.ef rename to catalog/games/journals/mor/vonstengelforges2008/fig9__Original_Layout.ef diff --git a/catalog/journals/other/reiley2008/fig1.efg b/catalog/games/journals/other/reiley2008/fig1.efg similarity index 100% rename from catalog/journals/other/reiley2008/fig1.efg rename to catalog/games/journals/other/reiley2008/fig1.efg diff --git a/catalog/journals/other/shapley1974/fig2.nfg b/catalog/games/journals/other/shapley1974/fig2.nfg similarity index 100% rename from catalog/journals/other/shapley1974/fig2.nfg rename to catalog/games/journals/other/shapley1974/fig2.nfg diff --git a/catalog/journals/other/shapley1974/fig3.nfg b/catalog/games/journals/other/shapley1974/fig3.nfg similarity index 100% rename from catalog/journals/other/shapley1974/fig3.nfg rename to catalog/games/journals/other/shapley1974/fig3.nfg diff --git a/build_support/catalog/gtdraw_settings.yaml b/catalog/gtdraw_settings.yaml similarity index 100% rename from build_support/catalog/gtdraw_settings.yaml rename to catalog/gtdraw_settings.yaml diff --git a/build_support/catalog/catalog_hierarchy.yaml b/catalog/hierarchy.yaml similarity index 100% rename from build_support/catalog/catalog_hierarchy.yaml rename to catalog/hierarchy.yaml diff --git a/build_support/catalog/test_update.py b/catalog/test_build.py similarity index 80% rename from build_support/catalog/test_update.py rename to catalog/test_build.py index 58885b4ac..8bb0a4a17 100644 --- a/build_support/catalog/test_update.py +++ b/catalog/test_build.py @@ -1,4 +1,4 @@ -"""Tests for build_support/catalog/update.py. +"""Tests for catalog/build.py. All catalog slugs used here are clearly fictional (e.g. ``"testgroup2000/fig1"``) and do not correspond to any game in the real catalog. This is intentional: the @@ -7,17 +7,17 @@ Monkeypatching strategy ----------------------- -``update.py`` depends on four external resources that are replaced in tests: +``build.py`` depends on four external resources that are replaced in tests: 1. ``GTDRAW_SETTINGS_CONFIG`` (a ``Path``) — swapped for a tmp YAML file so ``catalog_gtdraw_settings`` reads controlled config without touching the - real ``gtdraw_settings.yaml``. ``monkeypatch.setattr(update, + real ``gtdraw_settings.yaml``. ``monkeypatch.setattr(build, "GTDRAW_SETTINGS_CONFIG", yaml_file)`` replaces the module-level path for the duration of a single test and restores it automatically on teardown. 2. ``CATALOG_HIERARCHY_CONFIG`` (a ``Path``) — swapped for a tmp YAML file so ``load_hierarchy_labels`` reads controlled labels without touching the real - ``catalog_hierarchy.yaml``. Swap via ``monkeypatch.setattr(update, + ``hierarchy.yaml``. Swap via ``monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", yaml_file)``. 3. ``tex`` / ``png`` / ``pdf`` / ``svg`` @@ -37,11 +37,12 @@ import pytest -pytest.importorskip("gtdraw") # update.py imports gtdraw at module level +pytest.importorskip("gtdraw") # build.py imports gtdraw at module level pytest.importorskip("yaml") import pandas as pd # noqa: E402 -import update # noqa: E402 + +import build # noqa: E402 # --------------------------------------------------------------------------- # Module-level test fixtures @@ -89,7 +90,7 @@ def _write_yaml(path, content=_YAML_CONFIG): """Write *content* to *path* and return *path*. Used to create a temporary gtdraw_settings YAML file that can be - pointed at via ``monkeypatch.setattr(update, "GTDRAW_SETTINGS_CONFIG", + pointed at via ``monkeypatch.setattr(build, "GTDRAW_SETTINGS_CONFIG", path)`` without touching the real config file. """ path.write_text(content, encoding="utf-8") @@ -104,7 +105,7 @@ def _efg_row(slug, title="Test EFG Game", description="A description."): "Game": slug, "Title": title, "Description": description, - "Download": f":download:`{slug}.efg <../catalog/{slug}.efg>`", + "Download": f":download:`{slug}.efg <../games/{slug}.efg>`", "Format": "efg", } @@ -115,7 +116,7 @@ def _nfg_row(slug, title="Test NFG Game", description="A description."): "Game": slug, "Title": title, "Description": description, - "Download": f":download:`{slug}.nfg <../catalog/{slug}.nfg>`", + "Download": f":download:`{slug}.nfg <../games/{slug}.nfg>`", "Format": "nfg", } @@ -164,16 +165,16 @@ def test_catalog_games_uses_requested_catalog_dir_and_restores_resource( calls = [] def fake_games(**kwargs): - calls.append((update.gbt.catalog._CATALOG_RESOURCE, kwargs)) + calls.append((build.gbt.catalog._CATALOG_RESOURCE, kwargs)) return _make_df(_efg_row("checkout/game1")) - monkeypatch.setattr(update.gbt.catalog, "_CATALOG_RESOURCE", stale_dir) - monkeypatch.setattr(update.gbt.catalog, "games", fake_games) + monkeypatch.setattr(build.gbt.catalog, "_CATALOG_RESOURCE", stale_dir) + monkeypatch.setattr(build.gbt.catalog, "games", fake_games) - df = update._catalog_games(checkout_dir) + df = build._catalog_games(checkout_dir) assert calls == [(checkout_dir, {"include_descriptions": True})] - assert stale_dir == update.gbt.catalog._CATALOG_RESOURCE + assert stale_dir == build.gbt.catalog._CATALOG_RESOURCE assert list(df["Game"]) == ["checkout/game1"] def test_generate_rst_table_uses_requested_catalog_dir_while_rendering( @@ -186,16 +187,16 @@ def test_generate_rst_table_uses_requested_catalog_dir_while_rendering( observed = [] def fake_write_tree_level(*args, **kwargs): - observed.append(update.gbt.catalog._CATALOG_RESOURCE) + observed.append(build.gbt.catalog._CATALOG_RESOURCE) - monkeypatch.setattr(update.gbt.catalog, "_CATALOG_RESOURCE", stale_dir) - monkeypatch.setattr(update, "load_hierarchy_labels", lambda: {}) - monkeypatch.setattr(update, "_write_tree_level", fake_write_tree_level) + monkeypatch.setattr(build.gbt.catalog, "_CATALOG_RESOURCE", stale_dir) + monkeypatch.setattr(build, "load_hierarchy_labels", lambda: {}) + monkeypatch.setattr(build, "_write_tree_level", fake_write_tree_level) - update.generate_rst_table(_make_df(), tmp_path / "out.rst", catalog_dir=checkout_dir) + build.generate_rst_table(_make_df(), tmp_path / "out.rst", catalog_dir=checkout_dir) assert observed == [checkout_dir] - assert stale_dir == update.gbt.catalog._CATALOG_RESOURCE + assert stale_dir == build.gbt.catalog._CATALOG_RESOURCE # --------------------------------------------------------------------------- @@ -215,24 +216,24 @@ class TestCatalogGtDrawSettings: def test_no_override_returns_defaults(self, tmp_path, monkeypatch): """A slug with no matching entry in ``overrides`` returns the defaults verbatim.""" yaml_file = _write_yaml(tmp_path / "settings.yaml") - monkeypatch.setattr(update, "GTDRAW_SETTINGS_CONFIG", yaml_file) - result = update.catalog_gtdraw_settings("unknowngame/v1") + monkeypatch.setattr(build, "GTDRAW_SETTINGS_CONFIG", yaml_file) + result = build.catalog_gtdraw_settings("unknowngame/v1") assert result == _YAML_DEFAULTS def test_exact_slug_override_applied(self, tmp_path, monkeypatch): """A key in ``overrides`` that exactly matches the slug is merged into defaults.""" yaml_file = _write_yaml(tmp_path / "settings.yaml") - monkeypatch.setattr(update, "GTDRAW_SETTINGS_CONFIG", yaml_file) - result = update.catalog_gtdraw_settings("testgroup2000/fig2") + monkeypatch.setattr(build, "GTDRAW_SETTINGS_CONFIG", yaml_file) + result = build.catalog_gtdraw_settings("testgroup2000/fig2") assert result["action_label_position"] == pytest.approx(0.4) assert result["color_scheme"] == "gambit" # defaults still present def test_prefix_slug_override_applied(self, tmp_path, monkeypatch): """A group-level key (e.g. ``"testgroup2000"``) matches any slug that starts with it.""" yaml_file = _write_yaml(tmp_path / "settings.yaml") - monkeypatch.setattr(update, "GTDRAW_SETTINGS_CONFIG", yaml_file) + monkeypatch.setattr(build, "GTDRAW_SETTINGS_CONFIG", yaml_file) # "testgroup2000/fig1" is not listed explicitly; it matches the group prefix - result = update.catalog_gtdraw_settings("testgroup2000/fig1") + result = build.catalog_gtdraw_settings("testgroup2000/fig1") assert result["sublevel_scaling"] == 1 def test_specific_key_wins_over_group(self, tmp_path, monkeypatch): @@ -254,19 +255,19 @@ def test_specific_key_wins_over_group(self, tmp_path, monkeypatch): sublevel_scaling: 2 """) yaml_file = _write_yaml(tmp_path / "settings.yaml", config) - monkeypatch.setattr(update, "GTDRAW_SETTINGS_CONFIG", yaml_file) - result = update.catalog_gtdraw_settings("testgroup2000/fig2") + monkeypatch.setattr(build, "GTDRAW_SETTINGS_CONFIG", yaml_file) + result = build.catalog_gtdraw_settings("testgroup2000/fig2") assert result["sublevel_scaling"] == 2 def test_group_override_does_not_bleed_to_other_game(self, tmp_path, monkeypatch): """A group-level override applies only to games whose slug starts with that prefix.""" yaml_file = _write_yaml(tmp_path / "settings.yaml") - monkeypatch.setattr(update, "GTDRAW_SETTINGS_CONFIG", yaml_file) + monkeypatch.setattr(build, "GTDRAW_SETTINGS_CONFIG", yaml_file) # "othergroup1999" override sets shared_terminal_depth = False - result_other = update.catalog_gtdraw_settings("othergroup1999/fig1") + result_other = build.catalog_gtdraw_settings("othergroup1999/fig1") assert result_other["shared_terminal_depth"] is False # "testgroup2000" has a different override; shared_terminal_depth should be True (default) - result_test = update.catalog_gtdraw_settings("testgroup2000/fig1") + result_test = build.catalog_gtdraw_settings("testgroup2000/fig1") assert result_test["shared_terminal_depth"] is True def test_no_overrides_section_returns_defaults(self, tmp_path, monkeypatch): @@ -277,8 +278,8 @@ def test_no_overrides_section_returns_defaults(self, tmp_path, monkeypatch): sublevel_scaling: 0 """) yaml_file = _write_yaml(tmp_path / "settings.yaml", config) - monkeypatch.setattr(update, "GTDRAW_SETTINGS_CONFIG", yaml_file) - result = update.catalog_gtdraw_settings("anygame/v1") + monkeypatch.setattr(build, "GTDRAW_SETTINGS_CONFIG", yaml_file) + result = build.catalog_gtdraw_settings("anygame/v1") assert result == {"color_scheme": "gambit", "sublevel_scaling": 0} @@ -312,7 +313,7 @@ def test_no_ef_files_returns_none(self, tmp_path): catalog_dir = tmp_path / "catalog" slug = "fakevariant2000/fig1" self._game_dir(catalog_dir, slug) - assert update.catalog_ef_file_variants(slug, catalog_dir) is None + assert build.catalog_ef_file_variants(slug, catalog_dir) is None def test_single_ef_file_returns_none(self, tmp_path): """A single curated .ef file → returns None (single image, no tabs needed).""" @@ -320,7 +321,7 @@ def test_single_ef_file_returns_none(self, tmp_path): slug = "fakevariant2000/fig1" game_dir = self._game_dir(catalog_dir, slug) (game_dir / "fig1.ef").touch() - assert update.catalog_ef_file_variants(slug, catalog_dir) is None + assert build.catalog_ef_file_variants(slug, catalog_dir) is None def test_two_ef_files_returns_variant_list(self, tmp_path): """Two .ef files → 2-item list with correct label, ef_path, and variant_key.""" @@ -329,7 +330,7 @@ def test_two_ef_files_returns_variant_list(self, tmp_path): game_dir = self._game_dir(catalog_dir, slug) (game_dir / "fig1.ef").touch() (game_dir / "fig1__wide.ef").touch() - result = update.catalog_ef_file_variants(slug, catalog_dir) + result = build.catalog_ef_file_variants(slug, catalog_dir) assert result is not None assert len(result) == 2 assert {v["label"] for v in result} == {"Default", "Wide"} @@ -343,7 +344,7 @@ def test_variant_without_primary_ef_returns_variants(self, tmp_path): slug = "fakevariant2000/fig1" game_dir = self._game_dir(catalog_dir, slug) (game_dir / "fig1__wide.ef").touch() - result = update.catalog_ef_file_variants(slug, catalog_dir) + result = build.catalog_ef_file_variants(slug, catalog_dir) assert result is not None assert len(result) == 2 assert {v["label"] for v in result} == {"Default", "Wide"} @@ -357,7 +358,7 @@ def test_label_derived_from_filename_suffix(self, tmp_path): game_dir = self._game_dir(catalog_dir, slug) (game_dir / "fig1.ef").touch() (game_dir / "fig1__compact.ef").touch() - result = update.catalog_ef_file_variants(slug, catalog_dir) + result = build.catalog_ef_file_variants(slug, catalog_dir) assert {v["label"] for v in result} == {"Default", "Compact"} def test_multi_word_suffix_title_cased(self, tmp_path): @@ -367,7 +368,7 @@ def test_multi_word_suffix_title_cased(self, tmp_path): game_dir = self._game_dir(catalog_dir, slug) (game_dir / "fig1.ef").touch() (game_dir / "fig1__very_wide.ef").touch() - result = update.catalog_ef_file_variants(slug, catalog_dir) + result = build.catalog_ef_file_variants(slug, catalog_dir) assert "Very Wide" in {v["label"] for v in result} def test_file_without_double_underscore_excluded(self, tmp_path): @@ -382,7 +383,7 @@ def test_file_without_double_underscore_excluded(self, tmp_path): game_dir = self._game_dir(catalog_dir, slug) (game_dir / "fig1.ef").touch() (game_dir / "fig1extra.ef").touch() # no __ separator — must be ignored - assert update.catalog_ef_file_variants(slug, catalog_dir) is None + assert build.catalog_ef_file_variants(slug, catalog_dir) is None # --------------------------------------------------------------------------- @@ -399,7 +400,7 @@ class TestGenerateRstTable: from the real catalog directory. ``_mock_generates`` uses ``monkeypatch.setattr`` to replace each of the - four gtdraw functions in the ``update`` module's namespace with + four gtdraw functions in the ``build`` module's namespace with a no-op. Because the replacement is scoped to the test, the originals are automatically restored afterward. @@ -416,7 +417,7 @@ def _no_op_generate(self, *args, **kwargs): def _mock_generates(self, monkeypatch): """Replace all four gtdraw image-generation functions with no-ops.""" for name in ["tex", "png", "pdf", "svg"]: - monkeypatch.setattr(update, name, self._no_op_generate) + monkeypatch.setattr(build, name, self._no_op_generate) def test_efg_row_produces_rst_with_slug_and_title(self, tmp_path, monkeypatch): """An EFG game row appears in the RST with its title, load call, and download links.""" @@ -426,7 +427,7 @@ def test_efg_row_produces_rst_with_slug_and_title(self, tmp_path, monkeypatch): _make_image_files(catalog_dir, slug, "efg") df = _make_df(_efg_row(slug, title="Fake Author (2000) Figure 1")) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert "Fake Author (2000) Figure 1" in rst assert f'pygambit.catalog.load("{slug}")' in rst @@ -441,9 +442,9 @@ def test_nfg_row_produces_rst_with_save_to(self, tmp_path, monkeypatch): _make_image_files(catalog_dir, slug, "nfg") df = _make_df(_nfg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() - assert f'save_to="../catalog/img/{slug}.png"' in rst + assert f'save_to="../games/img/{slug}.png"' in rst def test_unknown_format_row_is_skipped(self, tmp_path, monkeypatch): """A row whose Format is not 'efg' or 'nfg' is silently omitted from the RST.""" @@ -459,7 +460,7 @@ def test_unknown_format_row_is_skipped(self, tmp_path, monkeypatch): } df = _make_df(row) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert "Fake Game" not in rst @@ -470,7 +471,7 @@ def test_row_without_description_is_skipped(self, tmp_path, monkeypatch): catalog_dir.mkdir() df = _make_df(_efg_row("fakeauthor2000/fig1", description="")) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert "fakeauthor2000/fig1" not in rst @@ -481,17 +482,17 @@ def test_curated_ef_used_in_draw_call(self, tmp_path, monkeypatch): catalog_dir = tmp_path / "catalog" slug = "fakeauthor1999/fig1" _make_image_files(catalog_dir, slug, "efg") - # Place a curated .ef file alongside the game — this is what update.py checks for + # Place a curated .ef file alongside the game — this is what build.py checks for curated = catalog_dir / f"{slug}.ef" curated.parent.mkdir(parents=True, exist_ok=True) curated.touch() df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() # Find the draw( call line in the jupyter-execute block draw_call = next(line for line in rst.splitlines() if "draw(" in line) - assert f'"../catalog/{slug}.ef"' in draw_call + assert f'"../games/{slug}.ef"' in draw_call assert "catalog.load" not in draw_call def test_images_not_regenerated_when_all_exist(self, tmp_path, monkeypatch): @@ -499,41 +500,41 @@ def test_images_not_regenerated_when_all_exist(self, tmp_path, monkeypatch): False, none of the gtdraw image-generation functions are called.""" calls = [] # Replace generate_* with lambdas that record invocations - monkeypatch.setattr(update, "tex", lambda *a, **k: calls.append("tex")) - monkeypatch.setattr(update, "png", lambda *a, **k: calls.append("png")) - monkeypatch.setattr(update, "pdf", lambda *a, **k: calls.append("pdf")) - monkeypatch.setattr(update, "svg", lambda *a, **k: calls.append("svg")) + monkeypatch.setattr(build, "tex", lambda *a, **k: calls.append("tex")) + monkeypatch.setattr(build, "png", lambda *a, **k: calls.append("png")) + monkeypatch.setattr(build, "pdf", lambda *a, **k: calls.append("pdf")) + monkeypatch.setattr(build, "svg", lambda *a, **k: calls.append("svg")) catalog_dir = tmp_path / "catalog" slug = "fakeauthor2000/fig1" _make_image_files(catalog_dir, slug, "efg") # all images already exist df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, regenerate_images=False, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, regenerate_images=False, catalog_dir=catalog_dir) assert calls == [] def test_images_regenerated_when_flag_set(self, tmp_path, monkeypatch): """When ``regenerate_images=True``, all four generate functions are called even if the image files already exist. - A curated .ef file is placed in the catalog dir so ``update.py`` uses it + A curated .ef file is placed in the catalog dir so ``build.py`` uses it as the gtdraw source rather than calling ``gbt.catalog.load``, which would require the real catalog to be present. """ calls = [] - monkeypatch.setattr(update, "tex", lambda *a, **k: calls.append("tex")) - monkeypatch.setattr(update, "png", lambda *a, **k: calls.append("png")) - monkeypatch.setattr(update, "pdf", lambda *a, **k: calls.append("pdf")) - monkeypatch.setattr(update, "svg", lambda *a, **k: calls.append("svg")) + monkeypatch.setattr(build, "tex", lambda *a, **k: calls.append("tex")) + monkeypatch.setattr(build, "png", lambda *a, **k: calls.append("png")) + monkeypatch.setattr(build, "pdf", lambda *a, **k: calls.append("pdf")) + monkeypatch.setattr(build, "svg", lambda *a, **k: calls.append("svg")) catalog_dir = tmp_path / "catalog" slug = "fakeauthor2000/fig1" _make_image_files(catalog_dir, slug, "efg") - # Place a curated .ef file alongside the game — this is what update.py checks for + # Place a curated .ef file alongside the game — this is what build.py checks for curated = catalog_dir / f"{slug}.ef" curated.parent.mkdir(parents=True, exist_ok=True) curated.touch() df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) assert set(calls) == {"tex", "png", "pdf", "svg"} def test_multi_variant_efg_produces_tab_set(self, tmp_path, monkeypatch): @@ -551,7 +552,7 @@ def test_multi_variant_efg_produces_tab_set(self, tmp_path, monkeypatch): (game_dir / "fig1__wide.ef").touch() df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) rst = rst_path.read_text() assert ".. tab-set::" in rst assert ".. tab-item:: Default" in rst @@ -562,7 +563,7 @@ def test_multi_variant_efg_without_primary_ef_produces_tab_set(self, tmp_path, m containing both a Default and the custom variant, calling catalog.load for the Default. """ self._mock_generates(monkeypatch) - monkeypatch.setattr(update.gbt.catalog, "load", lambda slug: "dummy_game") + monkeypatch.setattr(build.gbt.catalog, "load", lambda slug: "dummy_game") catalog_dir = tmp_path / "catalog" slug = "fakevariant2001/fig1" game_dir = catalog_dir / "fakevariant2001" @@ -570,13 +571,13 @@ def test_multi_variant_efg_without_primary_ef_produces_tab_set(self, tmp_path, m (game_dir / "fig1__wide.ef").touch() df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) rst = rst_path.read_text() assert ".. tab-set::" in rst assert ".. tab-item:: Default" in rst assert ".. tab-item:: Wide" in rst assert 'draw(pygambit.catalog.load("fakevariant2001/fig1")' in rst - assert 'draw("../catalog/fakevariant2001/fig1__wide.ef"' in rst + assert 'draw("../games/fakevariant2001/fig1__wide.ef"' in rst def test_single_variant_efg_produces_no_tab_set(self, tmp_path, monkeypatch): """A single curated .ef file (or no .ef file) does not produce a ``tab-set``.""" @@ -589,7 +590,7 @@ def test_single_variant_efg_produces_no_tab_set(self, tmp_path, monkeypatch): (game_dir / "fig1.ef").touch() # only one .ef — no tabs df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert ".. tab-set::" not in rst @@ -599,10 +600,10 @@ def test_per_variant_images_generated(self, tmp_path, monkeypatch): Two variants × four generate functions = eight total calls. """ calls = [] - monkeypatch.setattr(update, "tex", lambda *a, **k: calls.append("tex")) - monkeypatch.setattr(update, "png", lambda *a, **k: calls.append("png")) - monkeypatch.setattr(update, "pdf", lambda *a, **k: calls.append("pdf")) - monkeypatch.setattr(update, "svg", lambda *a, **k: calls.append("svg")) + monkeypatch.setattr(build, "tex", lambda *a, **k: calls.append("tex")) + monkeypatch.setattr(build, "png", lambda *a, **k: calls.append("png")) + monkeypatch.setattr(build, "pdf", lambda *a, **k: calls.append("pdf")) + monkeypatch.setattr(build, "svg", lambda *a, **k: calls.append("svg")) catalog_dir = tmp_path / "catalog" slug = "fakevariant2001/fig1" game_dir = catalog_dir / "fakevariant2001" @@ -611,17 +612,17 @@ def test_per_variant_images_generated(self, tmp_path, monkeypatch): (game_dir / "fig1__wide.ef").touch() df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) assert len(calls) == 8 # 4 functions × 2 variants def test_per_variant_images_not_regenerated_when_all_exist(self, tmp_path, monkeypatch): """If all variant image files already exist and ``regenerate_images`` is False, generate functions are not called.""" calls = [] - monkeypatch.setattr(update, "tex", lambda *a, **k: calls.append("tex")) - monkeypatch.setattr(update, "png", lambda *a, **k: calls.append("png")) - monkeypatch.setattr(update, "pdf", lambda *a, **k: calls.append("pdf")) - monkeypatch.setattr(update, "svg", lambda *a, **k: calls.append("svg")) + monkeypatch.setattr(build, "tex", lambda *a, **k: calls.append("tex")) + monkeypatch.setattr(build, "png", lambda *a, **k: calls.append("png")) + monkeypatch.setattr(build, "pdf", lambda *a, **k: calls.append("pdf")) + monkeypatch.setattr(build, "svg", lambda *a, **k: calls.append("svg")) catalog_dir = tmp_path / "catalog" slug = "fakevariant2001/fig1" game_dir = catalog_dir / "fakevariant2001" @@ -633,7 +634,7 @@ def test_per_variant_images_not_regenerated_when_all_exist(self, tmp_path, monke _make_image_files(catalog_dir, vkey, "efg") df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, regenerate_images=False, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, regenerate_images=False, catalog_dir=catalog_dir) assert calls == [] @@ -641,7 +642,7 @@ def test_per_variant_images_not_regenerated_when_all_exist(self, tmp_path, monke # Tests for hierarchy helpers and hierarchical RST output # --------------------------------------------------------------------------- -# A minimal catalog_hierarchy.yaml used by hierarchy tests. +# A minimal hierarchy.yaml used by hierarchy tests. _HIERARCHY_YAML = textwrap.dedent("""\ labels: cat: "My Category" @@ -657,8 +658,8 @@ def test_load_hierarchy_labels_returns_dict(self, tmp_path, monkeypatch): """``load_hierarchy_labels`` returns the labels dict from the YAML.""" yaml_file = tmp_path / "hier.yaml" yaml_file.write_text(_HIERARCHY_YAML, encoding="utf-8") - monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", yaml_file) - labels = update.load_hierarchy_labels() + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = build.load_hierarchy_labels() assert labels["cat"] == "My Category" assert labels["cat/src"] == "My Source" @@ -666,22 +667,22 @@ def test_node_label_uses_yaml(self, tmp_path, monkeypatch): """``_node_label`` returns the YAML label when the prefix is present.""" yaml_file = tmp_path / "hier.yaml" yaml_file.write_text(_HIERARCHY_YAML, encoding="utf-8") - monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", yaml_file) - labels = update.load_hierarchy_labels() - assert update._node_label("cat", labels) == "My Category" + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = build.load_hierarchy_labels() + assert build._node_label("cat", labels) == "My Category" def test_node_label_fallback_title_case(self, tmp_path, monkeypatch): """``_node_label`` falls back to title-casing the last component.""" yaml_file = tmp_path / "hier.yaml" yaml_file.write_text(_HIERARCHY_YAML, encoding="utf-8") - monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", yaml_file) - labels = update.load_hierarchy_labels() - assert update._node_label("cat/unknownsrc", labels) == "Unknownsrc" + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = build.load_hierarchy_labels() + assert build._node_label("cat/unknownsrc", labels) == "Unknownsrc" def test_build_slug_tree_single_game(self): """A single-slug DataFrame builds a 2-level tree.""" df = _make_df(_efg_row("cat/src/game1")) - tree = update._build_slug_tree(df) + tree = build._build_slug_tree(df) assert "cat" in tree assert "src" in tree["cat"] assert "game1" in tree["cat"]["src"] @@ -689,19 +690,19 @@ def test_build_slug_tree_single_game(self): def test_build_slug_tree_groups_siblings(self): """Two slugs sharing a prefix are grouped under the same intermediate node.""" df = _make_df(_efg_row("cat/src/game1"), _efg_row("cat/src/game2")) - tree = update._build_slug_tree(df) + tree = build._build_slug_tree(df) assert set(tree["cat"]["src"].keys()) == {"game1", "game2"} def test_build_slug_tree_skips_unknown_format(self): """Rows with unrecognised Format are excluded from the tree.""" row = {**_efg_row("cat/src/game1"), "Format": "xyz"} df = _make_df(row) - assert update._build_slug_tree(df) == {} + assert build._build_slug_tree(df) == {} def test_build_slug_tree_skips_empty_description(self): """Rows with an empty description are excluded from the tree.""" df = _make_df(_efg_row("cat/src/game1", description="")) - assert update._build_slug_tree(df) == {} + assert build._build_slug_tree(df) == {} @pytest.mark.catalog_update @@ -710,7 +711,7 @@ class TestHierarchicalRstOutput: def _mock_generates(self, monkeypatch): for name in ["tex", "png", "pdf", "svg"]: - monkeypatch.setattr(update, name, lambda *a, **k: None) + monkeypatch.setattr(build, name, lambda *a, **k: None) def _write_hierarchy_yaml(self, tmp_path, content=_HIERARCHY_YAML): yaml_file = tmp_path / "hier.yaml" @@ -721,13 +722,13 @@ def test_top_level_dropdown_is_open(self, tmp_path, monkeypatch): """Top-level category dropdowns carry ``:open:`` so the first level is visible.""" self._mock_generates(monkeypatch) hier_yaml = self._write_hierarchy_yaml(tmp_path) - monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", hier_yaml) catalog_dir = tmp_path / "catalog" slug = "cat/src/game1" _make_image_files(catalog_dir, slug, "efg") df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert ".. dropdown:: My Category\n :open:" in rst @@ -735,13 +736,13 @@ def test_second_level_dropdown_is_not_open(self, tmp_path, monkeypatch): """Sub-category dropdowns do NOT carry ``:open:`` so they are collapsed by default.""" self._mock_generates(monkeypatch) hier_yaml = self._write_hierarchy_yaml(tmp_path) - monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", hier_yaml) catalog_dir = tmp_path / "catalog" slug = "cat/src/game1" _make_image_files(catalog_dir, slug, "efg") df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert " .. dropdown:: My Source\n \n" in rst # Confirm :open: does not immediately follow the second-level dropdown @@ -752,13 +753,13 @@ def test_game_dropdown_is_open(self, tmp_path, monkeypatch): """Individual game dropdowns carry ``:open:`` so game content is visible on expand.""" self._mock_generates(monkeypatch) hier_yaml = self._write_hierarchy_yaml(tmp_path) - monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", hier_yaml) catalog_dir = tmp_path / "catalog" slug = "cat/src/game1" _make_image_files(catalog_dir, slug, "efg") df = _make_df(_efg_row(slug, title="My Game Title")) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert " .. dropdown:: My Game Title\n :open:" in rst @@ -766,7 +767,7 @@ def test_sibling_games_both_appear_under_source(self, tmp_path, monkeypatch): """Two games sharing a source prefix both appear nested under the source dropdown.""" self._mock_generates(monkeypatch) hier_yaml = self._write_hierarchy_yaml(tmp_path) - monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", hier_yaml) catalog_dir = tmp_path / "catalog" for slug in ["cat/src/game1", "cat/src/game2"]: _make_image_files(catalog_dir, slug, "efg") @@ -775,7 +776,7 @@ def test_sibling_games_both_appear_under_source(self, tmp_path, monkeypatch): _efg_row("cat/src/game2", title="Game Two"), ) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert ".. dropdown:: My Category" in rst assert " .. dropdown:: My Source" in rst @@ -786,13 +787,13 @@ def test_no_list_table_in_output(self, tmp_path, monkeypatch): """The new output does not use ``.. list-table::`` (replaced by nested dropdowns).""" self._mock_generates(monkeypatch) hier_yaml = self._write_hierarchy_yaml(tmp_path) - monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", hier_yaml) catalog_dir = tmp_path / "catalog" slug = "cat/src/game1" _make_image_files(catalog_dir, slug, "efg") df = _make_df(_efg_row(slug)) rst_path = tmp_path / "out.rst" - update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + build.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() assert ".. list-table::" not in rst assert ".. contents::" not in rst @@ -817,10 +818,10 @@ def test_efg_and_nfg_files_included(self, tmp_path): (tmp_path / "subfolder").mkdir() (tmp_path / "subfolder" / "matrix.nfg").touch() am = tmp_path / "catalog.am" - update.update_makefile(catalog_dir=tmp_path, am_path=am) + build.update_makefile(catalog_dir=tmp_path, am_path=am) content = am.read_text() - assert "catalog/standalone.efg" in content - assert "catalog/subfolder/matrix.nfg" in content + assert "catalog/games/standalone.efg" in content + assert "catalog/games/subfolder/matrix.nfg" in content def test_curated_ef_included(self, tmp_path): """A curated .ef file committed alongside a game file appears in catalog.am.""" @@ -828,9 +829,9 @@ def test_curated_ef_included(self, tmp_path): (tmp_path / "fakegame" / "fig1.efg").touch() (tmp_path / "fakegame" / "fig1.ef").touch() # curated layout file am = tmp_path / "catalog.am" - update.update_makefile(catalog_dir=tmp_path, am_path=am) + build.update_makefile(catalog_dir=tmp_path, am_path=am) content = am.read_text() - assert "catalog/fakegame/fig1.ef" in content + assert "catalog/games/fakegame/fig1.ef" in content def test_ef_in_img_dir_excluded(self, tmp_path): """Generated .ef files under the img/ subdirectory are excluded from catalog.am.""" @@ -838,7 +839,7 @@ def test_ef_in_img_dir_excluded(self, tmp_path): img.mkdir(parents=True) (img / "fig1.ef").touch() # generated artifact — should not be distributed am = tmp_path / "catalog.am" - update.update_makefile(catalog_dir=tmp_path, am_path=am) + build.update_makefile(catalog_dir=tmp_path, am_path=am) content = am.read_text() assert "img" not in content @@ -847,7 +848,7 @@ def test_non_game_file_excluded(self, tmp_path): (tmp_path / "fakegame.efg_2").touch() # hidden/renamed file (tmp_path / "README.txt").touch() am = tmp_path / "catalog.am" - update.update_makefile(catalog_dir=tmp_path, am_path=am) + build.update_makefile(catalog_dir=tmp_path, am_path=am) content = am.read_text() assert "efg_2" not in content assert "README" not in content @@ -861,15 +862,15 @@ def test_no_write_when_content_unchanged(self, tmp_path): """ (tmp_path / "standalone.efg").touch() am = tmp_path / "catalog.am" - update.update_makefile(catalog_dir=tmp_path, am_path=am) + build.update_makefile(catalog_dir=tmp_path, am_path=am) mtime_after_first_write = am.stat().st_mtime - update.update_makefile(catalog_dir=tmp_path, am_path=am) + build.update_makefile(catalog_dir=tmp_path, am_path=am) assert am.stat().st_mtime == mtime_after_first_write def test_empty_catalog_produces_valid_am(self, tmp_path): """An empty catalog directory produces a catalog.am with a valid (empty) CATALOG_FILES.""" am = tmp_path / "catalog.am" - update.update_makefile(catalog_dir=tmp_path, am_path=am) + build.update_makefile(catalog_dir=tmp_path, am_path=am) content = am.read_text() assert content.startswith("CATALOG_FILES =") @@ -885,7 +886,7 @@ class TestWarnMissingDescriptions: def test_game_without_description_warns(self, capsys): """A game with an empty description produces a WARNING on stderr.""" df = _make_df(_efg_row("journals/nobody2025/fig1", description="")) - update._warn_missing_descriptions(df) + build._warn_missing_descriptions(df) err = capsys.readouterr().err assert "WARNING" in err assert "journals/nobody2025/fig1" in err @@ -893,6 +894,6 @@ def test_game_without_description_warns(self, capsys): def test_game_with_description_does_not_warn(self, capsys): """A game with a non-empty description produces no output.""" df = _make_df(_efg_row("journals/nobody2025/fig1")) - update._warn_missing_descriptions(df) + build._warn_missing_descriptions(df) err = capsys.readouterr().err assert err == "" diff --git a/doc/_bibstyle.py b/doc/_bibstyle.py new file mode 100644 index 000000000..f9b4673a4 --- /dev/null +++ b/doc/_bibstyle.py @@ -0,0 +1,173 @@ +"""Shared pybtex citation style, registered as the "keystyle" plugin. + +Imported by both this project's ``conf.py`` and ``catalog/doc/conf.py`` so the two +independent Sphinx projects render ``:cite:p:`` citations identically without +duplicating the style definition. +""" +import re + +import pybtex.plugin +from pybtex.richtext import Symbol, Text +from pybtex.style.formatting import toplevel +from pybtex.style.formatting.alpha import Style as AlphaStyle +from pybtex.style.labels import BaseLabelStyle +from pybtex.style.template import ( + field, + first_of, + join, + optional, + optional_field, + sentence, + tag, + words, +) + + +def dashify(text): + dash_re = re.compile(r"-+") + return Text(Symbol("ndash")).join(text.split(dash_re)) + + +pages = field("pages", apply_func=dashify) + + +class KeyLabelStyle(BaseLabelStyle): + """Custom label style that uses the BibTeX entry key as the citation label.""" + + def format_labels(self, sorted_entries): + for entry in sorted_entries: + yield entry.key + + +class CustomAlphaStyle(AlphaStyle): + """Custom formatting style that uses KeyLabelStyle for labels.""" + + default_label_style = "keystyle" + default_name_style = "lastfirst" + + def format_author_or_editor(self, e, as_sentence=False): + return first_of[ + optional[self.format_names("author", as_sentence=as_sentence)], + optional[self.format_editor(e, as_sentence=as_sentence)], + ] + + def format_editor(self, e, as_sentence=True): + editors = self.format_names("editor", as_sentence=False) + if "editor" not in e.persons: + return editors + word = "(eds)" if len(e.persons["editor"]) > 1 else "(ed.)" + result = join(sep=" ")[editors, word] + if as_sentence: + return sentence[result] + return result + + def get_article_template(self, e): + return toplevel[ + sentence[ + join(sep=", ")[ + join(sep=" ")[ + self.format_names("author", as_sentence=False), + field("year"), + ], + join["‘", field("title"), "’"], + tag("em")[field("journal")], + optional[words["vol.", field("volume")]], + optional[words["no.", field("number")]], + optional[words["pp.", pages]], + ] + ], + sentence[optional_field("note")], + self.format_web_refs(e), + ] + + def get_book_template(self, e): + return toplevel[ + sentence[ + join(sep=", ")[ + join(sep=" ")[ + self.format_author_or_editor(e, as_sentence=False), + field("year"), + ], + tag("em")[field("title")], + optional[words[field("edition"), "edn"]], + field("publisher"), + optional_field("address"), + ] + ], + sentence[optional_field("note")], + self.format_web_refs(e), + ] + + def get_incollection_template(self, e): + return toplevel[ + sentence[ + join(sep=", ")[ + join(sep=" ")[ + self.format_names("author", as_sentence=False), + field("year"), + ], + join["‘", field("title"), "’"], + words[ + "in", + join(sep=", ")[ + optional[self.format_editor(e, as_sentence=False)], + tag("em")[field("booktitle")], + optional[words["vol.", field("volume")]], + ], + ], + field("publisher"), + optional_field("address"), + optional[words["pp.", pages]], + ] + ], + sentence[optional_field("note")], + self.format_web_refs(e), + ] + + def get_inbook_template(self, e): + return self.get_incollection_template(e) + + def get_inproceedings_template(self, e): + return toplevel[ + sentence[ + join(sep=", ")[ + join(sep=" ")[ + self.format_names("author", as_sentence=False), + field("year"), + ], + join["‘", field("title"), "’"], + tag("em")[field("booktitle")], + optional[words["pp.", pages]], + ] + ], + sentence[optional_field("note")], + self.format_web_refs(e), + ] + + def get_techreport_template(self, e): + type_and_number = optional[ + words[ + first_of[optional_field("type"), "Technical Report"], + field("number"), + ] + ] + return toplevel[ + sentence[ + join(sep=", ")[ + join(sep=" ")[ + self.format_names("author", as_sentence=False), + field("year"), + ], + join["‘", field("title"), "’"], + type_and_number, + field("institution"), + optional_field("address"), + ] + ], + sentence[optional_field("note")], + self.format_web_refs(e), + ] + + +pybtex.plugin.register_plugin("pybtex.style.labels", "keystyle", KeyLabelStyle) +pybtex.plugin.register_plugin("pybtex.style.formatting", "keystyle", CustomAlphaStyle) diff --git a/doc/catalog.csv b/doc/catalog.csv new file mode 100644 index 000000000..3a7b50abf --- /dev/null +++ b/doc/catalog.csv @@ -0,0 +1,6 @@ +Game,Title +2smp,Two-stage matching pennies game +2x2x2,"2x2x2 Example from McKelvey-McLennan, with 9 Nash equilibria, 2 totally mixed" +myerson/fig_4_2,Myerson (1991) Fig 4.2 +pd,Two person Prisoner's Dilemma game +selten1975/fig1,"Selten's horse (Selten IJGT 1975, Figure 1)" diff --git a/doc/catalog.rst b/doc/catalog_generators.rst similarity index 80% rename from doc/catalog.rst rename to doc/catalog_generators.rst index 118a991f5..3e3ad7e46 100644 --- a/doc/catalog.rst +++ b/doc/catalog_generators.rst @@ -1,14 +1,12 @@ -.. _catalog: +.. _catalog-generators: -Catalog of games -================ +Generating games for the catalog +================================= -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 +Alongside the curated catalog of games (built and hosted separately; see the +`catalog's developer guide `_ +for how to contribute new games), pygambit can generate games on the fly from two +external sources: the OpenSpiel library and the GAMUT suite. .. _catalog-openspiel: diff --git a/doc/conf.py b/doc/conf.py index 45175ea3f..13aee504e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,21 +1,8 @@ import pathlib -import re - -import pybtex.plugin -from pybtex.richtext import Symbol, Text -from pybtex.style.formatting import toplevel -from pybtex.style.formatting.alpha import Style as AlphaStyle -from pybtex.style.labels import BaseLabelStyle -from pybtex.style.template import ( - field, - first_of, - join, - optional, - optional_field, - sentence, - tag, - words, -) +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).parent)) +import _bibstyle # noqa: E402,F401 registers the shared "keystyle" pybtex plugin # # Gambit documentation build configuration file, created by @@ -54,157 +41,8 @@ # BibTeX configuration -def dashify(text): - dash_re = re.compile(r"-+") - return Text(Symbol("ndash")).join(text.split(dash_re)) - - -pages = field("pages", apply_func=dashify) - bibtex_bibfiles = ["references.bib"] - -class KeyLabelStyle(BaseLabelStyle): - """Custom label style that uses the BibTeX entry key as the citation label.""" - - def format_labels(self, sorted_entries): - for entry in sorted_entries: - yield entry.key - - -class CustomAlphaStyle(AlphaStyle): - """Custom formatting style that uses KeyLabelStyle for labels.""" - - default_label_style = "keystyle" - default_name_style = "lastfirst" - - def format_author_or_editor(self, e, as_sentence=False): - return first_of[ - optional[self.format_names("author", as_sentence=as_sentence)], - optional[self.format_editor(e, as_sentence=as_sentence)], - ] - - def format_editor(self, e, as_sentence=True): - editors = self.format_names("editor", as_sentence=False) - if "editor" not in e.persons: - return editors - word = "(eds)" if len(e.persons["editor"]) > 1 else "(ed.)" - result = join(sep=" ")[editors, word] - if as_sentence: - return sentence[result] - return result - - def get_article_template(self, e): - return toplevel[ - sentence[ - join(sep=", ")[ - join(sep=" ")[ - self.format_names("author", as_sentence=False), - field("year"), - ], - join["\u2018", field("title"), "\u2019"], - tag("em")[field("journal")], - optional[words["vol.", field("volume")]], - optional[words["no.", field("number")]], - optional[words["pp.", pages]], - ] - ], - sentence[optional_field("note")], - self.format_web_refs(e), - ] - - def get_book_template(self, e): - return toplevel[ - sentence[ - join(sep=", ")[ - join(sep=" ")[ - self.format_author_or_editor(e, as_sentence=False), - field("year"), - ], - tag("em")[field("title")], - optional[words[field("edition"), "edn"]], - field("publisher"), - optional_field("address"), - ] - ], - sentence[optional_field("note")], - self.format_web_refs(e), - ] - - def get_incollection_template(self, e): - return toplevel[ - sentence[ - join(sep=", ")[ - join(sep=" ")[ - self.format_names("author", as_sentence=False), - field("year"), - ], - join["\u2018", field("title"), "\u2019"], - words[ - "in", - join(sep=", ")[ - optional[self.format_editor(e, as_sentence=False)], - tag("em")[field("booktitle")], - optional[words["vol.", field("volume")]], - ], - ], - field("publisher"), - optional_field("address"), - optional[words["pp.", pages]], - ] - ], - sentence[optional_field("note")], - self.format_web_refs(e), - ] - - def get_inbook_template(self, e): - return self.get_incollection_template(e) - - def get_inproceedings_template(self, e): - return toplevel[ - sentence[ - join(sep=", ")[ - join(sep=" ")[ - self.format_names("author", as_sentence=False), - field("year"), - ], - join["\u2018", field("title"), "\u2019"], - tag("em")[field("booktitle")], - optional[words["pp.", pages]], - ] - ], - sentence[optional_field("note")], - self.format_web_refs(e), - ] - - def get_techreport_template(self, e): - type_and_number = optional[ - words[ - first_of[optional_field("type"), "Technical Report"], - field("number"), - ] - ] - return toplevel[ - sentence[ - join(sep=", ")[ - join(sep=" ")[ - self.format_names("author", as_sentence=False), - field("year"), - ], - join["\u2018", field("title"), "\u2019"], - type_and_number, - field("institution"), - optional_field("address"), - ] - ], - sentence[optional_field("note")], - self.format_web_refs(e), - ] - - -pybtex.plugin.register_plugin("pybtex.style.labels", "keystyle", KeyLabelStyle) -pybtex.plugin.register_plugin("pybtex.style.formatting", "keystyle", CustomAlphaStyle) - # IPython directive configuration ipython_execlines = ["import pygambit as gbt", "import os", "import sys"] ipython_savefig_dir = "savefig" @@ -262,7 +100,7 @@ def get_techreport_template(self, e): # List of directories, relative to source directory, that shouldn't be searched # for source files. exclude_trees = ["_build"] -exclude_patterns = ["_build", "catalog_table.rst"] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None diff --git a/doc/developer.overview.rst b/doc/developer.overview.rst index 607400b75..a4e6aab30 100644 --- a/doc/developer.overview.rst +++ b/doc/developer.overview.rst @@ -23,7 +23,7 @@ Running ``make check`` has no effect (there are no tests defined in that build s The test suite makes use of a variety of games, which are drawn from examples in the game theory literature and textbooks, as well as examples constructed specifically to test edge cases. From Gambit 17, these are all curated as part of Gambit's -:ref:`catalog of games `. +`catalog of games `_. From Gambit 17, ``pygambit`` is also responsible for providing command-line interfaces to equilibrium computation and other facilities; previously the command-line wrappers were diff --git a/doc/developer.rst b/doc/developer.rst index ee1bb5fd4..c319b384b 100644 --- a/doc/developer.rst +++ b/doc/developer.rst @@ -5,6 +5,7 @@ Developer docs ============== This section contains information for developers who want to contribute to the Gambit project, including how to build Gambit from source, how to contribute code, and how to report bugs. +For instructions on contributing new games to the catalog, see the `catalog's own developer guide `_ (built and versioned separately from these docs). .. toctree:: :maxdepth: 2 @@ -12,4 +13,3 @@ This section contains information for developers who want to contribute to the G developer.overview developer.build developer.contributing - developer.catalog diff --git a/doc/index.rst b/doc/index.rst index ad354146a..9329f1b8a 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -47,14 +47,15 @@ construction and analysis of finite extensive and strategic games. .. grid-item-card:: 📚 Catalog of games :columns: 3 - Browse a curated collection of game theory models. + Browse a curated collection of games. - .. button-ref:: catalog - :ref-type: ref + .. button-link:: https://gambit-catalog.readthedocs.io/ :click-parent: :color: secondary :expand: + Open catalog + .. grid-item-card:: 🖱️ Graphical interface :columns: 3 @@ -109,7 +110,7 @@ construction and analysis of finite extensive and strategic games. pygambit tools gui - catalog + catalog_generators developer formats biblio diff --git a/doc/tools.enummixed.rst b/doc/tools.enummixed.rst index e367bc54c..76d87cdfe 100644 --- a/doc/tools.enummixed.rst +++ b/doc/tools.enummixed.rst @@ -41,7 +41,7 @@ See the :ref:`algorithm description ` for full details. Computing the equilibria, in mixed strategies, of the reduced strategic form of the example in Figure 2 of :cite:p:`Sel75`:: - $ gambit-enummixed catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-enummixed catalog/games/journals/ijgt/selten1975/fig2.efg Compute Nash equilibria by enumerating extreme points Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL @@ -53,7 +53,7 @@ In fact, this game has a one-dimensional continuum of equilibria. This fact can be observed by examining the connectedness information using the ``-c`` switch:: - $ gambit-enummixed -c catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-enummixed -c catalog/games/journals/ijgt/selten1975/fig2.efg Compute Nash equilibria by enumerating extreme points Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL diff --git a/doc/tools.enumpoly.rst b/doc/tools.enumpoly.rst index 91996b2a9..d7d27fbf6 100644 --- a/doc/tools.enumpoly.rst +++ b/doc/tools.enumpoly.rst @@ -90,7 +90,7 @@ and (in verbose mode) the support is identified with the label Computing equilibria of the example in Figure 1 of :cite:p:`Sel75`, sometimes called "Selten's horse":: - $ gambit-enumpoly -S catalog/journals/ijgt/selten1975/fig1.efg + $ gambit-enumpoly -S catalog/games/journals/ijgt/selten1975/fig1.efg Compute Nash equilibria by solving polynomial systems Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL diff --git a/doc/tools.enumpure.rst b/doc/tools.enumpure.rst index 29f17c3ef..2683c167d 100644 --- a/doc/tools.enumpure.rst +++ b/doc/tools.enumpure.rst @@ -54,7 +54,7 @@ See the :ref:`algorithm description ` for full details. Computing the pure-strategy equilibria of extensive game in Figure 2 of :cite:p:`Sel75`:: - $ gambit-enumpure catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-enumpure catalog/games/journals/ijgt/selten1975/fig2.efg Search for Nash equilibria in pure strategies Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project @@ -66,7 +66,7 @@ With the `-S` switch, the set of equilibria returned is the same, except expressed in strategic game strategies rather than behavior strategies:: - $ gambit-enumpure -S catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-enumpure -S catalog/games/journals/ijgt/selten1975/fig2.efg Search for Nash equilibria in pure strategies Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project @@ -78,7 +78,7 @@ The `-A` switch considers only behavior strategy profiles where there is no way for a player to improve his payoff by changing action at only one information set; therefore the set of solutions is larger:: - $ gambit-enumpure -A catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-enumpure -A catalog/games/journals/ijgt/selten1975/fig2.efg Search for Nash equilibria in pure strategies Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL diff --git a/doc/tools.gnm.rst b/doc/tools.gnm.rst index a823be655..c92fd2534 100644 --- a/doc/tools.gnm.rst +++ b/doc/tools.gnm.rst @@ -90,7 +90,7 @@ subsets of equilibria being found. Computing an equilibrium of the reduced strategic form of the example in Figure 2 of :cite:p:`Sel75`:: - $ gambit-gnm catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-gnm catalog/games/journals/ijgt/selten1975/fig2.efg Compute Nash equilibria using a global Newton method Gametracer version 0.2, Copyright (C) 2002, Ben Blum and Christian Shelton Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project diff --git a/doc/tools.hp.rst b/doc/tools.hp.rst index 86770fa32..b3a00b7d7 100644 --- a/doc/tools.hp.rst +++ b/doc/tools.hp.rst @@ -73,7 +73,7 @@ Figure 2 of :cite:p:`Sel75`, starting from the prior in which player 1 plays (0.5, 0.3, 0.2) and player 2 plays (0.6, 0.4):: $ echo "0.5,0.3,0.2,0.6,0.4" > prior.csv - $ gambit-hp -s prior.csv catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-hp -s prior.csv catalog/games/journals/ijgt/selten1975/fig2.efg Compute a Nash equilibrium using the Herings-Peeters (2001) homotopy method Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL diff --git a/doc/tools.ipa.rst b/doc/tools.ipa.rst index e0e1e765c..d2f83f391 100644 --- a/doc/tools.ipa.rst +++ b/doc/tools.ipa.rst @@ -61,7 +61,7 @@ equilibria being found. Computing an equilibrium of the reduced strategic form of the example in Figure 2 of :cite:p:`Sel75`:: - $ gambit-ipa catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-ipa catalog/games/journals/ijgt/selten1975/fig2.efg Compute Nash equilibria using iterated polymatrix approximation Gametracer version 0.2, Copyright (C) 2002, Ben Blum and Christian Shelton Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project diff --git a/doc/tools.lcp.rst b/doc/tools.lcp.rst index c122fd0c5..d3dbaa82e 100644 --- a/doc/tools.lcp.rst +++ b/doc/tools.lcp.rst @@ -64,7 +64,7 @@ See the :ref:`algorithm description ` for full details. Computing an equilibrium of the example in Figure 2 of :cite:p:`Sel75`:: - $ gambit-lcp catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-lcp catalog/games/journals/ijgt/selten1975/fig2.efg Compute Nash equilibria by solving a linear complementarity program Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL diff --git a/doc/tools.liap.rst b/doc/tools.liap.rst index f18c63675..78ade88c4 100644 --- a/doc/tools.liap.rst +++ b/doc/tools.liap.rst @@ -97,7 +97,7 @@ See the :ref:`algorithm description ` for full details. Computing an equilibrium in mixed strategies of the example in Figure 2 of :cite:p:`Sel75`:: - $ gambit-liap catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-liap catalog/games/journals/ijgt/selten1975/fig2.efg Compute Nash equilibria by minimizing the Lyapunov function Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL diff --git a/doc/tools.logit.rst b/doc/tools.logit.rst index 7cb9a142b..a24e9d49e 100644 --- a/doc/tools.logit.rst +++ b/doc/tools.logit.rst @@ -78,7 +78,7 @@ Computing the principal branch, in mixed strategies, of the reduced strategic form of the example in Figure 2 of :cite:p:`Sel75`:: - $ gambit-logit -S catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-logit -S catalog/games/journals/ijgt/selten1975/fig2.efg Compute a branch of the logit equilibrium correspondence Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL diff --git a/doc/tools.simpdiv.rst b/doc/tools.simpdiv.rst index 5b42f2b86..14a7a1a33 100644 --- a/doc/tools.simpdiv.rst +++ b/doc/tools.simpdiv.rst @@ -96,7 +96,7 @@ options to specify additional starting points for the algorithm. Computing an equilibrium in mixed strategies of the example in Figure 2 of :cite:p:`Sel75`:: - $ gambit-simpdiv catalog/journals/ijgt/selten1975/fig2.efg + $ gambit-simpdiv catalog/games/journals/ijgt/selten1975/fig2.efg Compute Nash equilibria using simplicial subdivision Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project This is free software, distributed under the GNU GPL diff --git a/doc/tutorials/#03_poker.ipynb# b/doc/tutorials/#03_poker.ipynb# new file mode 100644 index 000000000..6f9a70b78 --- /dev/null +++ b/doc/tutorials/#03_poker.ipynb# @@ -0,0 +1,1634 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "98eb65d8", + "metadata": {}, + "source": [ + "# 3) A one-card poker game with private information\n", + "\n", + "In this tutorial, we'll create an extensive form representation of a one-card poker game [[Mye91](#references)] and use it to demonstrate and explain the following with Gambit:\n", + "\n", + "1. Setting up an extensive form game with imperfect information using [information sets](#information-sets)\n", + "2. [Computing and interpreting Nash equilibria](#computing-and-interpreting-nash-equilibria) and understanding mixed behaviour and mixed strategy profiles\n", + "3. [Acceptance criteria for Nash equilibria](#acceptance-criteria-for-nash-equilibria)\n", + "\n", + "A version of this game also appears in [[RUW08](#references)], as a classroom game under the name \"stripped-down poker\".\n", + "This is perhaps the simplest interesting game with imperfect information.\n", + "\n", + "In our version of the game, there are two players, **Alice** and **Bob**, and a deck of cards, with equal numbers of **King** and **Queen** cards.\n", + "\n", + "- The game begins with each player putting \\$1 in the pot.\n", + " - A card is dealt at random to Alice\n", + " - Alice observes her card\n", + " - Bob does not observe the card\n", + "- Alice then chooses either to **Raise** or to **Fold**.\n", + " - If she chooses to Fold, Bob wins the pot and the game ends.\n", + " - If she chooses to Raise, she adds another \\$1 to the pot.\n", + "- Bob then chooses either to **Meet** or **Pass**.\n", + " - If he chooses to Pass, Alice wins the pot and the game ends.\n", + " - If he chooses to Meet, he adds another $1 to the pot.\n", + "- There is then a showdown, in which Alice reveals her card.\n", + " - If she has a King, then she wins the pot;\n", + " - If she has a Queen, then Bob wins the pot." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "69cbfe81", + "metadata": {}, + "outputs": [], + "source": [ + "import pygambit as gbt" + ] + }, + { + "cell_type": "markdown", + "id": "70819881", + "metadata": {}, + "source": [ + "Create the game with two players." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ad6a1119", + "metadata": {}, + "outputs": [], + "source": [ + "g = gbt.Game.new_tree(\n", + " players=[\"Alice\", \"Bob\"],\n", + " title=\"One card poker\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "d9796238", + "metadata": {}, + "source": [ + "In addition to the two named players, Gambit also instantiates a chance player." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "841f9f74", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Player(game=Game(title='One card poker'), label='Alice')\n", + "Player(game=Game(title='One card poker'), label='Bob')\n", + "ChancePlayer(game=Game(title='One card poker'))\n" + ] + } + ], + "source": [ + "print(g.players[\"Alice\"])\n", + "print(g.players[\"Bob\"])\n", + "print(g.players.chance)" + ] + }, + { + "cell_type": "markdown", + "id": "0d4c7f5b", + "metadata": {}, + "source": [ + "Moves belonging to the chance player can be added in the same way as to other players.\n", + "\n", + "At any new move created for the chance player, the action probabilities default to uniform randomization over the actions at the move.\n", + "\n", + "The first step in this game is that Alice is dealt a card which could be a King or Queen, each with probability 1/2.\n", + "\n", + "To simulate this in Gambit, we create a chance player move at the root node of the game." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fe80c64c", + "metadata": {}, + "outputs": [], + "source": [ + "g.append_move(\n", + " g.root,\n", + " player=g.players.chance,\n", + " actions=[\"King\", \"Queen\"] # By default, chance actions have equal probabilities\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "5cf73f0a", + "metadata": {}, + "source": [ + "## Information sets\n", + "\n", + "In this game, information structure is important.\n", + "Alice knows her card, so the two nodes at which she has the move are part of different **information sets**.\n", + "\n", + "We'll therefore need to append Alice's move separately for each of the root node's children, i.e. the scenarios where she has a King or a Queen.\n", + "Let's now add both of these possible moves." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0e3bb5ef", + "metadata": {}, + "outputs": [], + "source": [ + "for node in g.root.children:\n", + " g.append_move(\n", + " node,\n", + " player=\"Alice\",\n", + " actions=[\"Raise\", \"Fold\"]\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "4c8d0343", + "metadata": {}, + "source": [ + "The loop above causes each of the newly-appended moves to be in new information sets, reflecting the fact that Alice's decision depends on the knowledge of which card she holds.\n", + "\n", + "In contrast, Bob does not know Alice’s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", + "\n", + " - Chance player chooses King, then Alice Raises: `g.root.children[\"King\"].children[\"Raise\"]`\n", + " - Chance player chooses Queen, then Alice Raises: `g.root.children[\"Queen\"].children[\"Raise\"]`\n", + "\n", + "In other words, Bob's decision when Alice raises with a Queen should be part of the same information set as Bob's decision when Alice raises with a King.\n", + "\n", + "To set this scenario up in Gambit, we'll need to use `Game.append_infoset` to add a move as part of an existing information set (represented in Gambit as an `Infoset`).\n", + "\n", + "First, let's add Bob's move to the node where Alice has raised with a King." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "dbfa7035", + "metadata": {}, + "outputs": [], + "source": [ + "g.append_move(\n", + " g.root.children[\"King\"].children[\"Raise\"],\n", + " player=\"Bob\",\n", + " actions=[\"Meet\", \"Pass\"]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "689ce12c", + "metadata": {}, + "source": [ + "Now let's add the information set we created at the node where Alice raised with a King, to the node where Alice raised with a Queen." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "655cdae3", + "metadata": {}, + "outputs": [], + "source": [ + "g.append_infoset(\n", + " g.root.children[\"Queen\"].children[\"Raise\"],\n", + " infoset=g.root.children[\"King\"].children[\"Raise\"].infoset\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "c4eeb65f", + "metadata": {}, + "source": [ + "In game theory terms, this creates \"imperfect information\".\n", + "Bob cannot distinguish between these two nodes in the game tree, so he must use the same strategy (same probabilities for Meet vs. Pass) in both situations.\n", + "\n", + "This is crucial in games where players must make decisions without complete knowledge of their opponents' private information.\n", + "\n", + "Let's now set up the four possible payoff outcomes for the game." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "87c988be", + "metadata": {}, + "outputs": [], + "source": [ + "alice_winsbig = g.add_outcome([2, -2], label=\"Alice wins big\")\n", + "alice_wins = g.add_outcome([1, -1], label=\"Alice wins\")\n", + "bob_winsbig = g.add_outcome([-2, 2], label=\"Bob wins big\")\n", + "bob_wins = g.add_outcome([-1, 1], label=\"Bob wins\")" + ] + }, + { + "cell_type": "markdown", + "id": "467a2c39", + "metadata": {}, + "source": [ + "Finally, we should assign an outcome to each of the terminal nodes in the game tree." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "29aa60a0", + "metadata": {}, + "outputs": [], + "source": [ + "# Alice folds, Bob wins small\n", + "g.set_outcome(g.root.children[\"King\"].children[\"Fold\"], bob_wins)\n", + "g.set_outcome(g.root.children[\"Queen\"].children[\"Fold\"], bob_wins)\n", + "\n", + "# Bob sees Alice raise and calls, correctly believing she is bluffing, Bob wins big\n", + "g.set_outcome(g.root.children[\"Queen\"].children[\"Raise\"].children[\"Meet\"], bob_winsbig)\n", + "\n", + "# Bob sees Alice raise and calls, incorrectly believing she is bluffing, Alice wins big\n", + "g.set_outcome(g.root.children[\"King\"].children[\"Raise\"].children[\"Meet\"], alice_winsbig)\n", + "\n", + "# Bob does not call Alice's raise, Alice wins small\n", + "g.set_outcome(g.root.children[\"King\"].children[\"Raise\"].children[\"Pass\"], alice_wins)\n", + "g.set_outcome(g.root.children[\"Queen\"].children[\"Raise\"].children[\"Pass\"], alice_wins)" + ] + }, + { + "cell_type": "markdown", + "id": "17eb6af5", + "metadata": {}, + "source": [ + "## Computing and interpreting Nash equilibria\n", + "\n", + "\n", + "Since our one-card poker game is extensive form and has two players, we can use the `lcp_solve` algorithm in Gambit to compute the Nash equilibria." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4d92c8d9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "NashComputationResult(method='lcp', rational=True, use_strategic=False, equilibria=[[[[Rational(1, 1), Rational(0, 1)], [Rational(1, 3), Rational(2, 3)]], [[Rational(2, 3), Rational(1, 3)]]]], parameters={'stop_after': 0, 'max_depth': 0})" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = gbt.nash.lcp_solve(g)\n", + "result" + ] + }, + { + "cell_type": "markdown", + "id": "e5946077", + "metadata": {}, + "source": [ + "The result of the calculation is returned as a `NashComputationResult` object.\n", + "\n", + "The set of equilibria found is reported in `NashComputationResult.equilibria`; in this case, this is a list of `MixedBehaviorProfile`'s.\n", + "\n", + "For one-card poker, we expect to find a single equilibrium (one `MixedBehaviorProfile`):" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "9967d6f7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Number of equilibria found: 1\n" + ] + } + ], + "source": [ + "print(\"Number of equilibria found:\", len(result.equilibria))\n", + "eqm = result.equilibria[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "3293e818", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "pygambit.gambit.MixedBehaviorProfileRational" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# MixedBehaviorProfileRational is a subclass of MixedBehaviorProfile that uses\n", + "# rational numbers for probabilities.\n", + "type(eqm)" + ] + }, + { + "cell_type": "markdown", + "id": "69f67b5b", + "metadata": {}, + "source": [ + "A mixed behavior profile specifies, for each information set, the probability distribution over actions at that information set.\n", + "\n", + "Indexing a mixed behaviour profile by a player gives a `MixedBehavior`, which specifies probability distributions at each of the player's information sets:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "4cf38264", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "pygambit.gambit.MixedBehavior" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "type(eqm[\"Alice\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "85e7fdda", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[1,0\\right],\\left[\\frac{1}{3},\\frac{2}{3}\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(1, 1), Rational(0, 1)], [Rational(1, 3), Rational(2, 3)]]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "eqm[\"Alice\"]" + ] + }, + { + "cell_type": "markdown", + "id": "6615115d", + "metadata": {}, + "source": [ + "In this case, at Alice's first information set, the one at which she has the King, she always raises.\n", + "\n", + "At her second information set, where she has the Queen, she sometimes bluffs, raising with probability one-third.\n", + "\n", + "The probability distribution at an information set is represented by a `MixedAction`.\n", + "\n", + "`MixedBehavior.mixed_actions` iterates over these for the player:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "f45a82b6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "At information set 0, Alice plays Raise with probability: 1 and Fold with probability: 0\n", + "At information set 1, Alice plays Raise with probability: 1/3 and Fold with probability: 2/3\n" + ] + } + ], + "source": [ + "for infoset, mixed_action in eqm[\"Alice\"].mixed_actions():\n", + " print(\n", + " f\"At information set {infoset.number}, \"\n", + " f\"Alice plays Raise with probability: {mixed_action['Raise']}\"\n", + " f\" and Fold with probability: {mixed_action['Fold']}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "9eeae046", + "metadata": {}, + "source": [ + "We can alternatively iterate through each of a player's actions like so:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "83bbd3e5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "At information set 0, Alice plays Raise with probability: 1\n", + "At information set 0, Alice plays Fold with probability: 0\n", + "At information set 1, Alice plays Raise with probability: 1/3\n", + "At information set 1, Alice plays Fold with probability: 2/3\n" + ] + } + ], + "source": [ + "for action in g.players[\"Alice\"].actions:\n", + " print(\n", + " f\"At information set {action.infoset.number}, \"\n", + " f\"Alice plays {action.label} with probability: {eqm[action]}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "1f121d48", + "metadata": {}, + "source": [ + "Now let's look at Bob’s strategy:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "6bf51b38", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\left[\\left[\\frac{2}{3},\\frac{1}{3}\\right]\\right]$" + ], + "text/plain": [ + "[[Rational(2, 3), Rational(1, 3)]]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "eqm[\"Bob\"]" + ] + }, + { + "cell_type": "markdown", + "id": "e906c4c4", + "metadata": {}, + "source": [ + "Bob meets Alice’s raise two-thirds of the time.\n", + "The label “Raise” is used in more than one information set for Alice, so in the above we had to specify information sets when indexing.\n", + "\n", + "When there is no ambiguity, we can specify action labels directly.\n", + "So for example, because Bob has only one action named “Meet” in the game, we can extract the probability that Bob plays “Meet” by:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "2966e700", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\frac{2}{3}$" + ], + "text/plain": [ + "Rational(2, 3)" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "eqm[\"Bob\"][\"Meet\"]" + ] + }, + { + "cell_type": "markdown", + "id": "2ec69f8c", + "metadata": {}, + "source": [ + "Moreover, this is the only action with that label in the game, so we can index the profile directly using the action label without any ambiguity:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "f5a7f110", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\frac{2}{3}$" + ], + "text/plain": [ + "Rational(2, 3)" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "eqm[\"Meet\"]" + ] + }, + { + "cell_type": "markdown", + "id": "db19411b", + "metadata": {}, + "source": [ + "Because this is an equilibrium, Bob is indifferent between the two actions at his information set, meaning he has no reason to prefer one action over the other, given Alice's expected strategy.\n", + "\n", + "`MixedBehaviorProfile.action_value` returns the expected payoff of taking an action, conditional on reaching that action's information set:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "a7d3816d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "When Bob plays Meet his expected payoff is -1\n", + "When Bob plays Pass his expected payoff is -1\n" + ] + } + ], + "source": [ + "# Remember that Bob has a single information set\n", + "for action in g.players[\"Bob\"].infosets[0].actions:\n", + " print(\n", + " f\"When Bob plays {action.label} his expected payoff is {eqm.action_value(action)}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "6491fdda", + "metadata": {}, + "source": [ + "Bob's indifference between his actions arises because of his beliefs given Alice's strategy.\n", + "\n", + "`MixedBehaviorProfile.belief` returns the probability of reaching a node, conditional on its information set being reached.\n", + "\n", + "Recall that the two nodes in Bob's only information set are `g.root.children[\"King\"].children[\"Raise\"]` and `g.root.children[\"Queen\"].children[\"Raise\"]`):" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "4a54b20c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Bob's belief in reaching the King -> Raise node is: 3/4\n", + "Bob's belief in reaching the Queen -> Raise node is: 1/4\n" + ] + } + ], + "source": [ + "for node in g.players[\"Bob\"].infosets[0].members:\n", + " print(\n", + " f\"Bob's belief in reaching the {node.parent.prior_action.label} -> \"\n", + " f\"{node.prior_action.label} node is: {eqm.belief(node)}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "351bb3ce", + "metadata": {}, + "source": [ + "Bob believes that, conditional on Alice raising, there's a 3/4 chance that she has the King; therefore, the expected payoff to meeting is in fact -1 as computed.\n", + "\n", + "`MixedBehaviorProfile.infoset_prob` returns the probability that an information set is reached:" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "b250c1cd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\frac{2}{3}$" + ], + "text/plain": [ + "Rational(2, 3)" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "eqm.infoset_prob(g.players[\"Bob\"].infosets[0])" + ] + }, + { + "cell_type": "markdown", + "id": "9216ea34", + "metadata": {}, + "source": [ + "The corresponding probability that a node is reached in the play of the game is given by `MixedBehaviorProfile.realiz_prob`, and the expected payoff to a player conditional on reaching a node is given by `MixedBehaviorProfile.node_value`." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "6f01846b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The probability that the node King -> Raise is reached is: 1/2. Bob's expected payoff conditional on reaching this node is -5/3\n", + "The probability that the node Queen -> Raise is reached is: 1/6. Bob's expected payoff conditional on reaching this node is 1\n" + ] + } + ], + "source": [ + "for node in g.players[\"Bob\"].infosets[0].members:\n", + " print(\n", + " f\"The probability that the node {node.parent.prior_action.label} -> \"\n", + " f\"{node.prior_action.label} is reached is: {eqm.realiz_prob(node)}. \",\n", + " f\"Bob's expected payoff conditional on reaching this node is {eqm.node_value('Bob', node)}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "5ba0c241", + "metadata": {}, + "source": [ + "The overall expected payoff to a player given the behavior profile is returned by `MixedBehaviorProfile.payoff`:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "5079d231", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\frac{1}{3}$" + ], + "text/plain": [ + "Rational(1, 3)" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "eqm.payoff(\"Alice\")" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "c55f2c7a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\frac{-1}{3}$" + ], + "text/plain": [ + "Rational(-1, 3)" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "eqm.payoff(\"Bob\")" + ] + }, + { + "cell_type": "markdown", + "id": "26d5e8ff", + "metadata": {}, + "source": [ + "The equilibrium computed expresses probabilities in rational numbers.\n", + "\n", + "Because the numerical data of games in Gambit [are represented exactly](#representation-of-numerical-data-of-a-game), methods which are specialized to two-player games, `lp_solve`, `lcp_solve`, and `enummixed_solve`, can report exact probabilities for equilibrium strategy profiles.\n", + "\n", + "This is enabled by default for these methods.\n", + "\n", + "When a game has an extensive representation, equilibrium finding methods default to computing on that representation.\n", + "It is also possible to compute using the strategic representation.\n", + "`pygambit` transparently computes the reduced strategic form representation of an extensive game." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "d4ecff88", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['11', '12', '21', '22']" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "[s.label for s in g.players[\"Alice\"].strategies]" + ] + }, + { + "cell_type": "markdown", + "id": "a9bf9b73", + "metadata": {}, + "source": [ + "In the strategic form of this game, Alice has four strategies.\n", + "\n", + "The generated strategy labels list the action numbers taken at each information set.\n", + "For example, label '11' refers to the strategy gets dealt the King, then raises.\n", + "\n", + "We can therefore apply a method which operates on a strategic game to any game with an extensive representation." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "24e4b6e8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "NashComputationResult(method='gnm', rational=False, use_strategic=True, equilibria=[[[0.33333333333866677, 0.6666666666613335, 0.0, 0.0], [0.6666666666559997, 0.3333333333440004]]], parameters={'perturbation': [[1.0, 0.0, 0.0, 0.0], [1.0, 0.0]], 'end_lambda': -10.0, 'steps': 100, 'local_newton_interval': 3, 'local_newton_maxits': 10})" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gnm_result = gbt.nash.gnm_solve(g)\n", + "gnm_result" + ] + }, + { + "cell_type": "markdown", + "id": "d88b736b", + "metadata": {}, + "source": [ + "`gnm_solve` can be applied to any game with any number of players, and uses a path-following process in floating-point arithmetic, so it returns profiles with probabilities expressed as floating-point numbers.\n", + "\n", + "This method operates on the strategic representation of the game, so the returned results are of type `MixedStrategyProfile` (specifically `MixedStrategyProfileDouble`)." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "d9ffb4b8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "pygambit.gambit.MixedStrategyProfileDouble" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gnm_eqm = gnm_result.equilibria[0]\n", + "type(gnm_eqm)" + ] + }, + { + "cell_type": "markdown", + "id": "102d22c2", + "metadata": {}, + "source": [ + "Indexing a `MixedStrategyProfile` by a player gives the probability distribution over that player's strategies only.\n", + "\n", + "The expected payoff to a strategy is provided by `MixedStrategyProfile.strategy_value` and the overall expected payoff to a player is returned by `MixedStrategyProfile.payoff`:" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "56e2f847", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Alice's expected payoffs playing:\n", + "Strategy 11: 0.3333\n", + "Strategy 12: 0.3333\n", + "Strategy 21: -1.0000\n", + "Strategy 22: -1.0000\n", + "Alice's overall expected payoff: 0.3333\n", + "\n", + "Bob's expected payoffs playing:\n", + "Strategy 1: -0.3333\n", + "Strategy 2: -0.3333\n", + "Bob's overall expected payoff: -0.3333\n", + "\n" + ] + } + ], + "source": [ + "for player in g.players:\n", + " print(\n", + " f\"{player.label}'s expected payoffs playing:\"\n", + " )\n", + " for strategy in player.strategies:\n", + " print(\n", + " f\"Strategy {strategy.label}: {gnm_eqm.strategy_value(strategy):.4f}\"\n", + " )\n", + " print(\n", + " f\"{player.label}'s overall expected payoff: {gnm_eqm.payoff(player):.4f}\"\n", + " )\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "id": "874be231", + "metadata": {}, + "source": [ + "When a game has an extensive representation, we can convert freely between a mixed strategy profile and the corresponding mixed behaviour profile representation of the same strategies using `MixedStrategyProfile.as_behavior` and `MixedBehaviorProfile.as_strategy`.\n", + "\n", + "- A mixed **strategy** profile maps each strategy in a game to the corresponding probability with which that strategy is played.\n", + "- A mixed **behaviour** profile maps each action at each information set in a game to the corresponding probability with which the action is played, conditional on that information set being reached.\n", + "\n", + "Let's convert the equilibrium we found using `gnm_solve` to a mixed behaviour profile and iterate through the players actions to show their expected payoffs, comparing as we go with the payoffs found by `lcp_solve`:" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "d18a91f0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Alice's expected payoffs:\n", + "At information set 0, when playing Raise - gnm: 1.6667, lcp: 1.6667\n", + "At information set 0, when playing Fold - gnm: -1.0000, lcp: -1.0000\n", + "At information set 1, when playing Raise - gnm: -1.0000, lcp: -1.0000\n", + "At information set 1, when playing Fold - gnm: -1.0000, lcp: -1.0000\n", + "\n", + "Bob's expected payoffs:\n", + "At information set 0, when playing Meet - gnm: -1.0000, lcp: -1.0000\n", + "At information set 0, when playing Pass - gnm: -1.0000, lcp: -1.0000\n", + "\n" + ] + } + ], + "source": [ + "for player in g.players:\n", + " print(\n", + " f\"{player.label}'s expected payoffs:\"\n", + " )\n", + " for action in player.actions:\n", + " print(\n", + " f\"At information set {action.infoset.number}, \"\n", + " f\"when playing {action.label} - \"\n", + " f\"gnm: {gnm_eqm.as_behavior().action_value(action):.4f}\"\n", + " f\", lcp: {eqm.action_value(action):.4f}\"\n", + " )\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "id": "b2867dca", + "metadata": {}, + "source": [ + "Acceptance criteria for Nash equilibria\n", + "---------------------------------------\n", + "\n", + "Some methods for computing Nash equilibria operate using floating-point arithmetic and/or generate candidate equilibrium profiles using methods which involve some form of successive approximations.\n", + "The outputs of these methods therefore are in general $\\varepsilon$-equilibria, for some positive $\\varepsilon$.\n", + "\n", + "$\\varepsilon$-equilibria (from [Wikipedia](https://en.wikipedia.org/wiki/Epsilon-equilibrium)):\n", + "\n", + "> In game theory, an epsilon-equilibrium, or near-Nash equilibrium, is a strategy profile that approximately satisfies the condition of Nash equilibrium. In a Nash equilibrium, no player has an incentive to change his behavior. In an approximate Nash equilibrium, this requirement is weakened to allow the possibility that a player may have a small incentive to do something different.\n", + "\n", + "> Given a game and a real non-negative parameter $\\varepsilon$, a strategy profile is said to be an $\\varepsilon$-equilibrium if it is not possible for any player to gain more than $\\varepsilon$ in expected payoff by unilaterally deviating from his strategy. Every Nash Equilibrium is equivalent to an $\\varepsilon$-equilibrium where $\\varepsilon = 0$.\n", + "\n", + "\n", + "To provide a uniform interface across methods, where relevant Gambit provides a parameter\n", + "`maxregret`, which specifies the acceptance criterion for labeling the output of the\n", + "algorithm as an equilibrium.\n", + "This parameter is interpreted *proportionally* to the range of payoffs in the game.\n", + "Any profile returned as an equilibrium is guaranteed to be an $\\varepsilon$-equilibrium, for $\\varepsilon$ no more than `maxregret`\n", + "times the difference of the game's maximum and minimum payoffs.\n", + "\n", + "As an example, consider solving our one-card poker game using `logit_solve`. The range of the payoffs in this game is 4 (from +2 to -2).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "0c55f745", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(Rational(2, 1), Rational(-2, 1))" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g.max_payoff, g.min_payoff" + ] + }, + { + "cell_type": "markdown", + "id": "6263ad6e", + "metadata": {}, + "source": [ + "`logit_solve` is a globally-convergent method, in that it computes a sequence of profiles which is guaranteed to have a subsequence that converges to a\n", + "Nash equilibrium.\n", + "\n", + "The default value of `maxregret` for this method is set at $10^{-8}$:" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "101598c6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 32, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "logit_solve_result = gbt.nash.logit_solve(g, maxregret=1e-8)\n", + "len(logit_solve_result.equilibria)" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "9b142728", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "3.987411578698641e-08" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ls_eqm = logit_solve_result.equilibria[0]\n", + "ls_eqm.max_regret()" + ] + }, + { + "cell_type": "markdown", + "id": "a2ba06c4", + "metadata": {}, + "source": [ + "The value of `MixedBehaviorProfile.max_regret` of the computed profile exceeds $10^{-8}$ measured in payoffs of the game.\n", + "However, when considered relative to the scale of the game's payoffs, we see it is less than $10^{-8}$ of the payoff range, as requested:" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "ff405409", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "9.968528946746602e-09" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ls_eqm.max_regret() / (g.max_payoff - g.min_payoff)" + ] + }, + { + "cell_type": "markdown", + "id": "54635455", + "metadata": {}, + "source": [ + "In general, for globally-convergent methods especially, there is a tradeoff between precision and running time.\n", + "\n", + "We could instead ask only for an $\\varepsilon$-equilibrium with a (scaled) $\\varepsilon$ of no more than $10^{-4}$:" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "31b0143c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "9.395259956013202e-05" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.nash.logit_solve(g, maxregret=1e-4).equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)" + ] + }, + { + "cell_type": "markdown", + "id": "dc8c8509", + "metadata": {}, + "source": [ + "The tradeoff comes from some methods being slow to converge on some games, making it useful instead to get a more coarse approximation to an equilibrium (higher `maxregret` value) which is faster to calculate. " + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "7cfba34a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 2.84 ms, sys: 19 μs, total: 2.86 ms\n", + "Wall time: 2.86 ms\n" + ] + }, + { + "data": { + "text/plain": [ + "NashComputationResult(method='logit', rational=False, use_strategic=False, equilibria=[[[[1.0, 0.0], [0.3338351656285655, 0.666164834417892]], [[0.6670407651644307, 0.3329592348608147]]]], parameters={'first_step': 0.03, 'max_accel': 1.1})" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%time\n", + "gbt.nash.logit_solve(g, maxregret=1e-4)" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "6f1809a7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 5.51 ms, sys: 72 μs, total: 5.58 ms\n", + "Wall time: 5.59 ms\n" + ] + }, + { + "data": { + "text/plain": [ + "NashComputationResult(method='logit', rational=False, use_strategic=False, equilibria=[[[[1.0, 0.0], [0.33333338649882943, 0.6666666135011706]], [[0.6666667065407631, 0.3333332934592369]]]], parameters={'first_step': 0.03, 'max_accel': 1.1})" + ] + }, + "execution_count": 37, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%time\n", + "gbt.nash.logit_solve(g, maxregret=1e-8)" + ] + }, + { + "cell_type": "markdown", + "id": "76461069", + "metadata": {}, + "source": [ + "The convention of expressing `maxregret` scaled by the game's payoffs standardises the behavior of methods across games.\n", + "\n", + "For example, consider solving the poker game instead using `liap_solve()`." + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "id": "414b6f65", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "5.5099518433632255e-05" + ] + }, + "execution_count": 38, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(\n", + " gbt.nash.liap_solve(g.mixed_behavior_profile(), maxregret=1.0e-4)\n", + " .equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "c6853432", + "metadata": {}, + "source": [ + "If, instead, we double all payoffs, the output of the method is unchanged." + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "a892dc2b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "5.5099518433632255e-05" + ] + }, + "execution_count": 39, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "for outcome in g.outcomes:\n", + " outcome[\"Alice\"] = outcome[\"Alice\"] * 2\n", + " outcome[\"Bob\"] = outcome[\"Bob\"] * 2\n", + "\n", + "(\n", + " gbt.nash.liap_solve(g.mixed_behavior_profile(), maxregret=1.0e-4)\n", + " .equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "5f1f66e0", + "metadata": {}, + "source": [ + "## Representation of numerical data of a game\n", + "\n", + "Payoffs to players and probabilities of actions at chance information sets are specified as numbers.\n", + "Gambit represents the numerical values in a game in exact precision, using either decimal or rational representations.\n", + "\n", + "To illustrate, consider a trivial game which just has one move for the chance player:" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "2f79695a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[Rational(1, 3), Rational(1, 3), Rational(1, 3)]" + ] + }, + "execution_count": 40, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "small_game = gbt.Game.new_tree()\n", + "small_game.append_move(small_game.root, small_game.players.chance, [\"a\", \"b\", \"c\"])\n", + "[act.prob for act in small_game.root.infoset.actions]" + ] + }, + { + "cell_type": "markdown", + "id": "dc4522b5", + "metadata": {}, + "source": [ + "The default when creating a new move for chance is that all actions are chosen with equal probability.\n", + "These probabilities are represented as rational numbers, using `pygambit`'s `Rational` class, which is derived from Python's `fractions.Fraction`.\n", + "\n", + "Numerical data can be set as rational numbers. Here we update the chance action probabilities with `Rational` numbers:" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "5de6acb2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[Rational(1, 4), Rational(1, 2), Rational(1, 4)]" + ] + }, + "execution_count": 41, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "small_game.set_chance_probs(\n", + " small_game.root.infoset,\n", + " [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n", + ")\n", + "[act.prob for act in small_game.root.infoset.actions]" + ] + }, + { + "cell_type": "markdown", + "id": "23263b21", + "metadata": {}, + "source": [ + "Numerical data can also be explicitly specified as decimal numbers:" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "c47d2ab6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[Decimal('0.25'), Decimal('0.50'), Decimal('0.25')]" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "small_game.set_chance_probs(\n", + " small_game.root.infoset,\n", + " [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n", + ")\n", + "[act.prob for act in small_game.root.infoset.actions]" + ] + }, + { + "cell_type": "markdown", + "id": "bffda303", + "metadata": {}, + "source": [ + "Although the two representations above are mathematically equivalent, `pygambit` remembers the format in which the values were specified.\n", + "\n", + "Expressing rational or decimal numbers as above is verbose and tedious.\n", + "`pygambit` offers a more concise way to express numerical data in games: when setting numerical game data, `pygambit` will attempt to convert text strings to their rational or decimal representation.\n", + "The above can therefore be written more compactly using string representations:" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "04329084", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[Rational(1, 4), Rational(1, 2), Rational(1, 4)]" + ] + }, + "execution_count": 43, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "small_game.set_chance_probs(small_game.root.infoset, [\"1/4\", \"1/2\", \"1/4\"])\n", + "[act.prob for act in small_game.root.infoset.actions]" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "id": "9015e129", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[Decimal('0.25'), Decimal('0.50'), Decimal('0.25')]" + ] + }, + "execution_count": 44, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "small_game.set_chance_probs(small_game.root.infoset, [\".25\", \".50\", \".25\"])\n", + "[act.prob for act in small_game.root.infoset.actions]" + ] + }, + { + "cell_type": "markdown", + "id": "9f22d40d", + "metadata": {}, + "source": [ + "As a further convenience, `pygambit` will accept Python `int` and `float` values.\n", + "`int` values are always interpreted as `Rational` values.\n", + "\n", + "`pygambit` attempts to render `float` values in an appropriate `Decimal` equivalent.\n", + "In the majority of cases, this creates no problems.\n", + "For example," + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "id": "0a019aa5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[Decimal('0.25'), Decimal('0.5'), Decimal('0.25')]" + ] + }, + "execution_count": 45, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "small_game.set_chance_probs(small_game.root.infoset, [.25, .50, .25])\n", + "[act.prob for act in small_game.root.infoset.actions]" + ] + }, + { + "cell_type": "markdown", + "id": "d53adcd4", + "metadata": {}, + "source": [ + "However, rounding can cause difficulties when attempting to use `float` values to represent values which do not have an exact decimal representation" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "id": "1991d288", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ValueError: set_chance_probs(): must specify non-negative probabilities that sum to one\n" + ] + } + ], + "source": [ + "try:\n", + " small_game.set_chance_probs(small_game.root.infoset, [1/3, 1/3, 1/3])\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)\n" + ] + }, + { + "cell_type": "markdown", + "id": "89fefd34", + "metadata": {}, + "source": [ + "This behavior can be slightly surprising, especially in light of the fact that\n", + "in Python," + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "id": "b1dc37fd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1.0" + ] + }, + "execution_count": 47, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "1/3 + 1/3 + 1/3" + ] + }, + { + "cell_type": "markdown", + "id": "a06699af", + "metadata": {}, + "source": [ + "In checking whether these probabilities sum to one, `pygambit` first converts each of the probabilities to a `Decimal` representation, via the following method" + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "id": "dc1edea2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Decimal('0.3333333333333333')" + ] + }, + "execution_count": 48, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.Decimal(str(1/3))" + ] + }, + { + "cell_type": "markdown", + "id": "4bfff415", + "metadata": {}, + "source": [ + "and the sum-to-one check then fails because" + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "id": "1edd90d6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Decimal('0.9999999999999999')" + ] + }, + "execution_count": 49, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gbt.Decimal(str(1/3)) + gbt.Decimal(str(1/3)) + gbt.Decimal(str(1/3))" + ] + }, + { + "cell_type": "markdown", + "id": "5208b7a4", + "metadata": {}, + "source": [ + "Setting payoffs for players also follows the same rules.\n", + "Representing probabilities and payoffs exactly is essential, because `pygambit` offers (in particular for two-player games) the possibility of computation of equilibria exactly, because the Nash equilibria of any two-player game with rational payoffs and chance probabilities can be expressed exactly in terms of rational numbers.\n", + "\n", + "It is therefore advisable always to specify the numerical data of games either in terms of `Decimal` or `Rational` values, or their string equivalents.\n", + "It is safe to use `int` values, but `float` values should be used with some care to ensure the values are recorded as intended." + ] + }, + { + "cell_type": "markdown", + "id": "65def67e", + "metadata": {}, + "source": [ + "#### References\n", + "\n", + "Myerson, Roger B. (1991) *Game Theory: Analysis of Conflict*. Cambridge: Harvard University Press.\n", + "\n", + "Reiley, David H., Michael B. Urbancic and Mark Walker. (2008) \"Stripped-down poker: A classroom game with signaling and bluffing.\" *The Journal of Economic Education* 39(4): 323-341." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/tutorials/interoperability_tutorials/gamut.ipynb b/doc/tutorials/interoperability_tutorials/gamut.ipynb index be8088061..17d4154f0 100644 --- a/doc/tutorials/interoperability_tutorials/gamut.ipynb +++ b/doc/tutorials/interoperability_tutorials/gamut.ipynb @@ -4,22 +4,7 @@ "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" - ] + "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\nPyGambit'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_generators.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\nThis 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" }, { "cell_type": "code", diff --git a/pyproject.toml b/pyproject.toml index b3c9e30c4..6826e78f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,7 +110,7 @@ max-line-length = 99 [tool.pytest.ini_options] addopts = "--strict-markers" -pythonpath = ["build_support/catalog", "build_support/releases"] +pythonpath = ["catalog", "build_support/releases"] markers = [ "nash_enumpure_strategy: tests of enumpure_solve in pure strategies", "nash_enumpure_agent: tests of enumpure_solve in pure behaviors", @@ -134,7 +134,7 @@ markers = [ "qre_logit_branch: tests of logit_solve_branch", "nash: all tests of Nash equilibrium solvers", "slow: all time-consuming tests", - "catalog_update: tests of build_support/catalog/update.py", + "catalog_update: tests of catalog/build.py", ] [tool.setuptools] diff --git a/setup.py b/setup.py index 35a70b841..f0cc7f366 100644 --- a/setup.py +++ b/setup.py @@ -84,7 +84,7 @@ class GambitBuildPy(setuptools.command.build_py.build_py): def run(self) -> None: super().run() - catalog_source = pathlib.Path("catalog") + catalog_source = pathlib.Path("catalog") / "games" catalog_target = pathlib.Path(self.build_lib) / "pygambit/catalog_data" if catalog_target.exists(): shutil.rmtree(catalog_target) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index afdb0e357..253ecceb7 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -11,13 +11,14 @@ import pygambit as gbt -# Use the full string path to where the catalog data are placed in the package -_CATALOG_RESOURCE = files("pygambit") / "catalog_data" -# This ensures that catalog files are included in editable installs too -if not _CATALOG_RESOURCE.is_dir(): - _repo_catalog = Path(__file__).parent.parent.parent / "catalog" - if _repo_catalog.is_dir(): - _CATALOG_RESOURCE = _repo_catalog +# Prefer a co-located repo checkout's catalog (editable/dev installs) over any packaged +# catalog_data. A prior non-editable install in the same environment can leave a stale +# catalog_data directory in site-packages that nothing else would clean up; checking the repo +# path first means it's never even consulted when a checkout is present (gambit#920). +_repo_catalog = Path(__file__).parent.parent.parent / "catalog" / "games" +_CATALOG_RESOURCE = ( + _repo_catalog if _repo_catalog.is_dir() else files("pygambit") / "catalog_data" +) READERS = { ".nfg": gbt.read_nfg, @@ -467,7 +468,7 @@ def append_record( if include_descriptions: record["Description"] = game.description ext = "efg" if game.is_tree else "nfg" - record["Download"] = f":download:`{slug}.{ext} <../catalog/{slug}.{ext}>`" + record["Download"] = f":download:`{slug}.{ext} <../games/{slug}.{ext}>`" record["Format"] = ext records.append(record) From a13dbd3fb1bcf43b3464502f2873687682b48fb6 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 10 Sep 2026 12:03:29 +0100 Subject: [PATCH 2/2] Package catalog as part of GUI builds and add support for loading games from the catalog --- .github/workflows/lint.yml | 10 +- .github/workflows/tools.yml | 8 +- Makefile.am | 28 +- build_support/msw/gambit.wxs.in | 9 +- catalog/build.py | 138 ++++++++- catalog/catalog.am | 3 +- catalog/doc/developer.rst | 7 +- catalog/games/manifest.json | 522 ++++++++++++++++++++++++++++++++ catalog/test_build.py | 234 ++++++++++++++ pyproject.toml | 1 + src/gui/app.cc | 5 + src/gui/catalogdata.cc | 474 +++++++++++++++++++++++++++++ src/gui/catalogdata.h | 67 ++++ src/gui/dlcatalog.cc | 247 +++++++++++++++ src/gui/dlcatalog.h | 80 +++++ src/gui/gameframe.cc | 12 + src/gui/gameframe.h | 1 + src/gui/menuconst.h | 1 + src/gui/welcome.cc | 47 ++- src/gui/welcome.h | 6 + 20 files changed, 1880 insertions(+), 20 deletions(-) create mode 100644 catalog/games/manifest.json create mode 100644 src/gui/catalogdata.cc create mode 100644 src/gui/catalogdata.h create mode 100644 src/gui/dlcatalog.cc create mode 100644 src/gui/dlcatalog.h diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 48f092c06..cbec188bc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -65,7 +65,7 @@ jobs: run: cython-lint . catalog-am-sync: - name: catalog.am is up to date + name: catalog.am and manifest.json are up to date runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: @@ -78,11 +78,11 @@ jobs: run: | python -m pip install -v -e ".[doc]" pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" - - name: Regenerate catalog/catalog.am + - name: Regenerate catalog/catalog.am and catalog/games/manifest.json run: python catalog/build.py --build --skip-table - - name: Fail if catalog.am is out of sync with catalog/games + - name: Fail if catalog.am or manifest.json is out of sync with catalog/games run: | - if ! git diff --exit-code -- catalog/catalog.am; then - echo "::error::catalog/catalog.am is out of sync with catalog/games. Run 'python catalog/build.py --build' and commit the result." + if ! git diff --exit-code -- catalog/catalog.am catalog/games/manifest.json; then + echo "::error::catalog/catalog.am or catalog/games/manifest.json is out of sync with catalog/games. Run 'python catalog/build.py --build' and commit the result." exit 1 fi diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml index 39fad82e8..47eb1d13b 100644 --- a/.github/workflows/tools.yml +++ b/.github/workflows/tools.yml @@ -66,8 +66,12 @@ jobs: run: | mkdir installer cp gambit* installer - "${WIX}bin/candle" -arch x64 build_support/msw/gambit.wxs - "${WIX}bin/light" -ext WixUIExtension gambit.wixobj + cp -r catalog/games installer/catalog + "${WIX}bin/heat" dir installer/catalog -cg CatalogFiles -gg -scom -sreg -sfrag -srd \ + -dr CATALOGFOLDER -var var.CatalogSourceDir -out build_support/msw/catalog.wxs + "${WIX}bin/candle" -arch x64 -dCatalogSourceDir=installer/catalog \ + build_support/msw/gambit.wxs build_support/msw/catalog.wxs + "${WIX}bin/light" -ext WixUIExtension -out gambit.msi gambit.wixobj catalog.wixobj - uses: actions/upload-artifact@v7 with: name: artifact-msw diff --git a/Makefile.am b/Makefile.am index f64b1b628..ea3603147 100644 --- a/Makefile.am +++ b/Makefile.am @@ -357,6 +357,16 @@ endif if WITH_GUI bin_PROGRAMS = gambit + +# Install the games catalog alongside the GUI so its catalog browser can find it via +# wxStandardPaths::GetDataDir() (see src/gui/catalogdata.cc). Only the GUI build needs +# this; a CLI-only build has nothing that reads it. +install-data-local: + $(MKDIR_P) $(DESTDIR)$(pkgdatadir)/catalog + cp -r $(top_srcdir)/catalog/games/. $(DESTDIR)$(pkgdatadir)/catalog/ + +uninstall-local: + rm -rf $(DESTDIR)$(pkgdatadir)/catalog endif # Define VERSION for all C++ compilations and set include paths @@ -386,12 +396,16 @@ gambit_SOURCES = \ src/gui/analysis.h \ src/gui/app.cc \ src/gui/app.h \ + src/gui/catalogdata.cc \ + src/gui/catalogdata.h \ src/gui/editlabel.cc \ src/gui/editlabel.h \ src/gui/edittext.cc \ src/gui/edittext.h \ src/gui/dlabout.cc \ src/gui/dlabout.h \ + src/gui/dlcatalog.cc \ + src/gui/dlcatalog.h \ src/gui/dleditmove.cc \ src/gui/dleditmove.h \ src/gui/dleditnode.cc \ @@ -454,7 +468,9 @@ gambit_SOURCES = \ # Add wxWidgets compilation flags for the GUI program gambit_CXXFLAGS = $(AM_CXXFLAGS) $(WX_CXXFLAGS) -gambit_CPPFLAGS = $(AM_CPPFLAGS) $(WX_CXXFLAGS) +# GAMBIT_SRCDIR lets the GUI find catalog/games in a developer's own checkout when +# running straight out of the build tree, without needing any packaging step. +gambit_CPPFLAGS = $(AM_CPPFLAGS) $(WX_CXXFLAGS) -DGAMBIT_SRCDIR=\"$(abs_top_srcdir)\" gambit_LDADD_LIBS = libbimatrix.a libliap.a libhomotopy.a libgtracer.a \ libsimpdiv.a libenumpoly.a libgames.a libcore.a @@ -479,6 +495,7 @@ osx-bundle: -mkdir Gambit.app/Contents/Resources cp build_support/osx/Info.plist Gambit.app/Contents cp ${top_srcdir}/build_support/osx/gambit.icns Gambit.app/Contents/Resources/gambit.icns + cp -r ${top_srcdir}/catalog/games Gambit.app/Contents/Resources/catalog echo -n 'APPL????' > Gambit.app/Contents/PkgInfo cp gambit Gambit.app/Contents/MacOS/gambit cp ${bin_PROGRAMS} Gambit.app/Contents/MacOS @@ -489,8 +506,13 @@ osx-dmg: osx-bundle msw-msi: -mkdir installer cp gambit* installer - candle build_support/msw/gambit.wxs - light -ext WixUIExtension gambit.wixobj + -rm -rf installer/catalog + cp -r ${top_srcdir}/catalog/games installer/catalog + heat dir installer/catalog -cg CatalogFiles -gg -scom -sreg -sfrag -srd \ + -dr CATALOGFOLDER -var var.CatalogSourceDir -out build_support/msw/catalog.wxs + candle -arch x64 -dCatalogSourceDir=installer/catalog \ + build_support/msw/gambit.wxs build_support/msw/catalog.wxs + light -ext WixUIExtension -out gambit.msi gambit.wixobj catalog.wixobj clang-tidy: clang-tidy ${top_srcdir}/src/core/*.cc ${top_srcdir}/src/games/*.cc ${top_srcdir}/src/games/*/*.cc ${top_srcdir}/src/solvers/*/*.cc -- --std=c++20 -I ${top_srcdir}/src -DVERSION=\"$(VERSION)\" -Wall -Wsign-compare -Wunreachable-code -Wextra-semi -Wdouble-promotion diff --git a/build_support/msw/gambit.wxs.in b/build_support/msw/gambit.wxs.in index f43976bd5..4503be759 100644 --- a/build_support/msw/gambit.wxs.in +++ b/build_support/msw/gambit.wxs.in @@ -68,7 +68,13 @@ - + + + + @@ -105,6 +111,7 @@ + diff --git a/catalog/build.py b/catalog/build.py index 2c8f22376..69732946b 100644 --- a/catalog/build.py +++ b/catalog/build.py @@ -1,10 +1,13 @@ import argparse +import json +import re import shutil import sys from contextlib import contextmanager from pathlib import Path import pandas as pd +import pybtex.database import yaml from gtdraw import pdf, png, svg, tex @@ -12,10 +15,13 @@ CATALOG_RST_TABLE = Path(__file__).parent / "doc" / "_table.rst" CATALOG_DIR = Path(__file__).parent / "games" +CATALOG_MANIFEST = Path(__file__).parent / "games" / "manifest.json" MAKEFILE_AM = Path(__file__).parent.parent / "Makefile.am" GTDRAW_SETTINGS_CONFIG = Path(__file__).parent / "gtdraw_settings.yaml" CATALOG_HIERARCHY_CONFIG = Path(__file__).parent / "hierarchy.yaml" +REFERENCES_BIB = Path(__file__).parent.parent / "doc" / "references.bib" SUPPORTED_GAME_FORMATS = {"efg", "nfg"} +_CITE_RE = re.compile(r":cite:p:`([^`]+)`") @contextmanager @@ -373,6 +379,10 @@ def update_makefile( if resource_path.is_file() and catalog_dir / "img" not in resource_path.parents: rel_path = resource_path.relative_to(catalog_dir) slugs.append(rel_path.as_posix()) + for resource_path in sorted(catalog_dir.rglob("*.json")): + if resource_path.is_file(): + rel_path = resource_path.relative_to(catalog_dir) + slugs.append(rel_path.as_posix()) game_files = [] for slug in slugs: @@ -400,6 +410,126 @@ def update_makefile( print(f"No changes to add to {str(am_path)}") +def _format_citation(entry: pybtex.database.Entry) -> str: + """Return a short "(Author Year)" citation string for a single bibtex entry.""" + persons = entry.persons.get("author", []) + last_names = [ + " ".join(str(part) for part in p.prelast_names + p.last_names) + for p in persons + if p.last_names + ] + if len(last_names) == 0: + author_text = "" + elif len(last_names) == 1: + author_text = last_names[0] + elif len(last_names) == 2: + author_text = f"{last_names[0]} and {last_names[1]}" + else: + author_text = f"{last_names[0]} et al." + year = entry.fields.get("year", "") + if author_text and year: + return f"({author_text} {year})" + return f"({author_text or year})" + + +def load_citation_texts(bib_path: Path | None = None) -> dict[str, str]: + """Return a mapping from bibtex citation key to a short "(Author Year)" citation string.""" + bib_path = bib_path or REFERENCES_BIB + bib_data = pybtex.database.parse_file(str(bib_path), bib_format="bibtex") + return {key: _format_citation(entry) for key, entry in bib_data.entries.items()} + + +def resolve_citations(description: str, citation_texts: dict[str, str]) -> str: + """Replace every ``:cite:p:`key``` occurrence in *description* with a short citation string. + + Falls back to ``(key)`` for a key not found in *citation_texts*, so a manifest build + never silently drops a citation just because the bib entry couldn't be resolved. + """ + return _CITE_RE.sub(lambda m: citation_texts.get(m.group(1), f"({m.group(1)})"), description) + + +def _hierarchy_breadcrumbs(slug: str, labels: dict[str, str]) -> list[str]: + """Return the human-readable label for each ancestor group of *slug*, root to leaf.""" + breadcrumbs = [] + prefix = "" + for part in slug.split("/")[:-1]: + prefix = f"{prefix}/{part}" if prefix else part + breadcrumbs.append(_node_label(prefix, labels)) + return breadcrumbs + + +def _game_stats(game: gbt.Game) -> dict: + """Return a subset of *game*'s structural attributes, for catalog-browser filtering.""" + return { + "n_players": len(game.players), + "is_tree": game.is_tree, + "is_const_sum": game.is_const_sum, + "n_strategies": sum(len(game.get_strategies(p)) for p in game.players), + } + + +def build_manifest( + catalog_dir: Path | None = None, + bib_path: Path | None = None, +) -> list[dict]: + """Build the GUI-facing catalog manifest. + + One entry per game, with a citation-resolved plain-text description, hierarchy + breadcrumbs (for display/filtering), and a subset of structural stats mirroring + :func:`pygambit.catalog.games`'s filter parameters. Does not require gtdraw or a LaTeX + toolchain — unlike :func:`generate_rst_table`, it never renders an image. + """ + catalog_dir = catalog_dir or CATALOG_DIR + df = _catalog_games(catalog_dir) + labels = load_hierarchy_labels() + citation_texts = load_citation_texts(bib_path) + + entries = [] + with _using_catalog_dir(catalog_dir): + for _, row in df.iterrows(): + if row.get("Format") not in SUPPORTED_GAME_FORMATS: + continue + slug = row["Game"] + description = str(row.get("Description", "")).strip() + if not description: + continue + game = gbt.catalog.load(slug) + breadcrumbs = _hierarchy_breadcrumbs(slug, labels) + entries.append( + { + "slug": slug, + "title": row["Title"], + "description": resolve_citations(description, citation_texts), + "format": row["Format"], + "category": breadcrumbs[0] if breadcrumbs else "", + "group": breadcrumbs[-1] if breadcrumbs else "", + "thumbnail": f"img/{slug}.png", + **_game_stats(game), + } + ) + entries.sort(key=lambda e: e["slug"]) + return entries + + +def write_manifest(entries: list[dict], manifest_path: Path | None = None) -> None: + """Write *entries* to *manifest_path* as JSON, only touching the file if content changed.""" + manifest_path = manifest_path or CATALOG_MANIFEST + updated_content = json.dumps(entries, indent=2, ensure_ascii=False) + "\n" + + if manifest_path.exists(): + with open(manifest_path, encoding="utf-8") as f: + content = f.read() + else: + content = "" + + if content != updated_content: + with open(manifest_path, "w", encoding="utf-8") as f: + f.write(updated_content) + print(f"Updated {str(manifest_path)}") + else: + print(f"No changes to add to {str(manifest_path)}") + + if __name__ == "__main__": parser = argparse.ArgumentParser( description=( @@ -412,8 +542,9 @@ def update_makefile( "--build", action="store_true", help=( - "Also update catalog/catalog.am with the current list of " - "catalog game files. Required after adding or removing games." + "Also update catalog/catalog.am with the current list of catalog game files, " + "and regenerate catalog/games/manifest.json (the GUI-facing catalog manifest). " + "Required after adding, removing, or editing games." ), ) parser.add_argument( @@ -444,5 +575,8 @@ def update_makefile( generate_rst_table(df, CATALOG_RST_TABLE, regenerate_images=args.regenerate_images) print(f"Generated {CATALOG_RST_TABLE} for use in local docs build. DO NOT COMMIT.") if args.build: + # Regenerate the GUI-facing manifest before catalog.am, so a brand-new + # manifest.json is already on disk when update_makefile() scans for *.json files. + write_manifest(build_manifest()) # Update the Makefile.am with the current list of catalog files update_makefile() diff --git a/catalog/catalog.am b/catalog/catalog.am index d91d45e75..a32780f99 100644 --- a/catalog/catalog.am +++ b/catalog/catalog.am @@ -50,4 +50,5 @@ CATALOG_FILES = \ catalog/games/journals/mor/vonstengelforges2008/fig9__Original_Layout.ef \ catalog/games/journals/other/reiley2008/fig1.efg \ catalog/games/journals/other/shapley1974/fig2.nfg \ - catalog/games/journals/other/shapley1974/fig3.nfg + catalog/games/journals/other/shapley1974/fig3.nfg \ + catalog/games/manifest.json diff --git a/catalog/doc/developer.rst b/catalog/doc/developer.rst index 1c9b8d61a..a917aa7c6 100644 --- a/catalog/doc/developer.rst +++ b/catalog/doc/developer.rst @@ -119,6 +119,11 @@ Currently supported representations are: - If haven't done an editable install of ``pygambit`` in your python environment, you'll need to re-install it before running the build script to include new games in the catalog module. - Running the script with the ``--build`` flag updates ``catalog/catalog.am``, which is included in ``Makefile.am``. If you moved games that were previously in ``contrib/games`` you'll need to also manually remove those files from ``EXTRA_DIST`` in ``Makefile.am``. + - The ``--build`` flag also regenerates ``catalog/games/manifest.json``, the catalog + manifest consumed by the GUI's catalog browser (title, citation-resolved description, + hierarchy grouping, and structural stats for each game). It's a **committed** file, unlike + ``catalog/doc/_table.rst``, since it needs to physically ship alongside the game files. A + CI check fails if it's out of sync with the current catalog. .. tip:: @@ -148,7 +153,7 @@ Currently supported representations are: 6. **Submit a pull request to GitHub with all changes.** Submit a PR according to the `usual workflow `_. - Ensure that any additions and changes to game files, ``catalog/gtdraw_settings.yaml``, ``catalog/build.py`` and ``catalog/catalog.am`` are included. + Ensure that any additions and changes to game files, ``catalog/gtdraw_settings.yaml``, ``catalog/build.py``, ``catalog/catalog.am`` and ``catalog/games/manifest.json`` are included. .. important:: diff --git a/catalog/games/manifest.json b/catalog/games/manifest.json new file mode 100644 index 000000000..42e18933b --- /dev/null +++ b/catalog/games/manifest.json @@ -0,0 +1,522 @@ +[ + { + "slug": "books/myerson1991/fig2_1", + "title": "A simple Poker game", + "description": "This is a simple game of one-card poker from (Myerson 1991), used as the\nintroductory example for game models.\n\nNote that as specified in the text, the game has the slightly unusual feature\nthat folding with the high (red) card results in the player winning rather than\nlosing.\n\nSee also (Reiley et al. 2008)\n Another one-card poker game where folding with the high card is a loss rather\n than a win.", + "format": "efg", + "category": "Books", + "group": "Myerson (1991) — Game Theory: Analysis of Conflict", + "thumbnail": "img/books/myerson1991/fig2_1.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": true, + "n_strategies": 6 + }, + { + "slug": "books/myerson1991/fig4_2", + "title": "Myerson (1991) Figure 4.2", + "description": "An example from (Myerson 1991) which illustrates the distinction between\nan equilibrium of an extensive form game and an equilibrium of its\n(multi)agent representation. The actions B1, Z1, and W2 form a\nbehavior profile which is an equilibrium in the (multi)agent\nrepresentation. However, it is not a Nash equilibrium of the extensive\ngame, because Player 1 would prefer to switch from (B1, Z1) to\n(A1, Y1); the (multi)agent representation rules out such coordinated\ndeviations across information sets.", + "format": "efg", + "category": "Books", + "group": "Myerson (1991) — Game Theory: Analysis of Conflict", + "thumbnail": "img/books/myerson1991/fig4_2.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 5 + }, + { + "slug": "books/shohamleytonbrown2008/fig5_1", + "title": "Fig 5.1 from Shoham and Leyton-Brown (2008)", + "description": "Figure 5.1 from (Shoham and Leyton-Brown 2008).\nThis is a sharing game. Imagine a brother and sister sharing two indivisible and identical presents\nfrom their parents. First the brother suggests a split, which can be one of three: he\nkeeps both, she keeps both, or they each keep one. Then the sister chooses whether\nto accept or reject the split. If she accepts they each get their allocated present(s),\nand otherwise neither gets any gift.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig5_1.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 11 + }, + { + "slug": "books/shohamleytonbrown2008/fig5_10", + "title": "Fig 5.10 from Shoham and Leyton-Brown (2008)", + "description": "Figure 5.10 from (Shoham and Leyton-Brown 2008).\nThis is an example of an imperfect-information game.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig5_10.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 5 + }, + { + "slug": "books/shohamleytonbrown2008/fig5_11", + "title": "Fig 5.11 from Shoham and Leyton-Brown (2008)", + "description": "Figure 5.11 from (Shoham and Leyton-Brown 2008).\nThe Prisoner's Dilemma game in extensive form.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig5_11.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 4 + }, + { + "slug": "books/shohamleytonbrown2008/fig5_12", + "title": "Fig 5.12 from Shoham and Leyton-Brown (2008)", + "description": "Figure 5.12 from (Shoham and Leyton-Brown 2008).\nA game with imperfect recall, in particular absent-mindedness.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig5_12.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 4 + }, + { + "slug": "books/shohamleytonbrown2008/fig5_15", + "title": "Fig 5.15 from Shoham and Leyton-Brown (2008)", + "description": "Figure 5.15 from (Shoham and Leyton-Brown 2008).\nA game with imperfect information.\nThis example shows how a requirement that a substrategy be a best response in\nall subgames is too simplistic for defining SPE in games with imperfect information.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig5_15.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 5 + }, + { + "slug": "books/shohamleytonbrown2008/fig5_2", + "title": "Fig 5.2 from Shoham and Leyton-Brown (2008)", + "description": "Figure 5.2 from (Shoham and Leyton-Brown 2008).\nThis is an example of a perfect-information game in extensive form.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig5_2.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 7 + }, + { + "slug": "books/shohamleytonbrown2008/fig5_9", + "title": "Fig 5.9 from Shoham and Leyton-Brown (2008)", + "description": "Figure 5.9 from (Shoham and Leyton-Brown 2008).\nThis is the centipede game. In this game two players alternate in making decisions, at each\nturn choosing between going down and ending the game or going across and\ncontinuing it.\nThis example is used to explain the criticisms of backward induction for finding subgame-perfect equilibrium.\nNote that centipede is also a parametrized game, with the parameter being the number of rounds.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig5_9.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 7 + }, + { + "slug": "books/shohamleytonbrown2008/fig6_2", + "title": "Fig 6.2 from Shoham and Leyton-Brown (2008)", + "description": "Figure 6.2 from (Shoham and Leyton-Brown 2008).\nThis is a repeated game, where Prisoner's Dilemma is played twice.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig6_2.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 16 + }, + { + "slug": "books/shohamleytonbrown2008/fig6_8", + "title": "Fig 6.8 from Shoham and Leyton-Brown (2008)", + "description": "Figure 6.8 from (Shoham and Leyton-Brown 2008).\nThis is a Bayesian game, represented in EFG with Nature deciding the types.", + "format": "efg", + "category": "Books", + "group": "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations", + "thumbnail": "img/books/shohamleytonbrown2008/fig6_8.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 8 + }, + { + "slug": "books/vonstengel2022/fig10.1", + "title": "Figure 10.1 from von Stengel (2022)", + "description": "Figure 10.1 from (von Stengel 2022).\nIt is essentially a type of poker game.\nIts description as a competition between software firms is due to\n(Turocy and von Stengel 2002).", + "format": "efg", + "category": "Books", + "group": "von Stengel (2022) — Game Theory Basics", + "thumbnail": "img/books/vonstengel2022/fig10.1.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": true, + "n_strategies": 6 + }, + { + "slug": "books/vonstengel2022/fig10.12", + "title": "Figure 10.12 from von Stengel (2022)", + "description": "Figure 10.12 from (von Stengel 2022).\nIt refers to a non-standard version of the Monty Hall problem where the television show host Monty\nHall has the option of opening another door without a prize to the contestant (player I), rather\nthan opening such a door all the time. Its purpose is to demonstrate a whole convex set of optimal\nstrategies for player II.", + "format": "efg", + "category": "Books", + "group": "von Stengel (2022) — Game Theory Basics", + "thumbnail": "img/books/vonstengel2022/fig10.12.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": true, + "n_strategies": 6 + }, + { + "slug": "books/vonstengel2022/fig10.5", + "title": "Figure 10.5 from von Stengel (2022)", + "description": "Figure 10.5 from (von Stengel 2022).\nPlayer II has four reduced strategies in this game, compared to eight unreduced strategies.", + "format": "efg", + "category": "Books", + "group": "von Stengel (2022) — Game Theory Basics", + "thumbnail": "img/books/vonstengel2022/fig10.5.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 6 + }, + { + "slug": "books/vonstengel2022/fig10.7", + "title": "Figure 10.7 from von Stengel (2022)", + "description": "Figure 10.7 from (von Stengel 2022).\nIt is a game with imperfect recall, but it has the same strategic form as the game in Figure 10.5\nfrom the same book.\nOne equilibrium strategy of player II as computed from the strategic form is not realization\nequivalent to any behavior strategy, namely the mixed strategy (0, 2/5, 3/5, 0)\nfor (la, lb, ra, rb).", + "format": "efg", + "category": "Books", + "group": "von Stengel (2022) — Game Theory Basics", + "thumbnail": "img/books/vonstengel2022/fig10.7.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 6 + }, + { + "slug": "books/watson2013/exercise29_6", + "title": "Princess Bride signaling game (from Watson)", + "description": "This game is Exercise 29.6 from Watson (Watson 2013), based on a scene from\nthe Rob Reiner film, The Princess Bride:\n\nWesley (the protagonist) confronts the evil prince Humperdinck. Wesley\nis one of two types: weak or strong. Wesley knows whether he is weak or\nstrong, but the prince only knows that he is weak with probability 1/2 and\nstrong with probability 1/2. Wesley is lying in a bed in the prince's\ncastle when the prince enters the room. Wesley decides whether to get out\nof bed or stay in bed. The prince observes Wesley's action but does not\nobserve Wesley's type. The prince then decides whether to fight or\nsurrender to Wesley. The payoffs are such that the prince prefers to fight\nonly with the weak Wesley, because otherwise the prince is an inferior\nswordsman. Also, the weak Wesley must pay a cost to get out of bed.\n\nIn the game in this file, the cost the weak Wesley pays to get out of bed\nis set to 2.", + "format": "efg", + "category": "Books", + "group": "Watson (2013) — Strategy: An Introduction to Game Theory", + "thumbnail": "img/books/watson2013/exercise29_6.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 8 + }, + { + "slug": "books/watson2013/fig29_1", + "title": "Job-market signaling game (version from Watson)", + "description": "This is a version of Spence's classic model of education being a job-market\nsignal, as presented in Figure 29.1 of Watson (Watson 2013).", + "format": "efg", + "category": "Books", + "group": "Watson (2013) — Strategy: An Introduction to Game Theory", + "thumbnail": "img/books/watson2013/fig29_1.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 8 + }, + { + "slug": "conf/itcs/jakobsen2016/fig1a", + "title": "Jakobsen, Sorensen, Conitzer (2016) Figure 1(a)", + "description": "An example from (Jakobsen et al. 2016) illustrating a game\nthat is not exactly timeable. A coin toss determines which player moves first.\nEach player guesses whether she went first, without distinguishing going first from going second.\nEach player receives a payoff of 1 for a correct guess and 0 otherwise.\nNo deterministic or randomized timing can implement this game without leaking information.", + "format": "efg", + "category": "Conferences", + "group": "Jakobsen et al. (2016)", + "thumbnail": "img/conf/itcs/jakobsen2016/fig1a.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 4 + }, + { + "slug": "conf/itcs/jakobsen2016/fig1b", + "title": "Jakobsen, Sorensen, Conitzer (2016) Figure 1(b)", + "description": "An example from (Jakobsen et al. 2016) illustrating a game\nthat has an exact deterministic timing. A coin toss determines the flow of the game;\nplayer 1 only plays if the coin comes up Heads, and if so plays first.\nPlayer 2 always plays, but cannot distinguish whether the coin came up Heads or Tails.\nEach player receives a payoff of 1 for a correct guess and 0 otherwise.\nThis game can be timed by letting player 1 play at time 1 and player 2 at time 2.", + "format": "efg", + "category": "Conferences", + "group": "Jakobsen et al. (2016)", + "thumbnail": "img/conf/itcs/jakobsen2016/fig1b.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 4 + }, + { + "slug": "conf/itcs/jakobsen2016/fig1c", + "title": "Jakobsen, Sorensen, Conitzer (2016) Figure 1(c)", + "description": "An example from (Jakobsen et al. 2016) illustrating a game\nthat is not exactly timeable. A coin toss determines the order of players.\nThe player moving second is only offered a bet if the player moving first guessed correctly.\nEach player receives a payoff of 1 for a correct guess and 0 otherwise.\nNo deterministic or randomized timing can implement this game without leaking information.", + "format": "efg", + "category": "Conferences", + "group": "Jakobsen et al. (2016)", + "thumbnail": "img/conf/itcs/jakobsen2016/fig1c.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 4 + }, + { + "slug": "conf/itcs/jakobsen2016/fig3", + "title": "Jakobsen, Sorensen, Conitzer (2016) Figure 3", + "description": "An example from (Jakobsen et al. 2016) illustrating\nthe extensive form of an onion routing game that is not exactly timeable.\nChance chooses a sender by drawing a signal from {0, 1, 2, 3} with equal probability.\nThe sender does not make a strategic choice; only the two intermediary players act.\nFor signal i:\n(a) the sender is the player whose index (mod 4) equals i,\n(b) the recipient is the player whose index (mod 4) equals i-1.\nThe first intermediary is Player i+2 (mod 4) and the second is Player i+1.\nThe full mapping is as follows:\nSignal 0: Player 4 sends to Player 3. Player 2 acts first, then Player 1.\nSignal 1: Player 1 sends to Player 4. Player 3 acts first, then Player 2.\nSignal 2: Player 2 sends to Player 1. Player 4 acts first, then Player 3.\nSignal 3: Player 3 sends to Player 2. Player 1 acts first, then Player 4.\nEach player has one information set with two member nodes, that is, they cannot distinguish which position they are at.\nEach intermediary chooses to either forward the envelope or obstruct by keeping it.\nEach player wants to obstruct the protocol if they are the first intermediary, but wants to help if they are the second.\nIf both intermediaries forward, the message is delivered.\nIn that case, the first intermediary receives -1 and the second intermediary receives 1+epsilon.\nAll other players receive 0.\nIf either intermediary obstructs, the message is not delivered and all players receive 0.\nWith epsilon set to 0.01, the payoffs for successful delivery are:\nSignal 0: (1.01, -1, 0, 0).\nSignal 1: (0, 1.01, -1, 0).\nSignal 2: (0, 0, 1.01, -1).\nSignal 3: (-1, 0, 0, 1.01).", + "format": "efg", + "category": "Conferences", + "group": "Jakobsen et al. (2016)", + "thumbnail": "img/conf/itcs/jakobsen2016/fig3.png", + "n_players": 4, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 8 + }, + { + "slug": "journals/dcg/vonstengel1999/6x6_game_with_75_eq", + "title": "von Stengel's 6x6 bimatrix game with 75 equilibria", + "description": "The example from (von Stengel 1997) as referenced in (von Stengel 1999).\nIt is a 6x6 bimatrix game with 75 equilibria,\nwhich is more than the previously conjectured bound of :math:`2^6 - 1 = 63`.", + "format": "nfg", + "category": "Journals", + "group": "von Stengel (1999)", + "thumbnail": "img/journals/dcg/vonstengel1999/6x6_game_with_75_eq.png", + "n_players": 2, + "is_tree": false, + "is_const_sum": false, + "n_strategies": 12 + }, + { + "slug": "journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs", + "title": "von Stengel's 6x6 bimatrix game with 75 equilibria (small payoffs)", + "description": "Taken from (Savani and von Stengel 2006), this version has much smaller payoffs than the original version from\n(von Stengel 1997) and referenced in (von Stengel 1999), which is also in this folder of the catalog.\nIt is a 6x6 bimatrix game with 75 equilibria,\nwhich is more than the previously conjectured bound of :math:`2^6 - 1 = 63`.", + "format": "nfg", + "category": "Journals", + "group": "von Stengel (1999)", + "thumbnail": "img/journals/dcg/vonstengel1999/6x6_game_with_75_eq_small_payoffs.png", + "n_players": 2, + "is_tree": false, + "is_const_sum": false, + "n_strategies": 12 + }, + { + "slug": "journals/geb/bagwell1995", + "title": "Bagwell (GEB 1995) commitment and (un)observability", + "description": "This is a Stackelberg-type game with imperfectly observed commitment, following the\nanalysis of (Bagwell 1995). The outcomes and payoffs are the same as in Bagwell's\nmodel. This example sets the probability that the follower 'correctly' observes the\nleader's action as .99 (99/100). The key result is that the only pure-strategy\nequilibrium that survives if observability is imperfect is the one in which players\nchoose the actions that would form an equilibrium if the game was a *simultaneous-move*\ngame. There is an equilibrium in which the 'Stackelberg' action is played with high\nprobability, but strictly less than one.", + "format": "efg", + "category": "Journals", + "group": "Games and Economic Behavior (GEB)", + "thumbnail": "img/journals/geb/bagwell1995.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 6 + }, + { + "slug": "journals/geb/gilboa1997/fig1", + "title": "Absent-Minded Driver (Gilboa 1997, GEB, Figure 2)", + "description": "The original absent-minded driver problem from\n(Gilboa 1997)", + "format": "efg", + "category": "Journals", + "group": "Gilboa (1997)", + "thumbnail": "img/journals/geb/gilboa1997/fig1.png", + "n_players": 1, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 2 + }, + { + "slug": "journals/geb/gilboa1997/fig2", + "title": "Two-Selves Absent-Minded Driver (Gilboa 1997, GEB, Figure 2)", + "description": "A reformulation of the absent-minded driver problem from\n(Gilboa 1997)\nusing a multi-self approach. A chance move determines the order in which two selves act,\neach facing a binary choice. Neither self knows the order of play, capturing absent-mindedness\nthrough information sets that cross the chance branches rather than through imperfect recall.", + "format": "efg", + "category": "Journals", + "group": "Gilboa (1997)", + "thumbnail": "img/journals/geb/gilboa1997/fig2.png", + "n_players": 1, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 4 + }, + { + "slug": "journals/geb/wichardt2008", + "title": "Wichardt (2008): 2 players, imperfect recall", + "description": "The first counterexample showing that finite extensive-form games\nwith imperfect recall need not have a Nash equilibrium in behaviour strategies", + "format": "efg", + "category": "Journals", + "group": "Games and Economic Behavior (GEB)", + "thumbnail": "img/journals/geb/wichardt2008.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": true, + "n_strategies": 6 + }, + { + "slug": "journals/ijgt/nau2004/sec3", + "title": "Battle of the Sexes", + "description": "The coordination game known as Battle of the Sexes (section 3 of (Nau et al. 2004)). Has three Nash equilibria: two pure-strategy (TL and BR) and one completely mixed.", + "format": "nfg", + "category": "Journals", + "group": "Nau et al. (2004)", + "thumbnail": "img/journals/ijgt/nau2004/sec3.png", + "n_players": 2, + "is_tree": false, + "is_const_sum": false, + "n_strategies": 4 + }, + { + "slug": "journals/ijgt/nau2004/sec4", + "title": "Three-player game with a unique Nash solution in irrational strategies", + "description": "A three-player game with a unique Nash equilibrium in irrational mixed strategies (section 4 of (Nau et al. 2004)). The correlated equilibrium polytope is seven-dimensional with 33 vertices.", + "format": "nfg", + "category": "Journals", + "group": "Nau et al. (2004)", + "thumbnail": "img/journals/ijgt/nau2004/sec4.png", + "n_players": 3, + "is_tree": false, + "is_const_sum": false, + "n_strategies": 6 + }, + { + "slug": "journals/ijgt/nau2004/sec5", + "title": "Game with a continuum of completely mixed-strategy Nash equilibria", + "description": "A three-player 2x2x2 game with 3 pure, 2 incompletely mixed, and a continuum of completely mixed Nash equilibria (section 5 of (Nau et al. 2004)). The correlated equilibrium polytope is seven-dimensional with 8 vertices.", + "format": "nfg", + "category": "Journals", + "group": "Nau et al. (2004)", + "thumbnail": "img/journals/ijgt/nau2004/sec5.png", + "n_players": 3, + "is_tree": false, + "is_const_sum": false, + "n_strategies": 6 + }, + { + "slug": "journals/ijgt/nau2004/sec6", + "title": "2x2x4 game with Nash equilibria in the relative interior of the correlated equilibrium polytope", + "description": "A three-player 2x2x4 game (section 6 of (Nau et al. 2004)). The correlated equilibrium polytope is four-dimensional with six vertices. The set of Nash equilibria is a line segment in the relative interior of the polytope.", + "format": "nfg", + "category": "Journals", + "group": "Nau et al. (2004)", + "thumbnail": "img/journals/ijgt/nau2004/sec6.png", + "n_players": 3, + "is_tree": false, + "is_const_sum": false, + "n_strategies": 8 + }, + { + "slug": "journals/ijgt/selten1975/fig1", + "title": "Selten's horse (Selten IJGT 1975, Figure 1)", + "description": "This is a three-player game presented in (Selten 1975), commonly referred\nto as \"Selten's horse\" owing to the layout in which it can be drawn.\nIt is the motivating example for his definition of (trembling-hand)\nperfect equilibrium, by showing a game that has an equilibrium which\nis \"unreasonable\", but which is not ruled out by subgame perfection because\nthis game has no proper subgames.", + "format": "efg", + "category": "Journals", + "group": "Selten (1975)", + "thumbnail": "img/journals/ijgt/selten1975/fig1.png", + "n_players": 3, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 6 + }, + { + "slug": "journals/ijgt/selten1975/fig2", + "title": "Selten (IJGT 1975) Figure 2", + "description": "This is a counterexample presented in (Selten 1975), to show that extensive and\nnormal form concepts of perfectness do not coincide. This game has one\nperfect equilibrium in the extensive from, but a distinct (pure) strategy\nequilibrium is also perfect in the normal form.", + "format": "efg", + "category": "Journals", + "group": "Selten (1975)", + "thumbnail": "img/journals/ijgt/selten1975/fig2.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 5 + }, + { + "slug": "journals/ijgt/selten1975/fig3", + "title": "Selten (IJGT 1975) Figure 3", + "description": "This is a counterexample presented in (Selten 1975), to show that extensive and\nnormal form concepts of perfectness do not coincide. Specifically, there\nare two equilibria which are perfect in the normal form but not perfect\nin the extensive form.", + "format": "efg", + "category": "Journals", + "group": "Selten (1975)", + "thumbnail": "img/journals/ijgt/selten1975/fig3.png", + "n_players": 3, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 7 + }, + { + "slug": "journals/mor/vonstengelforges2008/fig1", + "title": "Figure 1 from von Stengel and Forges (2008)", + "description": "Figure 1 from (von Stengel and Forges 2008).\nIt is a kind of a kind of signaling game.", + "format": "efg", + "category": "Journals", + "group": "von Stengel & Forges (2008)", + "thumbnail": "img/journals/mor/vonstengelforges2008/fig1.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 8 + }, + { + "slug": "journals/mor/vonstengelforges2008/fig6", + "title": "Figure 6 from von Stengel and Forges (2008)", + "description": "Figure 6 from (von Stengel and Forges 2008).\nThis game has perfect recall and no chance moves, yet it has a circular precedence structure among\nthe information sets of the two players. The payoffs are not important.", + "format": "efg", + "category": "Journals", + "group": "von Stengel & Forges (2008)", + "thumbnail": "img/journals/mor/vonstengelforges2008/fig6.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": true, + "n_strategies": 8 + }, + { + "slug": "journals/mor/vonstengelforges2008/fig9", + "title": "Figure 9 from von Stengel and Forges (2008)", + "description": "Figure 9 from (von Stengel and Forges 2008).\nIt encodes the SAT formula :math:`x\\wedge (\\neg x \\vee y) \\wedge (\\neg x \\vee \\neg y)`\n(writing $-x$ for :math:`\\neg x`).\nThe formula is satisfiable (which here it is not) if and only\nif the players can ensure a payoff of 1 in any Nash Equilibrium, Correlated Equilibrium,\nor Extensive-Form Correlated Equilibrium,\nwith a strategy of player 1 representing the corresponding truth assignment.", + "format": "efg", + "category": "Journals", + "group": "von Stengel & Forges (2008)", + "thumbnail": "img/journals/mor/vonstengelforges2008/fig9.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": false, + "n_strategies": 8 + }, + { + "slug": "journals/other/reiley2008/fig1", + "title": "Stripped-down poker (Reiley et al 2008)", + "description": "This is a one-card poker game used in (Reiley et al. 2008) as a teaching exercise.\n\nSee also (Myerson 1991)\n Another one-card poker game with slightly different rules.", + "format": "efg", + "category": "Journals", + "group": "Reiley (2008)", + "thumbnail": "img/journals/other/reiley2008/fig1.png", + "n_players": 2, + "is_tree": true, + "is_const_sum": true, + "n_strategies": 6 + }, + { + "slug": "journals/other/shapley1974/fig2", + "title": "Fig 2 from 'A Note on the Lemke-Howson Algorithm' (Shapley 1974)", + "description": "Fig 2 from (Shapley 1974). This bimatrix game is used to demonstrate the Lemke-Howson algorithm.", + "format": "nfg", + "category": "Journals", + "group": "Shapley (1974)", + "thumbnail": "img/journals/other/shapley1974/fig2.png", + "n_players": 2, + "is_tree": false, + "is_const_sum": false, + "n_strategies": 6 + }, + { + "slug": "journals/other/shapley1974/fig3", + "title": "Fig 3 from 'A Note on the Lemke-Howson Algorithm' (Shapley 1974)", + "description": "Fig 3 from (Shapley 1974). This bimatrix game has a pair of mixed-strategy equilibria\nthat are inaccessible to the Lemke-Howson algorithm. That is, the two equilibria in this\npair are connected to each other for all dropped labels, and can thus not be reached by\nany concatenation of paths from the artifical equilibrium, where the Lemke-Howson algorithm\nstarts.", + "format": "nfg", + "category": "Journals", + "group": "Shapley (1974)", + "thumbnail": "img/journals/other/shapley1974/fig3.png", + "n_players": 2, + "is_tree": false, + "is_const_sum": false, + "n_strategies": 6 + } +] diff --git a/catalog/test_build.py b/catalog/test_build.py index 8bb0a4a17..7557f1b56 100644 --- a/catalog/test_build.py +++ b/catalog/test_build.py @@ -33,6 +33,7 @@ temporary directory and avoiding any reads from or writes to the repo. """ +import json import textwrap import pytest @@ -97,6 +98,34 @@ def _write_yaml(path, content=_YAML_CONFIG): return path +def _write_efg_game(path, title="Test Game", description=""): + """Write a minimal, valid, 2-player extensive-form game file to *path*.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f'EFG 2 R "{title}" {{ "P1" "P2" }}\n' + f'"{description}"\n' + 'p "" 1 1 "" { "l" "r" } 0\n' + 't "" 1 "" { 1, -1 }\n' + 't "" 2 "" { 2, -2 }\n', + encoding="utf-8", + ) + return path + + +def _write_nfg_game(path, title="Test NFG Game", description=""): + """Write a minimal, valid, 2-player 2x2 normal-form game file to *path*.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f'NFG 1 R "{title}" {{ "P1" "P2" }}\n\n' + '{ { "1" "2" }\n{ "1" "2" }\n}\n' + f'"{description}"\n\n' + '{\n{ "" 1, 1 }\n{ "" 0, 0 }\n{ "" 0, 0 }\n{ "" 1, 1 }\n}\n' + "1 2 3 4\n", + encoding="utf-8", + ) + return path + + def _efg_row(slug, title="Test EFG Game", description="A description."): """Return a dict representing one row of the DataFrame produced by ``gbt.catalog.games(include_descriptions=True)`` for an extensive-form game. @@ -897,3 +926,208 @@ def test_game_with_description_does_not_warn(self, capsys): build._warn_missing_descriptions(df) err = capsys.readouterr().err assert err == "" + + +# --------------------------------------------------------------------------- +# Tests for the GUI-facing manifest (citation resolution, stats, build/write) +# --------------------------------------------------------------------------- + +_TEST_BIB = textwrap.dedent("""\ + @article{Solo2020, + author = {Solo, A.}, + year = {2020}, + } + @article{Duo2019, + author = {First, A. and Second, B.}, + year = {2019}, + } + @article{Trio2018, + author = {One, A. and Two, B. and Three, C.}, + year = {2018}, + } + @article{VonParticle2021, + author = {von Particle, X.}, + year = {2021}, + } +""") + + +def _write_bib(path, content=_TEST_BIB): + path.write_text(content, encoding="utf-8") + return path + + +@pytest.mark.catalog_update +class TestCitationResolution: + """Tests for ``load_citation_texts`` and ``resolve_citations``.""" + + def test_single_author(self, tmp_path): + texts = build.load_citation_texts(_write_bib(tmp_path / "refs.bib")) + assert texts["Solo2020"] == "(Solo 2020)" + + def test_two_authors(self, tmp_path): + texts = build.load_citation_texts(_write_bib(tmp_path / "refs.bib")) + assert texts["Duo2019"] == "(First and Second 2019)" + + def test_three_or_more_authors_uses_et_al(self, tmp_path): + texts = build.load_citation_texts(_write_bib(tmp_path / "refs.bib")) + assert texts["Trio2018"] == "(One et al. 2018)" + + def test_von_particle_kept_with_last_name(self, tmp_path): + """A "von"-style name particle must not be dropped (gambit's own bibliography + is full of these, e.g. "von Stengel").""" + texts = build.load_citation_texts(_write_bib(tmp_path / "refs.bib")) + assert texts["VonParticle2021"] == "(von Particle 2021)" + + def test_resolve_citations_substitutes_known_keys(self, tmp_path): + texts = build.load_citation_texts(_write_bib(tmp_path / "refs.bib")) + result = build.resolve_citations("See :cite:p:`Solo2020` for details.", texts) + assert result == "See (Solo 2020) for details." + + def test_resolve_citations_substitutes_multiple_keys(self, tmp_path): + texts = build.load_citation_texts(_write_bib(tmp_path / "refs.bib")) + result = build.resolve_citations( + "Compare :cite:p:`Solo2020` and :cite:p:`Duo2019`.", texts + ) + assert result == "Compare (Solo 2020) and (First and Second 2019)." + + def test_resolve_citations_falls_back_for_unknown_key(self): + result = build.resolve_citations("See :cite:p:`Missing2099`.", {}) + assert result == "See (Missing2099)." + + def test_resolve_citations_no_markup_is_unchanged(self): + assert build.resolve_citations("Plain text, no citations.", {}) == ( + "Plain text, no citations." + ) + + +@pytest.mark.catalog_update +class TestHierarchyBreadcrumbs: + """Tests for ``_hierarchy_breadcrumbs``.""" + + def test_breadcrumbs_use_yaml_labels(self, tmp_path, monkeypatch): + yaml_file = _write_yaml(tmp_path / "hier.yaml", _HIERARCHY_YAML) + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = build.load_hierarchy_labels() + assert build._hierarchy_breadcrumbs("cat/src/game1", labels) == [ + "My Category", + "My Source", + ] + + def test_breadcrumbs_fallback_for_unknown_prefix(self, tmp_path, monkeypatch): + yaml_file = _write_yaml(tmp_path / "hier.yaml", _HIERARCHY_YAML) + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = build.load_hierarchy_labels() + assert build._hierarchy_breadcrumbs("other/unknown_group/game1", labels) == [ + "Other", + "Unknown Group", + ] + + def test_breadcrumbs_empty_for_top_level_slug(self, tmp_path, monkeypatch): + """A slug with no parent directories has no breadcrumbs.""" + yaml_file = _write_yaml(tmp_path / "hier.yaml", _HIERARCHY_YAML) + monkeypatch.setattr(build, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = build.load_hierarchy_labels() + assert build._hierarchy_breadcrumbs("game1", labels) == [] + + +@pytest.mark.catalog_update +class TestGameStats: + """Tests for ``_game_stats``.""" + + def test_stats_of_extensive_form_game(self, tmp_path): + """Player 1 has 2 strategies (one decision, two actions); player 2 has no + decision nodes at all, so the reduced normal form gives them a single + trivial strategy — 2 + 1 = 3 total, not 2x2=4.""" + efg_path = _write_efg_game(tmp_path / "game1.efg") + game = build.gbt.read_efg(str(efg_path)) + stats = build._game_stats(game) + assert stats == { + "n_players": 2, + "is_tree": True, + "is_const_sum": True, + "n_strategies": 3, + } + + def test_stats_of_strategic_form_game(self, tmp_path): + nfg_path = _write_nfg_game(tmp_path / "game1.nfg") + game = build.gbt.read_nfg(str(nfg_path)) + stats = build._game_stats(game) + assert stats["n_players"] == 2 + assert stats["is_tree"] is False + assert stats["n_strategies"] == 4 + + +@pytest.mark.catalog_update +class TestBuildManifest: + """Tests for ``build_manifest`` and ``write_manifest``.""" + + def _make_catalog(self, tmp_path): + """Build a small fake catalog dir with one EFG and one NFG game.""" + catalog_dir = tmp_path / "catalog" + _write_efg_game( + catalog_dir / "books" / "author2020" / "game1.efg", + title="Tree Game", + description="A tree game from :cite:p:`Solo2020`.", + ) + _write_nfg_game( + catalog_dir / "journals" / "geb" / "author2019" / "game2.nfg", + title="Table Game", + description="A table game from :cite:p:`Duo2019`.", + ) + return catalog_dir + + def test_build_manifest_entries(self, tmp_path): + catalog_dir = self._make_catalog(tmp_path) + entries = build.build_manifest( + catalog_dir=catalog_dir, bib_path=_write_bib(tmp_path / "refs.bib") + ) + by_slug = {e["slug"]: e for e in entries} + assert set(by_slug) == {"books/author2020/game1", "journals/geb/author2019/game2"} + + efg_entry = by_slug["books/author2020/game1"] + assert efg_entry["title"] == "Tree Game" + assert efg_entry["description"] == "A tree game from (Solo 2020)." + assert efg_entry["format"] == "efg" + assert efg_entry["is_tree"] is True + assert efg_entry["thumbnail"] == "img/books/author2020/game1.png" + assert efg_entry["category"] == "Books" + + nfg_entry = by_slug["journals/geb/author2019/game2"] + assert nfg_entry["format"] == "nfg" + assert nfg_entry["is_tree"] is False + assert nfg_entry["description"] == "A table game from (First and Second 2019)." + + def test_build_manifest_skips_games_without_description(self, tmp_path): + catalog_dir = tmp_path / "catalog" + _write_efg_game(catalog_dir / "nodesc" / "game1.efg", description="") + entries = build.build_manifest( + catalog_dir=catalog_dir, bib_path=_write_bib(tmp_path / "refs.bib") + ) + assert entries == [] + + def test_build_manifest_sorted_by_slug(self, tmp_path): + catalog_dir = self._make_catalog(tmp_path) + entries = build.build_manifest( + catalog_dir=catalog_dir, bib_path=_write_bib(tmp_path / "refs.bib") + ) + assert [e["slug"] for e in entries] == sorted(e["slug"] for e in entries) + + def test_write_manifest_creates_file(self, tmp_path): + manifest_path = tmp_path / "manifest.json" + build.write_manifest([{"slug": "a/b"}], manifest_path=manifest_path) + assert json.loads(manifest_path.read_text(encoding="utf-8")) == [{"slug": "a/b"}] + + def test_write_manifest_no_op_when_unchanged(self, tmp_path, capsys): + manifest_path = tmp_path / "manifest.json" + build.write_manifest([{"slug": "a/b"}], manifest_path=manifest_path) + capsys.readouterr() + build.write_manifest([{"slug": "a/b"}], manifest_path=manifest_path) + assert "No changes" in capsys.readouterr().out + + def test_write_manifest_updates_on_change(self, tmp_path, capsys): + manifest_path = tmp_path / "manifest.json" + build.write_manifest([{"slug": "a/b"}], manifest_path=manifest_path) + capsys.readouterr() + build.write_manifest([{"slug": "a/c"}], manifest_path=manifest_path) + assert "Updated" in capsys.readouterr().out diff --git a/pyproject.toml b/pyproject.toml index 6826e78f2..469d9efa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ doc = [ "jupyter_sphinx", "pyyaml", "sphinxcontrib-bibtex", + "pybtex", "myst-parser", ] diff --git a/src/gui/app.cc b/src/gui/app.cc index 309ceed6b..fc8df5bee 100644 --- a/src/gui/app.cc +++ b/src/gui/app.cc @@ -66,6 +66,11 @@ wxBEGIN_EVENT_TABLE(Application, wxApp) EVT_TIMER(wxID_ANY, Application::OnSplas bool Application::OnInit() { + // Fixes the name wxStandardPaths derives paths from (e.g. GetDataDir() on Linux, + // used to locate the installed games catalog; see src/gui/catalogdata.cc) so it + // doesn't depend on how the binary happens to have been invoked or renamed. + SetAppName("gambit"); + wxConfigBase::Set(new wxConfig(_T("Gambit"), _T("Gambit"))); m_fileHistory.Load(*wxConfigBase::Get()); // Immediately saving this back forces the entries to be created at diff --git a/src/gui/catalogdata.cc b/src/gui/catalogdata.cc new file mode 100644 index 000000000..9c0f1d64a --- /dev/null +++ b/src/gui/catalogdata.cc @@ -0,0 +1,474 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/catalogdata.cc +// Loading and locating Gambit's games catalog for the catalog browser dialog +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +// + +#include "catalogdata.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +// Set via -DGAMBIT_SRCDIR="$(abs_top_srcdir)" in Makefile.am's AM_CPPFLAGS. Guarded here +// so the file still compiles (with the dev-checkout check simply never matching) if built +// outside the normal autotools flow. +#ifndef GAMBIT_SRCDIR +#define GAMBIT_SRCDIR "" +#endif + +namespace Gambit::GUI { + +namespace { + +// --------------------------------------------------------------------------- +// A minimal JSON parser, scoped to what catalog/games/manifest.json actually contains: +// an array of flat objects with string/number/bool values. Not a general-purpose JSON +// library -- Gambit's C++ core has no JSON dependency, and both the producer +// (catalog/build.py) and consumer of this format are Gambit's own code, so a parser +// exactly matched to this shape is simpler to reason about than vendoring one. +// --------------------------------------------------------------------------- + +class JsonParseError : public std::runtime_error { +public: + explicit JsonParseError(const std::string &p_message) : std::runtime_error(p_message) {} +}; + +class JsonValue { +public: + enum class Type { Null, Bool, Number, String, Array, Object }; + + Type m_type = Type::Null; + bool m_boolValue = false; + double m_numberValue = 0.0; + std::string m_stringValue; + std::vector m_arrayValue; + std::map m_objectValue; + + wxString GetString(const std::string &p_key) const + { + auto it = m_objectValue.find(p_key); + return (it != m_objectValue.end() && it->second.m_type == Type::String) + ? wxString::FromUTF8(it->second.m_stringValue) + : wxString(); + } + + int GetInt(const std::string &p_key) const + { + auto it = m_objectValue.find(p_key); + return (it != m_objectValue.end() && it->second.m_type == Type::Number) + ? static_cast(it->second.m_numberValue) + : 0; + } + + bool GetBool(const std::string &p_key) const + { + auto it = m_objectValue.find(p_key); + return it != m_objectValue.end() && it->second.m_type == Type::Bool && it->second.m_boolValue; + } +}; + +class JsonParser { +public: + explicit JsonParser(const std::string &p_text) : m_text(p_text), m_pos(0) {} + + JsonValue Parse() + { + JsonValue value = ParseValue(); + SkipWhitespace(); + if (m_pos != m_text.size()) { + throw JsonParseError("Unexpected trailing content after top-level JSON value"); + } + return value; + } + +private: + const std::string &m_text; + size_t m_pos; + + char Peek() const + { + if (m_pos >= m_text.size()) { + throw JsonParseError("Unexpected end of input"); + } + return m_text[m_pos]; + } + + char Next() + { + if (m_pos >= m_text.size()) { + throw JsonParseError("Unexpected end of input"); + } + return m_text[m_pos++]; + } + + void SkipWhitespace() + { + while (m_pos < m_text.size() && (m_text[m_pos] == ' ' || m_text[m_pos] == '\t' || + m_text[m_pos] == '\n' || m_text[m_pos] == '\r')) { + m_pos++; + } + } + + void Expect(char p_char) + { + if (Peek() != p_char) { + throw JsonParseError(std::string("Expected '") + p_char + "'"); + } + m_pos++; + } + + bool Consume(const std::string &p_literal) + { + if (m_text.compare(m_pos, p_literal.size(), p_literal) == 0) { + m_pos += p_literal.size(); + return true; + } + return false; + } + + JsonValue ParseValue() + { + SkipWhitespace(); + char c = Peek(); + if (c == '{') { + return ParseObject(); + } + if (c == '[') { + return ParseArray(); + } + if (c == '"') { + return ParseString(); + } + if (c == 't' || c == 'f') { + return ParseBool(); + } + if (c == 'n') { + return ParseNull(); + } + return ParseNumber(); + } + + JsonValue ParseObject() + { + JsonValue result; + result.m_type = JsonValue::Type::Object; + Expect('{'); + SkipWhitespace(); + if (Peek() == '}') { + m_pos++; + return result; + } + while (true) { + SkipWhitespace(); + JsonValue key = ParseString(); + SkipWhitespace(); + Expect(':'); + JsonValue value = ParseValue(); + result.m_objectValue.emplace(key.m_stringValue, std::move(value)); + SkipWhitespace(); + char c = Next(); + if (c == '}') { + break; + } + if (c != ',') { + throw JsonParseError("Expected ',' or '}' in object"); + } + } + return result; + } + + JsonValue ParseArray() + { + JsonValue result; + result.m_type = JsonValue::Type::Array; + Expect('['); + SkipWhitespace(); + if (Peek() == ']') { + m_pos++; + return result; + } + while (true) { + result.m_arrayValue.push_back(ParseValue()); + SkipWhitespace(); + char c = Next(); + if (c == ']') { + break; + } + if (c != ',') { + throw JsonParseError("Expected ',' or ']' in array"); + } + } + return result; + } + + static void AppendUtf8(std::string &p_out, unsigned int p_codepoint) + { + if (p_codepoint <= 0x7F) { + p_out += static_cast(p_codepoint); + } + else if (p_codepoint <= 0x7FF) { + p_out += static_cast(0xC0 | (p_codepoint >> 6)); + p_out += static_cast(0x80 | (p_codepoint & 0x3F)); + } + else { + p_out += static_cast(0xE0 | (p_codepoint >> 12)); + p_out += static_cast(0x80 | ((p_codepoint >> 6) & 0x3F)); + p_out += static_cast(0x80 | (p_codepoint & 0x3F)); + } + } + + JsonValue ParseString() + { + Expect('"'); + std::string s; + while (true) { + char c = Next(); + if (c == '"') { + break; + } + if (c == '\\') { + char esc = Next(); + switch (esc) { + case '"': + s += '"'; + break; + case '\\': + s += '\\'; + break; + case '/': + s += '/'; + break; + case 'n': + s += '\n'; + break; + case 't': + s += '\t'; + break; + case 'r': + s += '\r'; + break; + case 'b': + s += '\b'; + break; + case 'f': + s += '\f'; + break; + case 'u': { + // The manifest is generated by Python's json.dumps(ensure_ascii=False), so in + // practice \u escapes here are limited to control characters; surrogate pairs + // are not expected, but each \u is still decoded as its own code point if seen. + if (m_pos + 4 > m_text.size()) { + throw JsonParseError("Truncated \\u escape"); + } + unsigned int codepoint = 0; + for (int i = 0; i < 4; i++) { + char h = Next(); + codepoint <<= 4; + if (h >= '0' && h <= '9') { + codepoint |= (h - '0'); + } + else if (h >= 'a' && h <= 'f') { + codepoint |= (h - 'a' + 10); + } + else if (h >= 'A' && h <= 'F') { + codepoint |= (h - 'A' + 10); + } + else { + throw JsonParseError("Invalid \\u escape"); + } + } + AppendUtf8(s, codepoint); + break; + } + default: + throw JsonParseError("Invalid escape sequence"); + } + } + else { + s += c; + } + } + JsonValue result; + result.m_type = JsonValue::Type::String; + result.m_stringValue = std::move(s); + return result; + } + + JsonValue ParseBool() + { + JsonValue result; + result.m_type = JsonValue::Type::Bool; + if (Consume("true")) { + result.m_boolValue = true; + } + else if (Consume("false")) { + result.m_boolValue = false; + } + else { + throw JsonParseError("Invalid literal"); + } + return result; + } + + JsonValue ParseNull() + { + if (!Consume("null")) { + throw JsonParseError("Invalid literal"); + } + return JsonValue(); + } + + JsonValue ParseNumber() + { + size_t start = m_pos; + if (m_pos < m_text.size() && m_text[m_pos] == '-') { + m_pos++; + } + while (m_pos < m_text.size() && + (std::isdigit(static_cast(m_text[m_pos])) || m_text[m_pos] == '.' || + m_text[m_pos] == 'e' || m_text[m_pos] == 'E' || m_text[m_pos] == '+' || + m_text[m_pos] == '-')) { + m_pos++; + } + if (m_pos == start) { + throw JsonParseError("Invalid number"); + } + JsonValue result; + result.m_type = JsonValue::Type::Number; + result.m_numberValue = std::stod(m_text.substr(start, m_pos - start)); + return result; + } +}; + +wxString JoinPath(const wxString &p_base, const wxString &p_child) +{ + if (p_base.IsEmpty()) { + return p_child; + } + return p_base + wxFILE_SEP_PATH + p_child; +} + +/// A directory is the catalog root if it directly contains manifest.json. +bool IsCatalogRoot(const wxString &p_dir) +{ + return !p_dir.IsEmpty() && wxFileExists(JoinPath(p_dir, "manifest.json")); +} + +} // namespace + +wxString FindCatalogRoot() +{ + // A co-located source checkout, first: lets a developer running the GUI straight out + // of the build tree see live catalog edits with no packaging step needed. + wxString srcdirCatalog = + JoinPath(JoinPath(wxString::FromUTF8(GAMBIT_SRCDIR), "catalog"), "games"); + if (IsCatalogRoot(srcdirCatalog)) { + return srcdirCatalog; + } + +#if defined(__WXMAC__) + wxString bundled = JoinPath(wxStandardPaths::Get().GetResourcesDir(), "catalog"); + if (IsCatalogRoot(bundled)) { + return bundled; + } +#elif defined(__WXMSW__) + wxString exeDir = wxFileName(wxStandardPaths::Get().GetExecutablePath()).GetPath(); + wxString bundled = JoinPath(exeDir, "catalog"); + if (IsCatalogRoot(bundled)) { + return bundled; + } +#else + wxString bundled = JoinPath(wxStandardPaths::Get().GetDataDir(), "catalog"); + if (IsCatalogRoot(bundled)) { + return bundled; + } +#endif + + return wxString(); +} + +std::vector LoadCatalogManifest(const wxString &p_catalogRoot, wxString *p_error) +{ + std::vector entries; + if (p_error) { + p_error->Clear(); + } + + wxString manifestPath = JoinPath(p_catalogRoot, "manifest.json"); + std::ifstream file(manifestPath.ToStdString(), std::ios::binary); + if (!file) { + if (p_error) { + *p_error = wxString::Format("Could not open catalog manifest at %s", manifestPath); + } + return entries; + } + std::ostringstream buffer; + buffer << file.rdbuf(); + std::string text = buffer.str(); + + JsonValue root; + try { + root = JsonParser(text).Parse(); + } + catch (const JsonParseError &e) { + if (p_error) { + *p_error = wxString::Format("Could not parse catalog manifest: %s", e.what()); + } + return entries; + } + + if (root.m_type != JsonValue::Type::Array) { + if (p_error) { + *p_error = "Catalog manifest is malformed (expected a top-level array)"; + } + return entries; + } + + entries.reserve(root.m_arrayValue.size()); + for (const JsonValue &item : root.m_arrayValue) { + CatalogEntry entry; + entry.slug = item.GetString("slug"); + entry.title = item.GetString("title"); + entry.description = item.GetString("description"); + entry.format = item.GetString("format"); + entry.category = item.GetString("category"); + entry.group = item.GetString("group"); + entry.thumbnail = item.GetString("thumbnail"); + entry.numPlayers = item.GetInt("n_players"); + entry.isTree = item.GetBool("is_tree"); + entry.isConstSum = item.GetBool("is_const_sum"); + entry.numStrategies = item.GetInt("n_strategies"); + entries.push_back(std::move(entry)); + } + return entries; +} + +wxString CatalogGameFilePath(const wxString &p_catalogRoot, const CatalogEntry &p_entry) +{ + return JoinPath(p_catalogRoot, p_entry.slug + "." + p_entry.format); +} + +} // namespace Gambit::GUI diff --git a/src/gui/catalogdata.h b/src/gui/catalogdata.h new file mode 100644 index 000000000..8b002450d --- /dev/null +++ b/src/gui/catalogdata.h @@ -0,0 +1,67 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/catalogdata.h +// Loading and locating Gambit's games catalog for the catalog browser dialog +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +// + +#ifndef GAMBIT_GUI_CATALOGDATA_H +#define GAMBIT_GUI_CATALOGDATA_H + +#include +#include + +namespace Gambit::GUI { + +/// One entry from catalog/games/manifest.json, describing a single catalog game. +struct CatalogEntry { + wxString slug; // e.g. "books/watson2013/exercise29_6"; also its path under the + // catalog root, without a format extension + wxString title; + wxString description; // citation markup already resolved to plain text by catalog/build.py + wxString format; // "efg" or "nfg" + wxString category; // top-level grouping, e.g. "Books" + wxString group; // immediate grouping, e.g. "Watson (2013) -- Strategy: ..." + wxString thumbnail; // path to a preview image, relative to the catalog root + int numPlayers = 0; + bool isTree = false; + bool isConstSum = false; + int numStrategies = 0; +}; + +/// Locates the root directory of the games catalog (the directory containing +/// manifest.json, the game files, and img/), or returns an empty string if none can be +/// found. Checks a co-located source checkout first (so a developer running the GUI +/// straight out of the build tree sees live catalog edits with no packaging step needed), +/// then falls back to the platform-specific bundled/installed location. +wxString FindCatalogRoot(); + +/// Parses manifest.json from catalogRoot, returning one CatalogEntry per game. On failure +/// (missing or malformed file), returns an empty vector and sets *error to a message +/// suitable for display to the user; on success, *error (if non-null) is cleared. +std::vector LoadCatalogManifest(const wxString &catalogRoot, + wxString *error = nullptr); + +/// Returns the full path to entry's game file under catalogRoot (e.g. +/// "/books/watson2013/exercise29_6.efg"), ready to pass to +/// Application::LoadFile. +wxString CatalogGameFilePath(const wxString &catalogRoot, const CatalogEntry &entry); + +} // namespace Gambit::GUI + +#endif // GAMBIT_GUI_CATALOGDATA_H diff --git a/src/gui/dlcatalog.cc b/src/gui/dlcatalog.cc new file mode 100644 index 000000000..dec3a6b8c --- /dev/null +++ b/src/gui/dlcatalog.cc @@ -0,0 +1,247 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/dlcatalog.cc +// Dialog for browsing and opening a game from Gambit's games catalog +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +// + +#include + +#ifndef WX_PRECOMP +#include +#endif // WX_PRECOMP +#include +#include + +#include "dlcatalog.h" + +namespace Gambit::GUI { + +namespace { + +wxString FormatLabel(const CatalogEntry &p_entry) +{ + return p_entry.isTree ? wxString(_("Extensive")) : wxString(_("Strategic")); +} + +} // namespace + +CatalogBrowserDialog::CatalogBrowserDialog(wxWindow *p_parent) + : wxDialog(p_parent, wxID_ANY, _("Browse Games Catalog"), wxDefaultPosition, + wxSize(FromDIP(780), FromDIP(520)), wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) +{ + m_catalogRoot = FindCatalogRoot(); + wxString error; + if (!m_catalogRoot.IsEmpty()) { + m_allEntries = LoadCatalogManifest(m_catalogRoot, &error); + } + else { + error = _("Gambit could not locate its games catalog on this installation."); + } + + CreateControls(); + + if (m_allEntries.empty()) { + m_searchCtrl->Disable(); + m_formatFilter->Disable(); + m_list->Disable(); + m_detailDescription->SetValue(!error.IsEmpty() ? error + : wxString(_("The games catalog is empty."))); + } + else { + ApplyFilter(); + } +} + +void CatalogBrowserDialog::CreateControls() +{ + const int S = FromDIP(5); + auto *topSizer = new wxBoxSizer(wxVERTICAL); + + auto *filterSizer = new wxBoxSizer(wxHORIZONTAL); + m_searchCtrl = new wxSearchCtrl(this, wxID_ANY); + m_searchCtrl->ShowSearchButton(true); + m_searchCtrl->ShowCancelButton(true); + m_searchCtrl->SetDescriptiveText(_("Search catalog")); + filterSizer->Add(m_searchCtrl, 1, wxALL | wxALIGN_CENTER_VERTICAL, S); + + wxString formatChoices[] = {_("All formats"), _("Extensive form"), _("Strategic form")}; + m_formatFilter = + new wxChoice(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, 3, formatChoices); + m_formatFilter->SetSelection(0); + filterSizer->Add(m_formatFilter, 0, wxALL | wxALIGN_CENTER_VERTICAL, S); + + topSizer->Add(filterSizer, 0, wxEXPAND); + + auto *contentSizer = new wxBoxSizer(wxHORIZONTAL); + + m_list = new wxDataViewListCtrl(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(420), -1)); + m_list->AppendTextColumn(_("Title"), wxDATAVIEW_CELL_INERT, FromDIP(190)); + m_list->AppendTextColumn(_("Source"), wxDATAVIEW_CELL_INERT, FromDIP(150)); + m_list->AppendTextColumn(_("Players"), wxDATAVIEW_CELL_INERT, FromDIP(55)); + m_list->AppendTextColumn(_("Format"), wxDATAVIEW_CELL_INERT, FromDIP(75)); + contentSizer->Add(m_list, 1, wxALL | wxEXPAND, S); + + auto *detailSizer = new wxBoxSizer(wxVERTICAL); + m_detailTitle = new wxStaticText(this, wxID_ANY, wxEmptyString); + wxFont titleFont = m_detailTitle->GetFont(); + titleFont.SetWeight(wxFONTWEIGHT_BOLD); + titleFont.SetPointSize(titleFont.GetPointSize() + 2); + m_detailTitle->SetFont(titleFont); + detailSizer->Add(m_detailTitle, 0, wxALL | wxEXPAND, S); + + m_detailThumbnail = new wxStaticBitmap(this, wxID_ANY, wxNullBitmap, wxDefaultPosition, + wxSize(FromDIP(280), FromDIP(190))); + detailSizer->Add(m_detailThumbnail, 0, wxALL | wxALIGN_CENTER_HORIZONTAL, S); + + m_detailDescription = + new wxTextCtrl(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, + wxTE_MULTILINE | wxTE_READONLY | wxBORDER_NONE); + detailSizer->Add(m_detailDescription, 1, wxALL | wxEXPAND, S); + + contentSizer->Add(detailSizer, 1, wxEXPAND); + topSizer->Add(contentSizer, 1, wxEXPAND); + + if (auto *buttonSizer = CreateStdDialogButtonSizer(wxOK | wxCANCEL)) { + topSizer->Add(buttonSizer, 0, wxALL | wxEXPAND, S); + } + m_openButton = static_cast(FindWindow(wxID_OK)); + if (m_openButton) { + m_openButton->SetLabel(_("Open")); + m_openButton->Disable(); + } + + SetSizer(topSizer); + topSizer->SetSizeHints(this); + CenterOnParent(); + + m_searchCtrl->Bind(wxEVT_TEXT, &CatalogBrowserDialog::OnSearchText, this); + m_formatFilter->Bind(wxEVT_CHOICE, &CatalogBrowserDialog::OnFormatFilter, this); + m_list->Bind(wxEVT_DATAVIEW_SELECTION_CHANGED, &CatalogBrowserDialog::OnSelectionChanged, this); + m_list->Bind(wxEVT_DATAVIEW_ITEM_ACTIVATED, &CatalogBrowserDialog::OnItemActivated, this); + Bind(wxEVT_BUTTON, &CatalogBrowserDialog::OnOpen, this, wxID_OK); +} + +void CatalogBrowserDialog::ApplyFilter() +{ + wxString needle = m_searchCtrl->GetValue().Lower(); + int formatSelection = m_formatFilter->GetSelection(); // 0 = all, 1 = efg, 2 = nfg + + m_visibleIndices.clear(); + m_list->DeleteAllItems(); + + for (size_t i = 0; i < m_allEntries.size(); i++) { + const CatalogEntry &entry = m_allEntries[i]; + if (formatSelection == 1 && entry.format != "efg") { + continue; + } + if (formatSelection == 2 && entry.format != "nfg") { + continue; + } + if (!needle.IsEmpty()) { + wxString haystack = + (entry.title + " " + entry.description + " " + entry.group + " " + entry.category) + .Lower(); + if (haystack.Find(needle) == wxNOT_FOUND) { + continue; + } + } + + m_visibleIndices.push_back(i); + wxVector row; + row.push_back(wxVariant(entry.title)); + row.push_back(wxVariant(entry.group)); + row.push_back(wxVariant(wxString::Format("%d", entry.numPlayers))); + row.push_back(wxVariant(FormatLabel(entry))); + m_list->AppendItem(row); + } + + UpdateDetailsPane(); +} + +int CatalogBrowserDialog::GetSelectedEntryIndex() const +{ + int row = m_list->GetSelectedRow(); + if (row == wxNOT_FOUND || static_cast(row) >= m_visibleIndices.size()) { + return -1; + } + return static_cast(m_visibleIndices[row]); +} + +void CatalogBrowserDialog::UpdateDetailsPane() +{ + int index = GetSelectedEntryIndex(); + if (index < 0) { + m_detailTitle->SetLabel(wxEmptyString); + m_detailDescription->SetValue(wxEmptyString); + m_detailThumbnail->SetBitmap(wxNullBitmap); + m_openButton->Disable(); + return; + } + + const CatalogEntry &entry = m_allEntries[static_cast(index)]; + m_detailTitle->SetLabel(entry.title); + m_detailDescription->SetValue(entry.description); + + wxString thumbnailPath = m_catalogRoot + "/" + entry.thumbnail; + wxImage image; + if (!entry.thumbnail.IsEmpty() && wxFileExists(thumbnailPath) && + image.LoadFile(thumbnailPath, wxBITMAP_TYPE_PNG)) { + wxSize target = m_detailThumbnail->GetSize(); + double scale = std::min(static_cast(target.GetWidth()) / image.GetWidth(), + static_cast(target.GetHeight()) / image.GetHeight()); + if (scale < 1.0 && scale > 0.0) { + image.Rescale(static_cast(image.GetWidth() * scale), + static_cast(image.GetHeight() * scale), wxIMAGE_QUALITY_HIGH); + } + m_detailThumbnail->SetBitmap(wxBitmap(image)); + } + else { + m_detailThumbnail->SetBitmap(wxNullBitmap); + } + + m_openButton->Enable(); + Layout(); +} + +wxString CatalogBrowserDialog::GetSelectedFilePath() const { return m_selectedFilePath; } + +void CatalogBrowserDialog::OnSearchText(wxCommandEvent &) { ApplyFilter(); } + +void CatalogBrowserDialog::OnFormatFilter(wxCommandEvent &) { ApplyFilter(); } + +void CatalogBrowserDialog::OnSelectionChanged(wxDataViewEvent &) { UpdateDetailsPane(); } + +void CatalogBrowserDialog::OnItemActivated(wxDataViewEvent &) +{ + wxCommandEvent dummy; + OnOpen(dummy); +} + +void CatalogBrowserDialog::OnOpen(wxCommandEvent &) +{ + int index = GetSelectedEntryIndex(); + if (index < 0) { + return; + } + m_selectedFilePath = + CatalogGameFilePath(m_catalogRoot, m_allEntries[static_cast(index)]); + EndModal(wxID_OK); +} + +} // namespace Gambit::GUI diff --git a/src/gui/dlcatalog.h b/src/gui/dlcatalog.h new file mode 100644 index 000000000..a21ac6d9c --- /dev/null +++ b/src/gui/dlcatalog.h @@ -0,0 +1,80 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/dlcatalog.h +// Dialog for browsing and opening a game from Gambit's games catalog +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +// + +#ifndef GAMBIT_GUI_DLCATALOG_H +#define GAMBIT_GUI_DLCATALOG_H + +#include + +#include "catalogdata.h" + +class wxSearchCtrl; +class wxChoice; +class wxDataViewListCtrl; +class wxDataViewEvent; +class wxStaticText; +class wxStaticBitmap; +class wxTextCtrl; +class wxButton; + +namespace Gambit::GUI { + +/// Lets the user search, filter, and preview games from Gambit's catalog, and pick one to +/// open. On wxID_OK, GetSelectedFilePath() returns the file to pass to +/// Application::LoadFile(); if the catalog could not be located or its manifest could not +/// be parsed, the dialog shows that error in place of the browser and disables opening. +class CatalogBrowserDialog final : public wxDialog { +public: + explicit CatalogBrowserDialog(wxWindow *p_parent); + + /// Valid only after ShowModal() has returned wxID_OK. + wxString GetSelectedFilePath() const; + +private: + wxString m_catalogRoot; + std::vector m_allEntries; + std::vector m_visibleIndices; // indices into m_allEntries, in current row order + wxString m_selectedFilePath; + + wxSearchCtrl *m_searchCtrl = nullptr; + wxChoice *m_formatFilter = nullptr; + wxDataViewListCtrl *m_list = nullptr; + wxStaticText *m_detailTitle = nullptr; + wxStaticBitmap *m_detailThumbnail = nullptr; + wxTextCtrl *m_detailDescription = nullptr; + wxButton *m_openButton = nullptr; + + void CreateControls(); + void ApplyFilter(); + void UpdateDetailsPane(); + int GetSelectedEntryIndex() const; + + void OnSearchText(wxCommandEvent &p_event); + void OnFormatFilter(wxCommandEvent &p_event); + void OnSelectionChanged(wxDataViewEvent &p_event); + void OnItemActivated(wxDataViewEvent &p_event); + void OnOpen(wxCommandEvent &p_event); +}; + +} // namespace Gambit::GUI + +#endif // GAMBIT_GUI_DLCATALOG_H diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index 238b62013..b69854efd 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -50,6 +50,7 @@ #include "nfgpanel.h" #include "nfgprofile.h" +#include "dlcatalog.h" #include "dlexcept.h" #include "dlgameprop.h" #include "dlnash.h" @@ -211,6 +212,7 @@ BEGIN_EVENT_TABLE(GameFrame, wxFrame) EVT_MENU(GBT_MENU_FILE_NEW_EFG, GameFrame::OnFileNewEfg) EVT_MENU(GBT_MENU_FILE_NEW_NFG, GameFrame::OnFileNewNfg) EVT_MENU(wxID_OPEN, GameFrame::OnFileOpen) +EVT_MENU(GBT_MENU_FILE_OPEN_CATALOG, GameFrame::OnFileOpenCatalog) EVT_MENU(wxID_CLOSE, GameFrame::OnFileClose) EVT_MENU(wxID_SAVE, GameFrame::OnFileSave) EVT_MENU(wxID_SAVEAS, GameFrame::OnFileSave) @@ -391,6 +393,8 @@ void GameFrame::MakeMenus() fileMenu->Append(wxID_NEW, _("&New"), fileNewMenu, _("Create a new game")); fileMenu->Append(wxID_OPEN, _("&Open\tCtrl-O"), _("Open a saved game")); + fileMenu->Append(GBT_MENU_FILE_OPEN_CATALOG, _("Open from &Catalog..."), + _("Browse and open a game from Gambit's games catalog")); fileMenu->AppendSeparator(); fileMenu->Append(wxID_SAVE, _("&Save\tCtrl-S"), _("Save this game")); @@ -562,6 +566,14 @@ void GameFrame::OnFileOpen(wxCommandEvent &) } } +void GameFrame::OnFileOpenCatalog(wxCommandEvent &) +{ + CatalogBrowserDialog dialog(this); + if (dialog.ShowModal() == wxID_OK) { + wxGetApp().LoadFile(dialog.GetSelectedFilePath(), this); + } +} + void GameFrame::OnFileClose(wxCommandEvent &) { Close(); } void GameFrame::OnFileSave(wxCommandEvent &p_event) diff --git a/src/gui/gameframe.h b/src/gui/gameframe.h index e18e0ed23..82ddc28e0 100644 --- a/src/gui/gameframe.h +++ b/src/gui/gameframe.h @@ -54,6 +54,7 @@ class GameFrame final : public wxFrame, public GameView { void OnFileNewEfg(wxCommandEvent &); void OnFileNewNfg(wxCommandEvent &); void OnFileOpen(wxCommandEvent &); + void OnFileOpenCatalog(wxCommandEvent &); void OnFileClose(wxCommandEvent &); void OnFileSave(wxCommandEvent &); void OnFileExportGraphic(wxCommandEvent &); diff --git a/src/gui/menuconst.h b/src/gui/menuconst.h index 9cd202ce1..fedc7b0ee 100644 --- a/src/gui/menuconst.h +++ b/src/gui/menuconst.h @@ -27,6 +27,7 @@ namespace Gambit::GUI { enum MenuItems { GBT_MENU_FILE_NEW_EFG = 1098, GBT_MENU_FILE_NEW_NFG = 1099, + GBT_MENU_FILE_OPEN_CATALOG = 1101, GBT_MENU_FILE_EXPORT = 1102, GBT_MENU_FILE_EXPORT_BMP = 1103, GBT_MENU_FILE_EXPORT_JPEG = 1104, diff --git a/src/gui/welcome.cc b/src/gui/welcome.cc index 1ea42f35d..7e56f6924 100644 --- a/src/gui/welcome.cc +++ b/src/gui/welcome.cc @@ -10,6 +10,7 @@ #include "welcome.h" #include "app.h" +#include "dlcatalog.h" #include "dlnewtable.h" #include "gamedoc.h" #include "gameframe.h" @@ -19,11 +20,13 @@ using namespace Gambit::GUI; wxDEFINE_EVENT(wxEVT_WELCOME_OPEN, wxCommandEvent); wxDEFINE_EVENT(wxEVT_WELCOME_NEW, wxCommandEvent); +wxDEFINE_EVENT(wxEVT_WELCOME_CATALOG, wxCommandEvent); namespace { constexpr int ID_WELCOME_OPEN = wxID_HIGHEST + 100; constexpr int ID_WELCOME_NEW_NORMAL_FORM = wxID_HIGHEST + 101; constexpr int ID_WELCOME_NEW_EXTENSIVE_FORM = wxID_HIGHEST + 102; +constexpr int ID_WELCOME_CATALOG = wxID_HIGHEST + 103; } // namespace // -------------------- @@ -85,6 +88,9 @@ void WelcomePanel::CreateControls() new wxCommandLinkButton(this, ID_WELCOME_NEW_EXTENSIVE_FORM, "New extensive form game", "Create a game with a tree representation"); + m_catalogButton = new wxCommandLinkButton(this, ID_WELCOME_CATALOG, "Browse catalog", + "Open a curated game from Gambit's games catalog"); + m_openButton->SetBitmap(wxBitmapBundle::FromSVG(open_svg, wxSize(24, 24))); m_openButton->SetBitmapMargins(24, 12); @@ -98,10 +104,12 @@ void WelcomePanel::CreateControls() m_openButton->SetMinSize(buttonSize); m_newNormalFormButton->SetMinSize(buttonSize); m_newExtensiveFormButton->SetMinSize(buttonSize); + m_catalogButton->SetMinSize(buttonSize); Bind(wxEVT_BUTTON, &WelcomePanel::OnOpen, this, ID_WELCOME_OPEN); Bind(wxEVT_BUTTON, &WelcomePanel::OnNewNormalForm, this, ID_WELCOME_NEW_NORMAL_FORM); Bind(wxEVT_BUTTON, &WelcomePanel::OnNewExtensiveForm, this, ID_WELCOME_NEW_EXTENSIVE_FORM); + Bind(wxEVT_BUTTON, &WelcomePanel::OnCatalog, this, ID_WELCOME_CATALOG); } void WelcomePanel::LayoutControls() @@ -110,11 +118,14 @@ void WelcomePanel::LayoutControls() auto *row = new wxBoxSizer(wxHORIZONTAL); auto *content = new wxBoxSizer(wxVERTICAL); - content->Add(m_logoBitmap, 0, wxALIGN_CENTER_HORIZONTAL | wxBOTTOM, 16); - content->Add(m_titleText, 0, wxALIGN_CENTER_HORIZONTAL | wxBOTTOM, 20); + content->Add(m_logoBitmap, 0, wxALIGN_CENTER_HORIZONTAL | wxBOTTOM, 20); + content->Add(m_titleText, 0, wxALIGN_CENTER_HORIZONTAL | wxBOTTOM, 28); - content->Add(m_openButton, 0, wxEXPAND | wxBOTTOM, 16); - content->Add(m_newNormalFormButton, 0, wxEXPAND | wxBOTTOM, 16); + // "Open" and "Browse catalog" are both ways to load an existing game, grouped above + // the two "New ..." buttons, which create one from scratch. + content->Add(m_openButton, 0, wxEXPAND | wxBOTTOM, 18); + content->Add(m_catalogButton, 0, wxEXPAND | wxBOTTOM, 18); + content->Add(m_newNormalFormButton, 0, wxEXPAND | wxBOTTOM, 18); content->Add(m_newExtensiveFormButton, 0, wxEXPAND, 0); row->AddStretchSpacer(1); @@ -140,6 +151,8 @@ void WelcomePanel::OnNewExtensiveForm(wxCommandEvent &) SendNewEvent(WelcomeNewProblemKind::ExtensiveForm); } +void WelcomePanel::OnCatalog(wxCommandEvent &) { SendCatalogEvent(); } + void WelcomePanel::SendOpenEvent() { wxCommandEvent event(wxEVT_WELCOME_OPEN); @@ -155,12 +168,19 @@ void WelcomePanel::SendNewEvent(WelcomeNewProblemKind p_kind) GetParent()->ProcessWindowEvent(event); } +void WelcomePanel::SendCatalogEvent() +{ + wxCommandEvent event(wxEVT_WELCOME_CATALOG); + event.SetEventObject(this); + GetParent()->ProcessWindowEvent(event); +} + // -------------------- // WelcomeFrame // -------------------- WelcomeFrame::WelcomeFrame(wxWindow *parent) - : wxFrame(parent, wxID_ANY, wxT("Gambit"), wxDefaultPosition, wxSize(600, 500), + : wxFrame(parent, wxID_ANY, wxT("Gambit"), wxDefaultPosition, wxSize(600, 620), wxDEFAULT_FRAME_STYLE & ~(wxRESIZE_BORDER | wxMAXIMIZE_BOX)) { CreateControls(); @@ -168,6 +188,7 @@ WelcomeFrame::WelcomeFrame(wxWindow *parent) Bind(wxEVT_WELCOME_OPEN, &WelcomeFrame::OnWelcomeOpen, this); Bind(wxEVT_WELCOME_NEW, &WelcomeFrame::OnWelcomeNew, this); + Bind(wxEVT_WELCOME_CATALOG, &WelcomeFrame::OnWelcomeCatalog, this); Bind(wxEVT_CLOSE_WINDOW, &WelcomeFrame::OnClose, this); CentreOnScreen(); @@ -198,6 +219,13 @@ void WelcomeFrame::OnWelcomeNew(wxCommandEvent &p_event) } } +void WelcomeFrame::OnWelcomeCatalog(wxCommandEvent &) +{ + if (DoCatalog()) { + Destroy(); + } +} + void WelcomeFrame::OnClose(wxCloseEvent &p_event) { p_event.Skip(); } bool WelcomeFrame::DoOpen() @@ -232,3 +260,12 @@ bool WelcomeFrame::DoCreateNew(WelcomeNewProblemKind p_kind) return true; } + +bool WelcomeFrame::DoCatalog() +{ + CatalogBrowserDialog dialog(this); + if (dialog.ShowModal() != wxID_OK) { + return false; + } + return wxGetApp().LoadFile(dialog.GetSelectedFilePath(), this) == AppLoadResult::Success; +} diff --git a/src/gui/welcome.h b/src/gui/welcome.h index c74d484a0..8ef5ca832 100644 --- a/src/gui/welcome.h +++ b/src/gui/welcome.h @@ -15,6 +15,7 @@ enum class WelcomeNewProblemKind { NormalForm, ExtensiveForm }; wxDECLARE_EVENT(wxEVT_WELCOME_OPEN, wxCommandEvent); wxDECLARE_EVENT(wxEVT_WELCOME_NEW, wxCommandEvent); +wxDECLARE_EVENT(wxEVT_WELCOME_CATALOG, wxCommandEvent); class WelcomePanel : public wxPanel { public: @@ -27,9 +28,11 @@ class WelcomePanel : public wxPanel { void OnOpen(wxCommandEvent &p_event); void OnNewNormalForm(wxCommandEvent &p_event); void OnNewExtensiveForm(wxCommandEvent &p_event); + void OnCatalog(wxCommandEvent &p_event); void SendOpenEvent(); void SendNewEvent(WelcomeNewProblemKind p_kind); + void SendCatalogEvent(); wxStaticBitmap *m_logoBitmap{nullptr}; wxStaticText *m_titleText{nullptr}; @@ -37,6 +40,7 @@ class WelcomePanel : public wxPanel { wxCommandLinkButton *m_openButton{nullptr}; wxCommandLinkButton *m_newNormalFormButton{nullptr}; wxCommandLinkButton *m_newExtensiveFormButton{nullptr}; + wxCommandLinkButton *m_catalogButton{nullptr}; }; class WelcomeFrame : public wxFrame { @@ -49,10 +53,12 @@ class WelcomeFrame : public wxFrame { void OnWelcomeOpen(wxCommandEvent &p_event); void OnWelcomeNew(wxCommandEvent &p_event); + void OnWelcomeCatalog(wxCommandEvent &p_event); void OnClose(wxCloseEvent &p_event); bool DoOpen(); bool DoCreateNew(WelcomeNewProblemKind p_kind); + bool DoCatalog(); private: WelcomePanel *m_panel{nullptr};