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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
8 changes: 6 additions & 2 deletions .github/workflows/tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
9 changes: 8 additions & 1 deletion build_support/msw/gambit.wxs.in
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@

<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="ApplicationRootDirectory" Name="Gambit"/>
<Directory Id="ApplicationRootDirectory" Name="Gambit">
<!-- Populated by heat.exe harvesting installer\catalog into the
CatalogFiles component group (referenced below); see the
"windows" job in .github/workflows/tools.yml for the exact
heat/candle/light invocation. -->
<Directory Id="CATALOGFOLDER" Name="catalog"/>
</Directory>
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="Gambit"/>
Expand Down Expand Up @@ -105,6 +111,7 @@
<ComponentRef Id="Gambit.exe"/>
<ComponentRef Id="Gambit.copying"/>
<ComponentRef Id="ApplicationShortcut"/>
<ComponentGroupRef Id="CatalogFiles"/>
</Feature>
</Product>
</Wix>
33 changes: 33 additions & 0 deletions catalog/.readthedocs.yml
Original file line number Diff line number Diff line change
@@ -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
138 changes: 136 additions & 2 deletions catalog/build.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
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

import pygambit as gbt

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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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=(
Expand All @@ -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(
Expand Down Expand Up @@ -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()
3 changes: 2 additions & 1 deletion catalog/catalog.am
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion catalog/doc/developer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down Expand Up @@ -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 <https://gambitproject.readthedocs.io/en/latest/developer.contributing.html#submit-contribution>`_.
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::

Expand Down
Loading
Loading