diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f098605eb1..0d61c39446 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -61,7 +61,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: @@ -76,11 +76,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 a9c1f7a4d1..6969a10c07 100644 --- a/.github/workflows/tools.yml +++ b/.github/workflows/tools.yml @@ -75,8 +75,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 e843c4df99..7d8348568c 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 f43976bd56..4503be759c 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/.readthedocs.yml b/catalog/.readthedocs.yml new file mode 100644 index 0000000000..7dee191a91 --- /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/build.py b/catalog/build.py index 1aa76eba3b..c98cdeb017 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": isinstance(game, gbt.ExtensiveGame), + "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=( @@ -413,8 +543,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( @@ -445,5 +576,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 d91d45e75e..a32780f99b 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 dc33bc6b96..1a1744236e 100644 --- a/catalog/doc/developer.rst +++ b/catalog/doc/developer.rst @@ -119,6 +119,11 @@ Currently supported representations are: - If you 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:: @@ -152,7 +157,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 0000000000..42e18933bb --- /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 8bb0a4a175..7557f1b56c 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/doc/catalog.csv b/doc/catalog.csv new file mode 100644 index 0000000000..3a7b50abfb --- /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/pyproject.toml b/pyproject.toml index 90206b25e1..a3aa13173a 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 05a8e8b0f1..57099b091b 100644 --- a/src/gui/app.cc +++ b/src/gui/app.cc @@ -67,6 +67,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 0000000000..9c0f1d64a0 --- /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 0000000000..8b002450dd --- /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 0000000000..dec3a6b8c1 --- /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 0000000000..a21ac6d9c1 --- /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 0144a8a221..43298024d1 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -51,6 +51,7 @@ #include "nfgpanel.h" #include "nfgprofile.h" +#include "dlcatalog.h" #include "dlexcept.h" #include "dlgameprop.h" #include "dlnash.h" @@ -212,6 +213,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) @@ -392,6 +394,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")); @@ -563,6 +567,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 e18e0ed23b..82ddc28e0e 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 9cd202ce11..fedc7b0eef 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 b5267bdb48..8865fde789 100644 --- a/src/gui/welcome.cc +++ b/src/gui/welcome.cc @@ -19,6 +19,7 @@ #include "welcome.h" #include "app.h" +#include "dlcatalog.h" #include "dlnewtable.h" #include "gamedoc.h" #include "gameframe.h" @@ -28,6 +29,7 @@ using namespace Gambit::GUI; wxDEFINE_EVENT(wxEVT_WELCOME_OPEN, wxCommandEvent); wxDEFINE_EVENT(wxEVT_WELCOME_NEW, wxCommandEvent); +wxDEFINE_EVENT(wxEVT_WELCOME_CATALOG, wxCommandEvent); wxDEFINE_EVENT(wxEVT_WELCOME_OPEN_RECENT, wxCommandEvent); namespace { @@ -35,6 +37,7 @@ 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_RECENT = wxID_HIGHEST + 103; +constexpr int ID_WELCOME_CATALOG = wxID_HIGHEST + 104; constexpr int BUTTON_WIDTH = 340; constexpr int BUTTON_HEIGHT = 72; @@ -352,6 +355,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); @@ -365,6 +371,7 @@ void WelcomePanel::CreateControls() m_openButton->SetMinSize(wxSize(BUTTON_WIDTH - RECENT_BUTTON_WIDTH, BUTTON_HEIGHT)); m_newNormalFormButton->SetMinSize(wxSize(BUTTON_WIDTH, BUTTON_HEIGHT)); m_newExtensiveFormButton->SetMinSize(wxSize(BUTTON_WIDTH, BUTTON_HEIGHT)); + m_catalogButton->SetMinSize(wxSize(BUTTON_WIDTH, BUTTON_HEIGHT)); m_recentButton = new wxButton(this, ID_WELCOME_RECENT, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); @@ -375,6 +382,7 @@ void WelcomePanel::CreateControls() 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); Bind(wxEVT_BUTTON, &WelcomePanel::OnShowRecent, this, ID_WELCOME_RECENT); } @@ -391,14 +399,17 @@ 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); auto *openRow = new wxBoxSizer(wxHORIZONTAL); openRow->Add(m_openButton, 1, wxEXPAND); openRow->Add(m_recentButton, 0, wxEXPAND); + // "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(openRow, 0, wxEXPAND | wxBOTTOM, 16); + content->Add(m_catalogButton, 0, wxEXPAND | wxBOTTOM, 16); content->Add(m_newNormalFormButton, 0, wxEXPAND | wxBOTTOM, 16); content->Add(m_newExtensiveFormButton, 0, wxEXPAND, 0); @@ -425,6 +436,7 @@ void WelcomePanel::OnNewExtensiveForm(wxCommandEvent &) SendNewEvent(WelcomeNewProblemKind::ExtensiveForm); } +void WelcomePanel::OnCatalog(wxCommandEvent &) { SendCatalogEvent(); } void WelcomePanel::OnShowRecent(wxCommandEvent &) { if (!m_recentPopup) { @@ -463,12 +475,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(); @@ -476,6 +495,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_WELCOME_OPEN_RECENT, &WelcomeFrame::OnWelcomeOpenRecent, this); Bind(wxEVT_CLOSE_WINDOW, &WelcomeFrame::OnClose, this); @@ -507,6 +527,13 @@ void WelcomeFrame::OnWelcomeNew(wxCommandEvent &p_event) } } +void WelcomeFrame::OnWelcomeCatalog(wxCommandEvent &) +{ + if (DoCatalog()) { + Destroy(); + } +} + void WelcomeFrame::OnWelcomeOpenRecent(wxCommandEvent &p_event) { if (DoOpenRecent(p_event.GetString())) { @@ -558,3 +585,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 4d91a14d0e..4b88d66891 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); wxDECLARE_EVENT(wxEVT_WELCOME_OPEN_RECENT, wxCommandEvent); class WelcomePanel : public wxPanel { @@ -31,10 +32,12 @@ 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 OnShowRecent(wxCommandEvent &p_event); void SendOpenEvent(); void SendNewEvent(WelcomeNewProblemKind p_kind); + void SendCatalogEvent(); wxStaticBitmap *m_logoBitmap{nullptr}; wxStaticText *m_titleText{nullptr}; @@ -43,6 +46,7 @@ class WelcomePanel : public wxPanel { wxButton *m_recentButton{nullptr}; wxCommandLinkButton *m_newNormalFormButton{nullptr}; wxCommandLinkButton *m_newExtensiveFormButton{nullptr}; + wxCommandLinkButton *m_catalogButton{nullptr}; RecentFilesPopup *m_recentPopup{nullptr}; }; @@ -57,12 +61,14 @@ class WelcomeFrame : public wxFrame { void OnWelcomeOpen(wxCommandEvent &p_event); void OnWelcomeNew(wxCommandEvent &p_event); + void OnWelcomeCatalog(wxCommandEvent &p_event); void OnWelcomeOpenRecent(wxCommandEvent &p_event); void OnClose(wxCloseEvent &p_event); bool DoOpen(); bool DoOpenRecent(const wxString &p_filename); bool DoCreateNew(WelcomeNewProblemKind p_kind); + bool DoCatalog(); private: WelcomePanel *m_panel{nullptr};