From 6085e9dd68aac12142a6252631d8e138cfcee23a Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:29:40 +0100 Subject: [PATCH 01/32] docs: add wyz2368 as a contributor for code, and research (#970) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3b8675da2..02f07de69 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -73,6 +73,16 @@ "contributions": [ "code" ] + }, + { + "login": "wyz2368", + "name": "wyz2368", + "avatar_url": "https://avatars.githubusercontent.com/u/25018133?v=4", + "profile": "https://github.com/wyz2368", + "contributions": [ + "code", + "research" + ] } ] } diff --git a/README.md b/README.md index 69ed98074..288c52458 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ installable via PyPI. StephenPasteris
StephenPasteris

🔬 💻 Daniel Kadnikov
Daniel Kadnikov

🔬 💻 Andrés Fernández Cervell
Andrés Fernández Cervell

💻 + wyz2368
wyz2368

💻 🔬 From 5482aaae9936cc53141e498ff2d0c65ead68584d Mon Sep 17 00:00:00 2001 From: Daniel Kadnikov <165307096+d-kad@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:40:36 +0100 Subject: [PATCH 02/32] Reject malformed AGG/BAGG headers before construction (#971) Fixes a segfault in the AGG/BAGG file readers on malformed input. Malformed input (e.g. an `.nfg` to `read_bagg`, or a degenerate `"0 0 0"` header) slipped past the header checks and dereferenced an empty vector in the AGG constructor, segfaulting. The parser code is unchanged from `master` and runs before any label normalization. This validates header fields before construction; malformed headers now raise `ValueError`, valid files are unaffected. Tests added: `test_read_agg_zero_header` / `test_read_bagg_zero_header` read `"0 0 0\n"` and expect `ValueError`. These segfault on unpatched code (taking down the `pytest` process, as in CI) rather than failing as an assertion. --- ChangeLog | 3 +++ src/games/agg/agg.cc | 4 ++++ src/games/agg/bagg.cc | 22 +++++++++++++--------- tests/test_io.py | 10 ++++++++++ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/ChangeLog b/ChangeLog index bca1fd127..db414c5a8 100644 --- a/ChangeLog +++ b/ChangeLog @@ -25,6 +25,9 @@ - Corrected calculation of total number of actions for a player in `pygambit` (#938) - Corrected a regression in action graph games that left the internal data structure not fully initialised, leading to segmentation faults. +- Corrected handling of malformed AGG/BAGG files: files with an invalid or degenerate header + (for example a wrong file type, or a header declaring zero players) are now rejected with a + `ValueError` instead of causing a segmentation fault while constructing the game. ### Changed - Added a new welcome/landing window on launching the GUI without a game. This has the effect of diff --git a/src/games/agg/agg.cc b/src/games/agg/agg.cc index f5f1a2ba8..91e495c88 100644 --- a/src/games/agg/agg.cc +++ b/src/games/agg/agg.cc @@ -152,6 +152,10 @@ std::shared_ptr AGG::makeAGG(istream &in) if (!in.good()) { throw std::runtime_error("Error reading the number of function nodes"); } + if (n <= 0 || S < 0 || P < 0) { + throw std::runtime_error("Error in game file: invalid AGG header (number of players, " + "action nodes, or function nodes out of range)"); + } stripComment(in); // enter sizes of action sets: diff --git a/src/games/agg/bagg.cc b/src/games/agg/bagg.cc index ab58e63b6..12d36de06 100644 --- a/src/games/agg/bagg.cc +++ b/src/games/agg/bagg.cc @@ -73,18 +73,22 @@ std::shared_ptr BAGG::makeBAGG(istream &in) in >> S; stripComment(in); in >> P; + if (!in || N <= 0 || S < 0 || P < 0) { + throw std::runtime_error("Error in game file: expected BAGG header with number of " + "players, action nodes, and function nodes"); + } stripComment(in); vector numTypes(N); // input number of types for each player for (int i = 0; i < N; ++i) { - if (in.eof() || in.bad()) { + in >> numTypes[i]; + if (!in) { throw std::runtime_error( "Error in game file: integer expected for the number of types for player " + std::to_string(i)); } - in >> numTypes[i]; } // input the type distributions @@ -93,10 +97,10 @@ std::shared_ptr BAGG::makeBAGG(istream &in) for (int i = 0; i < N; ++i) { TDist.emplace_back(numTypes[i]); for (int j = 0; j < numTypes[i]; ++j) { - if (in.eof() || in.bad()) { + in >> TDist[i][j]; + if (!in) { throw std::runtime_error("Error in game file: number expected for type distribution"); } - in >> TDist[i][j]; } } @@ -105,12 +109,12 @@ std::shared_ptr BAGG::makeBAGG(istream &in) vector>> typeActionSets(N); for (int i = 0; i < N; ++i) { for (int j = 0; j < numTypes[i]; ++j) { - if (in.eof() || in.bad()) { + int temp; + in >> temp; + if (!in || temp < 0) { throw std::runtime_error( "Error in game file: integer expected for size of type action set"); } - int temp; - in >> temp; typeActionSets[i].emplace_back(temp); } } @@ -120,10 +124,10 @@ std::shared_ptr BAGG::makeBAGG(istream &in) for (int i = 0; i < N; ++i) { for (int j = 0; j < numTypes[i]; ++j) { for (int &el : typeActionSets[i][j]) { - if (in.eof() || in.bad()) { + in >> el; + if (!in) { throw std::runtime_error("Error in game file: integer expected for type action set"); } - in >> el; } } } diff --git a/tests/test_io.py b/tests/test_io.py index e0107f23a..826e7fc14 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -49,6 +49,11 @@ def test_read_agg_invalid(): gbt.read_agg(game_path) +def test_read_agg_zero_header(): + with pytest.raises(ValueError): + gbt.read_agg(io.StringIO("0 0 0\n")) + + def test_read_bagg(): game_path = os.path.join("contrib", "games", "Bayesian-Coffee-3-2-2-3.bagg") game = gbt.read_bagg(game_path) @@ -63,6 +68,11 @@ def test_read_bagg_invalid(): gbt.read_bagg(game_path) +def test_read_bagg_zero_header(): + with pytest.raises(ValueError): + gbt.read_bagg(io.StringIO("0 0 0\n")) + + def test_read_gbt_invalid(): game_path = os.path.join( "tests", "test_games", "2x2x2_nfg_from_local_max_cut_2_pure_1_mixed_eq.nfg" From 39deff0fa3863980b572037702457c7db02b509f Mon Sep 17 00:00:00 2001 From: ameliekleber <83069628+ameliekleber@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:41:25 +0200 Subject: [PATCH 03/32] Add enumpoly solver strategic-form tests (#969) * Added 3 player max cut test for enumpoly strategic form * added strategic game tests for enumpoly strategic form: 3x3 coordination game, 3-player unique Nash (nau2004 sec4), 2x2x2 3 pure Nash (nau2004 sec5) --- pyproject.toml | 1 + ...g_from_local_max_cut_2_pure_1_mixed_eq.nfg | 1 + tests/test_nash.py | 79 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1b36d50ef..e2e4e55f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,7 @@ markers = [ "nash_enumpure_strategy: tests of enumpure_solve in pure strategies", "nash_enumpure_agent: tests of enumpure_solve in pure behaviors", "nash_enummixed_strategy: tests of enummixed_solve in mixed strategies", + "nash_enumpoly_strategy: tests of enumpoly_solve in mixed strategies", "nash_enumpoly_behavior: tests of enumpoly_solve in mixed behaviors", "nash_lcp_strategy: tests of lcp_solve in mixed strategies", "nash_lcp_behavior: tests of lcp_solve in mixed behaviors", diff --git a/tests/test_games/2x2x2_nfg_from_local_max_cut_2_pure_1_mixed_eq.nfg b/tests/test_games/2x2x2_nfg_from_local_max_cut_2_pure_1_mixed_eq.nfg index f28f234f6..f1958f904 100644 --- a/tests/test_games/2x2x2_nfg_from_local_max_cut_2_pure_1_mixed_eq.nfg +++ b/tests/test_games/2x2x2_nfg_from_local_max_cut_2_pure_1_mixed_eq.nfg @@ -6,6 +6,7 @@ NFG 1 R "2x2x2 game with 2 pure and 1 mixed equilibrium" - Pure strategies {a,b} encode if respective player is on left or right of the cut - The payoff to a player is the sum of their incident edges across the implied cut - Pure equilibrium iff local max cuts; in addition, uniform mixture is an equilibrium +- In the mixed equilibrium all players mix strategies with equal probability (0.5, 0.5) - Equilibrium analysis for pure profiles: a a a: 0 0 0 -- Not Nash (regrets: 1, 4, 1) b a a: 1 2 -1 -- Not Nash (regrets: 0, 0, 3) diff --git a/tests/test_nash.py b/tests/test_nash.py index a3b171d0f..065f7fb28 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -411,6 +411,84 @@ class QREquilibriumTestCase: ] +ENUMPOLY_STRATEGY_CASES = [ + # 2x2x2 strategic form game based on local max cut -- 2 pure and 1 mixed + pytest.param( + EquilibriumTestCase( + factory=functools.partial( + games.read_from_file, "2x2x2_nfg_from_local_max_cut_2_pure_1_mixed_eq.nfg" + ), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [d(1, 0), d(0, 1), d(1, 0)], + [d(0, 1), d(1, 0), d(0, 1)], + [d("1/2", "1/2"), d("1/2", "1/2"), d("1/2", "1/2")], + ], + prob_tol=TOL, + regret_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_strategy, + id="test_enumpoly_strategy_1", + ), + # coordination game with 3 pure and 4 mixed equilibria + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=3), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None, use_strategic=True), + expected=[ + [d(1, 0, 0), d(1, 0, 0)], + [d(0, 1, 0), d(0, 1, 0)], + [d(0, 0, 1), d(0, 0, 1)], + [d("1/2", "1/2", 0), d("1/2", "1/2", 0)], + [d("1/2", 0, "1/2"), d("1/2", 0, "1/2")], + [d(0, "1/2", "1/2"), d(0, "1/2", "1/2")], + [d("1/3", "1/3", "1/3"), d("1/3", "1/3", "1/3")], + ], + prob_tol=TOL, + regret_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_strategy, + id="test_enumpoly_strategy_2", + ), + # A three-player game with a unique Nash equilibrium in irrational mixed strategies + # (nau2004 sec4 catalog game) + pytest.param( + EquilibriumTestCase( + factory=functools.partial(gbt.catalog.load, "journals/ijgt/nau2004/sec4"), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [ + d(0.6192325794725537, 0.3807674205274463), + d(0.4798042226776053, 0.5201957773223946), + d(0.3788253360656313, 0.6211746639343687) + ], + ], + prob_tol=TOL, + regret_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_strategy, + id="test_enumpoly_strategy_3", + ), + # A three-player 2x2x2 game with 3 pure, 2 incompletely mixed, and a + # continuum of completely mixed Nash equilibria (nau2004 sec5 catalog game) + pytest.param( + EquilibriumTestCase( + factory=functools.partial(gbt.catalog.load, "journals/ijgt/nau2004/sec5"), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [d(1, 0), d(0, 1), d(1, 0)], + [d(0, 1), d(1, 0), d(1, 0)], + [d(0, 1), d(0, 1), d(0, 1)], + ], + prob_tol=TOL, + regret_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_strategy, + id="test_enumpoly_strategy_4", + ), +] + + LP_STRATEGY_RATIONAL_CASES = [ pytest.param( EquilibriumTestCase( @@ -945,6 +1023,7 @@ class QREquilibriumTestCase: CASES += ENUMPURE_CASES CASES += ENUMMIXED_RATIONAL_CASES CASES += ENUMMIXED_DOUBLE_CASES +CASES += ENUMPOLY_STRATEGY_CASES CASES += LP_STRATEGY_RATIONAL_CASES CASES += LP_STRATEGY_DOUBLE_CASES CASES += LCP_STRATEGY_RATIONAL_CASES From 1bf645eba6fabd1fa53d4d321f44426fe8acc625 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:35:36 +0100 Subject: [PATCH 04/32] Bump actions/checkout from 6 to 7 (#975) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4d4ef171..a04ca91f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: create-release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Extract changelog for this version run: | From 469afe3ef7c1921516a38aa4bcbb5817a38c01ec Mon Sep 17 00:00:00 2001 From: Daniel Kadnikov <165307096+d-kad@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:49:44 +0100 Subject: [PATCH 05/32] Make label normalization mandatory and collision-safe, and retime it for players and strategies (#965) Make NormalizeGameLabels run unconditionally in every reader and remove the normalize_labels / p_normalizeLabels parameter throughout the read path: ReadEfgFile, ReadNfgFile, ReadGbtFile, ReadAggFile, ReadBaggFile, ReadGame, and the pygambit read_* wrappers. --- src/games/file.cc | 147 +++++++++++++++++++++++++------------- src/games/game.h | 14 ++-- src/games/gameagg.h | 10 +-- src/games/gamebagg.h | 10 +-- src/pygambit/gambit.pxd | 10 +-- src/pygambit/game.pxi | 45 ++++-------- src/pygambit/strategy.pxi | 9 --- src/pygambit/util.h | 18 ++--- tests/test_io.py | 12 +--- tests/test_mixed.py | 7 -- tests/test_node.py | 2 +- 11 files changed, 128 insertions(+), 156 deletions(-) diff --git a/src/games/file.cc b/src/games/file.cc index ea033eebf..08961017f 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -23,6 +23,7 @@ #include #include #include +#include #include #include "gambit.h" @@ -318,6 +319,53 @@ class TableFileGame { } }; +/// Normalizes labels in place so the resulting set is distinct and nonempty: +/// empty labels are given a suffix and repeated labels are de-duplicated by +/// appending "_n", choosing the next n not already present in the scope. +/// `p_get(element)` reads an element's label and `p_set(element, label)` +/// writes it, so this works both on a container of game objects (via GetLabel/SetLabel) +/// and on a container of raw label strings (read/write the string directly). +template +void NormalizeLabels(Container &&p_container, Getter p_get, Setter p_set) +{ + // NOLINTBEGIN(misc-const-correctness) + std::map counts; + std::set used; + // NOLINTEND(misc-const-correctness) + for (auto &&element : p_container) { + counts[p_get(element)] += 1; + used.insert(p_get(element)); + } + // NOLINTBEGIN(misc-const-correctness) + std::map visited; + // NOLINTEND(misc-const-correctness) + for (auto &&element : p_container) { + const auto label = p_get(element); + // A special case: If only one label is the empty string we still want to + // convert it to "_1" + if (counts[label] == 1 && label != "") { + continue; + } + // Generate the next "label_n" that is not already used in this scope, so + // that e.g. {"x", "x", "x_1"} does not renumber to a duplicate "x_1". + std::string candidate; + do { + const auto index = ++visited[label]; + candidate = label + "_" + std::to_string(index); + } while (used.count(candidate) > 0); + used.insert(candidate); + p_set(element, candidate); + } +} + +/// Normalizes a list of raw label strings. +template void NormalizeLabelStrings(Container &p_labels) +{ + NormalizeLabels( + p_labels, [](const std::string &s) { return s; }, + [](std::string &s, const std::string &v) { s = v; }); +} + void ReadPlayers(GameFileLexer &p_state, TableFileGame &p_data) { p_state.ExpectNextToken(TOKEN_LBRACE, "'{'"); @@ -472,10 +520,19 @@ class TreeData { void ReadPlayers(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData) { p_state.ExpectNextToken(TOKEN_LBRACE, "'{'"); + // Buffer the raw player labels so they can be normalized (made unique and nonempty) + // before the player objects are created. + // NOLINTBEGIN(misc-const-correctness) + std::vector player_labels; + // NOLINTEND(misc-const-correctness) while (p_state.GetNextToken() == TOKEN_TEXT) { - p_game->NewPlayer()->SetLabel(p_state.GetLastText()); + player_labels.push_back(p_state.GetLastText()); } p_state.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); + NormalizeLabelStrings(player_labels); + for (const auto &label : player_labels) { + p_game->NewPlayer()->SetLabel(label); + } } void CheckOutcomeDefinition(const GameFileLexer &p_state, int p_outcomeId, @@ -800,48 +857,27 @@ Game GameXMLSavefile::GetGame() const throw InvalidFileException("No game representation found in document"); } -template void NormalizeLabels(C &&p_container) -{ - // NOLINTBEGIN(misc-const-correctness) - std::map counts; - // NOLINTEND(misc-const-correctness) - for (const auto &element : p_container) { - counts[element->GetLabel()] += 1; - } - // NOLINTBEGIN(misc-const-correctness) - std::map visited; - // NOLINTEND(misc-const-correctness) - for (auto element : p_container) { - const auto label = element->GetLabel(); - // A special case: If only one label is the empty string we still want to - // convert it to "_1" - if (counts[label] == 1 && label != "") { - continue; - } - const auto index = ++visited[label]; - element->SetLabel(label + "_" + std::to_string(index)); - } -} - void NormalizeGameLabels(const Game &p_game) { - NormalizeLabels(p_game->GetPlayers()); - NormalizeLabels(p_game->GetOutcomes()); + const auto get_label = [](const auto &e) { return e->GetLabel(); }; + const auto set_label = [](const auto &e, const std::string &s) { e->SetLabel(s); }; + NormalizeLabels(p_game->GetPlayers(), get_label, set_label); + NormalizeLabels(p_game->GetOutcomes(), get_label, set_label); if (p_game->IsTree()) { for (const auto &player : p_game->GetPlayersWithChance()) { for (const auto &infoset : player->GetInfosets()) { - NormalizeLabels(infoset->GetActions()); + NormalizeLabels(infoset->GetActions(), get_label, set_label); } } } else { for (const auto &player : p_game->GetPlayers()) { - NormalizeLabels(player->GetStrategies()); + NormalizeLabels(player->GetStrategies(), get_label, set_label); } } } -Game ReadEfgFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) +Game ReadEfgFile(std::istream &p_stream) { GameFileLexer parser(p_stream); @@ -869,42 +905,53 @@ Game ReadEfgFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) parser.GetNextToken(); } ParseNode(parser, game, game->GetRoot(), treeData); - if (p_normalizeLabels) { - NormalizeGameLabels(game); - } + NormalizeGameLabels(game); return game; } -Game ReadNfgFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) +Game ReadNfgFile(std::istream &p_stream) { GameFileLexer parser(p_stream); TableFileGame data; ParseNfgHeader(parser, data); - auto game = BuildNfg(parser, data); - if (p_normalizeLabels) { - NormalizeGameLabels(game); + // Normalize player and strategy labels on the raw lists before the game is + // built, so labels are unique and nonempty at construction. + for (auto &player : data.m_players) { + NormalizeLabelStrings(player.m_strategies); } + { + // NOLINTBEGIN(misc-const-correctness) + std::vector player_labels; + // NOLINTEND(misc-const-correctness) + for (const auto &player : data.m_players) { + player_labels.push_back(player.m_name); + } + NormalizeLabelStrings(player_labels); + auto label_it = player_labels.begin(); + for (auto &player : data.m_players) { + player.m_name = *label_it; + ++label_it; + } + } + auto game = BuildNfg(parser, data); + NormalizeGameLabels(game); return game; } -Game ReadGbtFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) +Game ReadGbtFile(std::istream &p_stream) { std::stringstream buffer; buffer << p_stream.rdbuf(); auto game = GameXMLSavefile(buffer.str()).GetGame(); - if (p_normalizeLabels) { - NormalizeGameLabels(game); - } + NormalizeGameLabels(game); return game; } -Game ReadAggFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) +Game ReadAggFile(std::istream &p_stream) { try { auto game = std::make_shared(agg::AGG::makeAGG(p_stream)); - if (p_normalizeLabels) { - NormalizeGameLabels(game); - } + NormalizeGameLabels(game); return game; } catch (std::runtime_error &ex) { @@ -912,13 +959,11 @@ Game ReadAggFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) } } -Game ReadBaggFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) +Game ReadBaggFile(std::istream &p_stream) { try { auto game = std::make_shared(agg::BAGG::makeBAGG(p_stream)); - if (p_normalizeLabels) { - NormalizeGameLabels(game); - } + NormalizeGameLabels(game); return game; } catch (std::runtime_error &ex) { @@ -926,7 +971,7 @@ Game ReadBaggFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) } } -Game ReadGame(std::istream &p_file, bool p_normalizeLabels /* = false */) +Game ReadGame(std::istream &p_file) { std::stringstream buffer; buffer << p_file.rdbuf(); @@ -947,10 +992,10 @@ Game ReadGame(std::istream &p_file, bool p_normalizeLabels /* = false */) } buffer.seekg(0, std::ios::beg); if (parser.GetLastText() == "NFG") { - return ReadNfgFile(buffer, p_normalizeLabels); + return ReadNfgFile(buffer); } if (parser.GetLastText() == "EFG") { - return ReadEfgFile(buffer, p_normalizeLabels); + return ReadEfgFile(buffer); } if (parser.GetLastText() == "#AGG") { return ReadAggFile(buffer); diff --git a/src/games/game.h b/src/games/game.h index a6a82f03a..717b948c1 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1395,38 +1395,32 @@ Game NewTable(const std::vector &p_dim, bool p_sparseOutcomes = false); /// @brief Reads a game representation in .efg format /// /// @param[in] p_stream An input stream, positioned at the start of the text in .efg format -/// @param[in] p_normalizeLabels Require element labels to be nonempty and unique within -/// their scope /// @return A handle to the game representation constructed /// @throw InvalidFileException If the stream does not contain a valid serialisation /// of a game in .efg format. /// @sa Game::WriteEfgFile, ReadNfgFile, ReadAggFile, ReadBaggFile -Game ReadEfgFile(std::istream &p_stream, bool p_normalizeLabels = false); +Game ReadEfgFile(std::istream &p_stream); /// @brief Reads a game representation in .nfg format /// @param[in] p_stream An input stream, positioned at the start of the text in .nfg format -/// @param[in] p_normalizeLabels Require element labels to be nonempty and unique within -/// their scope /// @return A handle to the game representation constructed /// @throw InvalidFileException If the stream does not contain a valid serialisation /// of a game in .nfg format. /// @sa Game::WriteNfgFile, ReadEfgFile, ReadAggFile, ReadBaggFile -Game ReadNfgFile(std::istream &p_stream, bool p_normalizeLabels = false); +Game ReadNfgFile(std::istream &p_stream); /// @brief Reads a game representation from a graphical interface XML saveflie /// @param[in] p_stream An input stream, positioned at the start of the text -/// @param[in] p_normalizeLabels Require element labels to be nonempty and unique within -/// their scope /// @return A handle to the game representation constructed /// @throw InvalidFileException If the stream does not contain a valid serialisation /// of a game in an XML savefile /// @sa ReadEfgFile, ReadNfgFile, ReadAggFile, ReadBaggFile -Game ReadGbtFile(std::istream &p_stream, bool p_normalizeLabels = false); +Game ReadGbtFile(std::istream &p_stream); /// @brief Reads a game from the input stream, attempting to autodetect file format /// @deprecated Deprecated in favour of the various ReadXXXGame functions. /// @sa ReadEfgFile, ReadNfgFile, ReadGbtFile, ReadAggFile, ReadBaggFile -Game ReadGame(std::istream &p_stream, bool p_normalizeLabels = false); +Game ReadGame(std::istream &p_stream); /// @brief Generate a distribution over a simplex restricted to rational numbers of given /// denominator diff --git a/src/games/gameagg.h b/src/games/gameagg.h index bf000019e..3b0bb979e 100644 --- a/src/games/gameagg.h +++ b/src/games/gameagg.h @@ -113,15 +113,7 @@ class GameAGGRep : public GameRep { /// @return A handle to the game representation constructed /// @throw InvalidFileException If the stream does not contain a valid serialisation /// of a game in .agg format. -inline Game ReadAggFile(std::istream &p_stream) -{ - try { - return std::make_shared(agg::AGG::makeAGG(p_stream)); - } - catch (std::runtime_error &ex) { - throw InvalidFileException(ex.what()); - } -} +Game ReadAggFile(std::istream &p_stream); } // namespace Gambit diff --git a/src/games/gamebagg.h b/src/games/gamebagg.h index b4a28cb43..641a13735 100644 --- a/src/games/gamebagg.h +++ b/src/games/gamebagg.h @@ -121,15 +121,7 @@ class GameBAGGRep : public GameRep { /// @return A handle to the game representation constructed /// @throw InvalidFileException If the stream does not contain a valid serialisation /// of a game in .bagg format. -inline Game ReadBaggFile(std::istream &in) -{ - try { - return std::make_shared(agg::BAGG::makeBAGG(in)); - } - catch (std::runtime_error &ex) { - throw InvalidFileException(ex.what()); - } -} +Game ReadBaggFile(std::istream &in); } // end namespace Gambit diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 099e4aa2a..4d6781edb 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -488,11 +488,11 @@ cdef extern from "games/layout.h": cdef extern from "util.h": - c_Game ParseGbtGame(string, bint) except +IOError - c_Game ParseEfgGame(string, bint) except +IOError - c_Game ParseNfgGame(string, bint) except +IOError - c_Game ParseAggGame(string, bint) except +IOError - c_Game ParseBaggGame(string, bint) except +IOError + c_Game ParseGbtGame(string) except +IOError + c_Game ParseEfgGame(string) except +IOError + c_Game ParseNfgGame(string) except +IOError + c_Game ParseAggGame(string) except +IOError + c_Game ParseBaggGame(string) except +IOError string WriteEfgFile(c_Game) string WriteNfgFile(c_Game) string WriteNfgFileSupport(c_StrategySupportProfile) except +IOError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 39cf0acbd..403d095d8 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -31,12 +31,11 @@ import scipy.stats import pygambit.gameiter ctypedef string (*GameWriter)(const c_Game &) except +IOError -ctypedef c_Game (*GameParser)(const string &, bool) except +IOError +ctypedef c_Game (*GameParser)(const string &) except +IOError @cython.cfunc def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, - normalize_labels: bool, parser: GameParser): g = cython.declare(Game) @@ -48,23 +47,19 @@ def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, with open(filepath_or_buffer, "rb") as f: data = f.read() try: - g = Game.wrap(parser(data, normalize_labels)) + g = Game.wrap(parser(data)) except Exception as exc: raise ValueError(f"Parse error in game file: {exc}") from None return g -def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase, - normalize_labels: bool = False) -> Game: +def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: """Construct a game from its serialised representation in a GBT file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object - normalize_labels : bool (default False) - Ensure all labels are nonempty and unique within their scopes. - This will be enforced in a future version of Gambit. Returns ------- @@ -82,20 +77,16 @@ def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase, -------- read_efg, read_nfg, read_agg, read_bagg """ - return read_game(filepath_or_buffer, normalize_labels, parser=ParseGbtGame) + return read_game(filepath_or_buffer, parser=ParseGbtGame) -def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase, - normalize_labels: bool = False) -> Game: +def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: """Construct a game from its serialised representation in an EFG file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object - normalize_labels : bool (default False) - Ensure all labels are nonempty and unique within their scopes. - This will be enforced in a future version of Gambit. Returns ------- @@ -113,20 +104,16 @@ def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase, -------- read_gbt, read_nfg, read_agg, read_bagg """ - return read_game(filepath_or_buffer, normalize_labels, parser=ParseEfgGame) + return read_game(filepath_or_buffer, parser=ParseEfgGame) -def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase, - normalize_labels: bool = False) -> Game: +def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: """Construct a game from its serialised representation in a NFG file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object - normalize_labels : bool (default False) - Ensure all labels are nonempty and unique within their scopes. - This will be enforced in a future version of Gambit. Returns ------- @@ -144,20 +131,16 @@ def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase, -------- read_gbt, read_efg, read_agg, read_bagg """ - return read_game(filepath_or_buffer, normalize_labels, parser=ParseNfgGame) + return read_game(filepath_or_buffer, parser=ParseNfgGame) -def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase, - normalize_labels: bool = False) -> Game: +def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: """Construct a game from its serialised representation in an AGG file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object - normalize_labels : bool (default False) - Ensure all labels are nonempty and unique within their scopes. - This will be enforced in a future version of Gambit. Returns ------- @@ -175,20 +158,16 @@ def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase, -------- read_gbt, read_efg, read_nfg, read_bagg """ - return read_game(filepath_or_buffer, normalize_labels, parser=ParseAggGame) + return read_game(filepath_or_buffer, parser=ParseAggGame) -def read_bagg(filepath_or_buffer: str | pathlib.Path | io.IOBase, - normalize_labels: bool = False) -> Game: +def read_bagg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: """Construct a game from its serialised representation in a BAGG file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object - normalize_labels : bool (default False) - Ensure all labels are nonempty and unique within their scopes. - This will be enforced in a future version of Gambit. Returns ------- @@ -206,7 +185,7 @@ def read_bagg(filepath_or_buffer: str | pathlib.Path | io.IOBase, -------- read_gbt, read_efg, read_nfg, read_agg """ - return read_game(filepath_or_buffer, normalize_labels, parser=ParseBaggGame) + return read_game(filepath_or_buffer, parser=ParseBaggGame) @cython.cclass diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 048a9ef52..f46602b8b 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -150,11 +150,6 @@ class Sequence: return f"Sequence(player={self.player}, actions={self.actions})" def __eq__(self, other: typing.Any) -> bool: - print("__eq__") - print(isinstance(other, Sequence)) - print(type(other)) - if isinstance(other, Sequence): - print(self.sequence.deref() == cython.cast(Sequence, other).sequence.deref()) return ( isinstance(other, Sequence) and self.sequence.deref() == cython.cast(Sequence, other).sequence.deref() @@ -176,7 +171,6 @@ class Sequence: @property def parent(self) -> Sequence | None: """The parent (predecessor) of the sequence.""" - print(self) if self.sequence.deref().GetParent() == cython.cast(c_GameSequence, NULL): return None return Sequence.wrap(self.sequence.deref().GetParent()) @@ -185,10 +179,7 @@ class Sequence: def children(self) -> list[Sequence]: """The immediate children (successors) of the sequence.""" ret: list[Sequence] = [] - print("Looking for children of", self) for seq in self.player.sequences: - print("Sequence", seq) - print("Parent", seq.parent) if seq.parent == self: ret.append(seq) return ret diff --git a/src/pygambit/util.h b/src/pygambit/util.h index 89d5d9dc8..fca1c5353 100644 --- a/src/pygambit/util.h +++ b/src/pygambit/util.h @@ -38,31 +38,27 @@ using namespace std; using namespace Gambit; using namespace Gambit::Nash; -Game ParseGbtGame(std::string const &s, bool p_normalizeLabels) +Game ParseGbtGame(std::string const &s) { std::istringstream f(s); return ReadGbtFile(f); } - -Game ParseEfgGame(std::string const &s, bool p_normalizeLabels) +Game ParseEfgGame(std::string const &s) { std::istringstream f(s); - return ReadEfgFile(f, p_normalizeLabels); + return ReadEfgFile(f); } - -Game ParseNfgGame(std::string const &s, bool p_normalizeLabels) +Game ParseNfgGame(std::string const &s) { std::istringstream f(s); - return ReadNfgFile(f, p_normalizeLabels); + return ReadNfgFile(f); } - -Game ParseAggGame(std::string const &s, bool p_normalizeLabels) +Game ParseAggGame(std::string const &s) { std::istringstream f(s); return ReadAggFile(f); } - -Game ParseBaggGame(std::string const &s, bool p_normalizeLabels) +Game ParseBaggGame(std::string const &s) { std::istringstream f(s); return ReadBaggFile(f); diff --git a/tests/test_io.py b/tests/test_io.py index 826e7fc14..080d00472 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -157,17 +157,7 @@ def test_read_write_nfg(): nfg_game = games.read_from_file("2x2_bimatrix_all_zero_payoffs.nfg") serialized_nfg_game = nfg_game.to_nfg() deserialized_nfg_game = gbt.read_nfg( - io.BytesIO(serialized_nfg_game.encode()), normalize_labels=False + io.BytesIO(serialized_nfg_game.encode()) ) double_serialized_nfg_game = deserialized_nfg_game.to_nfg() assert serialized_nfg_game == double_serialized_nfg_game - - -def test_read_write_nfg_normalize(): - nfg_game = games.read_from_file("2x2_bimatrix_all_zero_payoffs.nfg") - serialized_nfg_game = nfg_game.to_nfg() - deserialized_nfg_game = gbt.read_nfg( - io.BytesIO(serialized_nfg_game.encode()), normalize_labels=True - ) - double_serialized_nfg_game = deserialized_nfg_game.to_nfg() - assert serialized_nfg_game != double_serialized_nfg_game diff --git a/tests/test_mixed.py b/tests/test_mixed.py index 0de526f84..047574c2f 100644 --- a/tests/test_mixed.py +++ b/tests/test_mixed.py @@ -309,13 +309,6 @@ def test_profile_indexing_by_invalid_strategy_label( game.mixed_strategy_profile(rational=rational_flag)[strategy_label] -def test_profile_indexing_by_player_and_duplicate_strategy_label(): - game = games.read_from_file("2x2_bimatrix_all_zero_payoffs.nfg") - profile = game.mixed_strategy_profile() - with pytest.raises(ValueError): - profile["Dan"]["defect"] - - @pytest.mark.parametrize( "game,strategy_label,prob,rational_flag", [ diff --git a/tests/test_node.py b/tests/test_node.py index ecf002edc..867077193 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -238,7 +238,7 @@ class SubgameRootsTestCase: factory=functools.partial( games.read_from_file, "subgame_roots_finder_overplapping_infosets_with_Nature.efg"), - expected_paths=[[], ["1"], ["1", "1"], ["1", "1", "1"]] + expected_paths=[[], ["1_2"], ["1_2", "1_3", "1_2"], ["1_3", "1_2"]] ), id="overlapping_infosets_inside_subgames_and_Nature_move" ), From 4d06f0065986418aaca1389dee18db540d7f07f1 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Mon, 6 Jul 2026 12:56:01 +0100 Subject: [PATCH 06/32] Improve UX of GUI player label editing (#976) This fixes editing of GUI player labels to work more like expected: * ESC cancels edits * TAB also commits edits * Losing focus commits edits --- src/gui/edittext.cc | 51 ++++++++++++++++++++++++++++++++++++++++----- src/gui/edittext.h | 12 ++++++++--- src/gui/efgpanel.cc | 19 ++++++++++------- src/gui/nfgpanel.cc | 19 ++++++++++------- 4 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/gui/edittext.cc b/src/gui/edittext.cc index 9db1e7a07..8c54e3900 100644 --- a/src/gui/edittext.cc +++ b/src/gui/edittext.cc @@ -68,6 +68,9 @@ EditableText::EditableText(wxWindow *p_parent, int p_id, const wxString &p_value Connect(m_textCtrl->GetId(), wxEVT_COMMAND_TEXT_ENTER, wxCommandEventHandler(EditableText::OnAccept)); + m_textCtrl->Bind(wxEVT_KILL_FOCUS, &EditableText::OnTextKillFocus, this); + m_textCtrl->Bind(wxEVT_CHAR_HOOK, &EditableText::OnTextCharHook, this); + auto *topSizer = new wxBoxSizer(wxHORIZONTAL); topSizer->Add(m_staticText, 1, wxALIGN_CENTER, 0); topSizer->Add(m_textCtrl, 1, wxEXPAND, 0); @@ -97,6 +100,33 @@ void EditableText::EndEdit(bool p_accept) GetSizer()->Layout(); } +void EditableText::AcceptEdit() +{ + if (!IsEditing() || m_endingEdit) { + return; + } + + m_endingEdit = true; + EndEdit(true); + + wxCommandEvent event(wxEVT_COMMAND_TEXT_ENTER); + event.SetId(GetId()); + wxPostEvent(GetParent(), event); + + m_endingEdit = false; +} + +void EditableText::CancelEdit() +{ + if (!IsEditing() || m_endingEdit) { + return; + } + + m_endingEdit = true; + EndEdit(false); + m_endingEdit = false; +} + wxString EditableText::GetValue() const { @@ -142,11 +172,22 @@ void EditableText::OnClick(wxCommandEvent &) wxPostEvent(GetParent(), event); } -void EditableText::OnAccept(wxCommandEvent &) +void EditableText::OnAccept(wxCommandEvent &) { AcceptEdit(); } + +void EditableText::OnTextKillFocus(wxFocusEvent &p_event) { - EndEdit(true); - wxCommandEvent event(wxEVT_COMMAND_TEXT_ENTER); - event.SetId(GetId()); - wxPostEvent(GetParent(), event); + AcceptEdit(); + p_event.Skip(); } + +void EditableText::OnTextCharHook(wxKeyEvent &p_event) +{ + if (p_event.GetKeyCode() == WXK_ESCAPE && IsEditing()) { + CancelEdit(); + return; + } + + p_event.Skip(); +} + } // namespace Gambit::GUI diff --git a/src/gui/edittext.h b/src/gui/edittext.h index 688ff1962..2f7a62625 100644 --- a/src/gui/edittext.h +++ b/src/gui/edittext.h @@ -47,14 +47,23 @@ class EditableText : public wxPanel { StaticTextButton *m_staticText; wxTextCtrl *m_textCtrl; + bool m_endingEdit = false; + /// @name Event handlers //@{ /// Called when the static text is clicked void OnClick(wxCommandEvent &); /// Called when the text control is dismissed via enter void OnAccept(wxCommandEvent &); + /// Called when the text control loses focus + void OnTextKillFocus(wxFocusEvent &); + /// Called to intercept Escape while editing + void OnTextCharHook(wxKeyEvent &); //@} + void AcceptEdit(); + void CancelEdit(); + public: EditableText(wxWindow *p_parent, int p_id, const wxString &p_value, const wxPoint &p_position, const wxSize &p_size); @@ -66,12 +75,9 @@ class EditableText : public wxPanel { wxString GetValue() const; void SetValue(const wxString &p_value); - // @name Overriding wxWindow methods - //@{ bool SetForegroundColour(const wxColour &) override; bool SetBackgroundColour(const wxColour &) override; bool SetFont(const wxFont &) override; - //@} }; } // namespace Gambit::GUI diff --git a/src/gui/efgpanel.cc b/src/gui/efgpanel.cc index 555989bb5..767a49bb8 100644 --- a/src/gui/efgpanel.cc +++ b/src/gui/efgpanel.cc @@ -294,14 +294,17 @@ void gbtTreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) void gbtTreePlayerPanel::PostPendingChanges() { - if (m_playerLabel->IsEditing()) { - m_playerLabel->EndEdit(true); - try { - m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } + if (!m_playerLabel->IsEditing()) { + return; + } + m_playerLabel->EndEdit(true); + try { + m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + m_playerLabel->SetValue( + wxString(m_doc->GetGame()->GetPlayer(m_player)->GetLabel().c_str(), *wxConvCurrent)); } } diff --git a/src/gui/nfgpanel.cc b/src/gui/nfgpanel.cc index f483c78e1..2fc3902d2 100644 --- a/src/gui/nfgpanel.cc +++ b/src/gui/nfgpanel.cc @@ -231,14 +231,17 @@ void TablePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) void TablePlayerPanel::PostPendingChanges() { - if (m_playerLabel->IsEditing()) { - m_playerLabel->EndEdit(true); - try { - m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } + if (!m_playerLabel->IsEditing()) { + return; + } + m_playerLabel->EndEdit(true); + try { + m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + m_playerLabel->SetValue( + wxString(m_doc->GetGame()->GetPlayer(m_player)->GetLabel().c_str(), *wxConvCurrent)); } } From 49930f37bf747cc8ef41c4d20b31dbbecd610377 Mon Sep 17 00:00:00 2001 From: Daniel Kadnikov <165307096+d-kad@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:37:19 +0100 Subject: [PATCH 07/32] Enforce optional, unique-when-present labels for nodes and information sets (#978) --- src/games/game.h | 45 ++++++++++++++++++++++++++++++++---------- tests/test_infosets.py | 10 ++++++++++ tests/test_node.py | 16 +++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index 717b948c1..286ddf12b 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -289,11 +289,7 @@ class GameInfosetRep : public std::enable_shared_from_this { bool IsChanceInfoset() const; - void SetLabel(const std::string &p_label) - { - CheckLabel(p_label); - m_label = p_label; - } + void SetLabel(const std::string &p_label); const std::string &GetLabel() const { return m_label; } /// @name Actions @@ -554,11 +550,7 @@ class GameNodeRep : public std::enable_shared_from_this { Game GetGame() const; const std::string &GetLabel() const { return m_label; } - void SetLabel(const std::string &p_label) - { - CheckLabel(p_label); - m_label = p_label; - } + void SetLabel(const std::string &p_label); int GetNumber() const; GameNode GetChild(const GameAction &p_action) @@ -1327,6 +1319,23 @@ inline Game GameActionRep::GetGame() const { return m_infoset->GetGame(); } inline Game GameInfosetRep::GetGame() const { return m_game->shared_from_this(); } inline GamePlayer GameInfosetRep::GetPlayer() const { return m_player->shared_from_this(); } +inline void GameInfosetRep::SetLabel(const std::string &p_label) +{ + if (p_label == m_label) { + return; + } + CheckLabel(p_label); + // Infoset labels may be empty, but a non-empty label must be unique among + // the infosets of the same player. + if (!p_label.empty()) { + for (const auto &infoset : GetPlayer()->GetInfosets()) { + if (infoset.get() != this && infoset->GetLabel() == p_label) { + throw ValueException("Infoset label must be unique for the player"); + } + } + } + m_label = p_label; +} inline bool GameInfosetRep::IsChanceInfoset() const { return m_player->IsChance(); } inline Game GamePlayerRep::GetGame() const { return m_game->shared_from_this(); } @@ -1347,6 +1356,22 @@ inline GamePlayerRep::Sequences GamePlayerRep::GetSequences() const } inline Game GameNodeRep::GetGame() const { return m_game->shared_from_this(); } +inline void GameNodeRep::SetLabel(const std::string &p_label) +{ + if (p_label == m_label) { + return; + } + CheckLabel(p_label); + // Node labels may be empty, but a non-empty label must be unique within the game. + if (!p_label.empty()) { + for (const auto &node : GetGame()->GetNodes()) { + if (node.get() != this && node->GetLabel() == p_label) { + throw ValueException("Node label must be unique within the game"); + } + } + } + m_label = p_label; +} inline int GameNodeRep::GetNumber() const { m_game->EnsureNodeOrdering(); diff --git a/tests/test_infosets.py b/tests/test_infosets.py index a32c53341..fb4ff58fe 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -1,5 +1,6 @@ import dataclasses import functools +import itertools import typing import pytest @@ -31,6 +32,15 @@ def test_infoset_label_non_ascii_rejected(label): game.root.infoset.label = label +def test_infoset_label_duplicate_within_player_raises_valueerror(): + game = games.read_from_file("subgames.efg") + player = next(p for p in game.players if sum(1 for _ in p.infosets) >= 2) + first, second = itertools.islice(player.infosets, 2) + first.label = "shared" + with pytest.raises(ValueError): + second.label = "shared" + + def test_infoset_player_retrieval(): game = games.read_from_file("basic_extensive_game.efg") p1, *_ = game.players diff --git a/tests/test_node.py b/tests/test_node.py index 867077193..3b2ec5c61 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1125,6 +1125,22 @@ def test_node_label_valid(label): assert game.root.label == label +def test_node_label_duplicate_raises_valueerror(): + game = games.read_from_file("basic_extensive_game.efg") + game.root.label = "shared" + with pytest.raises(ValueError): + game.root.children["U1"].label = "shared" + + +def test_node_label_empty_is_allowed(): + """Node labels may be empty (unlike outcomes/players); multiple empties coexist.""" + game = games.read_from_file("basic_extensive_game.efg") + game.root.label = "" + game.root.children["U1"].label = "" + assert game.root.label == "" + assert game.root.children["U1"].label == "" + + @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_node_label_invalid_raises_valueerror(label): game = games.read_from_file("basic_extensive_game.efg") From e02a610f9f3dbeb820bd8dfc91a2314a747714b2 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Wed, 8 Jul 2026 14:49:44 +0100 Subject: [PATCH 08/32] Small UX/layout improvement to dialog warning on unsaved changes (#980) --- src/gui/gameframe.cc | 102 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 15 deletions(-) diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index 7b27cf358..af3e103ad 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -34,6 +34,7 @@ #include #endif // !defined(__WXMSW__) || wxUSE_POSTSCRIPT #include +#include #include "gambit.h" @@ -1311,24 +1312,95 @@ void GameFrame::OnUnsplit(wxSplitterEvent &) namespace { -wxString CloseWarningMessage(GameDocument *p_doc) +struct CloseWarningText { + wxString primary; + wxString secondary; +}; + +CloseWarningText CloseWarningMessage(GameDocument *p_doc) { - if (p_doc->IsGameModified() && !p_doc->IsWorkspaceModified()) { - return _("This game has unsaved changes.\n\n" - "Close without saving?"); + const bool gameModified = p_doc->IsGameModified(); + const bool workspaceModified = p_doc->IsWorkspaceModified(); + + if (gameModified && !workspaceModified) { + return {_("This game has unsaved changes."), + _("If you close this window now, changes to the game will be lost.")}; } - if (!p_doc->IsGameModified() && p_doc->IsWorkspaceModified()) { - return _("There are unsaved computational results.\n\n" - "These can be saved in a Gambit workspace file.\n" - "Close without saving?"); + + if (!gameModified && workspaceModified) { + return {_("There are unsaved computational results."), + _("These results can be saved in a Gambit workspace file. " + "If you close this window now, the unsaved results will be lost.")}; } - if (p_doc->IsGameModified() && p_doc->IsWorkspaceModified()) { - return _("This game has unsaved changes, and there are unsaved computational results.\n\n" - "Close without saving?"); + + if (gameModified && workspaceModified) { + return {_("This game and its computational results have unsaved changes."), + _("If you close this window now, changes to the game and unsaved " + "computational results will be lost.")}; } - return wxEmptyString; + + return {wxEmptyString, wxEmptyString}; } +class CloseWarningDialog final : public wxDialog { +public: + CloseWarningDialog(wxWindow *parent, const CloseWarningText &text) + : wxDialog(parent, wxID_ANY, _("Unsaved Changes"), wxDefaultPosition, wxDefaultSize, + wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) + { + auto *topSizer = new wxBoxSizer(wxVERTICAL); + + auto *contentSizer = new wxBoxSizer(wxHORIZONTAL); + contentSizer->Add(new wxStaticBitmap(this, wxID_ANY, + wxArtProvider::GetBitmap(wxART_WARNING, wxART_MESSAGE_BOX, + wxSize(32, 32))), + 0, wxALIGN_TOP | wxRIGHT, FromDIP(16)); + + auto *textSizer = new wxBoxSizer(wxVERTICAL); + + auto *primary = new wxStaticText(this, wxID_ANY, text.primary); + auto font = primary->GetFont(); + font.SetWeight(wxFONTWEIGHT_BOLD); + primary->SetFont(font); + + auto *secondary = new wxStaticText(this, wxID_ANY, text.secondary); + secondary->Wrap(FromDIP(460)); + + auto *question = new wxStaticText(this, wxID_ANY, _("Close without saving?")); + question->Wrap(FromDIP(460)); + + textSizer->Add(primary, 0, wxBOTTOM, FromDIP(8)); + textSizer->Add(secondary, 0, wxBOTTOM, FromDIP(12)); + textSizer->Add(question, 0); + + contentSizer->Add(textSizer, 1, wxEXPAND); + + topSizer->Add(contentSizer, 1, wxEXPAND | wxALL, FromDIP(20)); + + auto *buttonSizer = new wxBoxSizer(wxHORIZONTAL); + + auto *cancelButton = new wxButton(this, wxID_CANCEL, _("Cancel")); + auto *closeButton = new wxButton(this, wxID_YES, _("Close Without Saving")); + + buttonSizer->AddStretchSpacer(); + buttonSizer->Add(cancelButton, 0, wxRIGHT, FromDIP(8)); + buttonSizer->Add(closeButton, 0); + + topSizer->Add(buttonSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(20)); + + SetSizerAndFit(topSizer); + SetMinSize(wxSize(FromDIP(540), -1)); + + SetEscapeId(wxID_CANCEL); + SetAffirmativeId(wxID_YES); + + cancelButton->SetDefault(); + cancelButton->SetFocus(); + + CentreOnParent(); + } +}; + } // namespace void GameFrame::OnCloseWindow(wxCloseEvent &p_event) @@ -1339,10 +1411,10 @@ void GameFrame::OnCloseWindow(wxCloseEvent &p_event) } if (m_doc->IsModified()) { - const int response = wxMessageBox(CloseWarningMessage(m_doc), _("Unsaved Changes"), - wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this); + const auto warning = CloseWarningMessage(m_doc); - if (response != wxYES) { + CloseWarningDialog dialog(this, warning); + if (dialog.ShowModal() != wxID_YES) { p_event.Veto(); return; } From df7ec062addb89c4fe5cfb7dd15544033fbec00a Mon Sep 17 00:00:00 2001 From: Daniel Kadnikov <165307096+d-kad@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:40:41 +0100 Subject: [PATCH 09/32] Document default object labeling in new_table, from_arrays, and from_dict (#983) --- src/pygambit/game.pxi | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 403d095d8..9dca309fd 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -570,6 +570,9 @@ class Game: def new_table(cls, dim, title: str = "Untitled strategic game") -> Game: """Create a new ``Game`` with a strategic representation. + Players are labeled ``"1"``, ``"2"``, and so on; + each player's strategies are likewise labeled ``"1"``, ``"2"``, and so on. + .. versionchanged:: 16.1.0 Added the `title` parameter. @@ -599,6 +602,9 @@ class Game: and have the same number of dimensions as the total number of players. + Players are labeled ``"1"``, ``"2"``, and so on; + each player's strategies are likewise labeled ``"1"``, ``"2"``, and so on. + .. versionchanged:: 16.1.0 Added the `title` parameter. @@ -674,6 +680,10 @@ class Game: and have the same number of dimensions as the total number of players. + The players are labeled with the keys of `payoffs`, and therefore + must be valid player labels. Each player's strategies are labeled + ``"1"``, ``"2"``, and so on. + Parameters ---------- payoffs : dict-like mapping str to array-like From 592fa0bbbb599c58f8401f366676dce313c83ec2 Mon Sep 17 00:00:00 2001 From: Daniel Kadnikov <165307096+d-kad@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:56:56 +0100 Subject: [PATCH 10/32] Enforce unique, nonempty labels for players (#979) --- src/games/file.cc | 2 +- src/games/game.cc | 4 +-- src/games/game.h | 43 ++++++++++++++++++++++----- src/games/gameagg.cc | 4 +-- src/games/gameagg.h | 2 +- src/games/gamebagg.cc | 5 ++-- src/games/gamebagg.h | 2 +- src/games/gametable.cc | 10 ++++--- src/games/gametable.h | 2 +- src/games/gametree.cc | 7 +++-- src/games/gametree.h | 2 +- src/gui/dlinsertmove.cc | 4 +-- src/gui/gamedoc.cc | 17 +++++++++-- src/gui/gamedoc.h | 6 ++-- src/pygambit/gambit.pxd | 2 +- src/pygambit/game.pxi | 25 +++++++++++----- src/pygambit/player.pxi | 5 ---- tests/test_extensive.py | 5 ---- tests/test_players.py | 65 +++++++++++++++++++++++++++++++++++++---- 19 files changed, 151 insertions(+), 61 deletions(-) diff --git a/src/games/file.cc b/src/games/file.cc index 08961017f..ed36d6a27 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -531,7 +531,7 @@ void ReadPlayers(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData) p_state.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); NormalizeLabelStrings(player_labels); for (const auto &label : player_labels) { - p_game->NewPlayer()->SetLabel(label); + p_game->NewPlayer(label); } } diff --git a/src/games/game.cc b/src/games/game.cc index a441bb0fb..9a467565b 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -67,8 +67,8 @@ GameAction GameStrategyRep::GetAction(const GameInfoset &p_infoset) const // class GamePlayerRep //======================================================================== -GamePlayerRep::GamePlayerRep(GameRep *p_game, int p_id, int p_strats) - : m_game(p_game), m_number(p_id) +GamePlayerRep::GamePlayerRep(GameRep *p_game, int p_id, const std::string &p_label, int p_strats) + : m_game(p_game), m_number(p_id), m_label(p_label) { for (int j = 1; j <= p_strats; j++) { m_strategies.push_back(std::make_shared(this, j, "")); diff --git a/src/games/game.h b/src/games/game.h index 286ddf12b..e7fff307f 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -460,8 +460,11 @@ class GamePlayerRep : public std::enable_shared_from_this { using Strategies = ElementCollection; using Sequences = ElementCollection; - GamePlayerRep(GameRep *p_game, int p_id) : m_game(p_game), m_number(p_id) {} - GamePlayerRep(GameRep *p_game, int p_id, int m_strats); + GamePlayerRep(GameRep *p_game, int p_id, const std::string &p_label) + : m_game(p_game), m_number(p_id), m_label(p_label) + { + } + GamePlayerRep(GameRep *p_game, int p_id, const std::string &p_label, int p_strats); ~GamePlayerRep(); bool IsValid() const { return m_valid; } @@ -471,11 +474,7 @@ class GamePlayerRep : public std::enable_shared_from_this { Game GetGame() const; const std::string &GetLabel() const { return m_label; } - void SetLabel(const std::string &p_label) - { - CheckLabel(p_label); - m_label = p_label; - } + void SetLabel(const std::string &p_label); bool IsChance() const { return (m_number == 0); } @@ -764,6 +763,8 @@ class GameRep : public std::enable_shared_from_this { /// Mark that the content of the game has changed void IncrementVersion() { m_version++; } void IndexStrategies() const; + /// Validate that p_label is a nonempty, valid, unique label for a player of this game, + void CheckPlayerLabel(const std::string &p_label) const; //@} /// Hooks for derived classes to update lazily-computed orderings if required @@ -1162,7 +1163,7 @@ class GameRep : public std::enable_shared_from_this { virtual GamePlayer GetChance() const = 0; auto GetPlayersWithChance() const { return prepend_value(GetChance(), GetPlayers()); } /// Creates a new player in the game, with no moves - virtual GamePlayer NewPlayer() = 0; + virtual GamePlayer NewPlayer(const std::string &p_label) = 0; //@} /// @name Dimensions of the game @@ -1336,9 +1337,35 @@ inline void GameInfosetRep::SetLabel(const std::string &p_label) } m_label = p_label; } +inline void GameRep::CheckPlayerLabel(const std::string &p_label) const +{ + if (p_label.empty()) { + throw ValueException("Player label must not be empty"); + } + CheckLabel(p_label); + if (IsTree() && p_label == GetChance()->GetLabel()) { + throw ValueException("Player label must not be the reserved chance player label"); + } + for (const auto &player : m_players) { + if (player->GetLabel() == p_label) { + throw ValueException("Player label must be unique within the game"); + } + } +} inline bool GameInfosetRep::IsChanceInfoset() const { return m_player->IsChance(); } inline Game GamePlayerRep::GetGame() const { return m_game->shared_from_this(); } +inline void GamePlayerRep::SetLabel(const std::string &p_label) +{ + if (IsChance()) { + throw ValueException("The chance player's label cannot be changed"); + } + if (p_label == m_label) { + return; + } + GetGame()->CheckPlayerLabel(p_label); + m_label = p_label; +} inline GameStrategy GamePlayerRep::GetStrategy(int st) const { m_game->BuildComputedValues(); diff --git a/src/games/gameagg.cc b/src/games/gameagg.cc index a702e8b01..af223c993 100644 --- a/src/games/gameagg.cc +++ b/src/games/gameagg.cc @@ -179,8 +179,8 @@ template class AGGMixedStrategyProfileRep; GameAGGRep::GameAGGRep(std::shared_ptr p_aggPtr) : aggPtr(p_aggPtr) { for (int pl = 1; pl <= aggPtr->getNumPlayers(); pl++) { - m_players.push_back(std::make_shared(this, pl, aggPtr->getNumActions(pl - 1))); - m_players.back()->m_label = lexical_cast(pl); + m_players.push_back(std::make_shared(this, pl, lexical_cast(pl), + aggPtr->getNumActions(pl - 1))); std::for_each(m_players.back()->m_strategies.begin(), m_players.back()->m_strategies.end(), [st = 1](const std::shared_ptr &s) mutable { s->m_label = std::to_string(st++); diff --git a/src/games/gameagg.h b/src/games/gameagg.h index 3b0bb979e..609924e6a 100644 --- a/src/games/gameagg.h +++ b/src/games/gameagg.h @@ -63,7 +63,7 @@ class GameAGGRep : public GameRep { /// Returns the chance (nature) player GamePlayer GetChance() const override { throw UndefinedException(); } /// Creates a new player in the game, with no moves - GamePlayer NewPlayer() override { throw UndefinedException(); } + GamePlayer NewPlayer(const std::string &) override { throw UndefinedException(); } //@} /// @name Nodes diff --git a/src/games/gamebagg.cc b/src/games/gamebagg.cc index fea3bb695..cfca1517f 100644 --- a/src/games/gamebagg.cc +++ b/src/games/gamebagg.cc @@ -213,9 +213,8 @@ GameBAGGRep::GameBAGGRep(std::shared_ptr _baggPtr) int k = 1; for (int pl = 1; pl <= baggPtr->getNumPlayers(); pl++) { for (int j = 0; j < baggPtr->getNumTypes(pl - 1); j++, k++) { - m_players.push_back( - std::make_shared(this, k, baggPtr->getNumActions(pl - 1, j))); - m_players.back()->m_label = std::to_string(k); + m_players.push_back(std::make_shared(this, k, std::to_string(k), + baggPtr->getNumActions(pl - 1, j))); agent2baggPlayer[k] = pl; std::for_each(m_players.back()->m_strategies.begin(), m_players.back()->m_strategies.end(), [st = 1](const std::shared_ptr &s) mutable { diff --git a/src/games/gamebagg.h b/src/games/gamebagg.h index 641a13735..c51a8a661 100644 --- a/src/games/gamebagg.h +++ b/src/games/gamebagg.h @@ -70,7 +70,7 @@ class GameBAGGRep : public GameRep { /// Returns the chance (nature) player GamePlayer GetChance() const override { throw UndefinedException(); } /// Creates a new player in the game, with no moves - GamePlayer NewPlayer() override { throw UndefinedException(); } + GamePlayer NewPlayer(const std::string &) override { throw UndefinedException(); } //@} /// @name Nodes diff --git a/src/games/gametable.cc b/src/games/gametable.cc index 078969d1a..8fd04871b 100644 --- a/src/games/gametable.cc +++ b/src/games/gametable.cc @@ -376,8 +376,9 @@ GameTableRep::GameTableRep(const std::vector &dim, bool p_sparseOutcomes /* : m_results(std::accumulate(dim.begin(), dim.end(), 1, std::multiplies<>())) { for (const auto &nstrat : dim) { - m_players.push_back(std::make_shared(this, m_players.size() + 1, nstrat)); - m_players.back()->m_label = lexical_cast(m_players.size()); + const auto pl = m_players.size() + 1; + m_players.push_back( + std::make_shared(this, pl, lexical_cast(pl), nstrat)); std::for_each(m_players.back()->m_strategies.begin(), m_players.back()->m_strategies.end(), [st = 1](const std::shared_ptr &s) mutable { s->m_label = std::to_string(st++); @@ -494,10 +495,11 @@ void GameTableRep::WriteNfgFile(std::ostream &p_file) const // GameTableRep: Players //------------------------------------------------------------------------ -GamePlayer GameTableRep::NewPlayer() +GamePlayer GameTableRep::NewPlayer(const std::string &p_label) { + CheckPlayerLabel(p_label); + auto player = std::make_shared(this, m_players.size() + 1, p_label, 1); IncrementVersion(); - auto player = std::make_shared(this, m_players.size() + 1, 1); m_players.push_back(player); for (const auto &outcome : m_outcomes) { outcome->m_payoffs[player.get()] = Number(); diff --git a/src/games/gametable.h b/src/games/gametable.h index 04facee7d..df331c363 100644 --- a/src/games/gametable.h +++ b/src/games/gametable.h @@ -76,7 +76,7 @@ class GameTableRep : public GameExplicitRep { /// Returns the chance (nature) player GamePlayer GetChance() const override { throw UndefinedException(); } /// Creates a new player in the game, with no moves - GamePlayer NewPlayer() override; + GamePlayer NewPlayer(const std::string &p_label) override; //@} /// @name Nodes diff --git a/src/games/gametree.cc b/src/games/gametree.cc index e89c4f0cf..535d1db19 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -718,7 +718,7 @@ GameInfoset GameTreeRep::InsertMove(GameNode p_node, GameInfoset p_infoset) GameTreeRep::GameTreeRep() : m_root(std::make_shared(this, nullptr)), - m_chance(std::make_shared(this, 0)) + m_chance(std::make_shared(this, 0, "Chance")) { } @@ -1524,10 +1524,11 @@ int GameTreeRep::BehavProfileLength() const // GameTreeRep: Players //------------------------------------------------------------------------ -GamePlayer GameTreeRep::NewPlayer() +GamePlayer GameTreeRep::NewPlayer(const std::string &p_label) { + CheckPlayerLabel(p_label); + auto player = std::make_shared(this, m_players.size() + 1, p_label); IncrementVersion(); - auto player = std::make_shared(this, m_players.size() + 1); m_players.push_back(player); for (const auto &outcome : m_outcomes) { outcome->m_payoffs[player.get()] = Number(); diff --git a/src/games/gametree.h b/src/games/gametree.h index 4cb6e09dd..1b2e15e9e 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -136,7 +136,7 @@ class GameTreeRep final : public GameExplicitRep { /// Returns the chance (nature) player GamePlayer GetChance() const override { return m_chance->shared_from_this(); } /// Creates a new player in the game, with no moves - GamePlayer NewPlayer() override; + GamePlayer NewPlayer(const std::string &p_label) override; //@} /// @name Nodes diff --git a/src/gui/dlinsertmove.cc b/src/gui/dlinsertmove.cc index 71c16a9e7..dbfc58eba 100644 --- a/src/gui/dlinsertmove.cc +++ b/src/gui/dlinsertmove.cc @@ -190,9 +190,7 @@ GamePlayer InsertMoveDialog::GetPlayer() const if (playerNumber <= static_cast(m_doc->GetGame()->NumPlayers())) { return m_doc->GetGame()->GetPlayer(playerNumber); } - const GamePlayer player = m_doc->GetGame()->NewPlayer(); - player->SetLabel("Player " + lexical_cast(m_doc->GetGame()->NumPlayers())); - return player; + return m_doc->DoNewPlayer(); } GameInfoset InsertMoveDialog::GetInfoset() const diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index f971996e2..7804e12b7 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -22,6 +22,7 @@ #include #include +#include #include "gambit.h" #include "core/tinyxml.h" // for XML parser for LoadDocument() @@ -479,14 +480,24 @@ void GameDocument::DoSetTitle(const wxString &p_title, const wxString &p_comment NotifyChanged(GameModificationType::GameLabels); } -void GameDocument::DoNewPlayer() +GamePlayer GameDocument::DoNewPlayer() { - const GamePlayer player = m_game->NewPlayer(); - player->SetLabel("Player " + lexical_cast(player->GetNumber())); + std::set playerLabels; + + for (const auto &player : m_game->GetPlayers()) { + playerLabels.insert(player->GetLabel()); + } + + int number = m_game->NumPlayers() + 1; + while (contains(playerLabels, "Player " + lexical_cast(number))) { + number++; + } + const GamePlayer player = m_game->NewPlayer("Player " + lexical_cast(number)); if (!m_game->IsTree()) { player->GetStrategy(1)->SetLabel("1"); } NotifyChanged(GameModificationType::GameForm); + return player; } void GameDocument::DoSetPlayerLabel(GamePlayer p_player, const wxString &p_label) diff --git a/src/gui/gamedoc.h b/src/gui/gamedoc.h index ac384fe22..b9dbb1dd2 100644 --- a/src/gui/gamedoc.h +++ b/src/gui/gamedoc.h @@ -295,7 +295,7 @@ class GameDocument { } void DoSave(const wxString &p_filename, GameSaveFormat p_format); void DoSetTitle(const wxString &p_title, const wxString &p_comment); - void DoNewPlayer(); + GamePlayer DoNewPlayer(); void DoSetPlayerLabel(GamePlayer p_player, const wxString &p_label); void DoNewStrategy(GamePlayer p_player); void DoDeleteStrategy(GameStrategy p_strategy); @@ -333,8 +333,8 @@ inline GameDocument *NewTreeDocument() { const Game efg = NewTree(); efg->SetTitle("Untitled Extensive Game"); - efg->NewPlayer()->SetLabel("Player 1"); - efg->NewPlayer()->SetLabel("Player 2"); + efg->NewPlayer("Player 1"); + efg->NewPlayer("Player 2"); return new GameDocument(efg); } diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 4d6781edb..a9e25bfb1 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -319,7 +319,7 @@ cdef extern from "games/game.h": c_GamePlayer GetPlayer(int) except +IndexError Players GetPlayers() except + c_GamePlayer GetChance() except + - c_GamePlayer NewPlayer() except + + c_GamePlayer NewPlayer(string) except +ValueError int NumOutcomes() except + c_GameOutcome GetOutcome(int) except +IndexError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 9dca309fd..e71465940 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -563,7 +563,7 @@ class Game: g = Game.wrap(NewTree()) g.title = title for player in (players or []): - Player.wrap(g.game.deref().NewPlayer()).label = str(player) + g.game.deref().NewPlayer(str(player).encode("ascii")) return g @classmethod @@ -2063,23 +2063,32 @@ class Game: "Operation only defined for games with a tree representation" ) - def add_player(self, label: str = "") -> Player: + def add_player(self, label: str) -> Player: """Add a new player to the game. + .. versionchanged:: 16.7.0 + A label is now required and must be nonempty and unique among the game's players. + In extensive games, the label cannot be ``"Chance"``, which is reserved for the + chance player. + Parameters ---------- - label : str, default "" - The label for the player. + label : str + The label for the new player. Must be nonempty and not the same as the label + of an existing player in the game. Returns ------- Player A reference to the newly-created player. + + Raises + ------ + ValueError + If `label` is empty, is already the label of another player, or (in an + extensive game) is ``"Chance"``, the reserved label of the chance player. """ - p = Player.wrap(self.game.deref().NewPlayer()) - if str(label) != "": - p.label = str(label) - return p + return Player.wrap(self.game.deref().NewPlayer(label.encode("ascii"))) def set_player(self, infoset: Infoset | str, player: Player | str) -> None: diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index dae64cee8..7cfc4f7de 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -256,11 +256,6 @@ class Player: @label.setter def label(self, value: str) -> None: - if value == self.label: - return - if value == "" or value in (player.label for player in self.game.players): - warnings.warn("In a future version, players must have unique labels", - FutureWarning) self.player.deref().SetLabel(value.encode("ascii")) @property diff --git a/tests/test_extensive.py b/tests/test_extensive.py index a18c24a29..5f5e35e1f 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -42,11 +42,6 @@ def test_game_add_players_label(players: list): assert player.label == label -def test_game_add_players_nolabel(): - game = gbt.Game.new_tree() - game.add_player() - - @pytest.mark.parametrize("game_input,expected_result", [ # Games with perfect recall from files (game_input is a string) (gbt.catalog.load("journals/ijgt/selten1975/fig2"), True), diff --git a/tests/test_players.py b/tests/test_players.py index bcc10b026..b5f13ed51 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -35,6 +35,59 @@ def test_player_label_non_ascii_rejected(label): player.label = label +def test_add_player_requires_label(): + """add_player now requires a label; omitting it is a TypeError.""" + game = gbt.Game.new_tree() + with pytest.raises(TypeError): + game.add_player() + + +def test_add_player_duplicate_label_raises_and_leaves_game_unchanged(): + game = gbt.Game.new_table([2, 2]) + existing = next(iter(game.players)).label + count_before = len(game.players) + with pytest.raises(ValueError): + game.add_player(existing) + assert len(game.players) == count_before + + +def test_add_player_empty_label_raises_and_leaves_game_unchanged(): + game = gbt.Game.new_table([2, 2]) + count_before = len(game.players) + with pytest.raises(ValueError): + game.add_player("") + assert len(game.players) == count_before + + +def test_add_player_reserved_chance_label_raises_and_leaves_game_unchanged(): + game = gbt.Game.new_tree() + count_before = len(game.players) + with pytest.raises(ValueError): + game.add_player("Chance") + assert len(game.players) == count_before + + +def test_chance_player_has_label(): + """The chance player is labeled "Chance" by default.""" + game = gbt.Game.new_tree() + assert game.players.chance.label == "Chance" + + +def test_chance_player_label_cannot_be_changed(): + """The chance player's label is reserved ("Chance") and cannot be changed.""" + game = gbt.Game.new_tree() + with pytest.raises(ValueError): + game.players.chance.label = "Nature" + + +def test_regular_player_cannot_be_relabeled_to_chance(): + game = gbt.Game.new_tree() + game.add_player("Alice") + player = next(iter(game.players)) + with pytest.raises(ValueError): + player.label = "Chance" + + def test_player_index_by_string(): game = gbt.Game.new_table([2, 2]) pl1, pl2 = game.players @@ -56,30 +109,30 @@ def test_player_label_invalid(): _ = game.players["Not a player"] -def test_set_empty_player_futurewarning(): +def test_set_empty_player_raises_valueerror(): game = games.create_stripped_down_poker_efg() player = next(iter(game.players)) - with pytest.warns(FutureWarning): + with pytest.raises(ValueError): player.label = "" -def test_set_duplicate_player_futurewarning(): +def test_set_duplicate_player_raises_valueerror(): game = games.create_stripped_down_poker_efg() pl1, pl2, *_ = game.players - with pytest.warns(FutureWarning): + with pytest.raises(ValueError): pl1.label = pl2.label def test_strategic_game_add_player(): game = gbt.Game.new_table([2, 2]) - new_player = game.add_player() + new_player = game.add_player("Player 3") assert len(game.players) == 3 assert len(new_player.strategies) == 1 def test_extensive_game_add_player(): game = gbt.Game.new_tree() - game.add_player() + game.add_player("Alice") pl1 = next(iter(game.players)) assert len(game.players) == 1 assert len(pl1.infosets) == 0 From 7d21fa67ef6603fe02349b3d55db0960db76ac54 Mon Sep 17 00:00:00 2001 From: Daniel Kadnikov <165307096+d-kad@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:56:58 +0100 Subject: [PATCH 11/32] Enforce unique, nonempty labels for strategies (#981) --- src/games/game.h | 29 +++++++++++++++++++++----- src/games/gametable.cc | 6 ++++-- src/gui/gamedoc.cc | 10 ++++++++- src/pygambit/game.pxi | 16 +++++++++----- src/pygambit/strategy.pxi | 11 ++++------ tests/test_players.py | 44 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index e7fff307f..279fdd90c 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -369,11 +369,7 @@ class GameStrategyRep : public std::enable_shared_from_this { /// Returns the text label associated with the strategy const std::string &GetLabel() const { return m_label; } /// Sets the text label associated with the strategy - void SetLabel(const std::string &p_label) - { - CheckLabel(p_label); - m_label = p_label; - } + void SetLabel(const std::string &p_label); /// Returns the game on which the strategy is defined Game GetGame() const; @@ -491,6 +487,8 @@ class GamePlayerRep : public std::enable_shared_from_this { GameStrategy GetStrategy(int st) const; /// Returns the collection of strategies available to the player Strategies GetStrategies() const; + /// Validate that p_label is a nonempty, valid, unique label for a strategy of this player. + void CheckStrategyLabel(const std::string &p_label) const; //@} /// @name Sequences @@ -1312,6 +1310,27 @@ inline void GameOutcomeRep::SetPayoff(const GamePlayer &p_player, const Number & inline GamePlayer GameStrategyRep::GetPlayer() const { return m_player->shared_from_this(); } inline Game GameStrategyRep::GetGame() const { return m_player->GetGame(); } +inline void GameStrategyRep::SetLabel(const std::string &p_label) +{ + if (p_label == m_label) { + return; + } + GetPlayer()->CheckStrategyLabel(p_label); + m_label = p_label; +} + +inline void GamePlayerRep::CheckStrategyLabel(const std::string &p_label) const +{ + if (p_label.empty()) { + throw ValueException("Strategy label must not be empty"); + } + CheckLabel(p_label); + for (const auto &strategy : m_strategies) { + if (strategy->GetLabel() == p_label) { + throw ValueException("Strategy label must be unique for the player"); + } + } +} inline Game GameSequenceRep::GetGame() const { return m_player->GetGame(); } inline GamePlayer GameSequenceRep::GetPlayer() const { return m_player->shared_from_this(); } diff --git a/src/games/gametable.cc b/src/games/gametable.cc index 8fd04871b..798c783c7 100644 --- a/src/games/gametable.cc +++ b/src/games/gametable.cc @@ -533,13 +533,15 @@ GameStrategy GameTableRep::NewStrategy(const GamePlayer &p_player, const std::st if (p_player->GetGame().get() != this) { throw MismatchException(); } + p_player->CheckStrategyLabel(p_label); + auto strategy = std::make_shared(p_player.get(), + p_player->m_strategies.size() + 1, p_label); IncrementVersion(); std::vector old_radices; for (const auto &player : m_players) { old_radices.push_back(player->m_strategies.size()); } - p_player->m_strategies.push_back(std::make_shared( - p_player.get(), p_player->m_strategies.size() + 1, p_label)); + p_player->m_strategies.push_back(strategy); RebuildTable(old_radices); return p_player->m_strategies.back(); } diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index 7804e12b7..61d5efd0f 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -508,7 +508,15 @@ void GameDocument::DoSetPlayerLabel(GamePlayer p_player, const wxString &p_label void GameDocument::DoNewStrategy(GamePlayer p_player) { - m_game->NewStrategy(p_player, std::to_string(p_player->GetStrategies().size() + 1)); + std::set strategyLabels; + for (const auto &strategy : p_player->GetStrategies()) { + strategyLabels.insert(strategy->GetLabel()); + } + int number = p_player->GetStrategies().size() + 1; + while (contains(strategyLabels, std::to_string(number))) { + number++; + } + m_game->NewStrategy(p_player, std::to_string(number)); NotifyChanged(GameModificationType::GameForm); } diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index e71465940..6685ff914 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2192,15 +2192,20 @@ class Game: resolved_outcome = cython.cast(Outcome, self._resolve_outcome(outcome, "set_outcome")) self.game.deref().SetOutcome(resolved_node.node, resolved_outcome.outcome) - def add_strategy(self, player: Player | str, label: str = None) -> Strategy: + def add_strategy(self, player: Player | str, label: str) -> Strategy: """Add a new strategy to the set of strategies for `player`. + .. versionchanged:: 16.7.0 + A label is now required and must be nonempty and unique among the + player's strategies. + Parameters ---------- player : Player or str The player to create the new strategy for - label : str, optional - The label to assign to the new strategy + label : str + The label for the new strategy. Must be nonempty and not already in use + by another of the player's strategies. Returns ------- @@ -2213,6 +2218,8 @@ class Game: If `player` is a `Player` from a different game. UndefinedOperationError If called on a game which has an extensive representation. + ValueError + If `label` is empty or is already the label of another of the player's strategies. """ if self.is_tree: raise UndefinedOperationError( @@ -2220,9 +2227,8 @@ class Game: ) resolved_player = cython.cast(Player, self._resolve_player(player, "add_strategy")) - label_bytes = (str(label) if label is not None else "").encode("ascii") return Strategy.wrap( - self.game.deref().NewStrategy(resolved_player.player, label_bytes) + self.game.deref().NewStrategy(resolved_player.player, label.encode("ascii")) ) def delete_strategy(self, strategy: Strategy | str) -> None: diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index f46602b8b..80a605460 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -55,18 +55,15 @@ class Strategy: """Get or set the text label associated with the strategy. .. versionchanged:: 16.7.0 - An invalid label now raises ``ValueError``: a label may contain only printable ASCII - characters and spaces, not begin/end with a space, nor have two consecutive spaces. + A strategy label must be nonempty and unique among the player's strategies; + an empty or duplicate label now raises ``ValueError``. A label may contain only + printable ASCII characters and spaces, not begin/end with a space, nor have two + consecutive spaces. """ return self.strategy.deref().GetLabel().decode("ascii") @label.setter def label(self, value: str) -> None: - if value == self.label: - return - if value == "" or value in (strategy.label for strategy in self.player.strategies): - warnings.warn("In a future version, strategies for a player must have unique labels", - FutureWarning) self.strategy.deref().SetLabel(value.encode("ascii")) @property diff --git a/tests/test_players.py b/tests/test_players.py index b5f13ed51..4dbfa0172 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -198,6 +198,27 @@ def test_add_strategy_label_invalid_raises_valueerror(label): game.add_strategy(next(iter(game.players)), label) +def test_add_strategy_requires_label(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(TypeError): + game.add_strategy(next(iter(game.players))) + + +def test_strategy_label_empty_raises_valueerror(): + game = gbt.Game.new_table([2, 2]) + strategy = next(iter(next(iter(game.players)).strategies)) + with pytest.raises(ValueError): + strategy.label = "" + + +def test_strategy_label_duplicate_within_player_raises_valueerror(): + game = gbt.Game.new_table([2, 2]) + pl1 = next(iter(game.players)) + s1, s2 = pl1.strategies + with pytest.raises(ValueError): + s2.label = s1.label + + def test_player_strategy_bad_label(): game = gbt.Game.new_table([2, 2]) pl1 = next(iter(game.players)) @@ -285,7 +306,7 @@ def test_player_get_min_payoff_null_outcome(): pl1, pl2 = game.players assert pl1.min_payoff == 1 assert pl2.min_payoff == 2 - game.add_strategy(pl1) + game.add_strategy(pl1, "new strategy") # Currently the outcomes associated with the new entries in the table # are null outcomes. So now minimum payoff should be zero from those. for player in game.players: @@ -311,8 +332,27 @@ def test_player_get_max_payoff_null_outcome(): pl1, pl2 = game.players assert pl1.max_payoff == -1 assert pl2.max_payoff == -2 - game.add_strategy(pl1) + game.add_strategy(pl1, "new strategy") # Currently the outcomes associated with the new entries in the table # are null outcomes. So now minimum payoff should be zero from those. for player in game.players: assert player.max_payoff == 0 + + +def test_add_strategy_duplicate_label_raises_and_leaves_game_unchanged(): + game = gbt.Game.new_table([2, 2]) + pl = next(iter(game.players)) + existing = next(iter(pl.strategies)).label + count_before = len(pl.strategies) + with pytest.raises(ValueError): + game.add_strategy(pl, existing) + assert len(pl.strategies) == count_before + + +def test_add_strategy_empty_label_raises_and_leaves_game_unchanged(): + game = gbt.Game.new_table([2, 2]) + pl = next(iter(game.players)) + count_before = len(pl.strategies) + with pytest.raises(ValueError): + game.add_strategy(pl, "") + assert len(pl.strategies) == count_before From f143296a544c961f0fb300220201c16a27a89a5f Mon Sep 17 00:00:00 2001 From: wyz2368 Date: Fri, 10 Jul 2026 05:55:04 -0400 Subject: [PATCH 12/32] Fix incorrect remembering of old files in catalog build (#985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_support/catalog/update.py` was not building a fresh catalog from Git directly. Instead, it was calling `gbt.catalog.games(include_descriptions=True)`, so it was using whatever `pygambit` the Python environment is importing. If that had an old installed/editable `pygambit`, or an old `build/lib.../pygambit/catalog_data`, it would “remember” deleted games. --- build_support/catalog/test_update.py | 51 ++++++++++++++++++++++++++++ build_support/catalog/update.py | 23 +++++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py index 645dc25f9..e58c63f55 100644 --- a/build_support/catalog/test_update.py +++ b/build_support/catalog/test_update.py @@ -147,6 +147,57 @@ def _make_image_files(catalog_dir, slug, fmt="efg"): (img_dir / f"{slug}.ef").touch() +# --------------------------------------------------------------------------- +# Tests for catalog resource selection +# --------------------------------------------------------------------------- + + +@pytest.mark.catalog_update +class TestCatalogResourceSelection: + def test_catalog_games_uses_requested_catalog_dir_and_restores_resource( + self, tmp_path, monkeypatch + ): + stale_dir = tmp_path / "installed_catalog_data" + checkout_dir = tmp_path / "checkout_catalog" + stale_dir.mkdir() + checkout_dir.mkdir() + calls = [] + + def fake_games(**kwargs): + calls.append((update.gbt.catalog._CATALOG_RESOURCE, kwargs)) + return _make_df(_efg_row("checkout/game1")) + + monkeypatch.setattr(update.gbt.catalog, "_CATALOG_RESOURCE", stale_dir) + monkeypatch.setattr(update.gbt.catalog, "games", fake_games) + + df = update._catalog_games(checkout_dir) + + assert calls == [(checkout_dir, {"include_descriptions": True})] + assert stale_dir == update.gbt.catalog._CATALOG_RESOURCE + assert list(df["Game"]) == ["checkout/game1"] + + def test_generate_rst_table_uses_requested_catalog_dir_while_rendering( + self, tmp_path, monkeypatch + ): + stale_dir = tmp_path / "installed_catalog_data" + checkout_dir = tmp_path / "checkout_catalog" + stale_dir.mkdir() + checkout_dir.mkdir() + observed = [] + + def fake_write_tree_level(*args, **kwargs): + observed.append(update.gbt.catalog._CATALOG_RESOURCE) + + monkeypatch.setattr(update.gbt.catalog, "_CATALOG_RESOURCE", stale_dir) + monkeypatch.setattr(update, "load_hierarchy_labels", lambda: {}) + monkeypatch.setattr(update, "_write_tree_level", fake_write_tree_level) + + update.generate_rst_table(_make_df(), tmp_path / "out.rst", catalog_dir=checkout_dir) + + assert observed == [checkout_dir] + assert stale_dir == update.gbt.catalog._CATALOG_RESOURCE + + # --------------------------------------------------------------------------- # Tests for catalog_gtdraw_settings # --------------------------------------------------------------------------- diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index 0cf51385e..5cc15443b 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -1,6 +1,7 @@ import argparse import shutil import sys +from contextlib import contextmanager from pathlib import Path import pandas as pd @@ -17,6 +18,24 @@ SUPPORTED_GAME_FORMATS = {"efg", "nfg"} +@contextmanager +def _using_catalog_dir(catalog_dir: Path): + """Temporarily make pygambit.catalog read data from the checked-out catalog.""" + old_resource = gbt.catalog._CATALOG_RESOURCE + gbt.catalog._CATALOG_RESOURCE = catalog_dir + try: + yield + finally: + gbt.catalog._CATALOG_RESOURCE = old_resource + + +def _catalog_games(catalog_dir: Path | None = None) -> pd.DataFrame: + """Return catalog games using files from *catalog_dir*, not installed package data.""" + catalog_dir = catalog_dir or CATALOG_DIR + with _using_catalog_dir(catalog_dir): + return gbt.catalog.games(include_descriptions=True) + + def catalog_gtdraw_settings(slug: str) -> dict: """Return the gtdraw settings for a given catalog slug.""" with open(GTDRAW_SETTINGS_CONFIG, encoding="utf-8") as f: @@ -325,7 +344,7 @@ def generate_rst_table( catalog_dir = catalog_dir or CATALOG_DIR labels = load_hierarchy_labels() tree = _build_slug_tree(df) - with open(rst_path, "w", encoding="utf-8") as f: + with _using_catalog_dir(catalog_dir), open(rst_path, "w", encoding="utf-8") as f: _write_tree_level( f, tree, "", labels, catalog_dir, indent="", regenerate_images=regenerate_images ) @@ -409,7 +428,7 @@ def update_makefile( args = parser.parse_args() # Create RST list-table used by doc/catalog.rst - df = gbt.catalog.games(include_descriptions=True) + df = _catalog_games() _warn_missing_descriptions(df) generate_rst_table(df, CATALOG_RST_TABLE, regenerate_images=args.regenerate_images) print(f"Generated {CATALOG_RST_TABLE} for use in local docs build. DO NOT COMMIT.") From 93b10bb83244ea9f8d0de1a9158e32da71916fe6 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Fri, 10 Jul 2026 11:15:31 +0100 Subject: [PATCH 13/32] Correct cross-reference link to enumpoly documentation in `enumpoly_solve` docstring (#986) Closes #984. --- src/pygambit/nash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 96201df0b..a5f354aef 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -666,7 +666,7 @@ def enumpoly_solve( ) -> NashComputationResult: """:ref:`Compute Nash equilibria by enumerating all support profiles of strategies or actions, and for each support finding all totally-mixed equilibria of - the game over that support.` + the game over that support. ` Parameters ---------- From 6e5a8bd6a8ad95cb0d769780e629dd516b644de1 Mon Sep 17 00:00:00 2001 From: Daniel Kadnikov <165307096+d-kad@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:18:48 +0100 Subject: [PATCH 14/32] Enforce unique, nonempty labels for outcomes (#982) --- doc/tutorials/02_extensive_form.ipynb | 75 ++++++++--- doc/tutorials/03_stripped_down_poker.ipynb | 63 +++++++-- .../openspiel.ipynb | 26 +++- src/games/file.cc | 120 ++++++++++++------ src/games/game.cc | 4 +- src/games/game.h | 32 ++++- src/games/gameexpl.cc | 5 +- src/games/gameexpl.h | 2 +- src/games/gametable.cc | 2 +- src/gui/gamedoc.cc | 37 +++++- src/pygambit/gambit.pxd | 2 +- src/pygambit/game.pxi | 19 +-- src/pygambit/outcome.pxi | 10 +- tests/games.py | 52 ++++---- tests/test_file.py | 13 ++ tests/test_outcomes.py | 26 +++- tests/test_players.py | 4 +- 17 files changed, 355 insertions(+), 137 deletions(-) diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 1f4f6fd0e..bba579e9f 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -4,7 +4,30 @@ "cell_type": "markdown", "id": "96019084", "metadata": {}, - "source": "# 2) Extensive-form games\n\nIn the first tutorial, we used Gambit to set up the Prisoner's Dilemma, an example of a normal (strategic) form game.\n\nGambit can also be used to set up extensive-form games; the game is represented as a tree, where each node represents a decision point for a player, and the branches represent the possible actions they can take.\n\n**Example: One-shot trust game with binary actions**\n\n[Kreps (1990)](#references) introduced a game commonly referred to as the **trust game**.\nWe will build a one-shot version of this game using Gambit's game transformation operations.\n\nThe game can be defined as follows:\n- There are two players, a **Buyer** and a **Seller**.\n- The Buyer moves first and has two actions, **Trust** or **Not trust**.\n- If the Buyer chooses **Not trust**, then the game ends, and both players receive payoffs of `0`.\n- If the Buyer chooses **Trust**, then the Seller has a choice with two actions, **Honor** or **Abuse**.\n- If the Seller chooses **Honor**, both players receive payoffs of `1`;\n- If the Seller chooses **Abuse**, the Buyer receives a payoff of `-1` and the Seller receives a payoff of `2`.\n\nIn addition to `pygambit`, this tutorial introduces the `gtdraw` package, which can be used to draw extensive form games in Python.\nIf you're running this tutorial on your local machine, you'll need to install the requirements for [gtdraw](https://www.gambit-project.org/gtdraw/), which include LaTeX, in order to run the `gtdraw` cells.\nAnother option for visualising extensive form games is to install the Gambit GUI and use it to load the EFG file generated at the end of this tutorial." + "source": [ + "# 2) Extensive-form games\n", + "\n", + "In the first tutorial, we used Gambit to set up the Prisoner's Dilemma, an example of a normal (strategic) form game.\n", + "\n", + "Gambit can also be used to set up extensive-form games; the game is represented as a tree, where each node represents a decision point for a player, and the branches represent the possible actions they can take.\n", + "\n", + "**Example: One-shot trust game with binary actions**\n", + "\n", + "[Kreps (1990)](#references) introduced a game commonly referred to as the **trust game**.\n", + "We will build a one-shot version of this game using Gambit's game transformation operations.\n", + "\n", + "The game can be defined as follows:\n", + "- There are two players, a **Buyer** and a **Seller**.\n", + "- The Buyer moves first and has two actions, **Trust** or **Not trust**.\n", + "- If the Buyer chooses **Not trust**, then the game ends, and both players receive payoffs of `0`.\n", + "- If the Buyer chooses **Trust**, then the Seller has a choice with two actions, **Honor** or **Abuse**.\n", + "- If the Seller chooses **Honor**, both players receive payoffs of `1`;\n", + "- If the Seller chooses **Abuse**, the Buyer receives a payoff of `-1` and the Seller receives a payoff of `2`.\n", + "\n", + "In addition to `pygambit`, this tutorial introduces the `gtdraw` package, which can be used to draw extensive form games in Python.\n", + "If you're running this tutorial on your local machine, you'll need to install the requirements for [gtdraw](https://www.gambit-project.org/gtdraw/), which include LaTeX, in order to run the `gtdraw` cells.\n", + "Another option for visualising extensive form games is to install the Gambit GUI and use it to load the EFG file generated at the end of this tutorial." + ] }, { "cell_type": "code", @@ -12,7 +35,11 @@ "id": "5946289b", "metadata": {}, "outputs": [], - "source": "from gtdraw import draw\n\nimport pygambit as gbt" + "source": [ + "from gtdraw import draw\n", + "\n", + "import pygambit as gbt" + ] }, { "cell_type": "markdown", @@ -49,7 +76,9 @@ "id": "3cd94917", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -81,7 +110,9 @@ "id": "45638fda-7e25-4c8e-b709-24b05780581b", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -111,7 +142,9 @@ "id": "ce41e9fe-cca4-46fb-8e9d-b2c27342e5ef", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -134,10 +167,7 @@ "source": [ "g.set_outcome(\n", " g.root.children[\"Trust\"].children[\"Honor\"],\n", - " outcome=g.add_outcome(\n", - " payoffs=[1, 1],\n", - " label=\"Trustworthy\"\n", - " )\n", + " outcome=g.add_outcome(\"Trustworthy\", [1, 1])\n", ")" ] }, @@ -147,7 +177,9 @@ "id": "b3408c55-714e-4a6f-b598-e338839442e4", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -166,10 +198,7 @@ "source": [ "g.set_outcome(\n", " g.root.children[\"Trust\"].children[\"Abuse\"],\n", - " outcome=g.add_outcome(\n", - " payoffs=[-1, 2],\n", - " label=\"Untrustworthy\"\n", - " )\n", + " outcome=g.add_outcome(\"Untrustworthy\", [-1, 2])\n", ")" ] }, @@ -179,7 +208,9 @@ "id": "09bedb3a-aac7-46e6-ae93-c47932c746d4", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -198,10 +229,7 @@ "source": [ "g.set_outcome(\n", " g.root.children[\"Not trust\"],\n", - " g.add_outcome(\n", - " payoffs=[0, 0],\n", - " label=\"Opt-out\"\n", - " )\n", + " g.add_outcome(\"Opt-out\", [0, 0])\n", ")" ] }, @@ -211,7 +239,9 @@ "id": "cba0e562-2989-4dae-a0f0-b121635ba032", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -258,7 +288,10 @@ "id": "56899a29-cc53-48db-9eb4-ed2295517400", "metadata": {}, "outputs": [], - "source": "g = gbt.catalog.load(\"journals/ijgt/selten1975/fig2\")\ndraw(g)" + "source": [ + "g = gbt.catalog.load(\"journals/ijgt/selten1975/fig2\")\n", + "draw(g)" + ] }, { "cell_type": "markdown", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 5dc52da3d..7f47f183f 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -4,7 +4,38 @@ "cell_type": "markdown", "id": "98eb65d8", "metadata": {}, - "source": "# 3) Stripped-down poker\n\nIn this tutorial, we'll create an extensive-form representation of a one-card poker game from [Reiley et al (2008)](#references), a classroom game under the name \"stripped-down poker\".\nThis is perhaps the simplest interesting game with imperfect information.\n\nWe'll use \"stripped-down poker\" to demonstrate and explain the following with Gambit:\n\n1. Setting up an extensive-form game with imperfect information using [information sets](#information-sets)\n2. [Computing and interpreting Nash equilibria](#computing-and-interpreting-nash-equilibria) and understanding mixed behaviour and mixed strategy profiles\n3. [Acceptance criteria for Nash equilibria](#acceptance-criteria-for-nash-equilibria)\n\nIn our version of the game, there are two players, **Alice** and **Bob**, and a deck of cards, with equal numbers of **King** and **Queen** cards.\n\n- The game begins with each player putting \\$1 in the pot.\n - A card is dealt at random to Alice.\n - Alice observes her card.\n - Bob does not observe the card.\n- Alice then chooses either to **Bet** or to **Fold**.\n - If she chooses to Fold, Bob wins the pot and the game ends.\n - If she chooses to Bet, she adds another \\$1 to the pot.\n- Bob then chooses either to **Call** or **Fold**.\n - If he chooses to Fold, Alice wins the pot and the game ends.\n - If he chooses to Call, he adds another $1 to the pot.\n- There is then a showdown, in which Alice reveals her card.\n - If she has a King, then she wins the pot.\n - If she has a Queen, then Bob wins the pot.\n\nIn addition to `pygambit`, this tutorial uses the `gtdraw` package, which can be used to draw extensive form games in Python.\nIf you're running this tutorial on your local machine, you'll need to install the requirements for [gtdraw](https://www.gambit-project.org/gtdraw/), which include LaTeX, in order to run the `gtdraw` cells.\nAnother option for visualising extensive form games is to install the Gambit GUI and use it to load a saved EFG file." + "source": [ + "# 3) Stripped-down poker\n", + "\n", + "In this tutorial, we'll create an extensive-form representation of a one-card poker game from [Reiley et al (2008)](#references), a classroom game under the name \"stripped-down poker\".\n", + "This is perhaps the simplest interesting game with imperfect information.\n", + "\n", + "We'll use \"stripped-down poker\" to demonstrate and explain the following with Gambit:\n", + "\n", + "1. Setting up an extensive-form game with imperfect information using [information sets](#information-sets)\n", + "2. [Computing and interpreting Nash equilibria](#computing-and-interpreting-nash-equilibria) and understanding mixed behaviour and mixed strategy profiles\n", + "3. [Acceptance criteria for Nash equilibria](#acceptance-criteria-for-nash-equilibria)\n", + "\n", + "In our version of the game, there are two players, **Alice** and **Bob**, and a deck of cards, with equal numbers of **King** and **Queen** cards.\n", + "\n", + "- The game begins with each player putting \\$1 in the pot.\n", + " - A card is dealt at random to Alice.\n", + " - Alice observes her card.\n", + " - Bob does not observe the card.\n", + "- Alice then chooses either to **Bet** or to **Fold**.\n", + " - If she chooses to Fold, Bob wins the pot and the game ends.\n", + " - If she chooses to Bet, she adds another \\$1 to the pot.\n", + "- Bob then chooses either to **Call** or **Fold**.\n", + " - If he chooses to Fold, Alice wins the pot and the game ends.\n", + " - If he chooses to Call, he adds another $1 to the pot.\n", + "- There is then a showdown, in which Alice reveals her card.\n", + " - If she has a King, then she wins the pot.\n", + " - If she has a Queen, then Bob wins the pot.\n", + "\n", + "In addition to `pygambit`, this tutorial uses the `gtdraw` package, which can be used to draw extensive form games in Python.\n", + "If you're running this tutorial on your local machine, you'll need to install the requirements for [gtdraw](https://www.gambit-project.org/gtdraw/), which include LaTeX, in order to run the `gtdraw` cells.\n", + "Another option for visualising extensive form games is to install the Gambit GUI and use it to load a saved EFG file." + ] }, { "cell_type": "code", @@ -12,7 +43,11 @@ "id": "69cbfe81", "metadata": {}, "outputs": [], - "source": "from gtdraw import draw\n\nimport pygambit as gbt" + "source": [ + "from gtdraw import draw\n", + "\n", + "import pygambit as gbt" + ] }, { "cell_type": "markdown", @@ -89,7 +124,9 @@ "id": "867cb1d8-7a5d-45d1-9349-9bbc2a4e2344", "metadata": {}, "outputs": [], - "source": "draw(g, color_scheme=\"gambit\")" + "source": [ + "draw(g, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", @@ -126,7 +163,9 @@ "id": "0c522c2d-992e-48b6-a1f8-0696d33cdbe0", "metadata": {}, "outputs": [], - "source": "draw(g, color_scheme=\"gambit\")" + "source": [ + "draw(g, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", @@ -166,7 +205,9 @@ "id": "e85b3346-2fea-4a73-aa72-9efb436c68c1", "metadata": {}, "outputs": [], - "source": "draw(g, color_scheme=\"gambit\")" + "source": [ + "draw(g, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", @@ -188,10 +229,10 @@ "metadata": {}, "outputs": [], "source": [ - "win_big = g.add_outcome([2, -2], label=\"Win Big\")\n", - "win = g.add_outcome([1, -1], label=\"Win\")\n", - "lose_big = g.add_outcome([-2, 2], label=\"Lose Big\")\n", - "lose = g.add_outcome([-1, 1], label=\"Lose\")" + "win_big = g.add_outcome(\"Win Big\", [2, -2])\n", + "win = g.add_outcome(\"Win\", [1, -1])\n", + "lose_big = g.add_outcome(\"Lose Big\", [-2, 2])\n", + "lose = g.add_outcome(\"Lose\", [-1, 1])" ] }, { @@ -230,7 +271,9 @@ "id": "fdee7b53-7820-44df-9d17-d15d0b9667aa", "metadata": {}, "outputs": [], - "source": "draw(g, color_scheme=\"gambit\")" + "source": [ + "draw(g, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 21fbf0256..de36447bc 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -468,7 +468,17 @@ "id": "b913fc7a", "metadata": {}, "outputs": [], - "source": "from gtdraw import draw\n\ndraw(\n gbt_hanabi_game,\n color_scheme=\"gambit\",\n edge_thickness=2,\n action_label_position=0.8,\n shared_terminal_depth=True\n)" + "source": [ + "from gtdraw import draw\n", + "\n", + "draw(\n", + " gbt_hanabi_game,\n", + " color_scheme=\"gambit\",\n", + " edge_thickness=2,\n", + " action_label_position=0.8,\n", + " shared_terminal_depth=True\n", + ")" + ] }, { "cell_type": "markdown", @@ -697,10 +707,10 @@ " actions=[\"Call\", \"Fold\"]\n", ")\n", "\n", - "win_big = gbt_one_card_poker.add_outcome([2, -2], label=\"Win Big\")\n", - "win = gbt_one_card_poker.add_outcome([1, -1], label=\"Win\")\n", - "lose_big = gbt_one_card_poker.add_outcome([-2, 2], label=\"Lose Big\")\n", - "lose = gbt_one_card_poker.add_outcome([-1, 1], label=\"Lose\")\n", + "win_big = gbt_one_card_poker.add_outcome(\"Win Big\", [2, -2])\n", + "win = gbt_one_card_poker.add_outcome(\"Win\", [1, -1])\n", + "lose_big = gbt_one_card_poker.add_outcome(\"Lose Big\", [-2, 2])\n", + "lose = gbt_one_card_poker.add_outcome(\"Lose\", [-1, 1])\n", "\n", "# Alice folds, Bob wins small\n", "gbt_one_card_poker.set_outcome(\n", @@ -741,7 +751,9 @@ "id": "ed920d33-b7c6-4cc1-b055-7244a5bf42d8", "metadata": {}, "outputs": [], - "source": "draw(gbt_one_card_poker, color_scheme=\"gambit\")" + "source": [ + "draw(gbt_one_card_poker, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", @@ -833,7 +845,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.13" + "version": "3.13.5" } }, "nbformat": 4, diff --git a/src/games/file.cc b/src/games/file.cc index ed36d6a27..0d772d18f 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -437,23 +437,39 @@ void ReadOutcomeList(GameFileLexer &p_parser, Game &p_nfg) auto players = p_nfg->GetPlayers(); p_parser.GetNextToken(); + // Buffer raw labels + payoffs so labels can be normalized (unique, nonempty) + // before the outcome objects are created (NewOutcome now rejects empty/duplicate). + std::vector labels; + std::vector> payoff_lists; + while (p_parser.GetCurrentToken() == TOKEN_LBRACE) { p_parser.ExpectNextToken(TOKEN_TEXT, "outcome name"); - auto outcome = p_nfg->NewOutcome(); - outcome->SetLabel(p_parser.GetLastText()); + labels.push_back(p_parser.GetLastText()); p_parser.GetNextToken(); - for (auto player : players) { + std::vector payoffs; + for (size_t i = 0; i < players.size(); ++i) { p_parser.ExpectCurrentToken(TOKEN_NUMBER, "numerical payoff"); - outcome->SetPayoff(player, Number(p_parser.GetLastText())); + payoffs.emplace_back(p_parser.GetLastText()); p_parser.AcceptNextToken(TOKEN_COMMA); } + payoff_lists.push_back(payoffs); + p_parser.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); p_parser.GetNextToken(); } - p_parser.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); p_parser.GetNextToken(); + + NormalizeLabelStrings(labels); + for (size_t i = 0; i < labels.size(); ++i) { + auto outcome = p_nfg->NewOutcome(labels[i]); + auto player_it = players.begin(); + for (const auto &payoff : payoff_lists[i]) { + outcome->SetPayoff(*player_it, payoff); + ++player_it; + } + } } void ParseOutcomeBody(GameFileLexer &p_parser, Game &p_nfg) @@ -511,9 +527,24 @@ Game BuildNfg(GameFileLexer &p_parser, TableFileGame &p_data) // Temporary representation classes //========================================================================= +/// An outcome definition encountered during the parse. Outcomes are not +/// created until the whole tree has been read, so that their labels can be +/// normalized in one pass before creation, as NewOutcome enforces +/// the label requirements at creation time. +struct OutcomeRecord { + std::string m_label; + std::vector m_payoffs; +}; + class TreeData { public: - std::map m_outcomeMap; + std::map m_outcomeRecords; + /// Outcome ids in order of first occurrence in the file; determines the + /// creation order (and hence numbering) of the outcomes, matching the + /// order in which the previous implementation created them. + std::vector m_outcomeOrder; + /// Deferred node-to-outcome attachments, replayed after outcomes are created. + std::vector> m_nodeOutcomes; std::map> m_infosetMap; }; @@ -536,32 +567,27 @@ void ReadPlayers(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData) } void CheckOutcomeDefinition(const GameFileLexer &p_state, int p_outcomeId, - const GameOutcome &p_outcome, const std::string &p_label, - const GameRep::Players &p_players, + const OutcomeRecord &p_record, const std::string &p_label, const std::vector &p_payoffs) { - if (p_outcome->GetLabel() != p_label) { + if (p_record.m_label != p_label) { p_state.OnParseError("Outcome label does not match previous definition " "(outcome " + std::to_string(p_outcomeId) + ")"); } - - if (p_players.size() != p_payoffs.size()) { + if (p_record.m_payoffs.size() != p_payoffs.size()) { p_state.OnParseError("Outcome payoff count mismatch " "(outcome " + std::to_string(p_outcomeId) + ")"); } - - auto player_it = p_players.begin(); - for (const auto &payoff : p_payoffs) { - if (p_outcome->GetPayoff(*player_it) != - static_cast(payoff)) { + for (size_t i = 0; i < p_payoffs.size(); ++i) { + if (static_cast(p_record.m_payoffs[i]) != + static_cast(p_payoffs[i])) { p_state.OnParseError("Outcome payoffs do not match previous definition " "(outcome " + - std::to_string(p_outcomeId) + ", player " + - std::to_string((*player_it)->GetNumber()) + ")"); + std::to_string(p_outcomeId) + ", player " + std::to_string(i + 1) + + ")"); } - ++player_it; } } @@ -589,32 +615,53 @@ void ParseOutcome(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData, Ga p_state.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); p_state.GetNextToken(); - GameOutcome outcome; - if (!contains(p_treeData.m_outcomeMap, outcomeId)) { - outcome = p_game->NewOutcome(); - p_treeData.m_outcomeMap[outcomeId] = outcome; - outcome->SetLabel(label); - auto player_it = p_game->GetPlayers().begin(); - for (const auto &payoff : payoffs) { - outcome->SetPayoff(*player_it, payoff); - ++player_it; - } + if (!contains(p_treeData.m_outcomeRecords, outcomeId)) { + p_treeData.m_outcomeRecords.emplace(outcomeId, OutcomeRecord{label, payoffs}); + p_treeData.m_outcomeOrder.push_back(outcomeId); } else { - outcome = p_treeData.m_outcomeMap.at(outcomeId); - CheckOutcomeDefinition(p_state, outcomeId, outcome, label, p_game->GetPlayers(), payoffs); + CheckOutcomeDefinition(p_state, outcomeId, p_treeData.m_outcomeRecords.at(outcomeId), label, + payoffs); } - p_game->SetOutcome(p_node, outcome); + p_treeData.m_nodeOutcomes.emplace_back(p_node, outcomeId); } else if (outcomeId != 0) { // The node entry does not contain information about the outcome. // This means the outcome should have been defined already. - try { - p_game->SetOutcome(p_node, p_treeData.m_outcomeMap.at(outcomeId)); - } - catch (std::out_of_range) { + if (!contains(p_treeData.m_outcomeRecords, outcomeId)) { p_state.OnParseError("Outcome not defined"); } + p_treeData.m_nodeOutcomes.emplace_back(p_node, outcomeId); + } +} + +/// Create the game's outcomes from the definitions buffered during the parse. +/// Labels are normalized in first-occurrence order before creation, so that +/// the label requirements enforced by NewOutcome (nonempty, unique) are +/// satisfied; this matches the treatment of outcome labels read from .nfg +/// files, and produces the same labels the previous post-parse normalization +/// pass produced. +void CreateOutcomes(const Game &p_game, const TreeData &p_treeData) +{ + std::vector labels; + for (const int id : p_treeData.m_outcomeOrder) { + labels.push_back(p_treeData.m_outcomeRecords.at(id).m_label); + } + NormalizeLabelStrings(labels); + + std::map created; + auto label_it = labels.begin(); + for (const int id : p_treeData.m_outcomeOrder) { + auto outcome = p_game->NewOutcome(*label_it++); + auto player_it = p_game->GetPlayers().begin(); + for (const auto &payoff : p_treeData.m_outcomeRecords.at(id).m_payoffs) { + outcome->SetPayoff(*player_it, payoff); + ++player_it; + } + created.emplace(id, outcome); + } + for (const auto &[node, id] : p_treeData.m_nodeOutcomes) { + p_game->SetOutcome(node, created.at(id)); } } @@ -905,6 +952,7 @@ Game ReadEfgFile(std::istream &p_stream) parser.GetNextToken(); } ParseNode(parser, game, game->GetRoot(), treeData); + CreateOutcomes(game, treeData); NormalizeGameLabels(game); return game; } diff --git a/src/games/game.cc b/src/games/game.cc index 9a467565b..f912c8179 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -39,8 +39,10 @@ namespace Gambit { // class GameOutcomeRep //======================================================================== -GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number) : m_game(p_game), m_number(p_number) +GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number, const std::string &p_label) + : m_game(p_game), m_number(p_number), m_label(p_label) { + CheckLabel(p_label); for (const auto &player : m_game->m_players) { m_payoffs[player.get()] = Number(); } diff --git a/src/games/game.h b/src/games/game.h index 279fdd90c..bd598b723 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -184,7 +184,7 @@ class GameOutcomeRep : public std::enable_shared_from_this { /// @name Lifecycle //@{ /// Creates a new outcome object, with payoffs set to zero - GameOutcomeRep(GameRep *p_game, int p_number); + GameOutcomeRep(GameRep *p_game, int p_number, const std::string &p_label); ~GameOutcomeRep() = default; //@} @@ -201,11 +201,7 @@ class GameOutcomeRep : public std::enable_shared_from_this { /// Returns the text label associated with the outcome const std::string &GetLabel() const { return m_label; } /// Sets the text label associated with the outcome - void SetLabel(const std::string &p_label) - { - CheckLabel(p_label); - m_label = p_label; - } + void SetLabel(const std::string &p_label); /// Gets the payoff associated with the outcome to the player template const T &GetPayoff(const GamePlayer &p_player) const; @@ -763,6 +759,8 @@ class GameRep : public std::enable_shared_from_this { void IndexStrategies() const; /// Validate that p_label is a nonempty, valid, unique label for a player of this game, void CheckPlayerLabel(const std::string &p_label) const; + /// Validate that p_label is a nonempty, valid, unique label for an outcome of this game. + void CheckOutcomeLabel(const std::string &p_label) const; //@} /// Hooks for derived classes to update lazily-computed orderings if required @@ -1224,7 +1222,7 @@ class GameRep : public std::enable_shared_from_this { return Outcomes(std::const_pointer_cast(shared_from_this()), &m_outcomes); } /// Creates a new outcome in the game - virtual GameOutcome NewOutcome() { throw UndefinedException(); } + virtual GameOutcome NewOutcome(const std::string &p_label) { throw UndefinedException(); } /// Deletes the specified outcome from the game virtual void DeleteOutcome(const GameOutcome &) { throw UndefinedException(); } //@} @@ -1278,6 +1276,14 @@ class GameRep : public std::enable_shared_from_this { // all classes to be defined. inline Game GameOutcomeRep::GetGame() const { return m_game->shared_from_this(); } +inline void GameOutcomeRep::SetLabel(const std::string &p_label) +{ + if (p_label == m_label) { + return; + } + GetGame()->CheckOutcomeLabel(p_label); + m_label = p_label; +} template const T &GameOutcomeRep::GetPayoff(const GamePlayer &p_player) const { @@ -1371,6 +1377,18 @@ inline void GameRep::CheckPlayerLabel(const std::string &p_label) const } } } +inline void GameRep::CheckOutcomeLabel(const std::string &p_label) const +{ + if (p_label.empty()) { + throw ValueException("Outcome label must not be empty"); + } + CheckLabel(p_label); + for (const auto &outcome : m_outcomes) { + if (outcome->GetLabel() == p_label) { + throw ValueException("Outcome label must be unique within the game"); + } + } +} inline bool GameInfosetRep::IsChanceInfoset() const { return m_player->IsChance(); } inline Game GamePlayerRep::GetGame() const { return m_game->shared_from_this(); } diff --git a/src/games/gameexpl.cc b/src/games/gameexpl.cc index 718b48dea..90823eb3b 100644 --- a/src/games/gameexpl.cc +++ b/src/games/gameexpl.cc @@ -58,9 +58,10 @@ Rational GameExplicitRep::GetMaxPayoff() const // GameExplicitRep: Outcomes //------------------------------------------------------------------------ -GameOutcome GameExplicitRep::NewOutcome() +GameOutcome GameExplicitRep::NewOutcome(const std::string &p_label) { - m_outcomes.push_back(std::make_shared(this, m_outcomes.size() + 1)); + CheckOutcomeLabel(p_label); + m_outcomes.push_back(std::make_shared(this, m_outcomes.size() + 1, p_label)); return m_outcomes.back(); } diff --git a/src/games/gameexpl.h b/src/games/gameexpl.h index 06a72308b..092e3b970 100644 --- a/src/games/gameexpl.h +++ b/src/games/gameexpl.h @@ -42,7 +42,7 @@ class GameExplicitRep : public GameRep { /// @name Outcomes //@{ /// Creates a new outcome in the game - GameOutcome NewOutcome() override; + GameOutcome NewOutcome(const std::string &p_label) override; /// @name Writing data files //@{ diff --git a/src/games/gametable.cc b/src/games/gametable.cc index 798c783c7..4bee172d8 100644 --- a/src/games/gametable.cc +++ b/src/games/gametable.cc @@ -392,7 +392,7 @@ GameTableRep::GameTableRep(const std::vector &dim, bool p_sparseOutcomes /* else { m_outcomes = std::vector>(m_results.size()); std::generate(m_outcomes.begin(), m_outcomes.end(), [this, outc = 1]() mutable { - return std::make_shared(this, outc++); + return std::make_shared(this, outc++, ""); }); std::transform(m_outcomes.begin(), m_outcomes.end(), m_results.begin(), [](const std::shared_ptr &c) { return c.get(); }); diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index 61d5efd0f..6dc4a2088 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -647,15 +647,40 @@ void GameDocument::DoSetPlayer(GameNode p_node, GamePlayer p_player) } } +namespace { + +std::string GenerateOutcomeLabel(const Game &p_game) +{ + std::set outcomeLabels; + for (const auto &outcome : p_game->GetOutcomes()) { + outcomeLabels.insert(outcome->GetLabel()); + } + int outc = p_game->GetOutcomes().size() + 1; + while (contains(outcomeLabels, "Outcome " + std::to_string(outc))) { + outc++; + } + return "Outcome " + std::to_string(outc); +} + +} // namespace + void GameDocument::DoNewOutcome(GameNode p_node) { - m_game->SetOutcome(p_node, m_game->NewOutcome()); + std::set outcomeLabels; + for (const auto &outcome : m_game->GetOutcomes()) { + outcomeLabels.insert(outcome->GetLabel()); + } + int outc = m_game->GetOutcomes().size() + 1; + while (contains(outcomeLabels, "Outcome " + std::to_string(outc))) { + outc++; + } + m_game->SetOutcome(p_node, m_game->NewOutcome(GenerateOutcomeLabel(m_game))); NotifyChanged(GameModificationType::GamePayoffs); } void GameDocument::DoNewOutcome(const PureStrategyProfile &p_profile) { - p_profile->SetOutcome(m_game->NewOutcome()); + p_profile->SetOutcome(m_game->NewOutcome(GenerateOutcomeLabel(m_game))); NotifyChanged(GameModificationType::GamePayoffs); } @@ -707,12 +732,10 @@ void GameDocument::DoSetOutcomeData(const GameNode &p_node, const wxString &p_la } if (!outcome) { - outcome = GetGame()->NewOutcome(); - GetGame()->SetOutcome(p_node, outcome); + outcome = m_game->NewOutcome(p_label.ToStdString()); + m_game->SetOutcome(p_node, outcome); } - outcome->SetLabel(label); - for (size_t player = 1; player <= GetGame()->NumPlayers(); ++player) { outcome->SetPayoff(GetGame()->GetPlayer(player), Number(p_payoffs[player - 1].ToStdString())); } @@ -731,7 +754,7 @@ void GameDocument::DoRemoveOutcome(GameNode p_node) void GameDocument::DoCopyOutcome(GameNode p_node, GameOutcome p_outcome) { - const GameOutcome outcome = m_game->NewOutcome(); + const GameOutcome outcome = m_game->NewOutcome(GenerateOutcomeLabel(m_game)); outcome->SetLabel("Outcome" + lexical_cast(outcome->GetNumber())); for (const auto &player : m_game->GetPlayers()) { outcome->SetPayoff(player, p_outcome->GetPayoff(player)); diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index a9e25bfb1..b6be8e913 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -324,7 +324,7 @@ cdef extern from "games/game.h": int NumOutcomes() except + c_GameOutcome GetOutcome(int) except +IndexError Outcomes GetOutcomes() except + - c_GameOutcome NewOutcome() except + + c_GameOutcome NewOutcome(string) except +ValueError void DeleteOutcome(c_GameOutcome) except + int NumNodes() except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 6685ff914..f95d47aba 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2112,22 +2112,27 @@ class Game: self.game.deref().SetPlayer(resolved_infoset.infoset, resolved_player.player) def add_outcome(self, - payoffs: list | None = None, - label: str = "") -> Outcome: + label: str, + payoffs: list | None = None) -> Outcome: """Add a new outcome to the game. + .. versionchanged:: 16.7.0 + A label is now required and must be nonempty and unique among the + game's outcomes. + Parameters ---------- + label : str + The label for the outcome. Must be nonempty and not already in use + by another outcome in the game. payoffs : list, optional The payoffs of the outcome to each player. - label : str, default "" - The label for the outcome Raises ------ ValueError If `payoffs` is specified but is not the same length as the number of players - in the game. + in the game, or if `label` is empty or already in use by another outcome. Returns ------- @@ -2139,9 +2144,7 @@ class Game: raise ValueError("add_outcome(): number of payoffs must equal number of players") else: payoffs = [0 for _ in self.players] - c = Outcome.wrap(self.game.deref().NewOutcome()) - if str(label) != "": - c.label = str(label) + c = Outcome.wrap(self.game.deref().NewOutcome(label.encode("ascii"))) for player, payoff in zip(self.players, payoffs, strict=True): c[player] = payoff return c diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 975cf9b34..5c42cfd95 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -65,18 +65,14 @@ class Outcome: """The text label associated with this outcome. .. versionchanged:: 16.7.0 - An invalid label now raises ``ValueError``: a label may contain only printable ASCII - characters and spaces, not begin/end with a space, nor have two consecutive spaces. + An outcome label must be nonempty and unique within the game; an empty or duplicate + label now raises ``ValueError``. A label may contain only printable ASCII characters + and spaces, not begin/end with a space, nor have two consecutive spaces. """ return self.outcome.deref().GetLabel().decode("ascii") @label.setter def label(self, value: str) -> None: - if value == self.label: - return - if value == "" or value in (outcome.label for outcome in self.game.outcomes): - warnings.warn("In a future version, outcomes must have unique labels", - FutureWarning) self.outcome.deref().SetLabel(value.encode("ascii")) @property diff --git a/tests/games.py b/tests/games.py index 872811320..9e73e32d2 100644 --- a/tests/games.py +++ b/tests/games.py @@ -48,7 +48,7 @@ def create_efg_corresponding_to_bimatrix_game( for i, j in itertools.product(range(m), range(n)): g.set_outcome( g.root.children[str(i)].children[str(j)], - g.add_outcome([A[i, j], B[i, j]]) + g.add_outcome(f"({i},{j})", [A[i, j], B[i, j]]) ) return g @@ -77,7 +77,7 @@ def create_2x2_zero_sum_efg(variant: None | str = None) -> gbt.Game: if variant == "missing term outcome": g.delete_outcome(g.root.children["0"].children["1"].outcome) elif variant == "with neutral outcome": - neutral = g.add_outcome([0, 0], label="neutral") + neutral = g.add_outcome("neutral", [0, 0]) g.set_outcome(g.root.children["0"], neutral) return g @@ -107,14 +107,14 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: deals = ["King", "Queen"] g.append_move(g.root, g.players.chance, deals) - ante_outcome = g.add_outcome([-1, -1], label="Ante") + ante_outcome = g.add_outcome("Ante", [-1, -1]) g.set_outcome(g.root, ante_outcome) - alice_folds_outcome = g.add_outcome([0, 2], label="Alice Folds") - alice_bets_outcome = g.add_outcome([-1, 0], label="Alice Bets") - bob_folds_outcome = g.add_outcome([3, 0], label="Bob Folds") - bob_calls_and_wins_outcome = g.add_outcome([0, 3], label="Bob Calls and Wins") - bob_calls_and_loses_outcome = g.add_outcome([4, -1], label="Bob Calls and Loses") + alice_folds_outcome = g.add_outcome("Alice Folds", [0, 2]) + alice_bets_outcome = g.add_outcome("Alice Bets", [-1, 0]) + bob_folds_outcome = g.add_outcome("Bob Folds", [3, 0]) + bob_calls_and_wins_outcome = g.add_outcome("Bob Calls and Wins", [0, 3]) + bob_calls_and_loses_outcome = g.add_outcome("Bob Calls and Loses", [4, -1]) for node in g.root.children: g.append_move(node, player="Alice", actions=["Bet", "Fold"]) @@ -237,10 +237,10 @@ def bet(player, payoffs, pot): # create 4 possible outcomes just once payoffs_to_outcomes = { - (1, -1): g.add_outcome([1, -1], label="Alice wins 1"), - (2, -2): g.add_outcome([2, -2], label="Alice wins 2"), - (-1, 1): g.add_outcome([-1, 1], label="Bob wins 1"), - (-2, 2): g.add_outcome([-2, 2], label="Bob wins 2"), + (1, -1): g.add_outcome("Alice wins 1", [1, -1]), + (2, -2): g.add_outcome("Alice wins 2", [2, -2]), + (-1, 1): g.add_outcome("Bob wins 1", [-1, 1]), + (-2, 2): g.add_outcome("BOb wins 2", [-2, 2]), } for term_node in [n for n in g.nodes if n.is_terminal]: @@ -256,7 +256,7 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: """ g = _create_kuhn_poker_efg_without_outcomes() - ante_outcome = g.add_outcome([-1, -1], label="Ante") + ante_outcome = g.add_outcome("Ante", [-1, -1]) g.set_outcome(g.root, ante_outcome) outcomes_dict = dict() @@ -264,27 +264,27 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: # non-terminal outcomes for betting payoffs = [-1, 0] if player == "Alice" else [0, -1] tmp = f"{player} bets" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) # terminal outcomes for showdown after both check (pot of 2) payoffs = [2, 0] if player == "Alice" else [0, 2] tmp = f"{player} wins showdown for pot of 2" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) # terminal outcomes after a player folds (pot of 3) payoffs = [0, 3] if player == "Alice" else [3, 0] tmp = f"{player} folds" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) # terminal outcomes after a player calls and wins: bet first (-1) then win pot (4) payoffs = [3, 0] if player == "Alice" else [0, 3] tmp = f"{player} calls and wins" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) # terminal outcomes after a player calls and loses: bet first (-1) then lose pot (4) payoffs = [-1, 4] if player == "Alice" else [4, -1] tmp = f"{player} calls and loses" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) def add_outcomes(term_node): def get_path(node): @@ -396,21 +396,21 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: g.append_move(g.root.children["Trust"], "Seller", ["Honor", "Abuse"]) g.set_outcome( g.root.children["Trust"].children["Honor"], - g.add_outcome([1, 1], label="Trustworthy") + g.add_outcome("Trustworthy", [1, 1]) ) if unique_NE_variant: g.set_outcome( g.root.children["Trust"].children["Abuse"], - g.add_outcome(["1/2", 2], label="Untrustworthy") + g.add_outcome("Untrustworthy", ["1/2", 2]) ) else: g.set_outcome( g.root.children["Trust"].children["Abuse"], - g.add_outcome([-1, 2], label="Untrustworthy") + g.add_outcome("Untrustworthy", [-1, 2]) ) g.set_outcome( g.root.children["Not trust"], - g.add_outcome([0, 0], label="Opt-out") + g.add_outcome("Opt-out", [0, 0]) ) return g @@ -509,12 +509,12 @@ def gbt_game(self): payoffs = [2**t * self.m0, 2**t * self.m1] # take payoffs if current_player == "2": payoffs.reverse() - g.set_outcome(current_node.children["Take"], g.add_outcome(payoffs)) + g.set_outcome(current_node.children["Take"], g.add_outcome(f"take_{t}", payoffs)) if t == self.N - 1: # for last round, push payoffs payoffs = [2 ** (t + 1) * self.m1, 2 ** (t + 1) * self.m0] if current_player == "2": payoffs.reverse() - g.set_outcome(current_node.children["Push"], g.add_outcome(payoffs)) + g.set_outcome(current_node.children["Push"], g.add_outcome(f"push_{t}", payoffs)) current_node = current_node.children["Push"] current_player = "2" if current_player == "1" else "1" return g @@ -635,7 +635,9 @@ def reduced_strategies(self): def create_binary_tree(self, g, node, whose_turn, depth, max_depth): # whose_turn cycles through 0,1,n_players-1; current player is str(whose_turn + 1) if depth == max_depth: - g.set_outcome(node, g.add_outcome([0] * self.n_players)) + g.set_outcome( + node, g.add_outcome(f"leaf_{len(list(g.outcomes))}", [0] * self.n_players) + ) else: current_player = str(whose_turn + 1) g.append_move(node, current_player, ["L", "R"]) diff --git a/tests/test_file.py b/tests/test_file.py index cd0d0c883..ee7dade88 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -15,6 +15,19 @@ def _parse_nfg(text: str) -> gbt.Game: return gbt.read_nfg(f) +LEGACY_EFG_HEADER = 'EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "l" "r" } 0\n' + + +def test_read_efg_empty_outcome_labels_are_normalized(): + g = _parse_efg(LEGACY_EFG_HEADER + 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') + assert [o.label for o in g.outcomes] == ["_1", "_2"] + + +def test_read_efg_repeated_outcome_id_consistent(): + g = _parse_efg(LEGACY_EFG_HEADER + 't "" 1 "" { 1, -1 }\nt "" 1 "" { 1, -1 }\n') + assert len(g.outcomes) == 1 + + def test_string_empty(): with pytest.raises(ValueError) as excinfo: _parse_efg("") diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index 6a29d399b..86a273747 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -10,7 +10,7 @@ ) def test_outcome_add(game: gbt.Game): outcome_count = len(game.outcomes) - game.add_outcome() + game.add_outcome(label="new outcome") assert len(game.outcomes) == outcome_count + 1 @@ -67,6 +67,12 @@ def test_outcome_index_unmatched_label(game: gbt.Game): _ = game.outcomes["not an outcome"] +def test_add_outcome_requires_label(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(TypeError): + game.add_outcome([0, 0]) + + @pytest.mark.parametrize( "game", [gbt.Game.new_table([2, 2])] ) @@ -89,3 +95,21 @@ def test_outcome_payoff_by_player_label(): assert out1["dan"] == 2 assert out2["joe"] == 3 assert out2["dan"] == 4 + + +@pytest.mark.parametrize("bad_label", ["", "win"]) +def test_add_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str): + game = gbt.Game.new_tree(players=["A", "B"]) + game.add_outcome("win", [1, 2]) + with pytest.raises(ValueError): + game.add_outcome(bad_label, [3, 4]) + assert [o.label for o in game.outcomes] == ["win"] + + +def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.add_outcome("win", [1, 2]) + outcome = game.add_outcome("lose", [0, 0]) + with pytest.raises(ValueError): + outcome.label = "win" + assert outcome.label == "lose" diff --git a/tests/test_players.py b/tests/test_players.py index 4dbfa0172..387baeb5b 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -294,7 +294,7 @@ def test_player_get_min_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.players["Alice"].min_payoff == -2 assert game.players["Bob"].min_payoff == -2 - game.set_outcome(game.root, game.add_outcome([-1, -1])) + game.set_outcome(game.root, game.add_outcome("outcome", [-1, -1])) assert game.players["Alice"].min_payoff == -3 assert game.players["Bob"].min_payoff == -3 @@ -320,7 +320,7 @@ def test_player_get_max_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.players["Alice"].max_payoff == 2 assert game.players["Bob"].max_payoff == 2 - game.set_outcome(game.root, game.add_outcome([-1, -1])) + game.set_outcome(game.root, game.add_outcome("outcome", [-1, -1])) assert game.players["Alice"].max_payoff == 1 assert game.players["Bob"].max_payoff == 1 From a79e54970bc31d123ff8092f06426892bcd70d59 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Fri, 10 Jul 2026 11:55:44 +0100 Subject: [PATCH 15/32] Improved UX for editing labels in GUI (#966) This implements a variety of UX improvements for editing of labels in the GUI, especially taking into account our new rules about what constitutes a valid label, for which objects labels are required, and the requirements for labels being unique (within appropriate scope). Closes #945. --- Makefile.am | 4 ++ src/gui/dleditmove.cc | 74 +++++++++++++++++---- src/gui/dleditmove.h | 9 ++- src/gui/dleditnode.cc | 30 +++++++-- src/gui/dleditnode.h | 8 ++- src/gui/dlexcept.h | 7 +- src/gui/editlabel.cc | 146 ++++++++++++++++++++++++++++++++++++++++++ src/gui/editlabel.h | 63 ++++++++++++++++++ src/gui/edittext.cc | 52 ++++++++------- src/gui/edittext.h | 11 ++-- src/gui/efgdisplay.cc | 137 +++++++++++++++++++++++++++++++++------ src/gui/efgpanel.cc | 71 +++++++++++--------- src/gui/gamedoc.cc | 3 + src/gui/gameframe.cc | 6 +- src/gui/labelcell.cc | 121 ++++++++++++++++++++++++++++++++++ src/gui/labelcell.h | 54 ++++++++++++++++ src/gui/nfgpanel.cc | 17 ++++- src/gui/nfgtable.cc | 42 ++++++++++-- src/gui/nfgtable.h | 4 +- 19 files changed, 742 insertions(+), 117 deletions(-) create mode 100644 src/gui/editlabel.cc create mode 100644 src/gui/editlabel.h create mode 100644 src/gui/labelcell.cc create mode 100644 src/gui/labelcell.h diff --git a/Makefile.am b/Makefile.am index 5ef18af9b..573c3780b 100644 --- a/Makefile.am +++ b/Makefile.am @@ -471,8 +471,12 @@ gambit_SOURCES = \ src/gui/analysis.h \ src/gui/app.cc \ src/gui/app.h \ + src/gui/editlabel.cc \ + src/gui/editlabel.h \ src/gui/edittext.cc \ src/gui/edittext.h \ + src/gui/labelcell.cc \ + src/gui/labelcell.h \ src/gui/dlabout.cc \ src/gui/dlabout.h \ src/gui/dleditmove.cc \ diff --git a/src/gui/dleditmove.cc b/src/gui/dleditmove.cc index 9bfdf0ea6..3b6d4c2f0 100644 --- a/src/gui/dleditmove.cc +++ b/src/gui/dleditmove.cc @@ -30,21 +30,24 @@ #include "gambit.h" #include "dleditmove.h" #include "valnumber.h" +#include "editlabel.h" namespace Gambit::GUI { class ActionPanel final : public wxScrolledWindow { std::vector m_actionProbValues; - std::vector m_actionNames; + std::vector m_actionLabels; std::vector m_actionProbs; public: ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset); - int NumActions() const { return static_cast(m_actionNames.size()); } + int NumActions() const { return static_cast(m_actionLabels.size()); } - wxString GetActionName(int p_act) const; + wxString GetActionLabel(int p_act) const; Array GetActionProbs() const; + + void FocusActionLabel(int p_act) { m_actionLabels.at(p_act - 1)->SetFocus(); } }; ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) @@ -57,7 +60,7 @@ ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) m_actionProbValues.reserve(p_infoset->GetActions().size()); m_actionProbs.reserve(p_infoset->GetActions().size()); } - m_actionNames.reserve(p_infoset->GetActions().size()); + m_actionLabels.reserve(p_infoset->GetActions().size()); const int numColumns = isChance ? 3 : 2; @@ -82,8 +85,9 @@ ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) wxALIGN_CENTER_VERTICAL | wxALIGN_RIGHT); auto *name = - new wxTextCtrl(this, wxID_ANY, wxString(action->GetLabel().c_str(), *wxConvCurrent)); - m_actionNames.push_back(name); + new LabelTextCtrl(this, wxID_ANY, wxString(action->GetLabel().c_str(), *wxConvCurrent), + LabelCharacterPolicy::AsciiOnly); + m_actionLabels.push_back(name); gridSizer->Add(name, 1, wxEXPAND); if (isChance) { @@ -110,9 +114,9 @@ ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) SetMinSize(wxSize(FromDIP(isChance ? 400 : 300), std::min(bestSize.GetHeight(), FromDIP(250)))); } -wxString ActionPanel::GetActionName(int p_act) const +wxString ActionPanel::GetActionLabel(int p_act) const { - return m_actionNames.at(p_act - 1)->GetValue(); + return m_actionLabels.at(p_act - 1)->GetNormalizedValue(); } Array ActionPanel::GetActionProbs() const @@ -138,9 +142,10 @@ EditMoveDialog::EditMoveDialog(wxWindow *p_parent, const GameInfoset &p_infoset) auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); labelSizer->Add(new wxStaticText(this, wxID_STATIC, _("Information set label")), 0, wxALL | wxALIGN_CENTER_VERTICAL, 5); - m_infosetName = - new wxTextCtrl(this, wxID_ANY, wxString(p_infoset->GetLabel().c_str(), *wxConvCurrent)); - labelSizer->Add(m_infosetName, 1, wxALL | wxEXPAND, 5); + m_infosetLabel = + new LabelTextCtrl(this, wxID_ANY, wxString(p_infoset->GetLabel().c_str(), *wxConvCurrent), + LabelCharacterPolicy::AsciiOnly); + labelSizer->Add(m_infosetLabel, 1, wxALL | wxEXPAND, 5); topSizer->Add(labelSizer, 0, wxEXPAND); { @@ -195,8 +200,51 @@ EditMoveDialog::EditMoveDialog(wxWindow *p_parent, const GameInfoset &p_infoset) Bind(wxEVT_BUTTON, &EditMoveDialog::OnOK, this, wxID_OK); } +bool EditMoveDialog::ValidateLabels() +{ + const wxString infosetLabel = m_infosetLabel->GetNormalizedValue(); + if (!infosetLabel.empty()) { + for (const auto &infoset : m_infoset->GetPlayer()->GetInfosets()) { + if (infoset != m_infoset && infoset->GetLabel() == infosetLabel) { + wxRichMessageDialog(this, _("Information set label must be unique for the player."), + _("Error"), wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_infosetLabel->SetFocus(); + return false; + } + } + } + + std::set actionLabels; + for (int act = 1; act <= m_actionPanel->NumActions(); ++act) { + const wxString actionLabel = m_actionPanel->GetActionLabel(act); + if (actionLabel.empty()) { + wxRichMessageDialog(this, _("Action labels cannot be empty."), _("Error"), + wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_actionPanel->FocusActionLabel(act); + return false; + } + + if (contains(actionLabels, actionLabel)) { + wxRichMessageDialog(this, _("Action labels must be unique within the information set."), + _("Error"), wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_actionPanel->FocusActionLabel(act); + return false; + } + + actionLabels.insert(actionLabel); + } + return true; +} + void EditMoveDialog::OnOK(wxCommandEvent &p_event) { + if (!ValidateLabels()) { + return; + } + if (!m_infoset->IsChanceInfoset()) { p_event.Skip(); return; @@ -215,9 +263,9 @@ void EditMoveDialog::OnOK(wxCommandEvent &p_event) int EditMoveDialog::NumActions() const { return m_actionPanel->NumActions(); } -wxString EditMoveDialog::GetActionName(int p_act) const +wxString EditMoveDialog::GetActionLabel(int p_act) const { - return m_actionPanel->GetActionName(p_act); + return m_actionPanel->GetActionLabel(p_act); } Array EditMoveDialog::GetActionProbs() const { return m_actionPanel->GetActionProbs(); } diff --git a/src/gui/dleditmove.h b/src/gui/dleditmove.h index 26aeb9c45..d9db63065 100644 --- a/src/gui/dleditmove.h +++ b/src/gui/dleditmove.h @@ -23,15 +23,18 @@ #ifndef GAMBIT_GUI_DLEDITMOVE_H #define GAMBIT_GUI_DLEDITMOVE_H +#include "editlabel.h" + namespace Gambit::GUI { class ActionPanel; class EditMoveDialog final : public wxDialog { GameInfoset m_infoset; wxChoice *m_player; - wxTextCtrl *m_infosetName; + LabelTextCtrl *m_infosetLabel; ActionPanel *m_actionPanel; + bool ValidateLabels(); void OnOK(wxCommandEvent &); public: @@ -39,11 +42,11 @@ class EditMoveDialog final : public wxDialog { EditMoveDialog(wxWindow *p_parent, const GameInfoset &p_infoset); // Data access (only valid when ShowModal() returns with wxID_OK) - wxString GetInfosetName() const { return m_infosetName->GetValue(); } + wxString GetInfosetLabel() const { return m_infosetLabel->GetNormalizedValue(); } int GetPlayer() const { return (m_player->GetSelection() + 1); } int NumActions() const; - wxString GetActionName(int p_act) const; + wxString GetActionLabel(int p_act) const; Array GetActionProbs() const; }; } // namespace Gambit::GUI diff --git a/src/gui/dleditnode.cc b/src/gui/dleditnode.cc index 7613a4a6f..d146c8e66 100644 --- a/src/gui/dleditnode.cc +++ b/src/gui/dleditnode.cc @@ -24,6 +24,7 @@ #ifndef WX_PRECOMP #include #endif // WX_PRECOMP +#include #include "gambit.h" #include "dleditnode.h" @@ -39,9 +40,9 @@ EditNodeDialog::EditNodeDialog(wxWindow *p_parent, const GameNode &p_node) auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); labelSizer->Add(new wxStaticText(this, wxID_STATIC, _("Node label")), 0, wxALL | wxCENTER, 5); - m_nodeName = - new wxTextCtrl(this, wxID_ANY, wxString(m_node->GetLabel().c_str(), *wxConvCurrent)); - labelSizer->Add(m_nodeName, 1, wxALL | wxCENTER | wxEXPAND, 5); + m_nodeLabel = + new LabelTextCtrl(this, wxID_ANY, wxString(m_node->GetLabel().c_str(), *wxConvCurrent)); + labelSizer->Add(m_nodeLabel, 1, wxALL | wxCENTER | wxEXPAND, 5); topSizer->Add(labelSizer, 0, wxALL | wxEXPAND, 5); auto *infosetSizer = new wxBoxSizer(wxHORIZONTAL); @@ -153,6 +154,25 @@ EditNodeDialog::EditNodeDialog(wxWindow *p_parent, const GameNode &p_node) wxTopLevelWindowBase::Layout(); CenterOnParent(); + + Bind(wxEVT_BUTTON, &EditNodeDialog::OnOK, this, wxID_OK); +} + +void EditNodeDialog::OnOK(wxCommandEvent &p_event) +{ + const wxString nodeLabel = m_nodeLabel->GetNormalizedValue(); + if (!nodeLabel.empty()) { + for (const auto &node : m_node->GetGame()->GetNodes()) { + if (node != m_node && node->GetLabel() == nodeLabel) { + wxRichMessageDialog(this, _("Node label must be unique in the game."), _("Error"), + wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_nodeLabel->SetFocus(); + return; + } + } + } + p_event.Skip(); } GameInfoset EditNodeDialog::GetInfoset() const @@ -160,8 +180,6 @@ GameInfoset EditNodeDialog::GetInfoset() const if (m_infoset->GetSelection() == 0) { return nullptr; } - else { - return m_infosetList[m_infoset->GetSelection()]; - } + return m_infosetList[m_infoset->GetSelection()]; } } // namespace Gambit::GUI diff --git a/src/gui/dleditnode.h b/src/gui/dleditnode.h index 9608c2c4f..a1c304893 100644 --- a/src/gui/dleditnode.h +++ b/src/gui/dleditnode.h @@ -23,19 +23,23 @@ #ifndef GAMBIT_GUI_DLEDITNODE_H #define GAMBIT_GUI_DLEDITNODE_H +#include "editlabel.h" + namespace Gambit::GUI { class EditNodeDialog final : public wxDialog { GameNode m_node; - wxTextCtrl *m_nodeName; + LabelTextCtrl *m_nodeLabel; wxChoice *m_outcome, *m_infoset; Array m_infosetList; + void OnOK(wxCommandEvent &); + public: // Lifecycle EditNodeDialog(wxWindow *p_parent, const GameNode &p_node); // Data access (only valid when ShowModal() returns with wxID_OK) - wxString GetNodeName() const { return m_nodeName->GetValue(); } + wxString GetNodeLabel() const { return m_nodeLabel->GetValue(); } int GetOutcome() const { return m_outcome->GetSelection(); } GameInfoset GetInfoset() const; }; diff --git a/src/gui/dlexcept.h b/src/gui/dlexcept.h index a534d468b..fcf762cbb 100644 --- a/src/gui/dlexcept.h +++ b/src/gui/dlexcept.h @@ -24,15 +24,16 @@ #define DLEXCEPT_H #include +#include namespace Gambit::GUI { // A general-purpose dialog box to display the description of an internal exception. -class ExceptionDialog final : public wxMessageDialog { +class ExceptionDialog final : public wxRichMessageDialog { public: ExceptionDialog(wxWindow *p_parent, const std::string &p_message) - : wxMessageDialog(p_parent, wxString(p_message.c_str(), *wxConvCurrent), - wxT("Internal exception in Gambit"), wxICON_ERROR | wxCANCEL) + : wxRichMessageDialog(p_parent, wxString(p_message.c_str(), *wxConvCurrent), + wxT("Internal exception in Gambit"), wxOK | wxCENTRE | wxICON_ERROR) { } }; diff --git a/src/gui/editlabel.cc b/src/gui/editlabel.cc new file mode 100644 index 000000000..0bed480d5 --- /dev/null +++ b/src/gui/editlabel.cc @@ -0,0 +1,146 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/editlabel.cc +// Text control for editing valid labels +// +// 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 "editlabel.h" + +namespace Gambit::GUI { + +bool LabelTextCtrl::IsAsciiPrintable(wxUniChar p_char) +{ + const auto value = static_cast(p_char); + return value >= 0x20 && value <= 0x7e; +} + +bool LabelTextCtrl::IsLabelWhitespace(wxUniChar p_char) +{ + return p_char == ' ' || p_char == '\t' || p_char == '\r' || p_char == '\n' || p_char == '\v' || + p_char == '\f'; +} + +bool LabelTextCtrl::IsAllowedNonWhitespace(wxUniChar p_char, LabelCharacterPolicy p_policy) +{ + switch (p_policy) { + case LabelCharacterPolicy::AsciiOnly: + return IsAsciiPrintable(p_char) && !IsLabelWhitespace(p_char); + + case LabelCharacterPolicy::Unicode: + return !IsLabelWhitespace(p_char); + + default: + return false; + } +} + +wxString LabelTextCtrl::Normalize(const wxString &p_value, bool p_stripTrailing, + LabelCharacterPolicy p_policy) +{ + wxString normalized; + bool sawNonWhitespace = false; + bool previousWasSpace = false; + + for (wxString::const_iterator iter = p_value.begin(); iter != p_value.end(); ++iter) { + const wxUniChar ch = *iter; + + if (IsLabelWhitespace(ch)) { + if (!sawNonWhitespace) { + continue; + } + if (!previousWasSpace) { + normalized << ' '; + previousWasSpace = true; + } + continue; + } + + if (!IsAllowedNonWhitespace(ch, p_policy)) { + continue; + } + + normalized << ch; + sawNonWhitespace = true; + previousWasSpace = false; + } + + if (p_stripTrailing && normalized.EndsWith(" ")) { + normalized.RemoveLast(); + } + + return normalized; +} + +void LabelTextCtrl::NormalizeInPlace(bool p_stripTrailing) +{ + if (m_normalizing) { + return; + } + + const wxString oldValue = GetValue(); + const wxString newValue = Normalize(oldValue, p_stripTrailing); + if (oldValue == newValue) { + return; + } + + const long insertionPoint = GetInsertionPoint(); + + m_normalizing = true; + ChangeValue(newValue); + SetInsertionPoint(std::min(insertionPoint, newValue.length())); + m_normalizing = false; +} + +void LabelTextCtrl::OnText(wxCommandEvent &p_event) +{ + NormalizeInPlace(false); + p_event.Skip(); +} + +void LabelTextCtrl::OnKillFocus(wxFocusEvent &p_event) +{ + NormalizeInPlace(true); + p_event.Skip(); +} + +LabelTextCtrl::LabelTextCtrl(wxWindow *p_parent, wxWindowID p_id, const wxString &p_value, + LabelCharacterPolicy p_policy, const wxPoint &p_pos, + const wxSize &p_size, long p_style) + : wxTextCtrl(p_parent, p_id, wxEmptyString, p_pos, p_size, p_style), m_policy(p_policy) +{ + ChangeValue(Normalize(p_value, true)); + + Bind(wxEVT_TEXT, &LabelTextCtrl::OnText, this); + Bind(wxEVT_KILL_FOCUS, &LabelTextCtrl::OnKillFocus, this); +} + +wxString LabelTextCtrl::GetNormalizedValue() +{ + NormalizeInPlace(true); + return GetValue(); +} + +} // namespace Gambit::GUI diff --git a/src/gui/editlabel.h b/src/gui/editlabel.h new file mode 100644 index 000000000..a0034163a --- /dev/null +++ b/src/gui/editlabel.h @@ -0,0 +1,63 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/editlabel.h +// Text control for editing valid labels +// +// 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 EDITLABEL_H +#define EDITLABEL_H + +#include +#ifndef WX_PRECOMP +#include +#endif // WX_PRECOMP + +namespace Gambit::GUI { + +enum class LabelCharacterPolicy { AsciiOnly, Unicode }; + +class LabelTextCtrl final : public wxTextCtrl { + LabelCharacterPolicy m_policy; + bool m_normalizing{false}; + + static bool IsAsciiPrintable(wxUniChar p_char); + static bool IsLabelWhitespace(wxUniChar p_char); + static bool IsAllowedNonWhitespace(wxUniChar p_char, LabelCharacterPolicy p_policy); + + wxString NormalizeValue(const wxString &p_value, bool p_stripTrailing) const; + void NormalizeInPlace(bool p_stripTrailing); + + void OnText(wxCommandEvent &p_event); + void OnKillFocus(wxFocusEvent &p_event); + +public: + static wxString Normalize(const wxString &p_value, bool p_stripTrailing, + LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly); + + LabelTextCtrl(wxWindow *p_parent, wxWindowID p_id, const wxString &p_value, + LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly, + const wxPoint &p_pos = wxDefaultPosition, const wxSize &p_size = wxDefaultSize, + long p_style = 0); + + wxString GetNormalizedValue(); +}; + +} // namespace Gambit::GUI + +#endif // EDITLABEL_H diff --git a/src/gui/edittext.cc b/src/gui/edittext.cc index 8c54e3900..ee5c2ba87 100644 --- a/src/gui/edittext.cc +++ b/src/gui/edittext.cc @@ -55,21 +55,22 @@ void StaticTextButton::OnLeftClick(wxMouseEvent &p_event) // class EditableText //========================================================================= -EditableText::EditableText(wxWindow *p_parent, int p_id, const wxString &p_value, - const wxPoint &p_position, const wxSize &p_size) - : wxPanel(p_parent, p_id, p_position, p_size) +EditableLabelText::EditableLabelText(wxWindow *p_parent, int p_id, const wxString &p_value, + const wxPoint &p_position, const wxSize &p_size) + : wxPanel(p_parent, p_id, p_position, p_size), m_committedValue(p_value) { m_staticText = new StaticTextButton(this, wxID_ANY, p_value, wxPoint(0, 0), p_size, wxALIGN_LEFT); Connect(m_staticText->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(EditableText::OnClick)); + wxCommandEventHandler(EditableLabelText::OnClick)); - m_textCtrl = new wxTextCtrl(this, wxID_ANY, p_value, wxPoint(0, 0), p_size, wxTE_PROCESS_ENTER); + m_textCtrl = new LabelTextCtrl(this, wxID_ANY, p_value, LabelCharacterPolicy::AsciiOnly, + wxPoint(0, 0), p_size, wxTE_PROCESS_ENTER); Connect(m_textCtrl->GetId(), wxEVT_COMMAND_TEXT_ENTER, - wxCommandEventHandler(EditableText::OnAccept)); + wxCommandEventHandler(EditableLabelText::OnAccept)); - m_textCtrl->Bind(wxEVT_KILL_FOCUS, &EditableText::OnTextKillFocus, this); - m_textCtrl->Bind(wxEVT_CHAR_HOOK, &EditableText::OnTextCharHook, this); + m_textCtrl->Bind(wxEVT_KILL_FOCUS, &EditableLabelText::OnTextKillFocus, this); + m_textCtrl->Bind(wxEVT_CHAR_HOOK, &EditableLabelText::OnTextCharHook, this); auto *topSizer = new wxBoxSizer(wxHORIZONTAL); topSizer->Add(m_staticText, 1, wxALIGN_CENTER, 0); @@ -79,7 +80,7 @@ EditableText::EditableText(wxWindow *p_parent, int p_id, const wxString &p_value wxWindowBase::Layout(); } -void EditableText::BeginEdit() +void EditableLabelText::BeginEdit() { m_textCtrl->SetValue(m_staticText->GetLabel()); m_textCtrl->SetSelection(-1, -1); @@ -89,10 +90,14 @@ void EditableText::BeginEdit() m_textCtrl->SetFocus(); } -void EditableText::EndEdit(bool p_accept) +void EditableLabelText::EndEdit(bool p_accept) { if (p_accept) { - m_staticText->SetLabel(m_textCtrl->GetValue()); + m_staticText->SetLabel(m_textCtrl->GetNormalizedValue()); + } + else { + m_textCtrl->SetValue(m_committedValue); + m_staticText->SetLabel(m_committedValue); } GetSizer()->Show(m_textCtrl, false); @@ -100,7 +105,7 @@ void EditableText::EndEdit(bool p_accept) GetSizer()->Layout(); } -void EditableText::AcceptEdit() +void EditableLabelText::AcceptEdit() { if (!IsEditing() || m_endingEdit) { return; @@ -116,7 +121,7 @@ void EditableText::AcceptEdit() m_endingEdit = false; } -void EditableText::CancelEdit() +void EditableLabelText::CancelEdit() { if (!IsEditing() || m_endingEdit) { return; @@ -127,60 +132,61 @@ void EditableText::CancelEdit() m_endingEdit = false; } -wxString EditableText::GetValue() const +wxString EditableLabelText::GetValue() const { if (GetSizer()->IsShown(m_textCtrl)) { - return m_textCtrl->GetValue(); + return m_textCtrl->GetNormalizedValue(); } else { return m_staticText->GetLabel(); } } -void EditableText::SetValue(const wxString &p_value) +void EditableLabelText::SetValue(const wxString &p_value) { + m_committedValue = p_value; m_textCtrl->SetValue(p_value); m_staticText->SetLabel(p_value); } -bool EditableText::SetForegroundColour(const wxColour &p_color) +bool EditableLabelText::SetForegroundColour(const wxColour &p_color) { m_staticText->SetForegroundColour(p_color); m_textCtrl->SetForegroundColour(p_color); return true; } -bool EditableText::SetBackgroundColour(const wxColour &p_color) +bool EditableLabelText::SetBackgroundColour(const wxColour &p_color) { m_staticText->SetBackgroundColour(p_color); m_textCtrl->SetBackgroundColour(p_color); return true; } -bool EditableText::SetFont(const wxFont &p_font) +bool EditableLabelText::SetFont(const wxFont &p_font) { m_staticText->SetFont(p_font); m_textCtrl->SetFont(p_font); return true; } -void EditableText::OnClick(wxCommandEvent &) +void EditableLabelText::OnClick(wxCommandEvent &) { wxCommandEvent event(wxEVT_COMMAND_BUTTON_CLICKED); event.SetId(GetId()); wxPostEvent(GetParent(), event); } -void EditableText::OnAccept(wxCommandEvent &) { AcceptEdit(); } +void EditableLabelText::OnAccept(wxCommandEvent &) { AcceptEdit(); } -void EditableText::OnTextKillFocus(wxFocusEvent &p_event) +void EditableLabelText::OnTextKillFocus(wxFocusEvent &p_event) { AcceptEdit(); p_event.Skip(); } -void EditableText::OnTextCharHook(wxKeyEvent &p_event) +void EditableLabelText::OnTextCharHook(wxKeyEvent &p_event) { if (p_event.GetKeyCode() == WXK_ESCAPE && IsEditing()) { CancelEdit(); diff --git a/src/gui/edittext.h b/src/gui/edittext.h index 2f7a62625..a1b79c3e1 100644 --- a/src/gui/edittext.h +++ b/src/gui/edittext.h @@ -23,6 +23,8 @@ #ifndef GAMBIT_GUI_EDITTEXT_H #define GAMBIT_GUI_EDITTEXT_H +#include "editlabel.h" + namespace Gambit::GUI { //! //! A StaticTextButton is a wxStaticText object that generates a @@ -43,10 +45,11 @@ class StaticTextButton final : public wxStaticText { //! This control looks like a wxStaticText, but when clicked it shows //! a wxTextCtrl to edit the value. //! -class EditableText : public wxPanel { +class EditableLabelText : public wxPanel { StaticTextButton *m_staticText; - wxTextCtrl *m_textCtrl; + LabelTextCtrl *m_textCtrl; + wxString m_committedValue; bool m_endingEdit = false; /// @name Event handlers @@ -65,8 +68,8 @@ class EditableText : public wxPanel { void CancelEdit(); public: - EditableText(wxWindow *p_parent, int p_id, const wxString &p_value, const wxPoint &p_position, - const wxSize &p_size); + EditableLabelText(wxWindow *p_parent, int p_id, const wxString &p_value, + const wxPoint &p_position, const wxSize &p_size); bool IsEditing() const { return GetSizer()->IsShown(m_textCtrl); } void BeginEdit(); diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index d38f8625d..a7ebc5bf1 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -50,10 +50,20 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { void OnDismiss() override; private: + struct ValidationResult { + bool ok{true}; + wxString message; + wxTextCtrl *ctrl{nullptr}; + }; + void BuildControls(); void LoadValues(); void PositionPopup(); void OnKeyDown(wxKeyEvent &p_event); + + ValidationResult ValidatePayoffs(std::vector &p_payoffs); + void ShowValidationFailure(const wxString &p_message, wxTextCtrl *p_ctrl); + void ClearValidationFailure(); void RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl); EfgDisplay *m_owner; @@ -63,17 +73,20 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { wxPanel *m_contentPanel; wxTextCtrl *m_labelCtrl; + wxStaticText *m_errorText; wxFlexGridSizer *m_gridSizer; std::vector m_payoffCtrls; int m_initialPlayer{0}; bool m_cancelled{false}; bool m_dismissing{false}; + bool m_committing{false}; + bool m_restoringAfterFailedCommit{false}; }; OutcomeEditorPopup::OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc) : wxPopupTransientWindow(p_owner, wxBORDER_NONE), m_owner(p_owner), m_doc(p_doc), - m_contentPanel(nullptr), m_labelCtrl(nullptr) + m_contentPanel(nullptr), m_labelCtrl(nullptr), m_errorText(nullptr) { SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); @@ -143,6 +156,13 @@ void OutcomeEditorPopup::BuildControls() outerSizer->Add(payoffSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, FromDIP(12)); + m_errorText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_errorText->SetForegroundColour(*wxRED); + m_errorText->Wrap(FromDIP(260)); + m_errorText->Hide(); + + outerSizer->Add(m_errorText, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(12)); + m_contentPanel->SetSizer(outerSizer); popupSizer->Add(m_contentPanel, 1, wxEXPAND | wxALL, FromDIP(1)); @@ -197,8 +217,11 @@ void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) m_initialPlayer = p_initialPlayer; m_cancelled = false; m_dismissing = false; + m_committing = false; + m_restoringAfterFailedCommit = false; LoadValues(); + ClearValidationFailure(); Fit(); PositionPopup(); @@ -217,7 +240,7 @@ void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) void OutcomeEditorPopup::OnDismiss() { - if (m_dismissing) { + if (m_dismissing || m_restoringAfterFailedCommit) { return; } @@ -253,70 +276,146 @@ void OutcomeEditorPopup::Cancel() return; } + ClearValidationFailure(); + m_cancelled = true; Dismiss(); } -bool OutcomeEditorPopup::Commit() +OutcomeEditorPopup::ValidationResult +OutcomeEditorPopup::ValidatePayoffs(std::vector &p_payoffs) { - if (!m_node) { - return false; - } - - std::vector payoffs; - payoffs.reserve(m_payoffCtrls.size()); + p_payoffs.clear(); + p_payoffs.reserve(m_payoffCtrls.size()); - for (auto *ctrl : m_payoffCtrls) { + for (size_t player = 1; player <= m_payoffCtrls.size(); ++player) { + wxTextCtrl *ctrl = m_payoffCtrls[player - 1]; wxString value = ctrl->GetValue(); if (value.EndsWith(wxT("/"))) { value.RemoveLast(); + ctrl->SetValue(value); + ctrl->SetInsertionPointEnd(); } try { lexical_cast(value.ToStdString()); } catch (const std::exception &) { - RestoreAfterFailedCommit(ctrl); - return false; + return {false, + wxString::Format(_("Payoff for player %lu is not a valid number."), + static_cast(player)), + ctrl}; } - payoffs.push_back(value); + p_payoffs.push_back(value); + } + + return {}; +} + +bool OutcomeEditorPopup::Commit() +{ + if (!m_node || m_committing) { + return false; + } + + m_committing = true; + + std::vector payoffs; + const ValidationResult validation = ValidatePayoffs(payoffs); + + if (!validation.ok) { + ShowValidationFailure(validation.message, validation.ctrl); + RestoreAfterFailedCommit(validation.ctrl); + m_committing = false; + return false; } try { m_doc->DoSetOutcomeData(m_node, m_labelCtrl->GetValue(), payoffs); } catch (const std::exception &ex) { - ExceptionDialog(m_owner, ex.what()).ShowModal(); + ShowValidationFailure(wxString::FromUTF8(ex.what()), m_labelCtrl); + RestoreAfterFailedCommit(m_labelCtrl); + m_committing = false; return false; } + ClearValidationFailure(); + m_dismissing = true; Dismiss(); m_dismissing = false; m_node = nullptr; + m_committing = false; return true; } -void OutcomeEditorPopup::RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl) +void OutcomeEditorPopup::ShowValidationFailure(const wxString &p_message, wxTextCtrl *p_ctrl) { wxBell(); + if (m_errorText) { + m_errorText->SetLabel(p_message); + m_errorText->Wrap(FromDIP(260)); + m_errorText->Show(); + } + + if (m_contentPanel) { + m_contentPanel->Layout(); + } + + Fit(); + PositionPopup(); + + if (p_ctrl) { + p_ctrl->SetFocus(); + p_ctrl->SelectAll(); + } +} + +void OutcomeEditorPopup::ClearValidationFailure() +{ + if (!m_errorText) { + return; + } + + m_errorText->SetLabel(wxEmptyString); + m_errorText->Hide(); + + if (m_contentPanel) { + m_contentPanel->Layout(); + } +} + +void OutcomeEditorPopup::RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl) +{ + if (m_restoringAfterFailedCommit) { + return; + } + + m_restoringAfterFailedCommit = true; + CallAfter([this, p_invalidCtrl]() { + m_restoringAfterFailedCommit = false; + if (!m_node) { return; } PositionPopup(); - Popup(); - p_invalidCtrl->SetFocus(); - p_invalidCtrl->SelectAll(); + if (!IsShown()) { + Popup(); + } + + wxTextCtrl *ctrl = p_invalidCtrl ? p_invalidCtrl : m_labelCtrl; + ctrl->SetFocus(); + ctrl->SelectAll(); }); } - //-------------------------------------------------------------------------- // Bitmap drawing functions //-------------------------------------------------------------------------- diff --git a/src/gui/efgpanel.cc b/src/gui/efgpanel.cc index 767a49bb8..73983fcd4 100644 --- a/src/gui/efgpanel.cc +++ b/src/gui/efgpanel.cc @@ -43,7 +43,7 @@ namespace Gambit::GUI { #include "bitmaps/color.xpm" #include "bitmaps/person.xpm" -class gbtTreePlayerIcon : public wxStaticBitmap { +class TreePlayerIcon : public wxStaticBitmap { private: int m_player; @@ -51,21 +51,21 @@ class gbtTreePlayerIcon : public wxStaticBitmap { void OnLeftClick(wxMouseEvent &); public: - gbtTreePlayerIcon(wxWindow *p_parent, int p_player); + TreePlayerIcon(wxWindow *p_parent, int p_player); DECLARE_EVENT_TABLE() }; -BEGIN_EVENT_TABLE(gbtTreePlayerIcon, wxStaticBitmap) -EVT_LEFT_DOWN(gbtTreePlayerIcon::OnLeftClick) +BEGIN_EVENT_TABLE(TreePlayerIcon, wxStaticBitmap) +EVT_LEFT_DOWN(TreePlayerIcon::OnLeftClick) END_EVENT_TABLE() -gbtTreePlayerIcon::gbtTreePlayerIcon(wxWindow *p_parent, int p_player) +TreePlayerIcon::TreePlayerIcon(wxWindow *p_parent, int p_player) : wxStaticBitmap(p_parent, wxID_ANY, wxBitmap(person_xpm)), m_player(p_player) { } -void gbtTreePlayerIcon::OnLeftClick(wxMouseEvent &) +void TreePlayerIcon::OnLeftClick(wxMouseEvent &) { wxString label; label << "P" << m_player; @@ -74,11 +74,11 @@ void gbtTreePlayerIcon::OnLeftClick(wxMouseEvent &) source.DoDragDrop(wxDrag_DefaultMove); } -class gbtTreePlayerPanel : public wxPanel { +class TreePlayerPanel : public wxPanel { private: GameDocument *m_doc; int m_player; - EditableText *m_playerLabel; + EditableLabelText *m_playerLabel; wxStaticText *m_payoff, *m_nodeValue, *m_nodeProb; wxStaticText *m_infosetValue, *m_infosetProb, *m_belief; @@ -95,7 +95,7 @@ class gbtTreePlayerPanel : public wxPanel { //@} public: - gbtTreePlayerPanel(wxWindow *, GameDocument *, int p_player); + TreePlayerPanel(wxWindow *, GameDocument *, int p_player); void OnUpdate(); void PostPendingChanges(); @@ -103,18 +103,18 @@ class gbtTreePlayerPanel : public wxPanel { DECLARE_EVENT_TABLE() }; -BEGIN_EVENT_TABLE(gbtTreePlayerPanel, wxPanel) -EVT_CHAR(gbtTreePlayerPanel::OnChar) +BEGIN_EVENT_TABLE(TreePlayerPanel, wxPanel) +EVT_CHAR(TreePlayerPanel::OnChar) END_EVENT_TABLE() -gbtTreePlayerPanel::gbtTreePlayerPanel(wxWindow *p_parent, GameDocument *p_doc, int p_player) +TreePlayerPanel::TreePlayerPanel(wxWindow *p_parent, GameDocument *p_doc, int p_player) : wxPanel(p_parent, wxID_ANY), m_doc(p_doc), m_player(p_player) { auto *topSizer = new wxBoxSizer(wxVERTICAL); auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); - wxStaticBitmap *playerIcon = new gbtTreePlayerIcon(this, m_player); + wxStaticBitmap *playerIcon = new TreePlayerIcon(this, m_player); labelSizer->Add(playerIcon, 0, wxALL | wxALIGN_CENTER, 0); auto *setColorIcon = new wxBitmapButton(this, wxID_ANY, wxBitmap(color_xpm), wxDefaultPosition, @@ -123,15 +123,16 @@ gbtTreePlayerPanel::gbtTreePlayerPanel(wxWindow *p_parent, GameDocument *p_doc, labelSizer->Add(setColorIcon, 0, wxALL | wxALIGN_CENTER, 0); Connect(setColorIcon->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(gbtTreePlayerPanel::OnSetColor)); + wxCommandEventHandler(TreePlayerPanel::OnSetColor)); - m_playerLabel = new EditableText(this, wxID_ANY, wxT(""), wxDefaultPosition, wxSize(125, -1)); + m_playerLabel = + new EditableLabelText(this, wxID_ANY, wxT(""), wxDefaultPosition, wxSize(125, -1)); m_playerLabel->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); labelSizer->Add(m_playerLabel, 1, wxLEFT | wxEXPAND, 10); Connect(m_playerLabel->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(gbtTreePlayerPanel::OnEditPlayerLabel)); + wxCommandEventHandler(TreePlayerPanel::OnEditPlayerLabel)); Connect(m_playerLabel->GetId(), wxEVT_COMMAND_TEXT_ENTER, - wxCommandEventHandler(gbtTreePlayerPanel::OnAcceptPlayerLabel)); + wxCommandEventHandler(TreePlayerPanel::OnAcceptPlayerLabel)); topSizer->Add(labelSizer, 0, wxALL, 0); @@ -178,7 +179,7 @@ gbtTreePlayerPanel::gbtTreePlayerPanel(wxWindow *p_parent, GameDocument *p_doc, OnUpdate(); } -void gbtTreePlayerPanel::OnUpdate() +void TreePlayerPanel::OnUpdate() { if (!m_doc->GetGame()->IsTree()) { return; @@ -249,7 +250,7 @@ void gbtTreePlayerPanel::OnUpdate() GetSizer()->Fit(this); } -void gbtTreePlayerPanel::OnChar(wxKeyEvent &p_event) +void TreePlayerPanel::OnChar(wxKeyEvent &p_event) { if (p_event.GetKeyCode() == WXK_ESCAPE) { m_playerLabel->EndEdit(false); @@ -259,7 +260,7 @@ void gbtTreePlayerPanel::OnChar(wxKeyEvent &p_event) } } -void gbtTreePlayerPanel::OnSetColor(wxCommandEvent &) +void TreePlayerPanel::OnSetColor(wxCommandEvent &) { wxColourData data; data.SetColour(m_doc->GetStyle().GetPlayerColor(m_doc->GetGame()->GetPlayer(m_player))); @@ -276,23 +277,33 @@ void gbtTreePlayerPanel::OnSetColor(wxCommandEvent &) } } -void gbtTreePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) +void TreePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) { m_doc->PostPendingChanges(); m_playerLabel->BeginEdit(); } -void gbtTreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) +void TreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) { + const wxString label = + LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + m_playerLabel->BeginEdit(); + return; + } + try { - m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); + m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), label); } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); } + m_playerLabel->SetValue(m_doc->GetGame()->GetPlayer(m_player)->GetLabel()); } -void gbtTreePlayerPanel::PostPendingChanges() +void TreePlayerPanel::PostPendingChanges() { if (!m_playerLabel->IsEditing()) { return; @@ -426,7 +437,7 @@ void gbtTreeChancePanel::OnSetColor(wxCommandEvent &) class gbtTreePlayerToolbar : public wxPanel, public GameView { private: gbtTreeChancePanel *m_chancePanel; - Array m_playerPanels; + Array m_playerPanels; // @name Implementation of GameView members //@{ @@ -447,7 +458,7 @@ gbtTreePlayerToolbar::gbtTreePlayerToolbar(wxWindow *p_parent, GameDocument *p_d topSizer->Add(m_chancePanel, 0, wxALL | wxEXPAND, 5); for (size_t pl = 1; pl <= m_doc->GetGame()->NumPlayers(); pl++) { - m_playerPanels.push_back(new gbtTreePlayerPanel(this, m_doc, pl)); + m_playerPanels.push_back(new TreePlayerPanel(this, m_doc, pl)); topSizer->Add(m_playerPanels[pl], 0, wxALL | wxEXPAND, 5); } @@ -458,27 +469,27 @@ gbtTreePlayerToolbar::gbtTreePlayerToolbar(wxWindow *p_parent, GameDocument *p_d void gbtTreePlayerToolbar::OnUpdate() { while (m_playerPanels.size() < m_doc->GetGame()->NumPlayers()) { - auto *panel = new gbtTreePlayerPanel(this, m_doc, m_playerPanels.size() + 1); + auto *panel = new TreePlayerPanel(this, m_doc, m_playerPanels.size() + 1); m_playerPanels.push_back(panel); GetSizer()->Add(panel, 0, wxALL | wxEXPAND, 5); } while (m_playerPanels.size() > m_doc->GetGame()->NumPlayers()) { - gbtTreePlayerPanel *panel = m_playerPanels.back(); + TreePlayerPanel *panel = m_playerPanels.back(); GetSizer()->Detach(panel); panel->Destroy(); m_playerPanels.pop_back(); } std::for_each(m_playerPanels.begin(), m_playerPanels.end(), - std::mem_fn(&gbtTreePlayerPanel::OnUpdate)); + std::mem_fn(&TreePlayerPanel::OnUpdate)); GetSizer()->Layout(); } void gbtTreePlayerToolbar::PostPendingChanges() { std::for_each(m_playerPanels.begin(), m_playerPanels.end(), - std::mem_fn(&gbtTreePlayerPanel::PostPendingChanges)); + std::mem_fn(&TreePlayerPanel::PostPendingChanges)); } //===================================================================== diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index 6dc4a2088..401072d27 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -735,6 +735,9 @@ void GameDocument::DoSetOutcomeData(const GameNode &p_node, const wxString &p_la outcome = m_game->NewOutcome(p_label.ToStdString()); m_game->SetOutcome(p_node, outcome); } + else { + outcome->SetLabel(label); + } for (size_t player = 1; player <= GetGame()->NumPlayers(); ++player) { outcome->SetPayoff(GetGame()->GetPlayer(player), Number(p_payoffs[player - 1].ToStdString())); diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index af3e103ad..afbf2f1d7 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -995,7 +995,7 @@ void GameFrame::OnEditNode(wxCommandEvent &) EditNodeDialog dialog(this, m_doc->GetSelectNode()); if (dialog.ShowModal() == wxID_OK) { try { - m_doc->DoSetNodeLabel(m_doc->GetSelectNode(), dialog.GetNodeName()); + m_doc->DoSetNodeLabel(m_doc->GetSelectNode(), dialog.GetNodeLabel()); if (dialog.GetOutcome() > 0) { m_doc->DoSetOutcome(m_doc->GetSelectNode(), m_doc->GetGame()->GetOutcome(dialog.GetOutcome())); @@ -1030,14 +1030,14 @@ void GameFrame::OnEditMove(wxCommandEvent &) EditMoveDialog dialog(this, infoset); if (dialog.ShowModal() == wxID_OK) { try { - m_doc->DoSetInfosetLabel(infoset, dialog.GetInfosetName()); + m_doc->DoSetInfosetLabel(infoset, dialog.GetInfosetLabel()); if (!infoset->IsChanceInfoset() && dialog.GetPlayer() != infoset->GetPlayer()->GetNumber()) { m_doc->DoSetPlayer(infoset, m_doc->GetGame()->GetPlayer(dialog.GetPlayer())); } for (const auto &action : infoset->GetActions()) { - m_doc->DoSetActionLabel(action, dialog.GetActionName(action->GetNumber())); + m_doc->DoSetActionLabel(action, dialog.GetActionLabel(action->GetNumber())); } if (infoset->IsChanceInfoset()) { m_doc->DoSetActionProbs(infoset, dialog.GetActionProbs()); diff --git a/src/gui/labelcell.cc b/src/gui/labelcell.cc new file mode 100644 index 000000000..dad184642 --- /dev/null +++ b/src/gui/labelcell.cc @@ -0,0 +1,121 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/labelcell.cc +// Implementation of wxSheet editor for Gambit labels +// +// 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 WX_PRECOMP +#include +#endif // WX_PRECOMP + +#include "labelcell.h" + +#include "wx/sheet/sheet.h" + +namespace Gambit::GUI { + +IMPLEMENT_DYNAMIC_CLASS(LabelEditorRefData, wxSheetCellTextEditorRefData) + +LabelEditorRefData::LabelEditorRefData(LabelCharacterPolicy p_policy) : m_policy(p_policy) {} + +void LabelEditorRefData::CreateEditor(wxWindow *parent, wxWindowID id, wxEvtHandler *evtHandler, + wxSheet *sheet) +{ + auto *textCtrl = + new LabelTextCtrl(parent, id, wxEmptyString, m_policy, wxDefaultPosition, wxDefaultSize, + wxTE_PROCESS_TAB | wxTE_CENTER | wxBORDER_NONE); + SetControl(textCtrl); + + textCtrl->Bind(wxEVT_KILL_FOCUS, [sheet](wxFocusEvent &event) { + if (!sheet->IsTabTraversing()) { + sheet->CallAfter([sheet]() { + if (!sheet->IsTabTraversing() && sheet->IsCellEditControlShown()) { + sheet->DisableCellEditControl(true); + sheet->Refresh(); + } + }); + } + event.Skip(); + }); + + if (m_maxChars != 0) { + textCtrl->SetMaxLength(m_maxChars); + } + + wxSheetCellEditorRefData::CreateEditor(parent, id, evtHandler, sheet); +} + +bool LabelEditorRefData::Copy(const LabelEditorRefData &p_other) +{ + m_policy = p_other.m_policy; + return wxSheetCellTextEditorRefData::Copy(p_other); +} + +bool LabelEditorRefData::IsAcceptedKey(wxKeyEvent &p_event) +{ + if (!wxSheetCellEditorRefData::IsAcceptedKey(p_event)) { + return false; + } + + const int keycode = p_event.GetKeyCode(); + + // Let the editor start on ordinary printable ASCII characters. The + // LabelTextCtrl itself performs full normalization and filtering, so this + // does not need to duplicate the complete label policy. + if (m_policy == LabelCharacterPolicy::AsciiOnly) { + return keycode >= 0x20 && keycode <= 0x7e; + } + + // For the future Unicode policy, accept the key here and let LabelTextCtrl + // normalize/filter the resulting text. + return true; +} + +void LabelEditorRefData::StartingKey(wxKeyEvent &p_event) +{ + const int keycode = p_event.GetKeyCode(); + + if (m_policy == LabelCharacterPolicy::AsciiOnly && (keycode < 0x20 || keycode > 0x7e)) { + p_event.Skip(); + return; + } + + wxSheetCellTextEditorRefData::StartingKey(p_event); +} + +bool LabelEditorRefData::EndEdit(const wxSheetCoords &p_coords, wxSheet *p_sheet) +{ + auto *textCtrl = wxStaticCast(GetTextCtrl(), LabelTextCtrl); + const wxString value = textCtrl->GetNormalizedValue(); + + if (value.empty()) { + wxBell(); + textCtrl->SetFocus(); + return false; + } + + if (value == p_sheet->GetCellValue(p_coords)) { + return false; + } + + p_sheet->SetCellValue(p_coords, value); + return true; +} + +} // namespace Gambit::GUI diff --git a/src/gui/labelcell.h b/src/gui/labelcell.h new file mode 100644 index 000000000..0b4c8f995 --- /dev/null +++ b/src/gui/labelcell.h @@ -0,0 +1,54 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/labelcell.h +// Declaration of wxSheet editor for Gambit labels +// +// 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_LABELCELLEDITOR_H +#define GAMBIT_GUI_LABELCELLEDITOR_H + +#include "wx/sheet/sheet.h" + +#include "editlabel.h" +#include "renratio.h" // for DECLARE_GAMBIT_SHEETOBJREFDATA_COPY_CLASS + +namespace Gambit::GUI { + +class LabelEditorRefData final : public wxSheetCellTextEditorRefData { + LabelCharacterPolicy m_policy{LabelCharacterPolicy::AsciiOnly}; + +public: + explicit LabelEditorRefData(LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly); + + void CreateEditor(wxWindow *, wxWindowID, wxEvtHandler *, wxSheet *) override; + + /// Override basic text editor behavior to normalize label editing. + bool IsAcceptedKey(wxKeyEvent &) override; + void StartingKey(wxKeyEvent &) override; + bool EndEdit(const wxSheetCoords &, wxSheet *) override; + + bool Copy(const LabelEditorRefData &p_other); + + // NOLINTNEXTLINE(modernize-use-auto) + DECLARE_GAMBIT_SHEETOBJREFDATA_COPY_CLASS(LabelEditorRefData, wxSheetCellTextEditorRefData) +}; + +} // namespace Gambit::GUI + +#endif // GAMBIT_GUI_LABELCELLEDITOR_H diff --git a/src/gui/nfgpanel.cc b/src/gui/nfgpanel.cc index 2fc3902d2..4e3846437 100644 --- a/src/gui/nfgpanel.cc +++ b/src/gui/nfgpanel.cc @@ -73,7 +73,7 @@ void TablePlayerIcon::OnLeftClick(wxMouseEvent &) class TablePlayerPanel final : public wxPanel { GameDocument *m_doc; int m_player; - EditableText *m_playerLabel; + EditableLabelText *m_playerLabel; wxStaticText *m_payoff; /// @name Event handlers @@ -133,7 +133,8 @@ TablePlayerPanel::TablePlayerPanel(wxWindow *p_parent, NfgPanel *p_nfgPanel, Gam Connect(setColorIcon->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(TablePlayerPanel::OnSetColor)); - m_playerLabel = new EditableText(this, wxID_ANY, wxT(""), wxDefaultPosition, wxSize(125, -1)); + m_playerLabel = + new EditableLabelText(this, wxID_ANY, wxT(""), wxDefaultPosition, wxSize(125, -1)); m_playerLabel->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); labelSizer->Add(m_playerLabel, 1, wxLEFT | wxEXPAND, 5); Connect(m_playerLabel->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, @@ -221,12 +222,22 @@ void TablePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) void TablePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) { + const wxString label = + LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + m_playerLabel->BeginEdit(); + return; + } + try { - m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); + m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), label); } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); } + m_playerLabel->SetValue(m_doc->GetGame()->GetPlayer(m_player)->GetLabel()); } void TablePlayerPanel::PostPendingChanges() diff --git a/src/gui/nfgtable.cc b/src/gui/nfgtable.cc index 13dc0bc45..1bd8b5aa0 100644 --- a/src/gui/nfgtable.cc +++ b/src/gui/nfgtable.cc @@ -31,6 +31,8 @@ #include "wx/sheet/sheet.h" #include "renratio.h" // special renderer for rational numbers +#include "editlabel.h" +#include "labelcell.h" #include "gamedoc.h" #include "nfgpanel.h" @@ -240,7 +242,19 @@ wxString RowPlayerWidget::GetCellValue(const wxSheetCoords &p_coords) void RowPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) { - m_table->RenameRowHeaderStrategy(p_coords.GetCol(), p_coords.GetRow(), p_value); + const wxString label = LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + return; + } + + const wxString result = m_table->RenameRowHeaderStrategy( + p_coords.GetCol(), p_coords.GetRow(), + LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); + if (!result.empty()) { + CallAfter([this, result] { ExceptionDialog(this, result.ToStdString()).ShowModal(); }); + } } wxSheetCellAttr RowPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetAttr_Type) const @@ -252,6 +266,7 @@ wxSheetCellAttr RowPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetA if (m_table->GetRowHeaderColCount() > 0) { attr.SetForegroundColour( m_table->GetPlayerColor(m_table->GetRowHeaderPlayer(p_coords.GetCol()))); + attr.SetEditor(wxSheetCellEditor(new LabelEditorRefData(LabelCharacterPolicy::AsciiOnly))); attr.SetReadOnly(m_table->IsReadOnly()); } else { @@ -536,7 +551,19 @@ wxString ColPlayerWidget::GetCellValue(const wxSheetCoords &p_coords) void ColPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) { - m_table->RenameColHeaderStrategy(p_coords.GetRow(), p_coords.GetCol(), p_value); + const wxString label = LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + return; + } + + const wxString result = m_table->RenameColHeaderStrategy( + p_coords.GetCol(), p_coords.GetRow(), + LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); + if (!result.empty()) { + CallAfter([this, result] { ExceptionDialog(this, result.ToStdString()).ShowModal(); }); + } } wxSheetCellAttr ColPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetAttr_Type) const @@ -548,6 +575,7 @@ wxSheetCellAttr ColPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetA if (m_table->GetColHeaderRowCount() > 0) { attr.SetForegroundColour( m_table->GetPlayerColor(m_table->GetColHeaderPlayer(p_coords.GetRow()))); + attr.SetEditor(wxSheetCellEditor(new LabelEditorRefData(LabelCharacterPolicy::AsciiOnly))); attr.SetReadOnly(m_table->IsReadOnly()); } else { @@ -1327,7 +1355,7 @@ void TableWidget::RenderGame(wxDC &p_dc, int p_marginX, int p_marginY) p_dc, wxSheetBlock(0, 0, m_payoffSheet->GetNumberRows(), m_payoffSheet->GetNumberCols())); } -void TableWidget::RenameRowHeaderStrategy(int headerCol, int headerRow, const wxString &value) +wxString TableWidget::RenameRowHeaderStrategy(int headerCol, int headerRow, const wxString &value) { const int player = GetRowHeaderPlayer(headerCol); const int strat = GetRowHeaderStrategy(headerCol, headerRow); @@ -1336,11 +1364,12 @@ void TableWidget::RenameRowHeaderStrategy(int headerCol, int headerRow, const wx m_doc->DoSetStrategyLabel(GetStrategyByPlayerAndIndex(player, strat), value); } catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); + return wxString::FromUTF8(ex.what()); } + return ""; } -void TableWidget::RenameColHeaderStrategy(int headerRow, int headerCol, const wxString &value) +wxString TableWidget::RenameColHeaderStrategy(int headerRow, int headerCol, const wxString &value) { const int player = GetColHeaderPlayer(headerRow); const int strat = GetColHeaderStrategy(headerRow, headerCol); @@ -1349,8 +1378,9 @@ void TableWidget::RenameColHeaderStrategy(int headerRow, int headerCol, const wx m_doc->DoSetStrategyLabel(GetStrategyByPlayerAndIndex(player, strat), value); } catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); + return wxString::FromUTF8(ex.what()); } + return ""; } void TableWidget::DeleteRowHeaderStrategy(int headerCol, int headerRow) diff --git a/src/gui/nfgtable.h b/src/gui/nfgtable.h index 0f4e522c2..bf29b6c90 100644 --- a/src/gui/nfgtable.h +++ b/src/gui/nfgtable.h @@ -426,8 +426,8 @@ class TableWidget final : public wxPanel { void GetSVG(const wxString &p_filename, int marginX, int marginY); /// Prints the game as currently displayed, centered on the DC void RenderGame(wxDC &p_dc, int marginX, int marginY); - void RenameRowHeaderStrategy(int headerCol, int headerRow, const wxString &value); - void RenameColHeaderStrategy(int headerRow, int headerCol, const wxString &value); + wxString RenameRowHeaderStrategy(int headerCol, int headerRow, const wxString &value); + wxString RenameColHeaderStrategy(int headerRow, int headerCol, const wxString &value); bool CanDeleteRowHeaderStrategy(int headerCol, int headerRow) const; bool CanDeleteColHeaderStrategy(int headerRow, int headerCol) const; From e962da720d84f5712a9cbfdf173d8baf5e3c639f Mon Sep 17 00:00:00 2001 From: wyz2368 Date: Fri, 10 Jul 2026 13:30:07 -0400 Subject: [PATCH 16/32] Add EFGs from shohamleytonbrown2008 (#987) --- build_support/catalog/catalog.am | 19 ++++ build_support/catalog/catalog_hierarchy.yaml | 1 + build_support/catalog/gtdraw_settings.yaml | 28 ++++++ .../books/shohamleytonbrown2008/fig5_1.efg | 21 +++++ .../books/shohamleytonbrown2008/fig5_10.efg | 16 ++++ .../fig5_10__original_layout.ef | 12 +++ .../books/shohamleytonbrown2008/fig5_11.efg | 14 +++ .../fig5_11__original_layout.ef | 10 ++ .../books/shohamleytonbrown2008/fig5_12.efg | 15 +++ .../fig5_12__original_layout.ef | 15 +++ .../books/shohamleytonbrown2008/fig5_15.efg | 17 ++++ .../fig5_15__original_layout.ef | 11 +++ .../fig5_1__original_layout.ef | 12 +++ .../books/shohamleytonbrown2008/fig5_2.efg | 16 ++++ .../fig5_2__original_layout.ef | 11 +++ .../books/shohamleytonbrown2008/fig5_9.efg | 22 +++++ .../fig5_9__original_layout.ef | 13 +++ .../books/shohamleytonbrown2008/fig6_2.efg | 38 ++++++++ .../fig6_2__original_layout.ef | 38 ++++++++ .../books/shohamleytonbrown2008/fig6_8.efg | 36 ++++++++ .../fig6_8__original_layout.ef | 92 +++++++++++++++++++ .../fig1__Original_Layout.ef | 57 ++++++++++++ doc/references.bib | 3 +- 23 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 catalog/books/shohamleytonbrown2008/fig5_1.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig5_10.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig5_10__original_layout.ef create mode 100644 catalog/books/shohamleytonbrown2008/fig5_11.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig5_11__original_layout.ef create mode 100644 catalog/books/shohamleytonbrown2008/fig5_12.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig5_12__original_layout.ef create mode 100644 catalog/books/shohamleytonbrown2008/fig5_15.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig5_15__original_layout.ef create mode 100644 catalog/books/shohamleytonbrown2008/fig5_1__original_layout.ef create mode 100644 catalog/books/shohamleytonbrown2008/fig5_2.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig5_2__original_layout.ef create mode 100644 catalog/books/shohamleytonbrown2008/fig5_9.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig5_9__original_layout.ef create mode 100644 catalog/books/shohamleytonbrown2008/fig6_2.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig6_2__original_layout.ef create mode 100644 catalog/books/shohamleytonbrown2008/fig6_8.efg create mode 100644 catalog/books/shohamleytonbrown2008/fig6_8__original_layout.ef create mode 100644 catalog/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef diff --git a/build_support/catalog/catalog.am b/build_support/catalog/catalog.am index 58c225f42..5af16f3bc 100644 --- a/build_support/catalog/catalog.am +++ b/build_support/catalog/catalog.am @@ -1,6 +1,24 @@ CATALOG_FILES = \ catalog/books/myerson1991/fig2_1.efg \ catalog/books/myerson1991/fig4_2.efg \ + catalog/books/shohamleytonbrown2008/fig5_1.efg \ + catalog/books/shohamleytonbrown2008/fig5_10.efg \ + catalog/books/shohamleytonbrown2008/fig5_10__original_layout.ef \ + catalog/books/shohamleytonbrown2008/fig5_11.efg \ + catalog/books/shohamleytonbrown2008/fig5_11__original_layout.ef \ + catalog/books/shohamleytonbrown2008/fig5_12.efg \ + catalog/books/shohamleytonbrown2008/fig5_12__original_layout.ef \ + catalog/books/shohamleytonbrown2008/fig5_15.efg \ + catalog/books/shohamleytonbrown2008/fig5_15__original_layout.ef \ + catalog/books/shohamleytonbrown2008/fig5_1__original_layout.ef \ + catalog/books/shohamleytonbrown2008/fig5_2.efg \ + catalog/books/shohamleytonbrown2008/fig5_2__original_layout.ef \ + catalog/books/shohamleytonbrown2008/fig5_9.efg \ + catalog/books/shohamleytonbrown2008/fig5_9__original_layout.ef \ + catalog/books/shohamleytonbrown2008/fig6_2.efg \ + catalog/books/shohamleytonbrown2008/fig6_2__original_layout.ef \ + catalog/books/shohamleytonbrown2008/fig6_8.efg \ + catalog/books/shohamleytonbrown2008/fig6_8__original_layout.ef \ catalog/books/vonstengel2022/fig10.1.efg \ catalog/books/vonstengel2022/fig10.12.efg \ catalog/books/vonstengel2022/fig10.5.efg \ @@ -23,6 +41,7 @@ CATALOG_FILES = \ catalog/journals/ijgt/selten1975/fig2.efg \ catalog/journals/ijgt/selten1975/fig3.efg \ catalog/journals/mor/vonstengelforges2008/fig1.efg \ + catalog/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef \ catalog/journals/mor/vonstengelforges2008/fig6.efg \ catalog/journals/mor/vonstengelforges2008/fig6__Original_Layout.ef \ catalog/journals/mor/vonstengelforges2008/fig9.efg \ diff --git a/build_support/catalog/catalog_hierarchy.yaml b/build_support/catalog/catalog_hierarchy.yaml index 9280493a6..591f21320 100644 --- a/build_support/catalog/catalog_hierarchy.yaml +++ b/build_support/catalog/catalog_hierarchy.yaml @@ -14,6 +14,7 @@ labels: books/myerson1991: "Myerson (1991) — Game Theory: Analysis of Conflict" books/vonstengel2022: "von Stengel (2022) — Game Theory Basics" books/watson2013: "Watson (2013) — Strategy: An Introduction to Game Theory" + books/shohamleytonbrown2008: "Shoham and Leyton-Brown (2008) — Multiagent Systems, Algorithmic, Game-Theoretic, and Logical Foundations" journals/geb/gilboa1997: "Gilboa (1997)" journals/geb/wichardt2008: "Wichardt (2008)" journals/ijgt/nau2004: "Nau et al. (2004)" diff --git a/build_support/catalog/gtdraw_settings.yaml b/build_support/catalog/gtdraw_settings.yaml index 2bbd9889c..956b107fa 100644 --- a/build_support/catalog/gtdraw_settings.yaml +++ b/build_support/catalog/gtdraw_settings.yaml @@ -44,3 +44,31 @@ overrides: 2: '#DD79E0' 3: '#1616BC' 4: '#985003' + + books/shohamleytonbrown2008/fig6_8: + scale_factor: 1.0 + level_scaling: 1.0 + sublevel_scaling: 1.0 + width_scaling: 1.0 + horizontal: false + mirror: false + shared_terminal_depth: false + color_scheme: colorblind + edge_thickness: 1.0 + action_label_position: 0.5 + action_label_position_by: player + action_label_dist: 1.0 + vary_action_label_positions: false + vary_action_label_positions_by: all + font_family: rmfamily + font_bold: false + font_italic: false + font_size: normalsize + node_size: 1.5 + label_bg: false + label_bg_color: white + label_bg_opacity: 0.8 + iset_fill: true + iset_fill_opacity: 0.2 + iset_boundary: none + legend_position: top-left diff --git a/catalog/books/shohamleytonbrown2008/fig5_1.efg b/catalog/books/shohamleytonbrown2008/fig5_1.efg new file mode 100644 index 000000000..49f598d28 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_1.efg @@ -0,0 +1,21 @@ +EFG 2 R "Fig 5.1 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 5.1 from :cite:p:`ShoLeyB08`. +This is a sharing game. Imagine a brother and sister sharing two indivisible and identical presents +from their parents. First the brother suggests a split, which can be one of three: he +keeps both, she keeps both, or they each keep one. Then the sister chooses whether +to accept or reject the split. If she accepts they each get their allocated present(s), +and otherwise neither gets any gift. +" + +p "" 1 1 "" { "2-0" "1-1" "0-2" } 0 +p "" 2 2 "" { "no" "yes" } 0 +t "" 1 "" { 0 0 } +t "" 2 "" { 2 0 } +p "" 2 3 "" { "no" "yes" } 0 +t "" 3 "" { 0 0 } +t "" 4 "" { 1 1 } +p "" 2 4 "" { "no" "yes" } 0 +t "" 5 "" { 0 0 } +t "" 6 "" { 0 2 } diff --git a/catalog/books/shohamleytonbrown2008/fig5_10.efg b/catalog/books/shohamleytonbrown2008/fig5_10.efg new file mode 100644 index 000000000..94cd759ff --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_10.efg @@ -0,0 +1,16 @@ +EFG 2 R "Fig 5.10 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 5.10 from :cite:p:`ShoLeyB08`. +This is an example of an imperfect-information game. +" + +p "" 1 2 "" { "L" "R" } 0 +p "" 2 3 "" { "A" "B" } 0 +p "" 1 1 "" { "l" "r" } 0 +t "" 1 "" { 0 0 } +t "" 2 "" { 2 4 } +p "" 1 1 0 +t "" 3 "" { 4 2 } +t "" 4 "" { 0 0 } +t "" 5 "" { 0 0 } diff --git a/catalog/books/shohamleytonbrown2008/fig5_10__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig5_10__original_layout.ef new file mode 100644 index 000000000..55e983222 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_10__original_layout.ef @@ -0,0 +1,12 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 1 +level 2 node 1 player 2 xshift -3.58 from 0,1 move L +level 2 node 2 xshift 3.58 from 0,1 move R payoffs 0 0 +level 4 node 1 xshift -2.86 from 2,1 move A +level 4 node 2 xshift 2.86 from 2,1 move B +level 6 node 1 xshift -1.43 from 4,1 move l payoffs 0 0 +level 6 node 2 xshift 1.43 from 4,1 move r payoffs 2 4 +level 6 node 3 xshift -1.43 from 4,2 move l payoffs 4 2 +level 6 node 4 xshift 1.43 from 4,2 move r payoffs 0 0 +iset 4,1 4,2 player 1 diff --git a/catalog/books/shohamleytonbrown2008/fig5_11.efg b/catalog/books/shohamleytonbrown2008/fig5_11.efg new file mode 100644 index 000000000..a972fb90b --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_11.efg @@ -0,0 +1,14 @@ +EFG 2 R "Fig 5.11 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 5.11 from :cite:p:`ShoLeyB08`. +The Prisoner's Dilemma game in extensive form. +" + +p "" 1 2 "" { "C" "D" } 0 +p "" 2 1 "" { "c" "d" } 0 +t "" 1 "" { -1 -1 } +t "" 2 "" { -4 0 } +p "" 2 1 0 +t "" 3 "" { 0 -4 } +t "" 4 "" { -3 -3 } diff --git a/catalog/books/shohamleytonbrown2008/fig5_11__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig5_11__original_layout.ef new file mode 100644 index 000000000..8aa60582e --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_11__original_layout.ef @@ -0,0 +1,10 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 1 +level 2 node 1 xshift -3.58 from 0,1 move C +level 2 node 2 xshift 3.58 from 0,1 move D +level 4 node 1 xshift -1.79 from 2,1 move c payoffs -1 -1 +level 4 node 2 xshift 1.79 from 2,1 move d payoffs -4 0 +level 4 node 3 xshift -1.79 from 2,2 move c payoffs 0 -4 +level 4 node 4 xshift 1.79 from 2,2 move d payoffs -3 -3 +iset 2,1 2,2 player 2 diff --git a/catalog/books/shohamleytonbrown2008/fig5_12.efg b/catalog/books/shohamleytonbrown2008/fig5_12.efg new file mode 100644 index 000000000..27c70afe2 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_12.efg @@ -0,0 +1,15 @@ +EFG 2 R "Fig 5.12 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 5.12 from :cite:p:`ShoLeyB08`. +A game with imperfect recall, in particular absent-mindedness. +" + + +p "" 1 1 "" { "L" "R" } 0 +p "" 1 1 0 +t "" 1 "" { 1 0 } +t "" 2 "" { 100 100 } +p "" 2 2 "" { "U" "D" } 0 +t "" 3 "" { 5 1 } +t "" 4 "" { 2 2 } diff --git a/catalog/books/shohamleytonbrown2008/fig5_12__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig5_12__original_layout.ef new file mode 100644 index 000000000..98b720cdc --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_12__original_layout.ef @@ -0,0 +1,15 @@ +% Extensive-form game transcribed from the uploaded image +player 1 name 1 +player 2 name 2 + +% Player 1's two decision nodes are in the same information set. +level 0 node 1 +level 2 node 1 xshift -3 from 0,1 move L +level 2 node 2 xshift 3 from 0,1 move R player 2 + +level 4 node 1 xshift -1 from 2,1 move L payoffs 1 0 +level 4 node 2 xshift 1 from 2,1 move R payoffs 100 100 +level 4 node 3 xshift -1 from 2,2 move U payoffs 5 1 +level 4 node 4 xshift 1 from 2,2 move D payoffs 2 2 + +iset 0,1 2,1 player 1 diff --git a/catalog/books/shohamleytonbrown2008/fig5_15.efg b/catalog/books/shohamleytonbrown2008/fig5_15.efg new file mode 100644 index 000000000..ceb402d94 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_15.efg @@ -0,0 +1,17 @@ +EFG 2 R "Fig 5.15 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 5.15 from :cite:p:`ShoLeyB08`. +A game with imperfect information. +This example shows how a requirement that a substrategy be a best response in +all subgames is too simplistic for defining SPE in games with imperfect information. +" + +p "" 1 2 "" { "L" "C" "R" } 0 +t "" 1 "" { 1 1 } +p "" 2 1 "" { "U" "D" } 0 +t "" 2 "" { 0 1000 } +t "" 3 "" { 0 0 } +p "" 2 1 0 +t "" 4 "" { 1 0 } +t "" 5 "" { 3 1 } diff --git a/catalog/books/shohamleytonbrown2008/fig5_15__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig5_15__original_layout.ef new file mode 100644 index 000000000..50d5d3446 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_15__original_layout.ef @@ -0,0 +1,11 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 1 +level 2 node 1 xshift -5.01 from 0,1 move L payoffs 1 1 +level 2 node 2 xshift -0.72 from 0,1 move C +level 2 node 3 xshift 5.01 from 0,1 move R +level 4 node 1 xshift -1.43 from 2,2 move U payoffs 0 1000 +level 4 node 2 xshift 1.43 from 2,2 move D payoffs 0 0 +level 4 node 3 xshift -1.43 from 2,3 move U payoffs 1 0 +level 4 node 4 xshift 1.43 from 2,3 move D payoffs 3 1 +iset 2,2 2,3 player 2 diff --git a/catalog/books/shohamleytonbrown2008/fig5_1__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig5_1__original_layout.ef new file mode 100644 index 000000000..492f04754 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_1__original_layout.ef @@ -0,0 +1,12 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 1 +level 2 node 1 player 2 xshift -4.77 from 0,1 move 2-0 +level 2 node 2 player 2 xshift 0 from 0,1 move 1-1 +level 4 node 1 xshift -1.19 from 2,1 move no payoffs 0 0 +level 4 node 2 xshift 1.19 from 2,1 move yes payoffs 2 0 +level 2 node 3 player 2 xshift 4.77 from 0,1 move 0-2 +level 4 node 3 xshift -1.19 from 2,2 move no payoffs 0 0 +level 4 node 4 xshift 1.19 from 2,2 move yes payoffs 1 1 +level 4 node 5 xshift -1.19 from 2,3 move no payoffs 0 0 +level 4 node 6 xshift 1.19 from 2,3 move yes payoffs 0 2 diff --git a/catalog/books/shohamleytonbrown2008/fig5_2.efg b/catalog/books/shohamleytonbrown2008/fig5_2.efg new file mode 100644 index 000000000..5ed1bd189 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_2.efg @@ -0,0 +1,16 @@ +EFG 2 R "Fig 5.2 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 5.2 from :cite:p:`ShoLeyB08`. +This is an example of a perfect-information game in extensive form. +" + +p "" 1 1 "" { "A" "B" } 0 +p "" 2 2 "" { "C" "D" } 0 +t "" 1 "" { 3 8 } +t "" 2 "" { 8 3 } +p "" 2 3 "" { "E" "F" } 0 +t "" 3 "" { 5 5 } +p "" 1 4 "" { "G" "H" } 0 +t "" 4 "" { 2 10 } +t "" 5 "" { 1 0 } diff --git a/catalog/books/shohamleytonbrown2008/fig5_2__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig5_2__original_layout.ef new file mode 100644 index 000000000..1d55ae617 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_2__original_layout.ef @@ -0,0 +1,11 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 1 +level 2 node 1 player 2 xshift -3.22 from 0,1 move A +level 2 node 2 player 2 xshift 3.22 from 0,1 move B +level 4 node 1 xshift -1.43 from 2,1 move C payoffs 3 8 +level 4 node 2 xshift 1.43 from 2,1 move D payoffs 8 3 +level 4 node 3 xshift -2.15 from 2,2 move E payoffs 5 5 +level 4 node 4 player 1 xshift 2.15 from 2,2 move F +level 6 node 1 xshift -1.43 from 4,4 move G payoffs 2 10 +level 6 node 2 xshift 1.43 from 4,4 move H payoffs 1 0 diff --git a/catalog/books/shohamleytonbrown2008/fig5_9.efg b/catalog/books/shohamleytonbrown2008/fig5_9.efg new file mode 100644 index 000000000..861c7c0b7 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_9.efg @@ -0,0 +1,22 @@ +EFG 2 R "Fig 5.9 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 5.9 from :cite:p:`ShoLeyB08`. +This is the centipede game. In this game two players alternate in making decisions, at each +turn choosing between going down and ending the game or going across and +continuing it. +This example is used to explain the criticisms of backward induction for finding subgame-perfect equilibrium. +Note that centipede is also a parametrized game, with the parameter being the number of rounds. +" + +p "" 1 1 "" { "A" "D" } 0 +t "" 1 "" { 1 0 } +p "" 2 2 "" { "A" "D" } 0 +t "" 2 "" { 0 2 } +p "" 1 3 "" { "A" "D" } 0 +t "" 3 "" { 3 1 } +p "" 2 4 "" { "A" "D" } 0 +t "" 4 "" { 2 4 } +p "" 1 5 "" { "A" "D" } 0 +t "" 5 "" { 4 3 } +t "" 6 "" { 3 5 } diff --git a/catalog/books/shohamleytonbrown2008/fig5_9__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig5_9__original_layout.ef new file mode 100644 index 000000000..9f477bc52 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig5_9__original_layout.ef @@ -0,0 +1,13 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 1 +level 2 node 1 xshift -2.31 from 0,1 move A payoffs 1 0 +level 2 node 2 player 2 xshift 2.31 from 0,1 move D +level 4 node 1 xshift -2.24 from 2,2 move A payoffs 0 2 +level 4 node 2 player 1 xshift 2.24 from 2,2 move D +level 6 node 1 xshift -2.09 from 4,2 move A payoffs 3 1 +level 6 node 2 player 2 xshift 2.09 from 4,2 move D +level 8 node 1 xshift -1.79 from 6,2 move A payoffs 2 4 +level 8 node 2 player 1 xshift 1.79 from 6,2 move D +level 10 node 1 xshift -1.19 from 8,2 move A payoffs 4 3 +level 10 node 2 xshift 1.19 from 8,2 move D payoffs 3 5 diff --git a/catalog/books/shohamleytonbrown2008/fig6_2.efg b/catalog/books/shohamleytonbrown2008/fig6_2.efg new file mode 100644 index 000000000..6a543e361 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig6_2.efg @@ -0,0 +1,38 @@ +EFG 2 R "Fig 6.2 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 6.2 from :cite:p:`ShoLeyB08`. +This is a repeated game, where Prisoner's Dilemma is played twice. +" + +p "" 1 6 "" { "C" "D" } 0 +p "" 2 1 "" { "c" "d" } 0 +p "" 1 7 "" { "C" "D" } 0 +p "" 2 4 "" { "c" "d" } 0 +t "" 1 "" { -2 -2 } +t "" 2 "" { -5 -1 } +p "" 2 4 0 +t "" 3 "" { -1 -5 } +t "" 4 "" { -4 -4 } +p "" 1 8 "" { "C" "D" } 0 +p "" 2 5 "" { "c" "d" } 0 +t "" 5 "" { -5 -1 } +t "" 6 "" { -8 0 } +p "" 2 5 0 +t "" 7 "" { -4 -4 } +t "" 8 "" { -7 -3 } +p "" 2 1 0 +p "" 1 9 "" { "C" "D" } 0 +p "" 2 2 "" { "c" "d" } 0 +t "" 9 "" { -1 -5 } +t "" 10 "" { -4 -4 } +p "" 2 2 0 +t "" 11 "" { 0 -8 } +t "" 12 "" { -3 -7 } +p "" 1 10 "" { "C" "D" } 0 +p "" 2 3 "" { "c" "d" } 0 +t "" 13 "" { -4 -4 } +t "" 14 "" { -7 -3 } +p "" 2 3 0 +t "" 15 "" { -3 -7 } +t "" 16 "" { -6 -6 } diff --git a/catalog/books/shohamleytonbrown2008/fig6_2__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig6_2__original_layout.ef new file mode 100644 index 000000000..563e3b0f0 --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig6_2__original_layout.ef @@ -0,0 +1,38 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 1 +level 2 node 1 xshift -6.37 from 0,1 move C +level 2 node 2 xshift 6.37 from 0,1 move D +level 4 node 1 player 1 xshift -3.19 from 2,1 move c +level 4 node 2 player 1 xshift 3.19 from 2,1 move d +level 4 node 3 player 1 xshift -3.19 from 2,2 move c +level 4 node 4 player 1 xshift 3.19 from 2,2 move d +level 6 node 1 xshift -1.59 from 4,1 move C +level 6 node 2 xshift 1.59 from 4,1 move D +level 6 node 3 xshift -1.59 from 4,2 move C +level 6 node 4 xshift 1.59 from 4,2 move D +level 6 node 5 xshift -1.59 from 4,3 move C +level 6 node 6 xshift 1.59 from 4,3 move D +level 6 node 7 xshift -1.59 from 4,4 move C +level 6 node 8 xshift 1.59 from 4,4 move D +level 8 node 1 xshift -0.8 from 6,1 move c payoffs -2 -2 +level 8 node 2 xshift 0.8 from 6,1 move d payoffs -5 -1 +level 8 node 3 xshift -0.8 from 6,2 move c payoffs -1 -5 +level 8 node 4 xshift 0.8 from 6,2 move d payoffs -4 -4 +level 8 node 5 xshift -0.8 from 6,3 move c payoffs -5 -1 +level 8 node 6 xshift 0.8 from 6,3 move d payoffs -8 0 +level 8 node 7 xshift -0.8 from 6,4 move c payoffs -4 -4 +level 8 node 8 xshift 0.8 from 6,4 move d payoffs -7 -3 +level 8 node 9 xshift -0.8 from 6,5 move c payoffs -1 -5 +level 8 node 10 xshift 0.8 from 6,5 move d payoffs -4 -4 +level 8 node 11 xshift -0.8 from 6,6 move c payoffs 0 -8 +level 8 node 12 xshift 0.8 from 6,6 move d payoffs -3 -7 +level 8 node 13 xshift -0.8 from 6,7 move c payoffs -4 -4 +level 8 node 14 xshift 0.8 from 6,7 move d payoffs -7 -3 +level 8 node 15 xshift -0.8 from 6,8 move c payoffs -3 -7 +level 8 node 16 xshift 0.8 from 6,8 move d payoffs -6 -6 +iset 2,1 2,2 player 2 +iset 6,5 6,6 player 2 +iset 6,7 6,8 player 2 +iset 6,1 6,2 player 2 +iset 6,3 6,4 player 2 diff --git a/catalog/books/shohamleytonbrown2008/fig6_8.efg b/catalog/books/shohamleytonbrown2008/fig6_8.efg new file mode 100644 index 000000000..08e0e3cba --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig6_8.efg @@ -0,0 +1,36 @@ +EFG 2 R "Fig 6.8 from Shoham and Leyton-Brown (2008)" { "1" "2" } + +" +Figure 6.8 from :cite:p:`ShoLeyB08`. +This is a Bayesian game, represented in EFG with Nature deciding the types. +" + +c "" 5 "" { "MP" 1/4 "PD" 1/4 "Coord" 1/4 "BoS" 1/4 } 0 +p "" 1 1 "" { "U" "D" } 0 +p "" 2 3 "" { "L" "R" } 0 +t "" 1 "" { 2 0 } +t "" 2 "" { 0 2 } +p "" 2 3 0 +t "" 3 "" { 0 2 } +t "" 4 "" { 2 0 } +p "" 1 1 0 +p "" 2 4 "" { "L" "R" } 0 +t "" 5 "" { 2 2 } +t "" 6 "" { 0 3 } +p "" 2 4 0 +t "" 7 "" { 3 0 } +t "" 8 "" { 1 1 } +p "" 1 2 "" { "U" "D" } 0 +p "" 2 3 0 +t "" 9 "" { 2 2 } +t "" 10 "" { 0 0 } +p "" 2 3 0 +t "" 11 "" { 0 0 } +t "" 12 "" { 1 1 } +p "" 1 2 0 +p "" 2 4 0 +t "" 13 "" { 2 1 } +t "" 14 "" { 0 0 } +p "" 2 4 0 +t "" 15 "" { 0 0 } +t "" 16 "" { 1 2 } diff --git a/catalog/books/shohamleytonbrown2008/fig6_8__original_layout.ef b/catalog/books/shohamleytonbrown2008/fig6_8__original_layout.ef new file mode 100644 index 000000000..a01dfbf0f --- /dev/null +++ b/catalog/books/shohamleytonbrown2008/fig6_8__original_layout.ef @@ -0,0 +1,92 @@ +% Four games with uniform chance and non-overlapping information sets. +% Nature chooses among MP, PD, Coord, and BoS with probability 1/4 each. +% +% The Player 2 information sets are separated vertically: +% - MP and Coord nodes are at level 5. +% - PD and BoS nodes are at level 11. +% +% The additional spacing prevents the U/D labels and the terminal +% payoffs of the upper subtrees from overlapping either information set. + +player 0 name Nature +player 1 name 1 +player 2 name 2 + +% Nature root +level 0 node 1 player 0 + +% Nature moves +% MP and PD belong to Player 1's first information set. +level 2 node 1 xshift -7.5 from 0,1 move MP~(1/4) +level 2 node 2 xshift -2.5 from 0,1 move PD~(1/4) + +% Coord and BoS belong to Player 1's second information set. +level 3 node 3 xshift 2.5 from 0,1 move Coord~(1/4) +level 3 node 4 xshift 7.5 from 0,1 move BoS~(1/4) + +% Player 1 moves U/D + +% Matching Pennies: +% These nodes belong to Player 2 information set 1. +level 5 node 1 xshift -1 from 2,1 move U +level 5 node 2 xshift 1 from 2,1 move D + +% Prisoner's Dilemma: +% These nodes belong to Player 2 information set 2. +level 11 node 3 xshift -1 from 2,2 move U +level 11 node 4 xshift 1 from 2,2 move D + +% Coordination: +% These nodes belong to Player 2 information set 1. +level 5 node 5 xshift -1 from 3,3 move U +level 5 node 6 xshift 1 from 3,3 move D + +% Battle of the Sexes: +% These nodes belong to Player 2 information set 2. +level 11 node 7 xshift -1 from 3,4 move U +level 11 node 8 xshift 1 from 3,4 move D + +% Player 2 moves L/R and terminal payoffs + +% Matching Pennies +% The terminal level is sufficiently above the second information set. +level 8 node 1 xshift -0.55 from 5,1 move L payoffs 2 0 +level 8 node 2 xshift 0.55 from 5,1 move R payoffs 0 2 +level 8 node 3 xshift -0.55 from 5,2 move L payoffs 0 2 +level 8 node 4 xshift 0.55 from 5,2 move R payoffs 2 0 + +% Prisoner's Dilemma +level 14 node 5 xshift -0.55 from 11,3 move L payoffs 2 2 +level 14 node 6 xshift 0.55 from 11,3 move R payoffs 0 3 +level 14 node 7 xshift -0.55 from 11,4 move L payoffs 3 0 +level 14 node 8 xshift 0.55 from 11,4 move R payoffs 1 1 + +% Coordination +% These payoffs are placed at level 8, leaving three levels before +% the lower Player 2 information set at level 11. +level 8 node 9 xshift -0.55 from 5,5 move L payoffs 2 2 +level 8 node 10 xshift 0.55 from 5,5 move R payoffs 0 0 +level 8 node 11 xshift -0.55 from 5,6 move L payoffs 0 0 +level 8 node 12 xshift 0.55 from 5,6 move R payoffs 1 1 + +% Battle of the Sexes +level 14 node 13 xshift -0.55 from 11,7 move L payoffs 2 1 +level 14 node 14 xshift 0.55 from 11,7 move R payoffs 0 0 +level 14 node 15 xshift -0.55 from 11,8 move L payoffs 0 0 +level 14 node 16 xshift 0.55 from 11,8 move R payoffs 1 2 + +% Player 1 information sets + +% Player 1 cannot distinguish MP from PD. +iset 2,1 2,2 player 1 + +% Player 1 cannot distinguish Coordination from Battle of the Sexes. +iset 3,3 3,4 player 1 + +% Player 2 information sets + +% Player 2 cannot distinguish MP from Coordination. +iset 5,1 5,2 5,5 5,6 player 2 + +% Player 2 cannot distinguish PD from Battle of the Sexes. +iset 11,3 11,4 11,7 11,8 player 2 diff --git a/catalog/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef b/catalog/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef new file mode 100644 index 000000000..9896620e8 --- /dev/null +++ b/catalog/journals/mor/vonstengelforges2008/fig1__Original_Layout.ef @@ -0,0 +1,57 @@ +% Extensive-form game transcribed from the provided figure. +% DrawTree .ef format. +% Layout is chosen to preserve the original geometry of the image: +% chance at the top, two Player 1 singleton information sets, +% crossed middle branches, and two horizontal Player 2 information sets. +% +% Player 0: Chance +% Player 1: 1 +% Player 2: 2 +% +% Chance chooses G or B with probability 1/2 each. +% Player 1 observes the chance outcome and chooses X or Y, with labels +% indexed by the realized chance outcome: X_G, Y_G, X_B, Y_B. +% Player 2 observes only whether the signal is X or Y, not the chance outcome. + +player 0 name Chance +player 1 name 1 +player 2 name 2 + +% Root chance node. +level 0 node n1 player 0 + +% Chance branches, arranged as in the figure. +level 2 node n2 xshift -2.8 from n1 move \frac{1}{2} player 1 +level 2 node n3 xshift 2.8 from n1 move \frac{1}{2} player 1 + +% Player 1 branches. +% Left Player 1 node: chance outcome G. +level 4 node n4 xshift -2.5 yshift -0.9 from n2 move X_G +level 4 node n5 xshift 3.9 yshift -0.9 from n2 move Y_G + +% Right Player 1 node: chance outcome B. +% The X_B and Y_G branches cross, matching the original layout. +level 4 node n6 xshift -3.9 yshift -0.9 from n3 move X_B +level 4 node n7 xshift 2.5 yshift -0.9 from n3 move Y_B + +% Player 2 information sets. +% X-signal information set: histories (G,X_G) and (B,X_B). +iset n4 n6 player 2 + +% Y-signal information set: histories (G,Y_G) and (B,Y_B). +iset n5 n7 player 2 + +% Player 2 actions and terminal payoffs, listed left-to-right as in the image. +% At the X-signal information set. +level 6 node n8 xshift -0.45 from n4 move l_X payoffs 4 10 +level 6 node n9 xshift 0.45 from n4 move r_X payoffs 0 6 + +level 6 node n10 xshift -0.45 from n6 move l_X payoffs 6 0 +level 6 node n11 xshift 0.45 from n6 move r_X payoffs 0 6 + +% At the Y-signal information set. +level 6 node n12 xshift -0.45 from n5 move l_Y payoffs 4 10 +level 6 node n13 xshift 0.45 from n5 move r_Y payoffs 0 6 + +level 6 node n14 xshift -0.45 from n7 move l_Y payoffs 6 0 +level 6 node n15 xshift 0.45 from n7 move r_Y payoffs 0 6 diff --git a/doc/references.bib b/doc/references.bib index f39c14834..eb28ae7e5 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -420,5 +420,6 @@ @book{ShoLeyB08 title={Multiagent systems: Algorithmic, game-theoretic, and logical foundations}, author={Shoham, Yoav and Leyton-Brown, Kevin}, year={2008}, - publisher={Cambridge University Press} + publisher={Cambridge University Press}, + category = {textbooks} } From 2e3e53f0ec684bcca394d716da3d4d4b865c9a2c Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Sat, 11 Jul 2026 12:41:52 +0100 Subject: [PATCH 17/32] Bump version and prepare ChangeLog for 16.7 (#988) --- ChangeLog | 5 ++++- build_support/GAMBIT_VERSION | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ChangeLog b/ChangeLog index db414c5a8..19965ca19 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,4 +1,4 @@ -## [16.7.0] - unreleased +## [16.7.0] - 2026-07-11 ### Added - Added `Player.sequences` as the collection of sequences available to a player. @@ -42,6 +42,9 @@ - In `pygambit`, indexing game object collections by integer position has been removed. (#942) - Validity of game object labels is enforced (printable ASCII and spaces only, no leading/trailing or double spaces); invalid labels raise `ValueError` in `pygambit`. (#944) +- Nonempty labels are now required for all players, outcomes, actions, and strategies. + Labels must be unique within the game for players and outcomes, within the player for + strategies, and within the information set for actions. - Refined and clarified the graphical interface's handling (and persisting) of "workspaces", and improved warning messages on closing windows for games or workspaces with unsaved changes. - In `pygambit`, the `.outcome`, `.player`, and `.infoset` attributes are now treated as predicates diff --git a/build_support/GAMBIT_VERSION b/build_support/GAMBIT_VERSION index bd015b903..2e4239c31 100644 --- a/build_support/GAMBIT_VERSION +++ b/build_support/GAMBIT_VERSION @@ -1 +1 @@ -16.6.0 +16.7.0 From a611f8b7b0459083b6fbe1b0ecb154881e4aa252 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Mon, 20 Jul 2026 17:18:03 -0400 Subject: [PATCH 18/32] Refactor control of long-running external processes in GUI (#993) This refactors and improves the control of long-running external processes. * Clarifies the difference between the monitor dialog and the actual controller of the external process * Makes the handling of the external process more robust * Moves parsing of external process output into the process handler rather than the analysis output. This is preparatory work which will enable the ability to run long-running processes as threads. --- Makefile.am | 1 - src/gui/analysis.cc | 74 +------ src/gui/analysis.h | 5 +- src/gui/dlnashmon.cc | 447 +++++++++++++++++++++++++++++++++---------- src/gui/dlnashmon.h | 56 ------ src/gui/gamedoc.cc | 6 +- src/gui/gamedoc.h | 2 +- src/gui/gameframe.cc | 9 +- 8 files changed, 362 insertions(+), 238 deletions(-) delete mode 100644 src/gui/dlnashmon.h diff --git a/Makefile.am b/Makefile.am index 573c3780b..20d84943f 100644 --- a/Makefile.am +++ b/Makefile.am @@ -498,7 +498,6 @@ gambit_SOURCES = \ src/gui/dlnash.cc \ src/gui/dlnash.h \ src/gui/dlnashmon.cc \ - src/gui/dlnashmon.h \ src/gui/dlnewtable.cc \ src/gui/dlnewtable.h \ src/gui/dlnfglogit.cc \ diff --git a/src/gui/analysis.cc b/src/gui/analysis.cc index 84f1d3a7f..6ccb6981b 100644 --- a/src/gui/analysis.cc +++ b/src/gui/analysis.cc @@ -25,7 +25,6 @@ #include #endif // WX_PRECOMP #include - #include "gambit.h" #include "core/tinyxml.h" // for XML parser for Load() @@ -37,77 +36,22 @@ namespace Gambit::GUI { // class AnalysisProfileList //========================================================================= -// Use anonymous namespace to make these helpers private -namespace { - -class NotNashException final : public std::runtime_error { -public: - NotNashException() : std::runtime_error("Output line does not contain a Nash equilibrium") {} - ~NotNashException() noexcept override = default; -}; - template -MixedStrategyProfile OutputToMixedProfile(GameDocument *p_doc, const wxString &p_text) +void AnalysisProfileList::AddProfile(const MixedStrategyProfile &p_profile) { - MixedStrategyProfile profile(p_doc->GetGame()->NewMixedStrategyProfile(static_cast(0.0))); - - if (wxStringTokenizer tok(p_text, wxT(",")); tok.GetNextToken() == wxT("NE")) { - if (tok.CountTokens() == static_cast(profile.MixedProfileLength())) { - for (size_t i = 1; i <= profile.MixedProfileLength(); i++) { - profile[i] = - lexical_cast(std::string((const char *)tok.GetNextToken().mb_str())); - } - return profile; - } + m_mixedProfiles.push_back(std::make_shared>(p_profile)); + if (m_doc->GetGame()->IsTree()) { + m_behavProfiles.push_back(std::make_shared>(p_profile)); } - - throw NotNashException(); + m_current = m_mixedProfiles.size(); } template -MixedBehaviorProfile OutputToBehavProfile(GameDocument *p_doc, const wxString &p_text) -{ - MixedBehaviorProfile profile(p_doc->GetGame()); - - wxStringTokenizer tok(p_text, wxT(",")); - - if (tok.GetNextToken() == wxT("NE")) { - if (tok.CountTokens() == static_cast(profile.BehaviorProfileLength())) { - for (size_t i = 1; i <= profile.BehaviorProfileLength(); i++) { - profile[i] = lexical_cast(std::string(tok.GetNextToken().mb_str())); - } - return profile; - } - } - - throw NotNashException(); -} - -} // end anonymous namespace - -template void AnalysisProfileList::AddOutput(const wxString &p_output) +void AnalysisProfileList::AddProfile(const MixedBehaviorProfile &p_profile) { - try { - if (m_isBehav) { - auto profile = - std::make_shared>(OutputToBehavProfile(m_doc, p_output)); - m_behavProfiles.push_back(profile); - m_mixedProfiles.push_back( - std::make_shared>(profile->ToMixedProfile())); - m_current = m_behavProfiles.size(); - } - else { - auto profile = - std::make_shared>(OutputToMixedProfile(m_doc, p_output)); - m_mixedProfiles.push_back(profile); - if (m_doc->GetGame()->IsTree()) { - m_behavProfiles.push_back(std::make_shared>(*profile)); - } - m_current = m_mixedProfiles.size(); - } - } - catch (NotNashException &) { - } + m_behavProfiles.push_back(std::make_shared>(p_profile)); + m_mixedProfiles.push_back(std::make_shared>(p_profile.ToMixedProfile())); + m_current = m_behavProfiles.size(); } template void AnalysisProfileList::BuildNfg() diff --git a/src/gui/analysis.h b/src/gui/analysis.h index 24a79e665..70a53107c 100644 --- a/src/gui/analysis.h +++ b/src/gui/analysis.h @@ -98,8 +98,6 @@ class AnalysisOutput { virtual std::string GetStrategyProb(int p_strategy, int p_index = -1) const = 0; virtual std::string GetStrategyValue(int p_strategy, int p_index = -1) const = 0; - virtual void AddOutput(const wxString &) = 0; - /// Map all behavior profiles to corresponding mixed profiles virtual void BuildNfg() = 0; @@ -169,7 +167,8 @@ template class AnalysisProfileList final : public AnalysisOutput { //! @name Adding profiles to the list //! //@{ - void AddOutput(const wxString &) override; + void AddProfile(const MixedStrategyProfile &); + void AddProfile(const MixedBehaviorProfile &); /// Map all behavior profiles to corresponding mixed profiles void BuildNfg() override; diff --git a/src/gui/dlnashmon.cc b/src/gui/dlnashmon.cc index 35e2e542a..b83951346 100644 --- a/src/gui/dlnashmon.cc +++ b/src/gui/dlnashmon.cc @@ -25,50 +25,308 @@ #include #endif // WX_PRECOMP #include +#include +#include +#include +#include +#include "wx/sheet/sheet.h" -#include "dlnashmon.h" #include "gamedoc.h" #include "efgprofile.h" #include "nfgprofile.h" -namespace Gambit::GUI { -constexpr int GBT_ID_TIMER = 1000; -constexpr int GBT_ID_PROCESS = 1001; +using namespace Gambit; +using namespace Gambit::GUI; + +namespace { -BEGIN_EVENT_TABLE(NashMonitorDialog, wxDialog) -EVT_END_PROCESS(GBT_ID_PROCESS, NashMonitorDialog::OnEndProcess) -EVT_IDLE(NashMonitorDialog::OnIdle) -EVT_TIMER(GBT_ID_TIMER, NashMonitorDialog::OnTimer) -END_EVENT_TABLE() +wxDECLARE_EVENT(wxEVT_EXTERNAL_RUNNER_PROFILE, wxThreadEvent); +wxDEFINE_EVENT(wxEVT_EXTERNAL_RUNNER_PROFILE, wxThreadEvent); + +wxDECLARE_EVENT(wxEVT_EXTERNAL_RUNNER_FINISHED, wxThreadEvent); +wxDEFINE_EVENT(wxEVT_EXTERNAL_RUNNER_FINISHED, wxThreadEvent); #include "bitmaps/stop.xpm" +using ComputedProfile = std::variant, MixedStrategyProfile, + MixedBehaviorProfile, MixedBehaviorProfile>; + +template +void AddProfile(AnalysisOutput &p_output, const MixedStrategyProfile &p_profile) +{ + dynamic_cast &>(p_output).AddProfile(p_profile); +} + +template +void AddProfile(AnalysisOutput &p_output, const MixedBehaviorProfile &p_profile) +{ + dynamic_cast &>(p_output).AddProfile(p_profile); +} + +class ExternalProcessRunner final : public wxEvtHandler { + wxEvtHandler *m_parent; + Game m_game; + const AnalysisOutput &m_output; + wxProcess *m_process{nullptr}; + long m_pid{0}; + wxTimer m_timer; + wxString m_pending; + + void OnTimer(wxTimerEvent &) + { + ReadAvailableOutput(); + if (m_process) { + m_timer.StartOnce(1000); + } + } + + void OnEndProcess(wxProcessEvent &p_event) + { + m_timer.Stop(); + ReadAvailableOutput(); + FlushPendingLine(); + + auto *evt = new wxThreadEvent(wxEVT_EXTERNAL_RUNNER_FINISHED); + evt->SetInt(p_event.GetExitCode()); + wxQueueEvent(m_parent, evt); + + delete m_process; + m_process = nullptr; + m_pid = 0; + } + + template std::optional ParseProfile(const wxString &p_line) const + { + wxStringTokenizer tokens(p_line, wxT(",")); + if (tokens.GetNextToken() != wxT("NE")) { + return std::nullopt; + } + + if (m_output.IsBehavior()) { + MixedBehaviorProfile profile(m_game); + if (tokens.CountTokens() != profile.BehaviorProfileLength()) { + return std::nullopt; + } + for (size_t i = 1; i <= profile.BehaviorProfileLength(); ++i) { + profile[i] = lexical_cast(std::string(tokens.GetNextToken().mb_str())); + } + return ComputedProfile(std::move(profile)); + } + else { + auto profile = m_game->NewMixedStrategyProfile(static_cast(0)); + if (tokens.CountTokens() != profile.MixedProfileLength()) { + return std::nullopt; + } + for (size_t i = 1; i <= profile.MixedProfileLength(); ++i) { + profile[i] = lexical_cast(std::string(tokens.GetNextToken().mb_str())); + } + return ComputedProfile(std::move(profile)); + } + } + + void ProcessLine(const wxString &p_line) const + { + try { + std::optional profile; + if (dynamic_cast *>(&m_output)) { + profile = ParseProfile(p_line); + } + else if (dynamic_cast *>(&m_output)) { + profile = ParseProfile(p_line); + } + if (!profile) { + return; + } + + auto *event = new wxThreadEvent(wxEVT_EXTERNAL_RUNNER_PROFILE); + event->SetPayload(std::move(*profile)); + wxQueueEvent(m_parent, event); + } + catch (const std::exception &) { + } + } + + void FlushPendingLine() + { + if (!m_pending.empty()) { + ProcessLine(m_pending); + m_pending.clear(); + } + } + +public: + enum class RunnerStartResult { Ok, LaunchFailed, NoOutputPipe, StdinWriteFailed }; + + ExternalProcessRunner(wxEvtHandler *p_parent, const Game &p_game, const AnalysisOutput &p_output) + : m_parent(p_parent), m_game(p_game), m_output(p_output), m_timer(this) + { + Bind(wxEVT_TIMER, &ExternalProcessRunner::OnTimer, this); + Bind(wxEVT_END_PROCESS, &ExternalProcessRunner::OnEndProcess, this); + } + + ~ExternalProcessRunner() override + { + m_timer.Stop(); + if (m_process) { + delete m_process; + } + } + + RunnerStartResult Start(const wxString &p_command, const wxString &p_stdin) + { + m_process = new wxProcess(this); + m_process->Redirect(); + m_pid = wxExecute(p_command, wxEXEC_ASYNC, m_process); + if (m_pid == 0) { + delete m_process; + m_process = nullptr; + return RunnerStartResult::LaunchFailed; + } + + const auto out = m_process->GetOutputStream(); + if (!out || !out->IsOk()) { + Stop(); + return RunnerStartResult::NoOutputPipe; + } + + const wxScopedCharBuffer bytes = p_stdin.utf8_str(); + const char *data = bytes.data(); + + if (const size_t len = std::strlen(data); !out->WriteAll(data, len)) { + Stop(); + return RunnerStartResult::StdinWriteFailed; + } + m_process->CloseOutput(); + m_timer.StartOnce(1000); + return RunnerStartResult::Ok; + } + + bool Stop() + { + m_timer.Stop(); + if (!m_process || m_pid == 0) { + return false; + } +#ifdef __WXMSW__ + constexpr wxSignal signal = wxSIGKILL; +#else + constexpr wxSignal signal = wxSIGTERM; +#endif + + switch (const auto rc = wxProcess::Kill(m_pid, signal)) { + case wxKILL_OK: + return true; + case wxKILL_NO_PROCESS: + m_pid = 0; + return false; + default: + return false; + } + } + + void ReadAvailableOutput() + { + if (!m_process || !m_process->IsInputAvailable()) { + return; + } + + wxInputStream *stream = m_process->GetInputStream(); + + while (m_process->IsInputAvailable()) { + char ch; + stream->Read(&ch, 1); + if (stream->LastRead() != 1) { + break; + } + + if (ch == '\n') { + ProcessLine(m_pending); + m_pending.clear(); + } + else if (ch != '\r') { + m_pending += ch; + } + } + } +}; + +class NashMonitorDialog final : public wxDialog { + GameDocument *m_doc; + std::unique_ptr m_runner; + wxWindow *m_profileList; + wxStaticText *m_statusText, *m_countText; + wxButton *m_stopButton, *m_okButton; + std::shared_ptr m_output; + bool m_stopRequested{false}; + + void Start(const std::shared_ptr &p_command); + + void OnClose(wxCloseEvent &); + void OnStop(wxCommandEvent &); + void OnRunnerProfile(wxThreadEvent &); + void OnRunnerFinished(wxThreadEvent &); + + void SetStatusRunning() const + { + m_statusText->SetLabel(wxT("The computation is currently in progress.")); + m_statusText->SetForegroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); + m_stopButton->Enable(true); + m_okButton->Enable(false); + } + void SetStatusStopping() const + { + m_statusText->SetLabel(wxT("Stopping computation...")); + m_statusText->SetForegroundColour(wxColour(196, 128, 0)); + m_stopButton->Enable(false); + } + void SetStatusStopped() const + { + m_statusText->SetLabel(wxT("The computation has been stopped.")); + m_statusText->SetForegroundColour(wxColour(196, 128, 0)); + m_okButton->Enable(true); + m_stopButton->Enable(false); + } + void SetStatusFinishedNormally() const + { + m_statusText->SetLabel(wxT("The computation has completed.")); + m_statusText->SetForegroundColour(wxColour(0, 192, 0)); + m_okButton->Enable(true); + m_stopButton->Enable(false); + } + void SetStatusFinishedAbnormally(const wxString &p_message) const + { + m_statusText->SetLabel(p_message); + m_statusText->SetForegroundColour(*wxRED); + m_okButton->Enable(true); + m_stopButton->Enable(false); + } + +public: + NashMonitorDialog(wxWindow *p_parent, GameDocument *p_doc, + const std::shared_ptr &p_command); +}; + NashMonitorDialog::NashMonitorDialog(wxWindow *p_parent, GameDocument *p_doc, const std::shared_ptr &p_command) : wxDialog(p_parent, wxID_ANY, wxT("Computing Nash equilibria"), wxDefaultPosition), - m_doc(p_doc), m_process(nullptr), m_timer(this, GBT_ID_TIMER), m_output(p_command) + m_doc(p_doc), m_output(p_command) { auto *sizer = new wxBoxSizer(wxVERTICAL); auto *startSizer = new wxBoxSizer(wxHORIZONTAL); - m_statusText = - new wxStaticText(this, wxID_STATIC, wxT("The computation is currently in progress.")); - m_statusText->SetForegroundColour(*wxBLUE); + m_statusText = new wxStaticText(this, wxID_STATIC, "The computation is currently in progress."); startSizer->Add(m_statusText, 0, wxALL | wxALIGN_CENTER, 5); m_countText = new wxStaticText(this, wxID_STATIC, wxT("Number of equilibria found so far: 0 ")); startSizer->Add(m_countText, 0, wxALL | wxALIGN_CENTER, 5); - m_stopButton = new wxBitmapButton(this, wxID_CANCEL, wxBitmap(stop_xpm)); + m_stopButton = new wxBitmapButton(this, wxID_ANY, wxBitmap(stop_xpm)); m_stopButton->Enable(false); m_stopButton->SetToolTip(_("Stop the computation")); startSizer->Add(m_stopButton, 0, wxALL | wxALIGN_CENTER, 5); - Connect(wxID_CANCEL, wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(NashMonitorDialog::OnStop)); - sizer->Add(startSizer, 0, wxALL | wxALIGN_CENTER, 5); if (p_command->IsBehavior()) { @@ -77,23 +335,27 @@ NashMonitorDialog::NashMonitorDialog(wxWindow *p_parent, GameDocument *p_doc, else { m_profileList = new MixedStrategyProfileList(this, m_doc); } - m_profileList->SetSizeHints(wxSize(500, 300)); + m_profileList->SetMinSize(wxSize(500, 300)); sizer->Add(m_profileList, 1, wxALL | wxEXPAND, 5); - m_okButton = new wxButton(this, wxID_OK, wxT("OK")); - sizer->Add(m_okButton, 0, wxALL | wxALIGN_RIGHT, 5); + auto *buttonSizer = CreateStdDialogButtonSizer(wxOK); + sizer->Add(buttonSizer, 0, wxALL | wxEXPAND, 5); + m_okButton = dynamic_cast(FindWindow(wxID_OK)); m_okButton->Enable(false); - SetSizer(sizer); - sizer->Fit(this); + SetSizerAndFit(sizer); sizer->SetSizeHints(this); - wxTopLevelWindowBase::Layout(); CenterOnParent(); + Bind(wxEVT_EXTERNAL_RUNNER_PROFILE, &NashMonitorDialog::OnRunnerProfile, this); + Bind(wxEVT_EXTERNAL_RUNNER_FINISHED, &NashMonitorDialog::OnRunnerFinished, this); + m_stopButton->Bind(wxEVT_BUTTON, &NashMonitorDialog::OnStop, this); + Bind(wxEVT_CLOSE_WINDOW, &NashMonitorDialog::OnClose, this); + Start(p_command); } -void NashMonitorDialog::Start(std::shared_ptr p_command) +void NashMonitorDialog::Start(const std::shared_ptr &p_command) { if (!p_command->IsBehavior()) { // Make sure we have a normal form representation @@ -102,11 +364,6 @@ void NashMonitorDialog::Start(std::shared_ptr p_command) m_doc->DoAddEquilibriumOutput(p_command); - m_process = new wxProcess(this, GBT_ID_PROCESS); - m_process->Redirect(); - - m_pid = wxExecute(p_command->GetCommand(), wxEXEC_ASYNC, m_process); - std::ostringstream s; if (p_command->IsBehavior()) { m_doc->GetGame()->Write(s, "efg"); @@ -114,100 +371,86 @@ void NashMonitorDialog::Start(std::shared_ptr p_command) else { m_doc->GetGame()->Write(s, "nfg"); } - wxString str(wxString(s.str().c_str(), *wxConvCurrent)); - - // It is possible that the whole string won't write on one go, so - // we should take this possibility into account. If this doesn't - // complete the whole way, we take a 100-millisecond siesta and try - // again. (This seems to primarily be an issue with -- you guessed it -- - // Windows!) - while (str.length() > 0) { - wxTextOutputStream os(*m_process->GetOutputStream()); - // It appears that (at least with mingw) the string itself contains - // only '\n' for newlines. If we don't SetMode here, these get - // converted to '\r\n' sequences, and so the number of characters - // LastWrite() returns does not match the number of characters in - // our string. Setting this explicitly solves this problem. - os.SetMode(wxEOL_UNIX); - os.WriteString(str); - str.Remove(0, m_process->GetOutputStream()->LastWrite()); - wxMilliSleep(100); + m_runner = std::make_unique(this, m_doc->GetGame(), *m_output); + switch (const auto result = m_runner->Start(p_command->GetCommand(), + wxString(s.str().c_str(), *wxConvCurrent))) { + case ExternalProcessRunner::RunnerStartResult::Ok: + SetStatusRunning(); + break; + case ExternalProcessRunner::RunnerStartResult::LaunchFailed: + SetStatusFinishedAbnormally("Failed to launch solver."); + break; + case ExternalProcessRunner::RunnerStartResult::NoOutputPipe: + SetStatusFinishedAbnormally("Solver launched, but I/O redirection failed."); + break; + case ExternalProcessRunner::RunnerStartResult::StdinWriteFailed: + SetStatusFinishedAbnormally("Failed to send input to solver."); + break; } - m_process->CloseOutput(); - - m_stopButton->Enable(true); - - m_timer.Start(1000, false); } -void NashMonitorDialog::OnIdle(wxIdleEvent &p_event) +void NashMonitorDialog::OnRunnerProfile(wxThreadEvent &p_event) { - if (!m_process) { - return; - } - - if (m_process->IsInputAvailable()) { - wxTextInputStream tis(*m_process->GetInputStream()); + const auto &profile = p_event.GetPayload(); + std::visit([this](const auto &p) { AddProfile(*m_output, p); }, profile); + m_doc->DoAnalysisOutputChanged(); + wxString label; + label << wxT("Number of equilibria found so far: ") << m_output->NumProfiles(); + m_countText->SetLabel(label); +} - wxString msg; - msg << tis.ReadLine(); +void NashMonitorDialog::OnRunnerFinished(wxThreadEvent &p_event) +{ + m_stopButton->Enable(false); - m_doc->DoAddOutput(*m_output, msg); - wxString label; - label << wxT("Number of equilibria found so far: ") << m_output->NumProfiles(); - m_countText->SetLabel(label); - p_event.RequestMore(); + if (m_stopRequested) { + SetStatusStopped(); + } + else if (p_event.GetInt() == 0) { + SetStatusFinishedNormally(); } else { - m_timer.Start(1000, false); + SetStatusFinishedAbnormally( + wxString::Format("The computation ended abnormally (code %d)", p_event.GetInt())); } } -void NashMonitorDialog::OnTimer(wxTimerEvent &p_event) { wxWakeUpIdle(); } - -void NashMonitorDialog::OnEndProcess(wxProcessEvent &p_event) +void NashMonitorDialog::OnStop(wxCommandEvent &) { - m_stopButton->Enable(false); - m_timer.Stop(); - - while (m_process->IsInputAvailable()) { - wxTextInputStream tis(*m_process->GetInputStream()); - - wxString msg; - msg << tis.ReadLine(); + m_stopRequested = true; + SetStatusStopping(); + m_runner->Stop(); +} - if (!msg.empty()) { - m_doc->DoAddOutput(*m_output, msg); - wxString label; - label << wxT("Number of equilibria found so far: ") << m_output->NumProfiles(); - m_countText->SetLabel(label); - } +void NashMonitorDialog::OnClose(wxCloseEvent &p_event) +{ + if (!m_runner || !m_stopButton->IsEnabled()) { + p_event.Skip(); + return; } - if (p_event.GetExitCode() == 0) { - m_statusText->SetLabel(wxT("The computation has completed.")); - m_statusText->SetForegroundColour(wxColour(0, 192, 0)); + m_stopRequested = true; + SetStatusStopping(); + m_runner->Stop(); + + if (p_event.CanVeto()) { + p_event.Veto(); } else { - m_statusText->SetLabel(wxT("The computation ended abnormally.")); - m_statusText->SetForegroundColour(*wxRED); + p_event.Skip(); } - - m_okButton->Enable(true); } -void NashMonitorDialog::OnStop(wxCommandEvent &p_event) -{ - // Per the wxWidgets wiki, under Windows, programs that run - // without a console window don't respond to the more polite - // SIGTERM, so instead we must be rude and SIGKILL it. - m_stopButton->Enable(false); +} // anonymous namespace -#ifdef __WXMSW__ - wxProcess::Kill(m_pid, wxSIGKILL); -#else - wxProcess::Kill(m_pid, wxSIGTERM); -#endif // __WXMSW__ +namespace Gambit::GUI { + +void ShowNashMonitorDialog(wxWindow *p_parent, GameDocument *p_doc, + const std::shared_ptr &p_command) +{ + NashMonitorDialog dialog(p_parent, p_doc, p_command); + dialog.ShowModal(); } + } // namespace Gambit::GUI diff --git a/src/gui/dlnashmon.h b/src/gui/dlnashmon.h deleted file mode 100644 index 336926cdd..000000000 --- a/src/gui/dlnashmon.h +++ /dev/null @@ -1,56 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/gui/dlnashmon.h -// Dialog for monitoring Nash equilibrium computation progress -// -// 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_DLNASHMON_H -#define GAMBIT_GUI_DLNASHMON_H - -#include -#include "wx/sheet/sheet.h" -#include "gamedoc.h" - -namespace Gambit::GUI { -class NashMonitorDialog final : public wxDialog { - GameDocument *m_doc; - int m_pid{0}; - wxProcess *m_process; - wxWindow *m_profileList; - wxStaticText *m_statusText, *m_countText; - wxButton *m_stopButton, *m_okButton; - wxTimer m_timer; - std::shared_ptr m_output; - - void Start(std::shared_ptr p_command); - - void OnStop(wxCommandEvent &); - void OnTimer(wxTimerEvent &); - void OnIdle(wxIdleEvent &); - void OnEndProcess(wxProcessEvent &); - -public: - NashMonitorDialog(wxWindow *p_parent, GameDocument *p_doc, - const std::shared_ptr &p_command); - - DECLARE_EVENT_TABLE() -}; -} // namespace Gambit::GUI - -#endif // GAMBIT_GUI_DLNASHMON_H diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index 401072d27..e4eb781ea 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -397,11 +397,7 @@ void GameDocument::DoAddEquilibriumOutput(std::shared_ptr p_prof NotifyChanged(GameModificationType::Workspace); } -void GameDocument::DoAddOutput(AnalysisOutput &p_list, const wxString &p_output) -{ - p_list.AddOutput(p_output); - NotifyChanged(GameModificationType::Workspace); -} +void GameDocument::DoAnalysisOutputChanged() { NotifyChanged(GameModificationType::Workspace); } void GameDocument::DoSelectEquilibriumOutput(int p_index) { diff --git a/src/gui/gamedoc.h b/src/gui/gamedoc.h index b9dbb1dd2..164128b67 100644 --- a/src/gui/gamedoc.h +++ b/src/gui/gamedoc.h @@ -326,7 +326,7 @@ class GameDocument { void DoCopyOutcome(GameNode p_node, GameOutcome p_outcome); void DoSetPayoff(GameOutcome p_outcome, int p_player, const wxString &p_value); - void DoAddOutput(AnalysisOutput &p_list, const wxString &p_output); + void DoAnalysisOutputChanged(); }; inline GameDocument *NewTreeDocument() diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index afbf2f1d7..413ae6a17 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -52,7 +52,6 @@ #include "dlexcept.h" #include "dlgameprop.h" #include "dlnash.h" -#include "dlnashmon.h" #include "dlefglogit.h" #include "dlabout.h" @@ -1228,6 +1227,9 @@ void GameFrame::OnToolsDominance(wxCommandEvent &p_event) } } +extern void ShowNashMonitorDialog(wxWindow *p_parent, GameDocument *p_doc, + const std::shared_ptr &p_command); + void GameFrame::OnToolsEquilibrium(wxCommandEvent &) { if (!m_doc->GetGame()->IsPerfectRecall()) { @@ -1256,10 +1258,7 @@ void GameFrame::OnToolsEquilibrium(wxCommandEvent &) } } - auto command = dialog.GetCommand(); - - NashMonitorDialog monitordialog(this, m_doc, command); - monitordialog.ShowModal(); + ShowNashMonitorDialog(this, m_doc, dialog.GetCommand()); if (!m_splitter->IsSplit()) { if (m_efgPanel && m_efgPanel->IsShown()) { From 6b8020d4fdb525a8ba64523bba0e3a789a138613 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Mon, 20 Jul 2026 17:41:17 -0400 Subject: [PATCH 19/32] Create bespoke XML read/write for legacy file format. (#995) This implements a minimal, pseudo-XML parser targeted specifically at the de-facto file format written as the "Gambit workspace" .gbt file format. All reading and writing is consolidated with a data interface model that does not depend on wxWidgets. This allows the removal of tinyxml as a dependency being carried around in the source. Closes #897. --- AUTHORS | 7 - ChangeLog | 7 + Makefile.am | 8 +- src/core/tinyxml.cc | 1559 ------------------------------------ src/core/tinyxml.h | 1602 ------------------------------------- src/core/tinyxmlerror.cc | 51 -- src/core/tinyxmlparser.cc | 1511 ---------------------------------- src/games/file.cc | 66 +- src/games/workspace.cc | 556 +++++++++++++ src/games/workspace.h | 97 +++ src/gui/analysis.cc | 67 +- src/gui/analysis.h | 11 +- src/gui/gamedoc.cc | 174 ++-- src/gui/gamedoc.h | 6 +- src/gui/style.cc | 389 ++------- src/gui/style.h | 27 +- tests/test_io.py | 34 + 17 files changed, 875 insertions(+), 5297 deletions(-) delete mode 100644 src/core/tinyxml.cc delete mode 100644 src/core/tinyxml.h delete mode 100644 src/core/tinyxmlerror.cc delete mode 100644 src/core/tinyxmlparser.cc create mode 100644 src/games/workspace.cc create mode 100644 src/games/workspace.h diff --git a/AUTHORS b/AUTHORS index dd2de970e..89d048503 100644 --- a/AUTHORS +++ b/AUTHORS @@ -57,10 +57,3 @@ wxWidgets, which are Copyright (C) 2004-5 by John Labenski, and distributed under the wxWidgets license. The version included here is the version dated 20 July 2005; see http://wxcode.sourceforge.net for details on these classes. - -TinyXML parser: ---------------- -The graphical interface reads and writes files in XML format. The parsing -is done using the TinyXml package from http://www.grinninglizard.com/tinyxml, -and is distributed under the zlib license. These are the files named -tiny* in the sources/gui directory of the distribution. diff --git a/ChangeLog b/ChangeLog index 19965ca19..bce4d092d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,10 @@ +## [17.0.0] - unreleased + +### Changed +- Implemented bespoke XML parser that handles the subset in the de-facto legacy XML workbook .gbt format; + removes dependency on tinyxml. (#897) + + ## [16.7.0] - 2026-07-11 ### Added diff --git a/Makefile.am b/Makefile.am index 20d84943f..7f6d2d87b 100644 --- a/Makefile.am +++ b/Makefile.am @@ -223,11 +223,7 @@ core_SOURCES = \ src/core/rational.h \ src/core/matrix.cc \ src/core/function.cc \ - src/core/function.h \ - src/core/tinyxml.cc \ - src/core/tinyxmlerror.cc \ - src/core/tinyxmlparser.cc \ - src/core/tinyxml.h + src/core/function.h agg_SOURCES = \ src/games/gameagg.cc \ @@ -270,6 +266,8 @@ game_SOURCES = \ src/games/stratpure.h \ src/games/stratmixed.h \ src/games/file.cc \ + src/games/workspace.cc \ + src/games/workspace.h \ src/games/writer.cc \ src/games/writer.h \ src/games/layout.cc \ diff --git a/src/core/tinyxml.cc b/src/core/tinyxml.cc deleted file mode 100644 index 2f4e58d5f..000000000 --- a/src/core/tinyxml.cc +++ /dev/null @@ -1,1559 +0,0 @@ -/* -www.sourceforge.net/projects/tinyxml -Original code (2.0 and earlier )copyright (c) 2000-2002 Lee Thomason (www.grinninglizard.com) - -This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any -damages arising from the use of this software. - -Permission is granted to anyone to use this software for any -purpose, including commercial applications, and to alter it and -redistribute it freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must -not claim that you wrote the original software. If you use this -software in a product, an acknowledgment in the product documentation -would be appreciated but is not required. - -2. Altered source versions must be plainly marked as such, and -must not be misrepresented as being the original software. - -3. This notice may not be removed or altered from any source -distribution. -*/ - -#include "tinyxml.h" - -#ifdef TIXML_USE_STL -#include -#endif - -bool TiXmlBase::condenseWhiteSpace = true; - -void TiXmlBase::PutString(const TIXML_STRING &str, TIXML_OSTREAM *stream) -{ - TIXML_STRING buffer; - PutString(str, &buffer); - (*stream) << buffer; -} - -void TiXmlBase::PutString(const TIXML_STRING &str, TIXML_STRING *outString) -{ - int i = 0; - - while (i < (int)str.length()) { - auto c = (unsigned char)str[i]; - - if (c == '&' && i < ((int)str.length() - 2) && str[i + 1] == '#' && str[i + 2] == 'x') { - // Hexadecimal character reference. - // Pass through unchanged. - // © -- copyright symbol, for example. - // - // The -1 is a bug fix from Rob Laveaux. It keeps - // an overflow from happening if there is no ';'. - // There are actually 2 ways to exit this loop - - // while fails (error case) and break (semicolon found). - // However, there is no mechanism (currently) for - // this function to return an error. - while (i < (int)str.length() - 1) { - outString->append(str.c_str() + i, 1); - ++i; - if (str[i] == ';') { - break; - } - } - } - else if (c == '&') { - outString->append(entity[0].str, entity[0].strLength); - ++i; - } - else if (c == '<') { - outString->append(entity[1].str, entity[1].strLength); - ++i; - } - else if (c == '>') { - outString->append(entity[2].str, entity[2].strLength); - ++i; - } - else if (c == '\"') { - outString->append(entity[3].str, entity[3].strLength); - ++i; - } - else if (c == '\'') { - outString->append(entity[4].str, entity[4].strLength); - ++i; - } - else if (c < 32) { - // Easy pass at non-alpha/numeric/symbol - // Below 32 is symbolic. - char buf[32]; - -#if defined(TIXML_SNPRINTF) - TIXML_SNPRINTF(buf, sizeof(buf), "&#x%02X;", (unsigned)(c & 0xff)); -#else - sprintf(buf, "&#x%02X;", (unsigned)(c & 0xff)); -#endif - - //*ME: warning C4267: convert 'size_t' to 'int' - //*ME: Int-Cast to make compiler happy ... - outString->append(buf, (int)strlen(buf)); - ++i; - } - else { - // char realc = (char) c; - // outString->append( &realc, 1 ); - *outString += (char)c; // somewhat more efficient function call. - ++i; - } - } -} - -// <-- Strange class for a bug fix. Search for STL_STRING_BUG -TiXmlBase::StringToBuffer::StringToBuffer(const TIXML_STRING &str) - : buffer(new char[str.length() + 1]) -{ - if (buffer) { - strcpy(buffer, str.c_str()); - } -} - -TiXmlBase::StringToBuffer::~StringToBuffer() { delete[] buffer; } -// End strange bug fix. --> - -TiXmlNode::~TiXmlNode() -{ - const TiXmlNode *node = firstChild; - const TiXmlNode *temp = nullptr; - - while (node) { - temp = node; - node = node->next; - delete temp; - } -} - -void TiXmlNode::CopyTo(TiXmlNode *target) const -{ - target->SetValue(value.c_str()); - target->userData = userData; -} - -void TiXmlNode::Clear() -{ - const TiXmlNode *node = firstChild; - const TiXmlNode *temp = nullptr; - - while (node) { - temp = node; - node = node->next; - delete temp; - } - - firstChild = nullptr; - lastChild = nullptr; -} - -TiXmlNode *TiXmlNode::LinkEndChild(TiXmlNode *node) -{ - node->parent = this; - - node->prev = lastChild; - node->next = nullptr; - - if (lastChild) { - lastChild->next = node; - } - else { - firstChild = node; // it was an empty list. - } - - lastChild = node; - return node; -} - -TiXmlNode *TiXmlNode::InsertEndChild(const TiXmlNode &addThis) -{ - TiXmlNode *node = addThis.Clone(); - if (!node) { - return nullptr; - } - - return LinkEndChild(node); -} - -TiXmlNode *TiXmlNode::InsertBeforeChild(TiXmlNode *beforeThis, const TiXmlNode &addThis) -{ - if (!beforeThis || beforeThis->parent != this) { - return nullptr; - } - - TiXmlNode *node = addThis.Clone(); - if (!node) { - return nullptr; - } - node->parent = this; - - node->next = beforeThis; - node->prev = beforeThis->prev; - if (beforeThis->prev) { - beforeThis->prev->next = node; - } - else { - assert(firstChild == beforeThis); - firstChild = node; - } - beforeThis->prev = node; - return node; -} - -TiXmlNode *TiXmlNode::InsertAfterChild(TiXmlNode *afterThis, const TiXmlNode &addThis) -{ - if (!afterThis || afterThis->parent != this) { - return nullptr; - } - - TiXmlNode *node = addThis.Clone(); - if (!node) { - return nullptr; - } - node->parent = this; - - node->prev = afterThis; - node->next = afterThis->next; - if (afterThis->next) { - afterThis->next->prev = node; - } - else { - assert(lastChild == afterThis); - lastChild = node; - } - afterThis->next = node; - return node; -} - -TiXmlNode *TiXmlNode::ReplaceChild(TiXmlNode *replaceThis, const TiXmlNode &withThis) -{ - if (replaceThis->parent != this) { - return nullptr; - } - - TiXmlNode *node = withThis.Clone(); - if (!node) { - return nullptr; - } - - node->next = replaceThis->next; - node->prev = replaceThis->prev; - - if (replaceThis->next) { - replaceThis->next->prev = node; - } - else { - lastChild = node; - } - - if (replaceThis->prev) { - replaceThis->prev->next = node; - } - else { - firstChild = node; - } - - delete replaceThis; - node->parent = this; - return node; -} - -bool TiXmlNode::RemoveChild(TiXmlNode *removeThis) -{ - if (removeThis->parent != this) { - assert(0); - return false; - } - - if (removeThis->next) { - removeThis->next->prev = removeThis->prev; - } - else { - lastChild = removeThis->prev; - } - - if (removeThis->prev) { - removeThis->prev->next = removeThis->next; - } - else { - firstChild = removeThis->next; - } - - delete removeThis; - return true; -} - -const TiXmlNode *TiXmlNode::FirstChild(const char *_value) const -{ - const TiXmlNode *node; - for (node = firstChild; node; node = node->next) { - if (strcmp(node->Value(), _value) == 0) { - return node; - } - } - return nullptr; -} - -TiXmlNode *TiXmlNode::FirstChild(const char *_value) -{ - TiXmlNode *node; - for (node = firstChild; node; node = node->next) { - if (strcmp(node->Value(), _value) == 0) { - return node; - } - } - return nullptr; -} - -const TiXmlNode *TiXmlNode::LastChild(const char *_value) const -{ - const TiXmlNode *node; - for (node = lastChild; node; node = node->prev) { - if (strcmp(node->Value(), _value) == 0) { - return node; - } - } - return nullptr; -} - -TiXmlNode *TiXmlNode::LastChild(const char *_value) -{ - TiXmlNode *node; - for (node = lastChild; node; node = node->prev) { - if (strcmp(node->Value(), _value) == 0) { - return node; - } - } - return nullptr; -} - -const TiXmlNode *TiXmlNode::IterateChildren(const TiXmlNode *previous) const -{ - if (!previous) { - return FirstChild(); - } - else { - assert(previous->parent == this); - return previous->NextSibling(); - } -} - -TiXmlNode *TiXmlNode::IterateChildren(TiXmlNode *previous) -{ - if (!previous) { - return FirstChild(); - } - else { - assert(previous->parent == this); - return previous->NextSibling(); - } -} - -const TiXmlNode *TiXmlNode::IterateChildren(const char *val, const TiXmlNode *previous) const -{ - if (!previous) { - return FirstChild(val); - } - else { - assert(previous->parent == this); - return previous->NextSibling(val); - } -} - -TiXmlNode *TiXmlNode::IterateChildren(const char *val, TiXmlNode *previous) -{ - if (!previous) { - return FirstChild(val); - } - else { - assert(previous->parent == this); - return previous->NextSibling(val); - } -} - -const TiXmlNode *TiXmlNode::NextSibling(const char *_value) const -{ - const TiXmlNode *node; - for (node = next; node; node = node->next) { - if (strcmp(node->Value(), _value) == 0) { - return node; - } - } - return nullptr; -} - -TiXmlNode *TiXmlNode::NextSibling(const char *_value) -{ - TiXmlNode *node; - for (node = next; node; node = node->next) { - if (strcmp(node->Value(), _value) == 0) { - return node; - } - } - return nullptr; -} - -const TiXmlNode *TiXmlNode::PreviousSibling(const char *_value) const -{ - const TiXmlNode *node; - for (node = prev; node; node = node->prev) { - if (strcmp(node->Value(), _value) == 0) { - return node; - } - } - return nullptr; -} - -TiXmlNode *TiXmlNode::PreviousSibling(const char *_value) -{ - TiXmlNode *node; - for (node = prev; node; node = node->prev) { - if (strcmp(node->Value(), _value) == 0) { - return node; - } - } - return nullptr; -} - -void TiXmlElement::RemoveAttribute(const char *name) -{ - TiXmlAttribute *node = attributeSet.Find(name); - if (node) { - attributeSet.Remove(node); - delete node; - } -} - -const TiXmlElement *TiXmlNode::FirstChildElement() const -{ - const TiXmlNode *node; - - for (node = FirstChild(); node; node = node->NextSibling()) { - if (node->ToElement()) { - return node->ToElement(); - } - } - return nullptr; -} - -TiXmlElement *TiXmlNode::FirstChildElement() -{ - TiXmlNode *node; - - for (node = FirstChild(); node; node = node->NextSibling()) { - if (node->ToElement()) { - return node->ToElement(); - } - } - return nullptr; -} - -const TiXmlElement *TiXmlNode::FirstChildElement(const char *_value) const -{ - const TiXmlNode *node; - - for (node = FirstChild(_value); node; node = node->NextSibling(_value)) { - if (node->ToElement()) { - return node->ToElement(); - } - } - return nullptr; -} - -TiXmlElement *TiXmlNode::FirstChildElement(const char *_value) -{ - TiXmlNode *node; - - for (node = FirstChild(_value); node; node = node->NextSibling(_value)) { - if (node->ToElement()) { - return node->ToElement(); - } - } - return nullptr; -} - -const TiXmlElement *TiXmlNode::NextSiblingElement() const -{ - const TiXmlNode *node; - - for (node = NextSibling(); node; node = node->NextSibling()) { - if (node->ToElement()) { - return node->ToElement(); - } - } - return nullptr; -} - -TiXmlElement *TiXmlNode::NextSiblingElement() -{ - TiXmlNode *node; - - for (node = NextSibling(); node; node = node->NextSibling()) { - if (node->ToElement()) { - return node->ToElement(); - } - } - return nullptr; -} - -const TiXmlElement *TiXmlNode::NextSiblingElement(const char *_value) const -{ - const TiXmlNode *node; - - for (node = NextSibling(_value); node; node = node->NextSibling(_value)) { - if (node->ToElement()) { - return node->ToElement(); - } - } - return nullptr; -} - -TiXmlElement *TiXmlNode::NextSiblingElement(const char *_value) -{ - TiXmlNode *node; - - for (node = NextSibling(_value); node; node = node->NextSibling(_value)) { - if (node->ToElement()) { - return node->ToElement(); - } - } - return nullptr; -} - -const TiXmlDocument *TiXmlNode::GetDocument() const -{ - const TiXmlNode *node; - - for (node = this; node; node = node->parent) { - if (node->ToDocument()) { - return node->ToDocument(); - } - } - return nullptr; -} - -TiXmlDocument *TiXmlNode::GetDocument() -{ - TiXmlNode *node; - - for (node = this; node; node = node->parent) { - if (node->ToDocument()) { - return node->ToDocument(); - } - } - return nullptr; -} - -TiXmlElement::TiXmlElement(const char *_value) : TiXmlNode(TiXmlNode::ELEMENT) -{ - firstChild = lastChild = nullptr; - value = _value; -} - -#ifdef TIXML_USE_STL -TiXmlElement::TiXmlElement(const std::string &_value) : TiXmlNode(TiXmlNode::ELEMENT) -{ - firstChild = lastChild = nullptr; - value = _value; -} -#endif - -TiXmlElement::TiXmlElement(const TiXmlElement ©) : TiXmlNode(TiXmlNode::ELEMENT) -{ - firstChild = lastChild = nullptr; - copy.CopyTo(this); -} - -void TiXmlElement::operator=(const TiXmlElement &base) -{ - ClearThis(); - base.CopyTo(this); -} - -TiXmlElement::~TiXmlElement() { ClearThis(); } - -void TiXmlElement::ClearThis() -{ - Clear(); - while (attributeSet.First()) { - TiXmlAttribute *node = attributeSet.First(); - attributeSet.Remove(node); - delete node; - } -} - -const char *TiXmlElement::Attribute(const char *name) const -{ - const TiXmlAttribute *node = attributeSet.Find(name); - - if (node) { - return node->Value(); - } - - return nullptr; -} - -const char *TiXmlElement::Attribute(const char *name, int *i) const -{ - const char *s = Attribute(name); - if (i) { - if (s) { - *i = atoi(s); - } - else { - *i = 0; - } - } - return s; -} - -const char *TiXmlElement::Attribute(const char *name, double *d) const -{ - const char *s = Attribute(name); - if (d) { - if (s) { - *d = atof(s); - } - else { - *d = 0; - } - } - return s; -} - -int TiXmlElement::QueryIntAttribute(const char *name, int *ival) const -{ - const TiXmlAttribute *node = attributeSet.Find(name); - if (!node) { - return TIXML_NO_ATTRIBUTE; - } - - return node->QueryIntValue(ival); -} - -int TiXmlElement::QueryDoubleAttribute(const char *name, double *dval) const -{ - const TiXmlAttribute *node = attributeSet.Find(name); - if (!node) { - return TIXML_NO_ATTRIBUTE; - } - - return node->QueryDoubleValue(dval); -} - -void TiXmlElement::SetAttribute(const char *name, int val) -{ - char buf[64]; -#if defined(TIXML_SNPRINTF) - TIXML_SNPRINTF(buf, sizeof(buf), "%d", val); -#else - sprintf(buf, "%d", val); -#endif - SetAttribute(name, buf); -} - -void TiXmlElement::SetDoubleAttribute(const char *name, double val) -{ - char buf[256]; -#if defined(TIXML_SNPRINTF) - TIXML_SNPRINTF(buf, sizeof(buf), "%f", val); -#else - sprintf(buf, "%f", val); -#endif - SetAttribute(name, buf); -} - -void TiXmlElement::SetAttribute(const char *name, const char *_value) -{ - TiXmlAttribute *node = attributeSet.Find(name); - if (node) { - node->SetValue(_value); - return; - } - - auto *attrib = new TiXmlAttribute(name, _value); - if (attrib) { - attributeSet.Add(attrib); - } - else { - TiXmlDocument *document = GetDocument(); - if (document) { - document->SetError(TIXML_ERROR_OUT_OF_MEMORY, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - } - } -} - -void TiXmlElement::Print(FILE *cfile, int depth) const -{ - int i; - for (i = 0; i < depth; i++) { - fprintf(cfile, " "); - } - - fprintf(cfile, "<%s", value.c_str()); - - const TiXmlAttribute *attrib; - for (attrib = attributeSet.First(); attrib; attrib = attrib->Next()) { - fprintf(cfile, " "); - attrib->Print(cfile, depth); - } - - // There are 3 different formatting approaches: - // 1) An element without children is printed as a node - // 2) An element with only a text child is printed as text - // 3) An element with children is printed on multiple lines. - TiXmlNode *node; - if (!firstChild) { - fprintf(cfile, " />"); - } - else if (firstChild == lastChild && firstChild->ToText()) { - fprintf(cfile, ">"); - firstChild->Print(cfile, depth + 1); - fprintf(cfile, "", value.c_str()); - } - else { - fprintf(cfile, ">"); - - for (node = firstChild; node; node = node->NextSibling()) { - if (!node->ToText()) { - fprintf(cfile, "\n"); - } - node->Print(cfile, depth + 1); - } - fprintf(cfile, "\n"); - for (i = 0; i < depth; ++i) { - fprintf(cfile, " "); - } - fprintf(cfile, "", value.c_str()); - } -} - -void TiXmlElement::StreamOut(TIXML_OSTREAM *stream) const -{ - (*stream) << "<" << value; - - const TiXmlAttribute *attrib; - for (attrib = attributeSet.First(); attrib; attrib = attrib->Next()) { - (*stream) << " "; - attrib->StreamOut(stream); - } - - // If this node has children, give it a closing tag. Else - // make it an empty tag. - TiXmlNode *node; - if (firstChild) { - (*stream) << ">"; - - for (node = firstChild; node; node = node->NextSibling()) { - node->StreamOut(stream); - } - (*stream) << ""; - } - else { - (*stream) << " />"; - } -} - -void TiXmlElement::CopyTo(TiXmlElement *target) const -{ - // superclass: - TiXmlNode::CopyTo(target); - - // Element class: - // Clone the attributes, then clone the children. - const TiXmlAttribute *attribute = nullptr; - for (attribute = attributeSet.First(); attribute; attribute = attribute->Next()) { - target->SetAttribute(attribute->Name(), attribute->Value()); - } - - TiXmlNode *node = nullptr; - for (node = firstChild; node; node = node->NextSibling()) { - target->LinkEndChild(node->Clone()); - } -} - -TiXmlNode *TiXmlElement::Clone() const -{ - auto *clone = new TiXmlElement(Value()); - if (!clone) { - return nullptr; - } - - CopyTo(clone); - return clone; -} - -const char *TiXmlElement::GetText() const -{ - const TiXmlNode *child = this->FirstChild(); - if (child) { - const TiXmlText *childText = child->ToText(); - if (childText) { - return childText->Value(); - } - } - return nullptr; -} - -TiXmlDocument::TiXmlDocument() : TiXmlNode(TiXmlNode::DOCUMENT) { ClearError(); } - -TiXmlDocument::TiXmlDocument(const char *documentName) : TiXmlNode(TiXmlNode::DOCUMENT) -{ - value = documentName; - ClearError(); -} - -#ifdef TIXML_USE_STL -TiXmlDocument::TiXmlDocument(const std::string &documentName) : TiXmlNode(TiXmlNode::DOCUMENT) -{ - value = documentName; - ClearError(); -} -#endif - -TiXmlDocument::TiXmlDocument(const TiXmlDocument ©) : TiXmlNode(TiXmlNode::DOCUMENT) -{ - copy.CopyTo(this); -} - -void TiXmlDocument::operator=(const TiXmlDocument ©) -{ - Clear(); - copy.CopyTo(this); -} - -bool TiXmlDocument::LoadFile(TiXmlEncoding encoding) -{ - // See STL_STRING_BUG below. - const StringToBuffer buf(value); - - if (buf.buffer && LoadFile(buf.buffer, encoding)) { - return true; - } - - return false; -} - -bool TiXmlDocument::SaveFile() const -{ - // See STL_STRING_BUG below. - const StringToBuffer buf(value); - return (buf.buffer && SaveFile(buf.buffer)); -} - -bool TiXmlDocument::LoadFile(const char *filename, TiXmlEncoding encoding) -{ - // Delete the existing data: - Clear(); - location.Clear(); - - // There was a really terrifying little bug here. The code: - // value = filename - // in the STL case, cause the assignment method of the std::string to - // be called. What is strange, is that the std::string had the same - // address as it's c_str() method, and so bad things happen. Looks - // like a bug in the Microsoft STL implementation. - // See STL_STRING_BUG above. - // Fixed with the StringToBuffer class. - value = filename; - - // reading in binary mode so that tinyxml can normalize the EOL - FILE *file = fopen(value.c_str(), "rb"); - - if (file) { - // Get the file size, so we can pre-allocate the string. HUGE speed impact. - long length = 0; - fseek(file, 0, SEEK_END); - length = ftell(file); - fseek(file, 0, SEEK_SET); - - // Strange case, but good to handle up front. - if (length == 0) { - fclose(file); - return false; - } - - // If we have a file, assume it is all one big XML file, and read it in. - // The document parser may decide the document ends sooner than the entire file, however. - TIXML_STRING data; - data.reserve(length); - - // Subtle bug here. TinyXml did use fgets. But from the XML spec: - // 2.11 End-of-Line Handling - // - // - // ...the XML processor MUST behave as if it normalized all line breaks in external - // parsed entities (including the document entity) on input, before parsing, by translating - // both the two-character sequence #xD #xA and any #xD that is not followed by #xA to - // a single #xA character. - // - // - // It is not clear fgets does that, and certainly isn't clear it works cross platform. - // Generally, you expect fgets to translate from the convention of the OS to the c/unix - // convention, and not work generally. - - /* - while( fgets( buf, sizeof(buf), file ) ) - { - data += buf; - } - */ - - char *buf = new char[length + 1]; - buf[0] = 0; - - if (fread(buf, length, 1, file) != 1) { - // if ( fread( buf, 1, length, file ) != (size_t)length ) { - SetError(TIXML_ERROR_OPENING_FILE, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - fclose(file); - return false; - } - fclose(file); - - const char *lastPos = buf; - const char *p = buf; - - buf[length] = 0; - while (*p) { - assert(p < (buf + length)); - if (*p == 0xa) { - // Newline character. No special rules for this. Append all the characters - // since the last string, and include the newline. - data.append(lastPos, p - lastPos + 1); // append, include the newline - ++p; // move past the newline - lastPos = p; // and point to the new buffer (may be 0) - assert(p <= (buf + length)); - } - else if (*p == 0xd) { - // Carriage return. Append what we have so far, then - // handle moving forward in the buffer. - if ((p - lastPos) > 0) { - data.append(lastPos, p - lastPos); // do not add the CR - } - data += (char)0xa; // a proper newline - - if (*(p + 1) == 0xa) { - // Carriage return - new line sequence - p += 2; - lastPos = p; - assert(p <= (buf + length)); - } - else { - // it was followed by something else...that is presumably characters again. - ++p; - lastPos = p; - assert(p <= (buf + length)); - } - } - else { - ++p; - } - } - // Handle any left over characters. - if (p - lastPos) { - data.append(lastPos, p - lastPos); - } - delete[] buf; - buf = nullptr; - - Parse(data.c_str(), nullptr, encoding); - - if (Error()) { - return false; - } - else { - return true; - } - } - SetError(TIXML_ERROR_OPENING_FILE, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - return false; -} - -bool TiXmlDocument::SaveFile(const char *filename) const -{ - // The old c stuff lives on... - FILE *fp = fopen(filename, "w"); - if (fp) { - if (useMicrosoftBOM) { - const unsigned char TIXML_UTF_LEAD_0 = 0xefU; - const unsigned char TIXML_UTF_LEAD_1 = 0xbbU; - const unsigned char TIXML_UTF_LEAD_2 = 0xbfU; - - fputc(TIXML_UTF_LEAD_0, fp); - fputc(TIXML_UTF_LEAD_1, fp); - fputc(TIXML_UTF_LEAD_2, fp); - } - Print(fp, 0); - fclose(fp); - return true; - } - return false; -} - -void TiXmlDocument::CopyTo(TiXmlDocument *target) const -{ - TiXmlNode::CopyTo(target); - - target->error = error; - target->errorDesc = errorDesc.c_str(); - - TiXmlNode *node = nullptr; - for (node = firstChild; node; node = node->NextSibling()) { - target->LinkEndChild(node->Clone()); - } -} - -TiXmlNode *TiXmlDocument::Clone() const -{ - auto *clone = new TiXmlDocument(); - if (!clone) { - return nullptr; - } - - CopyTo(clone); - return clone; -} - -void TiXmlDocument::Print(FILE *cfile, int depth) const -{ - const TiXmlNode *node; - for (node = FirstChild(); node; node = node->NextSibling()) { - node->Print(cfile, depth); - fprintf(cfile, "\n"); - } -} - -void TiXmlDocument::StreamOut(TIXML_OSTREAM *out) const -{ - const TiXmlNode *node; - for (node = FirstChild(); node; node = node->NextSibling()) { - node->StreamOut(out); - - // Special rule for streams: stop after the root element. - // The stream in code will only read one element, so don't - // write more than one. - if (node->ToElement()) { - break; - } - } -} - -const TiXmlAttribute *TiXmlAttribute::Next() const -{ - // We are using knowledge of the sentinel. The sentinel - // have a value or name. - if (next->value.empty() && next->name.empty()) { - return nullptr; - } - return next; -} - -TiXmlAttribute *TiXmlAttribute::Next() -{ - // We are using knowledge of the sentinel. The sentinel - // have a value or name. - if (next->value.empty() && next->name.empty()) { - return nullptr; - } - return next; -} - -const TiXmlAttribute *TiXmlAttribute::Previous() const -{ - // We are using knowledge of the sentinel. The sentinel - // have a value or name. - if (prev->value.empty() && prev->name.empty()) { - return nullptr; - } - return prev; -} - -TiXmlAttribute *TiXmlAttribute::Previous() -{ - // We are using knowledge of the sentinel. The sentinel - // have a value or name. - if (prev->value.empty() && prev->name.empty()) { - return nullptr; - } - return prev; -} - -void TiXmlAttribute::Print(FILE *cfile, int /*depth*/) const -{ - TIXML_STRING n, v; - - PutString(name, &n); - PutString(value, &v); - - if (value.find('\"') == TIXML_STRING::npos) { - fprintf(cfile, "%s=\"%s\"", n.c_str(), v.c_str()); - } - else { - fprintf(cfile, "%s='%s'", n.c_str(), v.c_str()); - } -} - -void TiXmlAttribute::StreamOut(TIXML_OSTREAM *stream) const -{ - if (value.find('\"') != TIXML_STRING::npos) { - PutString(name, stream); - (*stream) << "=" - << "'"; - PutString(value, stream); - (*stream) << "'"; - } - else { - PutString(name, stream); - (*stream) << "=" - << "\""; - PutString(value, stream); - (*stream) << "\""; - } -} - -int TiXmlAttribute::QueryIntValue(int *ival) const -{ - if (sscanf(value.c_str(), "%d", ival) == 1) { - return TIXML_SUCCESS; - } - return TIXML_WRONG_TYPE; -} - -int TiXmlAttribute::QueryDoubleValue(double *dval) const -{ - if (sscanf(value.c_str(), "%lf", dval) == 1) { - return TIXML_SUCCESS; - } - return TIXML_WRONG_TYPE; -} - -void TiXmlAttribute::SetIntValue(int _value) -{ - char buf[64]; -#if defined(TIXML_SNPRINTF) - TIXML_SNPRINTF(buf, sizeof(buf), "%d", _value); -#else - sprintf(buf, "%d", _value); -#endif - SetValue(buf); -} - -void TiXmlAttribute::SetDoubleValue(double _value) -{ - char buf[256]; -#if defined(TIXML_SNPRINTF) - TIXML_SNPRINTF(buf, sizeof(buf), "%lf", _value); -#else - sprintf(buf, "%lf", _value); -#endif - SetValue(buf); -} - -int TiXmlAttribute::IntValue() const { return atoi(value.c_str()); } - -double TiXmlAttribute::DoubleValue() const { return atof(value.c_str()); } - -TiXmlComment::TiXmlComment(const TiXmlComment ©) : TiXmlNode(TiXmlNode::COMMENT) -{ - copy.CopyTo(this); -} - -void TiXmlComment::operator=(const TiXmlComment &base) -{ - Clear(); - base.CopyTo(this); -} - -void TiXmlComment::Print(FILE *cfile, int depth) const -{ - for (int i = 0; i < depth; i++) { - fputs(" ", cfile); - } - fprintf(cfile, "", value.c_str()); -} - -void TiXmlComment::StreamOut(TIXML_OSTREAM *stream) const -{ - (*stream) << ""; -} - -void TiXmlComment::CopyTo(TiXmlComment *target) const { TiXmlNode::CopyTo(target); } - -TiXmlNode *TiXmlComment::Clone() const -{ - auto *clone = new TiXmlComment(); - - if (!clone) { - return nullptr; - } - - CopyTo(clone); - return clone; -} - -void TiXmlText::Print(FILE *cfile, int depth) const -{ - if (cdata) { - int i; - fprintf(cfile, "\n"); - for (i = 0; i < depth; i++) { - fprintf(cfile, " "); - } - fprintf(cfile, "\n"); - } - else { - TIXML_STRING buffer; - PutString(value, &buffer); - fprintf(cfile, "%s", buffer.c_str()); - } -} - -void TiXmlText::StreamOut(TIXML_OSTREAM *stream) const -{ - if (cdata) { - (*stream) << ""; - } - else { - PutString(value, stream); - } -} - -void TiXmlText::CopyTo(TiXmlText *target) const -{ - TiXmlNode::CopyTo(target); - target->cdata = cdata; -} - -TiXmlNode *TiXmlText::Clone() const -{ - TiXmlText *clone = nullptr; - clone = new TiXmlText(""); - - if (!clone) { - return nullptr; - } - - CopyTo(clone); - return clone; -} - -TiXmlDeclaration::TiXmlDeclaration(const char *_version, const char *_encoding, - const char *_standalone) - : TiXmlNode(TiXmlNode::DECLARATION), version(_version), encoding(_encoding), - standalone(_standalone) -{ -} - -#ifdef TIXML_USE_STL -TiXmlDeclaration::TiXmlDeclaration(const std::string &_version, const std::string &_encoding, - const std::string &_standalone) - : TiXmlNode(TiXmlNode::DECLARATION), version(_version), encoding(_encoding), - standalone(_standalone) -{ -} -#endif - -TiXmlDeclaration::TiXmlDeclaration(const TiXmlDeclaration ©) - : TiXmlNode(TiXmlNode::DECLARATION) -{ - copy.CopyTo(this); -} - -void TiXmlDeclaration::operator=(const TiXmlDeclaration ©) -{ - Clear(); - copy.CopyTo(this); -} - -void TiXmlDeclaration::Print(FILE *cfile, int /*depth*/) const -{ - fprintf(cfile, ""); -} - -void TiXmlDeclaration::StreamOut(TIXML_OSTREAM *stream) const -{ - (*stream) << ""; -} - -void TiXmlDeclaration::CopyTo(TiXmlDeclaration *target) const -{ - TiXmlNode::CopyTo(target); - - target->version = version; - target->encoding = encoding; - target->standalone = standalone; -} - -TiXmlNode *TiXmlDeclaration::Clone() const -{ - auto *clone = new TiXmlDeclaration(); - - if (!clone) { - return nullptr; - } - - CopyTo(clone); - return clone; -} - -void TiXmlUnknown::Print(FILE *cfile, int depth) const -{ - for (int i = 0; i < depth; i++) { - fprintf(cfile, " "); - } - fprintf(cfile, "<%s>", value.c_str()); -} - -void TiXmlUnknown::StreamOut(TIXML_OSTREAM *stream) const -{ - (*stream) << "<" << value << ">"; // Don't use entities here! It is unknown. -} - -void TiXmlUnknown::CopyTo(TiXmlUnknown *target) const { TiXmlNode::CopyTo(target); } - -TiXmlNode *TiXmlUnknown::Clone() const -{ - auto *clone = new TiXmlUnknown(); - - if (!clone) { - return nullptr; - } - - CopyTo(clone); - return clone; -} - -TiXmlAttributeSet::TiXmlAttributeSet() -{ - sentinel.next = &sentinel; - sentinel.prev = &sentinel; -} - -TiXmlAttributeSet::~TiXmlAttributeSet() -{ - assert(sentinel.next == &sentinel); - assert(sentinel.prev == &sentinel); -} - -void TiXmlAttributeSet::Add(TiXmlAttribute *addMe) -{ - assert(!Find(addMe->Name())); // Shouldn't be multiply adding to the set. - - addMe->next = &sentinel; - addMe->prev = sentinel.prev; - - sentinel.prev->next = addMe; - sentinel.prev = addMe; -} - -void TiXmlAttributeSet::Remove(TiXmlAttribute *removeMe) -{ - TiXmlAttribute *node; - - for (node = sentinel.next; node != &sentinel; node = node->next) { - if (node == removeMe) { - node->prev->next = node->next; - node->next->prev = node->prev; - node->next = nullptr; - node->prev = nullptr; - return; - } - } - assert(0); // we tried to remove a non-linked attribute. -} - -const TiXmlAttribute *TiXmlAttributeSet::Find(const char *name) const -{ - const TiXmlAttribute *node; - - for (node = sentinel.next; node != &sentinel; node = node->next) { - if (node->name == name) { - return node; - } - } - return nullptr; -} - -TiXmlAttribute *TiXmlAttributeSet::Find(const char *name) -{ - TiXmlAttribute *node; - - for (node = sentinel.next; node != &sentinel; node = node->next) { - if (node->name == name) { - return node; - } - } - return nullptr; -} - -#ifdef TIXML_USE_STL -TIXML_ISTREAM &operator>>(TIXML_ISTREAM &in, TiXmlNode &base) -{ - TIXML_STRING tag; - tag.reserve(8 * 1000); - base.StreamIn(&in, &tag); - - base.Parse(tag.c_str(), nullptr, TIXML_DEFAULT_ENCODING); - return in; -} -#endif - -TIXML_OSTREAM &operator<<(TIXML_OSTREAM &out, const TiXmlNode &base) -{ - base.StreamOut(&out); - return out; -} - -#ifdef TIXML_USE_STL -std::string &operator<<(std::string &out, const TiXmlNode &base) -{ - std::ostringstream os_stream(std::ostringstream::out); - base.StreamOut(&os_stream); - - out.append(os_stream.str()); - return out; -} -#endif - -TiXmlHandle TiXmlHandle::FirstChild() const -{ - if (node) { - TiXmlNode *child = node->FirstChild(); - if (child) { - return {child}; - } - } - return {nullptr}; -} - -TiXmlHandle TiXmlHandle::FirstChild(const char *value) const -{ - if (node) { - TiXmlNode *child = node->FirstChild(value); - if (child) { - return {child}; - } - } - return {nullptr}; -} - -TiXmlHandle TiXmlHandle::FirstChildElement() const -{ - if (node) { - TiXmlElement *child = node->FirstChildElement(); - if (child) { - return {child}; - } - } - return {nullptr}; -} - -TiXmlHandle TiXmlHandle::FirstChildElement(const char *value) const -{ - if (node) { - TiXmlElement *child = node->FirstChildElement(value); - if (child) { - return {child}; - } - } - return {nullptr}; -} - -TiXmlHandle TiXmlHandle::Child(int count) const -{ - if (node) { - int i; - TiXmlNode *child = node->FirstChild(); - for (i = 0; child && i < count; child = child->NextSibling(), ++i) { - // nothing - } - if (child) { - return {child}; - } - } - return {nullptr}; -} - -TiXmlHandle TiXmlHandle::Child(const char *value, int count) const -{ - if (node) { - int i; - TiXmlNode *child = node->FirstChild(value); - for (i = 0; child && i < count; child = child->NextSibling(value), ++i) { - // nothing - } - if (child) { - return {child}; - } - } - return {nullptr}; -} - -TiXmlHandle TiXmlHandle::ChildElement(int count) const -{ - if (node) { - int i; - TiXmlElement *child = node->FirstChildElement(); - for (i = 0; child && i < count; child = child->NextSiblingElement(), ++i) { - // nothing - } - if (child) { - return {child}; - } - } - return {nullptr}; -} - -TiXmlHandle TiXmlHandle::ChildElement(const char *value, int count) const -{ - if (node) { - int i; - TiXmlElement *child = node->FirstChildElement(value); - for (i = 0; child && i < count; child = child->NextSiblingElement(value), ++i) { - // nothing - } - if (child) { - return {child}; - } - } - return {nullptr}; -} diff --git a/src/core/tinyxml.h b/src/core/tinyxml.h deleted file mode 100644 index 209cf7c63..000000000 --- a/src/core/tinyxml.h +++ /dev/null @@ -1,1602 +0,0 @@ -/* -www.sourceforge.net/projects/tinyxml -Original code (2.0 and earlier )copyright (c) 2000-2002 Lee Thomason (www.grinninglizard.com) - -This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any -damages arising from the use of this software. - -Permission is granted to anyone to use this software for any -purpose, including commercial applications, and to alter it and -redistribute it freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must -not claim that you wrote the original software. If you use this -software in a product, an acknowledgment in the product documentation -would be appreciated but is not required. - -2. Altered source versions must be plainly marked as such, and -must not be misrepresented as being the original software. - -3. This notice may not be removed or altered from any source -distribution. -*/ - -#ifndef TINYXML_INCLUDED -#define TINYXML_INCLUDED - -#ifdef _MSC_VER -#pragma warning(push) -#pragma warning(disable : 4530) -#pragma warning(disable : 4786) -#endif - -#include -#include -#include -#include -#include - -// Help out windows: -#if defined(_DEBUG) && !defined(DEBUG) -#define DEBUG -#endif - -#if defined(DEBUG) && defined(_MSC_VER) -#include -#define TIXML_LOG OutputDebugString -#else -#define TIXML_LOG printf -#endif - -// Gambit change: we use STL, so we define this here. -#define TIXML_USE_STL 1 - -#ifdef TIXML_USE_STL -#include -#include -#define TIXML_STRING std::string -#define TIXML_ISTREAM std::istream -#define TIXML_OSTREAM std::ostream -#else -#include "tinystr.h" -#define TIXML_STRING TiXmlString -#define TIXML_OSTREAM TiXmlOutStream -#endif - -// Deprecated library function hell. Compilers want to use the -// new safe versions. This probably doesn't fully address the problem, -// but it gets closer. There are too many compilers for me to fully -// test. If you get compilation troubles, undefine TIXML_SAFE - -#define TIXML_SAFE // TinyXml isn't fully buffer overrun protected, safe code. This is work in - // progress. -#ifdef TIXML_SAFE -#if defined(_MSC_VER) && (_MSC_VER >= 1200) -// Microsoft visual studio, version 6 and higher. -// #pragma message( "Using _sn* functions." ) -#define TIXML_SNPRINTF _snprintf -#define TIXML_SNSCANF _snscanf -#elif defined(__GNUC__) && (__GNUC__ >= 3) -// GCC version 3 and higher.s -// #warning( "Using sn* functions." ) -#define TIXML_SNPRINTF snprintf -#define TIXML_SNSCANF snscanf -#endif -#endif - -class TiXmlDocument; -class TiXmlElement; -class TiXmlComment; -class TiXmlUnknown; -class TiXmlAttribute; -class TiXmlText; -class TiXmlDeclaration; -class TiXmlParsingData; - -const int TIXML_MAJOR_VERSION = 2; -const int TIXML_MINOR_VERSION = 4; -const int TIXML_PATCH_VERSION = 2; - -/* Internal structure for tracking location of items - in the XML file. -*/ -struct TiXmlCursor { - TiXmlCursor() = default; - void Clear() { row = col = -1; } - - int row{-1}; // 0 based. - int col{-1}; // 0 based. -}; - -// Only used by Attribute::Query functions -enum { TIXML_SUCCESS, TIXML_NO_ATTRIBUTE, TIXML_WRONG_TYPE }; - -// Used by the parsing routines. -enum TiXmlEncoding { TIXML_ENCODING_UNKNOWN, TIXML_ENCODING_UTF8, TIXML_ENCODING_LEGACY }; - -const TiXmlEncoding TIXML_DEFAULT_ENCODING = TIXML_ENCODING_UNKNOWN; - -/** TiXmlBase is a base class for every class in TinyXml. - It does little except to establish that TinyXml classes - can be printed and provide some utility functions. - - In XML, the document and elements can contain - other elements and other types of nodes. - - @verbatim - A Document can contain: Element (container or leaf) - Comment (leaf) - Unknown (leaf) - Declaration( leaf ) - - An Element can contain: Element (container or leaf) - Text (leaf) - Attributes (not on tree) - Comment (leaf) - Unknown (leaf) - - A Decleration contains: Attributes (not on tree) - @endverbatim -*/ -class TiXmlBase { - friend class TiXmlNode; - friend class TiXmlElement; - friend class TiXmlDocument; - -public: - TiXmlBase() = default; - virtual ~TiXmlBase() = default; - - /** All TinyXml classes can print themselves to a filestream. - This is a formatted print, and will insert tabs and newlines. - - (For an unformatted stream, use the << operator.) - */ - virtual void Print(FILE *cfile, int depth) const = 0; - - /** The world does not agree on whether white space should be kept or - not. In order to make everyone happy, these global, static functions - are provided to set whether or not TinyXml will condense all white space - into a single space or not. The default is to condense. Note changing this - values is not thread safe. - */ - static void SetCondenseWhiteSpace(bool condense) { condenseWhiteSpace = condense; } - - /// Return the current white space setting. - static bool IsWhiteSpaceCondensed() { return condenseWhiteSpace; } - - /** Return the position, in the original source file, of this node or attribute. - The row and column are 1-based. (That is the first row and first column is - 1,1). If the returns values are 0 or less, then the parser does not have - a row and column value. - - Generally, the row and column value will be set when the TiXmlDocument::Load(), - TiXmlDocument::LoadFile(), or any TiXmlNode::Parse() is called. It will NOT be set - when the DOM was created from operator>>. - - The values reflect the initial load. Once the DOM is modified programmatically - (by adding or changing nodes and attributes) the new values will NOT update to - reflect changes in the document. - - There is a minor performance cost to computing the row and column. Computation - can be disabled if TiXmlDocument::SetTabSize() is called with 0 as the value. - - @sa TiXmlDocument::SetTabSize() - */ - int Row() const { return location.row + 1; } - int Column() const { return location.col + 1; } ///< See Row() - - void SetUserData(void *user) { userData = user; } - void *GetUserData() { return userData; } - - // Table that returs, for a given lead byte, the total number of bytes - // in the UTF-8 sequence. - static const int utf8ByteTable[256]; - - virtual const char *Parse(const char *p, TiXmlParsingData *data, - TiXmlEncoding encoding /*= TIXML_ENCODING_UNKNOWN */) = 0; - - enum { - TIXML_NO_ERROR = 0, - TIXML_ERROR, - TIXML_ERROR_OPENING_FILE, - TIXML_ERROR_OUT_OF_MEMORY, - TIXML_ERROR_PARSING_ELEMENT, - TIXML_ERROR_FAILED_TO_READ_ELEMENT_NAME, - TIXML_ERROR_READING_ELEMENT_VALUE, - TIXML_ERROR_READING_ATTRIBUTES, - TIXML_ERROR_PARSING_EMPTY, - TIXML_ERROR_READING_END_TAG, - TIXML_ERROR_PARSING_UNKNOWN, - TIXML_ERROR_PARSING_COMMENT, - TIXML_ERROR_PARSING_DECLARATION, - TIXML_ERROR_DOCUMENT_EMPTY, - TIXML_ERROR_EMBEDDED_NULL, - TIXML_ERROR_PARSING_CDATA, - - TIXML_ERROR_STRING_COUNT - }; - -protected: - // See STL_STRING_BUG - // Utility class to overcome a bug. - class StringToBuffer { - public: - StringToBuffer(const TIXML_STRING &str); - ~StringToBuffer(); - char *buffer; - }; - - static const char *SkipWhiteSpace(const char *, TiXmlEncoding encoding); - inline static bool IsWhiteSpace(char c) - { - return (isspace((unsigned char)c) || c == '\n' || c == '\r'); - } - - virtual void StreamOut(TIXML_OSTREAM *) const = 0; - -#ifdef TIXML_USE_STL - static bool StreamWhiteSpace(TIXML_ISTREAM *in, TIXML_STRING *tag); - static bool StreamTo(TIXML_ISTREAM *in, int character, TIXML_STRING *tag); -#endif - - /* Reads an XML name into the string provided. Returns - a pointer just past the last character of the name, - or 0 if the function has an error. - */ - static const char *ReadName(const char *p, TIXML_STRING *name, TiXmlEncoding encoding); - - /* Reads text. Returns a pointer past the given end tag. - Wickedly complex options, but it keeps the (sensitive) code in one place. - */ - static const char *ReadText(const char *in, // where to start - TIXML_STRING *text, // the string read - bool ignoreWhiteSpace, // whether to keep the white space - const char *endTag, // what ends this text - bool ignoreCase, // whether to ignore case in the end tag - TiXmlEncoding encoding); // the current encoding - - // If an entity has been found, transform it into a character. - static const char *GetEntity(const char *in, char *value, int *length, TiXmlEncoding encoding); - - // Get a character, while interpreting entities. - // The length can be from 0 to 4 bytes. - inline static const char *GetChar(const char *p, char *_value, int *length, - TiXmlEncoding encoding) - { - assert(p); - if (encoding == TIXML_ENCODING_UTF8) { - *length = utf8ByteTable[*(reinterpret_cast(p))]; - assert(*length >= 0 && *length < 5); - } - else { - *length = 1; - } - - if (*length == 1) { - if (*p == '&') { - return GetEntity(p, _value, length, encoding); - } - *_value = *p; - return p + 1; - } - else if (*length) { - // strncpy( _value, p, *length ); // lots of compilers don't like this function (unsafe), - // and the null terminator isn't needed - for (int i = 0; p[i] && i < *length; ++i) { - _value[i] = p[i]; - } - return p + (*length); - } - else { - // Not valid text. - return nullptr; - } - } - - // Puts a string to a stream, expanding entities as it goes. - // Note this should not contian the '<', '>', etc, or they will be transformed into entities! - static void PutString(const TIXML_STRING &str, TIXML_OSTREAM *out); - - static void PutString(const TIXML_STRING &str, TIXML_STRING *out); - - // Return true if the next characters in the stream are any of the endTag sequences. - // Ignore case only works for english, and should only be relied on when comparing - // to English words: StringEqual( p, "version", true ) is fine. - static bool StringEqual(const char *p, const char *endTag, bool ignoreCase, - TiXmlEncoding encoding); - - static const char *errorString[TIXML_ERROR_STRING_COUNT]; - - TiXmlCursor location; - - /// Field containing a generic user pointer - void *userData{nullptr}; - - // None of these methods are reliable for any language except English. - // Good for approximation, not great for accuracy. - static int IsAlpha(unsigned char anyByte, TiXmlEncoding encoding); - static int IsAlphaNum(unsigned char anyByte, TiXmlEncoding encoding); - inline static int ToLower(int v, TiXmlEncoding encoding) - { - if (encoding == TIXML_ENCODING_UTF8) { - if (v < 128) { - return tolower(v); - } - return v; - } - else { - return tolower(v); - } - } - static void ConvertUTF32ToUTF8(unsigned long input, char *output, int *length); - -private: - TiXmlBase(const TiXmlBase &); // not implemented. - void operator=(const TiXmlBase &base); // not allowed. - - struct Entity { - const char *str; - unsigned int strLength; - char chr; - }; - enum { - NUM_ENTITY = 5, - MAX_ENTITY_LENGTH = 6 - - }; - static Entity entity[NUM_ENTITY]; - static bool condenseWhiteSpace; -}; - -/** The parent class for everything in the Document Object Model. - (Except for attributes). - Nodes have siblings, a parent, and children. A node can be - in a document, or stand on its own. The type of a TiXmlNode - can be queried, and it can be cast to its more defined type. -*/ -class TiXmlNode : public TiXmlBase { - friend class TiXmlDocument; - friend class TiXmlElement; - -public: -#ifdef TIXML_USE_STL - - /** An input stream operator, for every class. Tolerant of newlines and - formatting, but doesn't expect them. - */ - friend std::istream &operator>>(std::istream &in, TiXmlNode &base); - - /** An output stream operator, for every class. Note that this outputs - without any newlines or formatting, as opposed to Print(), which - includes tabs and new lines. - - The operator<< and operator>> are not completely symmetric. Writing - a node to a stream is very well defined. You'll get a nice stream - of output, without any extra whitespace or newlines. - - But reading is not as well defined. (As it always is.) If you create - a TiXmlElement (for example) and read that from an input stream, - the text needs to define an element or junk will result. This is - true of all input streams, but it's worth keeping in mind. - - A TiXmlDocument will read nodes until it reads a root element, and - all the children of that root element. - */ - friend std::ostream &operator<<(std::ostream &out, const TiXmlNode &base); - - /// Appends the XML node or attribute to a std::string. - friend std::string &operator<<(std::string &out, const TiXmlNode &base); - -#else - // Used internally, not part of the public API. - friend TIXML_OSTREAM &operator<<(TIXML_OSTREAM &out, const TiXmlNode &base); -#endif - - /** The types of XML nodes supported by TinyXml. (All the - unsupported types are picked up by UNKNOWN.) - */ - enum NodeType { DOCUMENT, ELEMENT, COMMENT, UNKNOWN, TEXT, DECLARATION, TYPECOUNT }; - - ~TiXmlNode() override; - - /** The meaning of 'value' changes for the specific type of - TiXmlNode. - @verbatim - Document: filename of the xml file - Element: name of the element - Comment: the comment text - Unknown: the tag contents - Text: the text string - @endverbatim - - The subclasses will wrap this function. - */ - const char *Value() const { return value.c_str(); } - -#ifdef TIXML_USE_STL - /** Return Value() as a std::string. If you only use STL, - this is more efficient than calling Value(). - Only available in STL mode. - */ - const std::string &ValueStr() const { return value; } -#endif - - /** Changes the value of the node. Defined as: - @verbatim - Document: filename of the xml file - Element: name of the element - Comment: the comment text - Unknown: the tag contents - Text: the text string - @endverbatim - */ - void SetValue(const char *_value) { value = _value; } - -#ifdef TIXML_USE_STL - /// STL std::string form. - void SetValue(const std::string &_value) - { - StringToBuffer const buf(_value); - SetValue(buf.buffer ? buf.buffer : ""); - } -#endif - - /// Delete all the children of this node. Does not affect 'this'. - void Clear(); - - /// One step up the DOM. - TiXmlNode *Parent() { return parent; } - const TiXmlNode *Parent() const { return parent; } - - const TiXmlNode *FirstChild() const - { - return firstChild; - } ///< The first child of this node. Will be null if there are no children. - TiXmlNode *FirstChild() { return firstChild; } - const TiXmlNode * - FirstChild(const char *value) const; ///< The first child of this node with the matching 'value'. - ///< Will be null if none found. - TiXmlNode *FirstChild(const char *value); ///< The first child of this node with the matching - ///< 'value'. Will be null if none found. - - const TiXmlNode *LastChild() const - { - return lastChild; - } /// The last child of this node. Will be null if there are no children. - TiXmlNode *LastChild() { return lastChild; } - const TiXmlNode * - LastChild(const char *value) const; /// The last child of this node matching 'value'. Will be - /// null if there are no children. - TiXmlNode *LastChild(const char *value); - -#ifdef TIXML_USE_STL - const TiXmlNode *FirstChild(const std::string &_value) const - { - return FirstChild(_value.c_str()); - } ///< STL std::string form. - TiXmlNode *FirstChild(const std::string &_value) - { - return FirstChild(_value.c_str()); - } ///< STL std::string form. - const TiXmlNode *LastChild(const std::string &_value) const - { - return LastChild(_value.c_str()); - } ///< STL std::string form. - TiXmlNode *LastChild(const std::string &_value) - { - return LastChild(_value.c_str()); - } ///< STL std::string form. -#endif - - /** An alternate way to walk the children of a node. - One way to iterate over nodes is: - @verbatim - for( child = parent->FirstChild(); child; child = child->NextSibling() ) - @endverbatim - - IterateChildren does the same thing with the syntax: - @verbatim - child = 0; - while( child = parent->IterateChildren( child ) ) - @endverbatim - - IterateChildren takes the previous child as input and finds - the next one. If the previous child is null, it returns the - first. IterateChildren will return null when done. - */ - const TiXmlNode *IterateChildren(const TiXmlNode *previous) const; - TiXmlNode *IterateChildren(TiXmlNode *previous); - - /// This flavor of IterateChildren searches for children with a particular 'value' - const TiXmlNode *IterateChildren(const char *value, const TiXmlNode *previous) const; - TiXmlNode *IterateChildren(const char *value, TiXmlNode *previous); - -#ifdef TIXML_USE_STL - const TiXmlNode *IterateChildren(const std::string &_value, const TiXmlNode *previous) const - { - return IterateChildren(_value.c_str(), previous); - } ///< STL std::string form. - TiXmlNode *IterateChildren(const std::string &_value, TiXmlNode *previous) - { - return IterateChildren(_value.c_str(), previous); - } ///< STL std::string form. -#endif - - /** Add a new node related to this. Adds a child past the LastChild. - Returns a pointer to the new object or NULL if an error occured. - */ - TiXmlNode *InsertEndChild(const TiXmlNode &addThis); - - /** Add a new node related to this. Adds a child past the LastChild. - - NOTE: the node to be added is passed by pointer, and will be - henceforth owned (and deleted) by tinyXml. This method is efficient - and avoids an extra copy, but should be used with care as it - uses a different memory model than the other insert functions. - - @sa InsertEndChild - */ - TiXmlNode *LinkEndChild(TiXmlNode *addThis); - - /** Add a new node related to this. Adds a child before the specified child. - Returns a pointer to the new object or NULL if an error occured. - */ - TiXmlNode *InsertBeforeChild(TiXmlNode *beforeThis, const TiXmlNode &addThis); - - /** Add a new node related to this. Adds a child after the specified child. - Returns a pointer to the new object or NULL if an error occured. - */ - TiXmlNode *InsertAfterChild(TiXmlNode *afterThis, const TiXmlNode &addThis); - - /** Replace a child of this node. - Returns a pointer to the new object or NULL if an error occured. - */ - TiXmlNode *ReplaceChild(TiXmlNode *replaceThis, const TiXmlNode &withThis); - - /// Delete a child of this node. - bool RemoveChild(TiXmlNode *removeThis); - - /// Navigate to a sibling node. - const TiXmlNode *PreviousSibling() const { return prev; } - TiXmlNode *PreviousSibling() { return prev; } - - /// Navigate to a sibling node. - const TiXmlNode *PreviousSibling(const char *) const; - TiXmlNode *PreviousSibling(const char *); - -#ifdef TIXML_USE_STL - const TiXmlNode *PreviousSibling(const std::string &_value) const - { - return PreviousSibling(_value.c_str()); - } ///< STL std::string form. - TiXmlNode *PreviousSibling(const std::string &_value) - { - return PreviousSibling(_value.c_str()); - } ///< STL std::string form. - const TiXmlNode *NextSibling(const std::string &_value) const - { - return NextSibling(_value.c_str()); - } ///< STL std::string form. - TiXmlNode *NextSibling(const std::string &_value) - { - return NextSibling(_value.c_str()); - } ///< STL std::string form. -#endif - - /// Navigate to a sibling node. - const TiXmlNode *NextSibling() const { return next; } - TiXmlNode *NextSibling() { return next; } - - /// Navigate to a sibling node with the given 'value'. - const TiXmlNode *NextSibling(const char *) const; - TiXmlNode *NextSibling(const char *); - - /** Convenience function to get through elements. - Calls NextSibling and ToElement. Will skip all non-Element - nodes. Returns 0 if there is not another element. - */ - const TiXmlElement *NextSiblingElement() const; - TiXmlElement *NextSiblingElement(); - - /** Convenience function to get through elements. - Calls NextSibling and ToElement. Will skip all non-Element - nodes. Returns 0 if there is not another element. - */ - const TiXmlElement *NextSiblingElement(const char *) const; - TiXmlElement *NextSiblingElement(const char *); - -#ifdef TIXML_USE_STL - const TiXmlElement *NextSiblingElement(const std::string &_value) const - { - return NextSiblingElement(_value.c_str()); - } ///< STL std::string form. - TiXmlElement *NextSiblingElement(const std::string &_value) - { - return NextSiblingElement(_value.c_str()); - } ///< STL std::string form. -#endif - - /// Convenience function to get through elements. - const TiXmlElement *FirstChildElement() const; - TiXmlElement *FirstChildElement(); - - /// Convenience function to get through elements. - const TiXmlElement *FirstChildElement(const char *value) const; - TiXmlElement *FirstChildElement(const char *value); - -#ifdef TIXML_USE_STL - const TiXmlElement *FirstChildElement(const std::string &_value) const - { - return FirstChildElement(_value.c_str()); - } ///< STL std::string form. - TiXmlElement *FirstChildElement(const std::string &_value) - { - return FirstChildElement(_value.c_str()); - } ///< STL std::string form. -#endif - - /** Query the type (as an enumerated value, above) of this node. - The possible types are: DOCUMENT, ELEMENT, COMMENT, - UNKNOWN, TEXT, and DECLARATION. - */ - int Type() const { return type; } - - /** Return a pointer to the Document this node lives in. - Returns null if not in a document. - */ - const TiXmlDocument *GetDocument() const; - TiXmlDocument *GetDocument(); - - /// Returns true if this node has no children. - bool NoChildren() const { return !firstChild; } - - const TiXmlDocument *ToDocument() const; - const TiXmlElement *ToElement() const; - const TiXmlComment *ToComment() const; - const TiXmlUnknown *ToUnknown() const; - const TiXmlText *ToText() const; - const TiXmlDeclaration *ToDeclaration() const; - - TiXmlDocument *ToDocument(); - TiXmlElement *ToElement(); - TiXmlComment *ToComment(); - TiXmlUnknown *ToUnknown(); - TiXmlText *ToText(); - TiXmlDeclaration *ToDeclaration(); - - /** Create an exact duplicate of this node and return it. The memory must be deleted - by the caller. - */ - virtual TiXmlNode *Clone() const = 0; - -protected: - TiXmlNode(NodeType _type) : TiXmlBase(), type(_type) {} - - // Copy to the allocated object. Shared functionality between Clone, Copy constructor, - // and the assignment operator. - void CopyTo(TiXmlNode *target) const; - -#ifdef TIXML_USE_STL - // The real work of the input operator. - virtual void StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) = 0; -#endif - - // Figure out what is at *p, and parse it. Returns null if it is not an xml node. - TiXmlNode *Identify(const char *start, TiXmlEncoding encoding); - - TiXmlNode *parent{nullptr}; - NodeType type; - - TiXmlNode *firstChild{nullptr}; - TiXmlNode *lastChild{nullptr}; - - TIXML_STRING value; - - TiXmlNode *prev{nullptr}; - TiXmlNode *next{nullptr}; - -private: - TiXmlNode(const TiXmlNode &); // not implemented. - void operator=(const TiXmlNode &base); // not allowed. -}; - -/** An attribute is a name-value pair. Elements have an arbitrary - number of attributes, each with a unique name. - - @note The attributes are not TiXmlNodes, since they are not - part of the tinyXML document object model. There are other - suggested ways to look at this problem. -*/ -class TiXmlAttribute : public TiXmlBase { - friend class TiXmlAttributeSet; - -public: - /// Construct an empty attribute. - TiXmlAttribute() : TiXmlBase(), document(nullptr) { prev = next = nullptr; } - -#ifdef TIXML_USE_STL - /// std::string constructor. - TiXmlAttribute(const std::string &_name, const std::string &_value) - : document(nullptr), name(_name), value(_value) - { - prev = next = nullptr; - } -#endif - - /// Construct an attribute with a name and value. - TiXmlAttribute(const char *_name, const char *_value) - : document(nullptr), name(_name), value(_value) - { - prev = next = nullptr; - } - - const char *Name() const { return name.c_str(); } ///< Return the name of this attribute. - const char *Value() const { return value.c_str(); } ///< Return the value of this attribute. - int IntValue() const; ///< Return the value of this attribute, converted to an integer. - double DoubleValue() const; ///< Return the value of this attribute, converted to a double. - - /** QueryIntValue examines the value string. It is an alternative to the - IntValue() method with richer error checking. - If the value is an integer, it is stored in 'value' and - the call returns TIXML_SUCCESS. If it is not - an integer, it returns TIXML_WRONG_TYPE. - - A specialized but useful call. Note that for success it returns 0, - which is the opposite of almost all other TinyXml calls. - */ - int QueryIntValue(int *_value) const; - /// QueryDoubleValue examines the value string. See QueryIntValue(). - int QueryDoubleValue(double *_value) const; - - void SetName(const char *_name) { name = _name; } ///< Set the name of this attribute. - void SetValue(const char *_value) { value = _value; } ///< Set the value. - - void SetIntValue(int _value); ///< Set the value from an integer. - void SetDoubleValue(double _value); ///< Set the value from a double. - -#ifdef TIXML_USE_STL - /// STL std::string form. - void SetName(const std::string &_name) - { - const StringToBuffer buf(_name); - SetName(buf.buffer ? buf.buffer : "error"); - } - /// STL std::string form. - void SetValue(const std::string &_value) - { - const StringToBuffer buf(_value); - SetValue(buf.buffer ? buf.buffer : "error"); - } -#endif - - /// Get the next sibling attribute in the DOM. Returns null at end. - const TiXmlAttribute *Next() const; - TiXmlAttribute *Next(); - /// Get the previous sibling attribute in the DOM. Returns null at beginning. - const TiXmlAttribute *Previous() const; - TiXmlAttribute *Previous(); - - bool operator==(const TiXmlAttribute &rhs) const { return rhs.name == name; } - bool operator<(const TiXmlAttribute &rhs) const { return name < rhs.name; } - bool operator>(const TiXmlAttribute &rhs) const { return name > rhs.name; } - - /* Attribute parsing starts: first letter of the name - returns: the next char after the value end quote - */ - const char *Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding encoding) override; - - // Prints this Attribute to a FILE stream. - void Print(FILE *cfile, int depth) const override; - - void StreamOut(TIXML_OSTREAM *out) const override; - // [internal use] - // Set the document pointer so the attribute can report errors. - void SetDocument(TiXmlDocument *doc) { document = doc; } - -private: - TiXmlAttribute(const TiXmlAttribute &); // not implemented. - void operator=(const TiXmlAttribute &base); // not allowed. - - TiXmlDocument *document; // A pointer back to a document, for error reporting. - TIXML_STRING name; - TIXML_STRING value; - TiXmlAttribute *prev; - TiXmlAttribute *next; -}; - -/* A class used to manage a group of attributes. - It is only used internally, both by the ELEMENT and the DECLARATION. - - The set can be changed transparent to the Element and Declaration - classes that use it, but NOT transparent to the Attribute - which has to implement a next() and previous() method. Which makes - it a bit problematic and prevents the use of STL. - - This version is implemented with circular lists because: - - I like circular lists - - it demonstrates some independence from the (typical) doubly linked list. -*/ -class TiXmlAttributeSet { -public: - TiXmlAttributeSet(); - ~TiXmlAttributeSet(); - - void Add(TiXmlAttribute *attribute); - void Remove(TiXmlAttribute *attribute); - - const TiXmlAttribute *First() const - { - return (sentinel.next == &sentinel) ? nullptr : sentinel.next; - } - TiXmlAttribute *First() { return (sentinel.next == &sentinel) ? nullptr : sentinel.next; } - const TiXmlAttribute *Last() const - { - return (sentinel.prev == &sentinel) ? nullptr : sentinel.prev; - } - TiXmlAttribute *Last() { return (sentinel.prev == &sentinel) ? nullptr : sentinel.prev; } - - const TiXmlAttribute *Find(const char *name) const; - TiXmlAttribute *Find(const char *name); - - TiXmlAttributeSet(const TiXmlAttributeSet &) = delete; // not allowed - void operator=(const TiXmlAttributeSet &) = delete; // not allowed (as TiXmlAttribute) - -private: - TiXmlAttribute sentinel; -}; - -/** The element is a container class. It has a value, the element name, - and can contain other elements, text, comments, and unknowns. - Elements also contain an arbitrary number of attributes. -*/ -class TiXmlElement : public TiXmlNode { -public: - /// Construct an element. - TiXmlElement(const char *in_value); - -#ifdef TIXML_USE_STL - /// std::string constructor. - TiXmlElement(const std::string &_value); -#endif - - TiXmlElement(const TiXmlElement &); - - void operator=(const TiXmlElement &base); - - ~TiXmlElement() override; - - /** Given an attribute name, Attribute() returns the value - for the attribute of that name, or null if none exists. - */ - const char *Attribute(const char *name) const; - - /** Given an attribute name, Attribute() returns the value - for the attribute of that name, or null if none exists. - If the attribute exists and can be converted to an integer, - the integer value will be put in the return 'i', if 'i' - is non-null. - */ - const char *Attribute(const char *name, int *i) const; - - /** Given an attribute name, Attribute() returns the value - for the attribute of that name, or null if none exists. - If the attribute exists and can be converted to an double, - the double value will be put in the return 'd', if 'd' - is non-null. - */ - const char *Attribute(const char *name, double *d) const; - - /** QueryIntAttribute examines the attribute - it is an alternative to the - Attribute() method with richer error checking. - If the attribute is an integer, it is stored in 'value' and - the call returns TIXML_SUCCESS. If it is not - an integer, it returns TIXML_WRONG_TYPE. If the attribute - does not exist, then TIXML_NO_ATTRIBUTE is returned. - */ - int QueryIntAttribute(const char *name, int *_value) const; - /// QueryDoubleAttribute examines the attribute - see QueryIntAttribute(). - int QueryDoubleAttribute(const char *name, double *_value) const; - /// QueryFloatAttribute examines the attribute - see QueryIntAttribute(). - int QueryFloatAttribute(const char *name, float *_value) const - { - double d; - const int result = QueryDoubleAttribute(name, &d); - if (result == TIXML_SUCCESS) { - *_value = (float)d; - } - return result; - } - - /** Sets an attribute of name to a given value. The attribute - will be created if it does not exist, or changed if it does. - */ - void SetAttribute(const char *name, const char *_value); - -#ifdef TIXML_USE_STL - const char *Attribute(const std::string &name) const { return Attribute(name.c_str()); } - const char *Attribute(const std::string &name, int *i) const - { - return Attribute(name.c_str(), i); - } - const char *Attribute(const std::string &name, double *d) const - { - return Attribute(name.c_str(), d); - } - int QueryIntAttribute(const std::string &name, int *_value) const - { - return QueryIntAttribute(name.c_str(), _value); - } - int QueryDoubleAttribute(const std::string &name, double *_value) const - { - return QueryDoubleAttribute(name.c_str(), _value); - } - - /// STL std::string form. - void SetAttribute(const std::string &name, const std::string &_value) - { - const StringToBuffer n(name); - const StringToBuffer v(_value); - if (n.buffer && v.buffer) { - SetAttribute(n.buffer, v.buffer); - } - } - ///< STL std::string form. - void SetAttribute(const std::string &name, int _value) - { - const StringToBuffer n(name); - if (n.buffer) { - SetAttribute(n.buffer, _value); - } - } -#endif - - /** Sets an attribute of name to a given value. The attribute - will be created if it does not exist, or changed if it does. - */ - void SetAttribute(const char *name, int value); - - /** Sets an attribute of name to a given value. The attribute - will be created if it does not exist, or changed if it does. - */ - void SetDoubleAttribute(const char *name, double value); - - /** Deletes an attribute with the given name. - */ - void RemoveAttribute(const char *name); -#ifdef TIXML_USE_STL - void RemoveAttribute(const std::string &name) - { - RemoveAttribute(name.c_str()); - } ///< STL std::string form. -#endif - - const TiXmlAttribute *FirstAttribute() const - { - return attributeSet.First(); - } ///< Access the first attribute in this element. - TiXmlAttribute *FirstAttribute() { return attributeSet.First(); } - const TiXmlAttribute *LastAttribute() const - { - return attributeSet.Last(); - } ///< Access the last attribute in this element. - TiXmlAttribute *LastAttribute() { return attributeSet.Last(); } - - /** Convenience function for easy access to the text inside an element. Although easy - and concise, GetText() is limited compared to getting the TiXmlText child - and accessing it directly. - - If the first child of 'this' is a TiXmlText, the GetText() - returns the character string of the Text node, else null is returned. - - This is a convenient method for getting the text of simple contained text: - @verbatim - This is text - const char* str = fooElement->GetText(); - @endverbatim - - 'str' will be a pointer to "This is text". - - Note that this function can be misleading. If the element foo was created from - this XML: - @verbatim - This is text - @endverbatim - - then the value of str would be null. The first child node isn't a text node, it is - another element. From this XML: - @verbatim - This is text - @endverbatim - GetText() will return "This is ". - - WARNING: GetText() accesses a child node - don't become confused with the - similarly named TiXmlHandle::Text() and TiXmlNode::ToText() which are - safe type casts on the referenced node. - */ - const char *GetText() const; - - /// Creates a new Element and returns it - the returned element is a copy. - TiXmlNode *Clone() const override; - // Print the Element to a FILE stream. - void Print(FILE *cfile, int depth) const override; - - /* Attribtue parsing starts: next char past '<' - returns: next char past '>' - */ - const char *Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding encoding) override; - -protected: - void CopyTo(TiXmlElement *target) const; - void ClearThis(); // like clear, but initializes 'this' object as well - -// Used to be public [internal use] -#ifdef TIXML_USE_STL - void StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) override; -#endif - void StreamOut(TIXML_OSTREAM *out) const override; - - /* [internal use] - Reads the "value" of the element -- another element, or text. - This should terminate with the current end tag. - */ - const char *ReadValue(const char *in, TiXmlParsingData *prevData, TiXmlEncoding encoding); - -private: - TiXmlAttributeSet attributeSet; -}; - -/** An XML comment. - */ -class TiXmlComment : public TiXmlNode { -public: - /// Constructs an empty comment. - TiXmlComment() : TiXmlNode(TiXmlNode::COMMENT) {} - TiXmlComment(const TiXmlComment &); - void operator=(const TiXmlComment &base); - - ~TiXmlComment() override = default; - - /// Returns a copy of this Comment. - TiXmlNode *Clone() const override; - /// Write this Comment to a FILE stream. - void Print(FILE *cfile, int depth) const override; - - /* Attribtue parsing starts: at the ! of the !-- - returns: next char past '>' - */ - const char *Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding encoding) override; - -protected: - void CopyTo(TiXmlComment *target) const; - -// used to be public -#ifdef TIXML_USE_STL - void StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) override; -#endif - void StreamOut(TIXML_OSTREAM *out) const override; - -private: -}; - -/** XML text. A text node can have 2 ways to output the next. "normal" output - and CDATA. It will default to the mode it was parsed from the XML file and - you generally want to leave it alone, but you can change the output mode with - SetCDATA() and query it with CDATA(). -*/ -class TiXmlText : public TiXmlNode { - friend class TiXmlElement; - -public: - /** Constructor for text element. By default, it is treated as - normal, encoded text. If you want it be output as a CDATA text - element, set the parameter _cdata to 'true' - */ - TiXmlText(const char *initValue) : TiXmlNode(TiXmlNode::TEXT), cdata(false) - { - SetValue(initValue); - } - ~TiXmlText() override = default; - -#ifdef TIXML_USE_STL - /// Constructor. - TiXmlText(const std::string &initValue) : TiXmlNode(TiXmlNode::TEXT), cdata(false) - { - SetValue(initValue); - } -#endif - - TiXmlText(const TiXmlText ©) : TiXmlNode(TiXmlNode::TEXT), cdata(copy.cdata) - { - copy.CopyTo(this); - } - void operator=(const TiXmlText &base) { base.CopyTo(this); } - - /// Write this text object to a FILE stream. - void Print(FILE *cfile, int depth) const override; - - /// Queries whether this represents text using a CDATA section. - bool CDATA() { return cdata; } - /// Turns on or off a CDATA representation of text. - void SetCDATA(bool _cdata) { cdata = _cdata; } - - const char *Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding encoding) override; - -protected: - /// [internal use] Creates a new Element and returns it. - TiXmlNode *Clone() const override; - void CopyTo(TiXmlText *target) const; - - void StreamOut(TIXML_OSTREAM *out) const override; - bool Blank() const; // returns true if all white space and new lines -// [internal use] -#ifdef TIXML_USE_STL - void StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) override; -#endif - -private: - bool cdata; // true if this should be input and output as a CDATA style text element -}; - -/** In correct XML the declaration is the first entry in the file. - @verbatim - - @endverbatim - - TinyXml will happily read or write files without a declaration, - however. There are 3 possible attributes to the declaration: - version, encoding, and standalone. - - Note: In this version of the code, the attributes are - handled as special cases, not generic attributes, simply - because there can only be at most 3 and they are always the same. -*/ -class TiXmlDeclaration : public TiXmlNode { -public: - /// Construct an empty declaration. - TiXmlDeclaration() : TiXmlNode(TiXmlNode::DECLARATION) {} - -#ifdef TIXML_USE_STL - /// Constructor. - TiXmlDeclaration(const std::string &_version, const std::string &_encoding, - const std::string &_standalone); -#endif - - /// Construct. - TiXmlDeclaration(const char *_version, const char *_encoding, const char *_standalone); - - TiXmlDeclaration(const TiXmlDeclaration ©); - void operator=(const TiXmlDeclaration ©); - - ~TiXmlDeclaration() override = default; - - /// Version. Will return an empty string if none was found. - const char *Version() const { return version.c_str(); } - /// Encoding. Will return an empty string if none was found. - const char *Encoding() const { return encoding.c_str(); } - /// Is this a standalone document? - const char *Standalone() const { return standalone.c_str(); } - - /// Creates a copy of this Declaration and returns it. - TiXmlNode *Clone() const override; - /// Print this declaration to a FILE stream. - void Print(FILE *cfile, int depth) const override; - - const char *Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding encoding) override; - -protected: - void CopyTo(TiXmlDeclaration *target) const; -// used to be public -#ifdef TIXML_USE_STL - void StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) override; -#endif - void StreamOut(TIXML_OSTREAM *out) const override; - -private: - TIXML_STRING version; - TIXML_STRING encoding; - TIXML_STRING standalone; -}; - -/** Any tag that tinyXml doesn't recognize is saved as an - unknown. It is a tag of text, but should not be modified. - It will be written back to the XML, unchanged, when the file - is saved. - - DTD tags get thrown into TiXmlUnknowns. -*/ -class TiXmlUnknown : public TiXmlNode { -public: - TiXmlUnknown() : TiXmlNode(TiXmlNode::UNKNOWN) {} - ~TiXmlUnknown() override = default; - - TiXmlUnknown(const TiXmlUnknown ©) : TiXmlNode(TiXmlNode::UNKNOWN) { copy.CopyTo(this); } - void operator=(const TiXmlUnknown ©) { copy.CopyTo(this); } - - /// Creates a copy of this Unknown and returns it. - TiXmlNode *Clone() const override; - /// Print this Unknown to a FILE stream. - void Print(FILE *cfile, int depth) const override; - - const char *Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding encoding) override; - -protected: - void CopyTo(TiXmlUnknown *target) const; - -#ifdef TIXML_USE_STL - void StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) override; -#endif - void StreamOut(TIXML_OSTREAM *out) const override; - -private: -}; - -/** Always the top level node. A document binds together all the - XML pieces. It can be saved, loaded, and printed to the screen. - The 'value' of a document node is the xml file name. -*/ -class TiXmlDocument : public TiXmlNode { -public: - /// Create an empty document, that has no name. - TiXmlDocument(); - /// Create a document with a name. The name of the document is also the filename of the xml. - TiXmlDocument(const char *documentName); - -#ifdef TIXML_USE_STL - /// Constructor. - TiXmlDocument(const std::string &documentName); -#endif - - TiXmlDocument(const TiXmlDocument ©); - void operator=(const TiXmlDocument ©); - - ~TiXmlDocument() override = default; - - /** Load a file using the current document value. - Returns true if successful. Will delete any existing - document data before loading. - */ - bool LoadFile(TiXmlEncoding encoding = TIXML_DEFAULT_ENCODING); - /// Save a file using the current document value. Returns true if successful. - bool SaveFile() const; - /// Load a file using the given filename. Returns true if successful. - bool LoadFile(const char *filename, TiXmlEncoding encoding = TIXML_DEFAULT_ENCODING); - /// Save a file using the given filename. Returns true if successful. - bool SaveFile(const char *filename) const; - -#ifdef TIXML_USE_STL - bool LoadFile(const std::string &filename, - TiXmlEncoding encoding = TIXML_DEFAULT_ENCODING) ///< STL std::string version. - { - const StringToBuffer f(filename); - return (f.buffer && LoadFile(f.buffer, encoding)); - } - bool SaveFile(const std::string &filename) const ///< STL std::string version. - { - const StringToBuffer f(filename); - return (f.buffer && SaveFile(f.buffer)); - } -#endif - - /** Parse the given null terminated block of xml data. Passing in an encoding to this - method (either TIXML_ENCODING_LEGACY or TIXML_ENCODING_UTF8 will force TinyXml - to use that encoding, regardless of what TinyXml might otherwise try to detect. - */ - const char *Parse(const char *p, TiXmlParsingData *data = nullptr, - TiXmlEncoding encoding = TIXML_DEFAULT_ENCODING) override; - - /** Get the root element -- the only top level element -- of the document. - In well formed XML, there should only be one. TinyXml is tolerant of - multiple elements at the document level. - */ - const TiXmlElement *RootElement() const { return FirstChildElement(); } - TiXmlElement *RootElement() { return FirstChildElement(); } - - /** If an error occurs, Error will be set to true. Also, - - The ErrorId() will contain the integer identifier of the error (not generally useful) - - The ErrorDesc() method will return the name of the error. (very useful) - - The ErrorRow() and ErrorCol() will return the location of the error (if known) - */ - bool Error() const { return error; } - - /// Contains a textual (english) description of the error if one occurs. - const char *ErrorDesc() const { return errorDesc.c_str(); } - - /** Generally, you probably want the error string ( ErrorDesc() ). But if you - prefer the ErrorId, this function will fetch it. - */ - int ErrorId() const { return errorId; } - - /** Returns the location (if known) of the error. The first column is column 1, - and the first row is row 1. A value of 0 means the row and column wasn't applicable - (memory errors, for example, have no row/column) or the parser lost the error. (An - error in the error reporting, in that case.) - - @sa SetTabSize, Row, Column - */ - int ErrorRow() { return errorLocation.row + 1; } - int ErrorCol() - { - return errorLocation.col + 1; - } ///< The column where the error occured. See ErrorRow() - - /** SetTabSize() allows the error reporting functions (ErrorRow() and ErrorCol()) - to report the correct values for row and column. It does not change the output - or input in any way. - - By calling this method, with a tab size - greater than 0, the row and column of each node and attribute is stored - when the file is loaded. Very useful for tracking the DOM back in to - the source file. - - The tab size is required for calculating the location of nodes. If not - set, the default of 4 is used. The tabsize is set per document. Setting - the tabsize to 0 disables row/column tracking. - - Note that row and column tracking is not supported when using operator>>. - - The tab size needs to be enabled before the parse or load. Correct usage: - @verbatim - TiXmlDocument doc; - doc.SetTabSize( 8 ); - doc.Load( "myfile.xml" ); - @endverbatim - - @sa Row, Column - */ - void SetTabSize(int _tabsize) { tabsize = _tabsize; } - - int TabSize() const { return tabsize; } - - /** If you have handled the error, it can be reset with this call. The error - state is automatically cleared if you Parse a new XML block. - */ - void ClearError() - { - error = false; - errorId = 0; - errorDesc = ""; - errorLocation.row = errorLocation.col = 0; - // errorLocation.last = 0; - } - - /** Dump the document to standard out. */ - void Print() const { Print(stdout, 0); } - - /// Print this Document to a FILE stream. - void Print(FILE *cfile, int depth = 0) const override; - // [internal use] - void SetError(int err, const char *errorLocation, TiXmlParsingData *prevData, - TiXmlEncoding encoding); - -protected: - void StreamOut(TIXML_OSTREAM *out) const override; - // [internal use] - TiXmlNode *Clone() const override; -#ifdef TIXML_USE_STL - void StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) override; -#endif - -private: - void CopyTo(TiXmlDocument *target) const; - - bool error{false}; - int errorId{0}; - TIXML_STRING errorDesc; - int tabsize{4}; - TiXmlCursor errorLocation; - bool useMicrosoftBOM{false}; // the UTF-8 BOM were found when read. Note this, and try to write. -}; - -/** - A TiXmlHandle is a class that wraps a node pointer with null checks; this is - an incredibly useful thing. Note that TiXmlHandle is not part of the TinyXml - DOM structure. It is a separate utility class. - - Take an example: - @verbatim - - - - - - - @endverbatim - - Assuming you want the value of "attributeB" in the 2nd "Child" element, it's very - easy to write a *lot* of code that looks like: - - @verbatim - TiXmlElement* root = document.FirstChildElement( "Document" ); - if ( root ) - { - TiXmlElement* element = root->FirstChildElement( "Element" ); - if ( element ) - { - TiXmlElement* child = element->FirstChildElement( "Child" ); - if ( child ) - { - TiXmlElement* child2 = child->NextSiblingElement( "Child" ); - if ( child2 ) - { - // Finally do something useful. - @endverbatim - - And that doesn't even cover "else" cases. TiXmlHandle addresses the verbosity - of such code. A TiXmlHandle checks for null pointers so it is perfectly safe - and correct to use: - - @verbatim - TiXmlHandle docHandle( &document ); - TiXmlElement* child2 = docHandle.FirstChild( "Document" ).FirstChild( "Element" ).Child( - "Child", 1 ).Element(); if ( child2 ) - { - // do something useful - @endverbatim - - Which is MUCH more concise and useful. - - It is also safe to copy handles - internally they are nothing more than node pointers. - @verbatim - TiXmlHandle handleCopy = handle; - @endverbatim - - What they should not be used for is iteration: - - @verbatim - int i=0; - while ( true ) - { - TiXmlElement* child = docHandle.FirstChild( "Document" ).FirstChild( "Element" - ).Child( "Child", i ).Element(); if ( !child ) break; - // do something - ++i; - } - @endverbatim - - It seems reasonable, but it is in fact two embedded while loops. The Child method is - a linear walk to find the element, so this code would iterate much more than it needs - to. Instead, prefer: - - @verbatim - TiXmlElement* child = docHandle.FirstChild( "Document" ).FirstChild( "Element" - ).FirstChild( "Child" ).Element(); - - for( child; child; child=child->NextSiblingElement() ) - { - // do something - } - @endverbatim -*/ -class TiXmlHandle { -public: - /// Create a handle from any node (at any depth of the tree.) This can be a null pointer. - TiXmlHandle(TiXmlNode *_node) : node(_node) {} - /// Copy constructor - TiXmlHandle(const TiXmlHandle &ref) = default; - TiXmlHandle operator=(const TiXmlHandle &ref) - { - this->node = ref.node; - return *this; - } - - /// Return a handle to the first child node. - TiXmlHandle FirstChild() const; - /// Return a handle to the first child node with the given name. - TiXmlHandle FirstChild(const char *value) const; - /// Return a handle to the first child element. - TiXmlHandle FirstChildElement() const; - /// Return a handle to the first child element with the given name. - TiXmlHandle FirstChildElement(const char *value) const; - - /** Return a handle to the "index" child with the given name. - The first child is 0, the second 1, etc. - */ - TiXmlHandle Child(const char *value, int index) const; - /** Return a handle to the "index" child. - The first child is 0, the second 1, etc. - */ - TiXmlHandle Child(int index) const; - /** Return a handle to the "index" child element with the given name. - The first child element is 0, the second 1, etc. Note that only TiXmlElements - are indexed: other types are not counted. - */ - TiXmlHandle ChildElement(const char *value, int index) const; - /** Return a handle to the "index" child element. - The first child element is 0, the second 1, etc. Note that only TiXmlElements - are indexed: other types are not counted. - */ - TiXmlHandle ChildElement(int index) const; - -#ifdef TIXML_USE_STL - TiXmlHandle FirstChild(const std::string &_value) const { return FirstChild(_value.c_str()); } - TiXmlHandle FirstChildElement(const std::string &_value) const - { - return FirstChildElement(_value.c_str()); - } - - TiXmlHandle Child(const std::string &_value, int index) const - { - return Child(_value.c_str(), index); - } - TiXmlHandle ChildElement(const std::string &_value, int index) const - { - return ChildElement(_value.c_str(), index); - } -#endif - - /// Return the handle as a TiXmlNode. This may return null. - TiXmlNode *Node() const { return node; } - /// Return the handle as a TiXmlElement. This may return null. - TiXmlElement *Element() const - { - return ((node && node->ToElement()) ? node->ToElement() : nullptr); - } - /// Return the handle as a TiXmlText. This may return null. - TiXmlText *Text() const { return ((node && node->ToText()) ? node->ToText() : nullptr); } - /// Return the handle as a TiXmlUnknown. This may return null; - TiXmlUnknown *Unknown() const - { - return ((node && node->ToUnknown()) ? node->ToUnknown() : nullptr); - } - -private: - TiXmlNode *node; -}; - -inline const TiXmlDocument *TiXmlNode::ToDocument() const -{ - return (type == DOCUMENT) ? dynamic_cast(this) : nullptr; -} -inline const TiXmlElement *TiXmlNode::ToElement() const -{ - return (type == ELEMENT) ? dynamic_cast(this) : nullptr; -} -inline const TiXmlComment *TiXmlNode::ToComment() const -{ - return (type == COMMENT) ? dynamic_cast(this) : nullptr; -} -inline const TiXmlUnknown *TiXmlNode::ToUnknown() const -{ - return (type == UNKNOWN) ? dynamic_cast(this) : nullptr; -} -inline const TiXmlText *TiXmlNode::ToText() const -{ - return (type == TEXT) ? dynamic_cast(this) : nullptr; -} -inline const TiXmlDeclaration *TiXmlNode::ToDeclaration() const -{ - return (type == DECLARATION) ? dynamic_cast(this) : nullptr; -} - -inline TiXmlDocument *TiXmlNode::ToDocument() -{ - return (type == DOCUMENT) ? dynamic_cast(this) : nullptr; -} -inline TiXmlElement *TiXmlNode::ToElement() -{ - return (type == ELEMENT) ? dynamic_cast(this) : nullptr; -} -inline TiXmlComment *TiXmlNode::ToComment() -{ - return (type == COMMENT) ? dynamic_cast(this) : nullptr; -} -inline TiXmlUnknown *TiXmlNode::ToUnknown() -{ - return (type == UNKNOWN) ? dynamic_cast(this) : nullptr; -} -inline TiXmlText *TiXmlNode::ToText() -{ - return (type == TEXT) ? dynamic_cast(this) : nullptr; -} -inline TiXmlDeclaration *TiXmlNode::ToDeclaration() -{ - return (type == DECLARATION) ? dynamic_cast(this) : nullptr; -} - -#ifdef _MSC_VER -#pragma warning(pop) -#endif - -#endif diff --git a/src/core/tinyxmlerror.cc b/src/core/tinyxmlerror.cc deleted file mode 100644 index 1c1466b01..000000000 --- a/src/core/tinyxmlerror.cc +++ /dev/null @@ -1,51 +0,0 @@ -/* -www.sourceforge.net/projects/tinyxml -Original code (2.0 and earlier )copyright (c) 2000-2002 Lee Thomason (www.grinninglizard.com) - -This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any -damages arising from the use of this software. - -Permission is granted to anyone to use this software for any -purpose, including commercial applications, and to alter it and -redistribute it freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must -not claim that you wrote the original software. If you use this -software in a product, an acknowledgment in the product documentation -would be appreciated but is not required. - -2. Altered source versions must be plainly marked as such, and -must not be misrepresented as being the original software. - -3. This notice may not be removed or altered from any source -distribution. -*/ - -#include "tinyxml.h" - -// The goal of the seperate error file is to make the first -// step towards localization. tinyxml (currently) only supports -// english error messages, but the could now be translated. -// -// It also cleans up the code a bit. -// - -const char *TiXmlBase::errorString[TIXML_ERROR_STRING_COUNT] = { - "No error", - "Error", - "Failed to open file", - "Memory allocation failed.", - "Error parsing Element.", - "Failed to read Element name", - "Error reading Element value.", - "Error reading Attributes.", - "Error: empty tag.", - "Error reading end tag.", - "Error parsing Unknown.", - "Error parsing Comment.", - "Error parsing Declaration.", - "Error document empty.", - "Error null (0) or unexpected EOF found in input stream.", - "Error parsing CDATA.", -}; diff --git a/src/core/tinyxmlparser.cc b/src/core/tinyxmlparser.cc deleted file mode 100644 index 7c0c11e13..000000000 --- a/src/core/tinyxmlparser.cc +++ /dev/null @@ -1,1511 +0,0 @@ -/* -www.sourceforge.net/projects/tinyxml -Original code (2.0 and earlier )copyright (c) 2000-2002 Lee Thomason (www.grinninglizard.com) - -This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any -damages arising from the use of this software. - -Permission is granted to anyone to use this software for any -purpose, including commercial applications, and to alter it and -redistribute it freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must -not claim that you wrote the original software. If you use this -software in a product, an acknowledgment in the product documentation -would be appreciated but is not required. - -2. Altered source versions must be plainly marked as such, and -must not be misrepresented as being the original software. - -3. This notice may not be removed or altered from any source -distribution. -*/ - -#include -#include - -#include "tinyxml.h" - -// #define DEBUG_PARSER - -// Note tha "PutString" hardcodes the same list. This -// is less flexible than it appears. Changing the entries -// or order will break putstring. -TiXmlBase::Entity TiXmlBase::entity[NUM_ENTITY] = {{"&", 5, '&'}, - {"<", 4, '<'}, - {">", 4, '>'}, - {""", 6, '\"'}, - {"'", 6, '\''}}; - -// Bunch of unicode info at: -// http://www.unicode.org/faq/utf_bom.html -// Including the basic of this table, which determines the #bytes in the -// sequence from the lead byte. 1 placed for invalid sequences -- -// although the result will be junk, pass it through as much as possible. -// Beware of the non-characters in UTF-8: -// ef bb bf (Microsoft "lead bytes") -// ef bf be -// ef bf bf - -const unsigned char TIXML_UTF_LEAD_0 = 0xefU; -const unsigned char TIXML_UTF_LEAD_1 = 0xbbU; -const unsigned char TIXML_UTF_LEAD_2 = 0xbfU; - -const int TiXmlBase::utf8ByteTable[256] = { - // 0 1 2 3 4 5 6 7 8 9 a b - // c d e f - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x00 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x10 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x20 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x30 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x40 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x50 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x60 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x70 End of ASCII range - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x80 0x80 to 0xc1 invalid - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0x90 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0xa0 - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, // 0xb0 - 1, 1, 2, 2, 2, 2, 2, 2, - 2, 2, 2, 2, 2, 2, 2, 2, // 0xc0 0xc2 to 0xdf 2 byte - 2, 2, 2, 2, 2, 2, 2, 2, - 2, 2, 2, 2, 2, 2, 2, 2, // 0xd0 - 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, // 0xe0 0xe0 to 0xef 3 byte - 4, 4, 4, 4, 4, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1 // 0xf0 0xf0 to 0xf4 4 byte, 0xf5 and higher invalid -}; - -void TiXmlBase::ConvertUTF32ToUTF8(unsigned long input, char *output, int *length) -{ - const unsigned long BYTE_MASK = 0xBF; - const unsigned long BYTE_MARK = 0x80; - const unsigned long FIRST_BYTE_MARK[7] = {0x00, 0x00, 0xC0, 0xE0, 0xF0, 0xF8, 0xFC}; - - if (input < 0x80) { - *length = 1; - } - else if (input < 0x800) { - *length = 2; - } - else if (input < 0x10000) { - *length = 3; - } - else if (input < 0x200000) { - *length = 4; - } - else { - *length = 0; - return; - } // This code won't covert this correctly anyway. - - output += *length; - - // Scary scary fall throughs. - switch (*length) { - case 4: - --output; - *output = (char)((input | BYTE_MARK) & BYTE_MASK); - input >>= 6; - case 3: - --output; - *output = (char)((input | BYTE_MARK) & BYTE_MASK); - input >>= 6; - case 2: - --output; - *output = (char)((input | BYTE_MARK) & BYTE_MASK); - input >>= 6; - case 1: - --output; - *output = (char)(input | FIRST_BYTE_MARK[*length]); - } -} - -/*static*/ int TiXmlBase::IsAlpha(unsigned char anyByte, TiXmlEncoding /*encoding*/) -{ - // This will only work for low-ascii, everything else is assumed to be a valid - // letter. I'm not sure this is the best approach, but it is quite tricky trying - // to figure out alhabetical vs. not across encoding. So take a very - // conservative approach. - - // if ( encoding == TIXML_ENCODING_UTF8 ) - // { - if (anyByte < 127) { - return isalpha(anyByte); - } - else { - return 1; // What else to do? The unicode set is huge...get the english ones right. - } - // } - // else - // { - // return isalpha( anyByte ); - // } -} - -/*static*/ int TiXmlBase::IsAlphaNum(unsigned char anyByte, TiXmlEncoding /*encoding*/) -{ - // This will only work for low-ascii, everything else is assumed to be a valid - // letter. I'm not sure this is the best approach, but it is quite tricky trying - // to figure out alhabetical vs. not across encoding. So take a very - // conservative approach. - - // if ( encoding == TIXML_ENCODING_UTF8 ) - // { - if (anyByte < 127) { - return isalnum(anyByte); - } - else { - return 1; // What else to do? The unicode set is huge...get the english ones right. - } - // } - // else - // { - // return isalnum( anyByte ); - // } -} - -class TiXmlParsingData { - friend class TiXmlDocument; - -public: - void Stamp(const char *now, TiXmlEncoding encoding); - - const TiXmlCursor &Cursor() { return cursor; } - -private: - // Only used by the document! - TiXmlParsingData(const char *start, int _tabsize, int row, int col) - { - assert(start); - stamp = start; - tabsize = _tabsize; - cursor.row = row; - cursor.col = col; - } - - TiXmlCursor cursor; - const char *stamp; - int tabsize; -}; - -void TiXmlParsingData::Stamp(const char *now, TiXmlEncoding encoding) -{ - assert(now); - - // Do nothing if the tabsize is 0. - if (tabsize < 1) { - return; - } - - // Get the current row, column. - int row = cursor.row; - int col = cursor.col; - const char *p = stamp; - assert(p); - - while (p < now) { - // Treat p as unsigned, so we have a happy compiler. - const auto *pU = reinterpret_cast(p); - - // Code contributed by Fletcher Dunn: (modified by lee) - switch (*pU) { - case 0: - // We *should* never get here, but in case we do, don't - // advance past the terminating null character, ever - return; - - case '\r': - // bump down to the next line - ++row; - col = 0; - // Eat the character - ++p; - - // Check for \r\n sequence, and treat this as a single character - if (*p == '\n') { - ++p; - } - break; - - case '\n': - // bump down to the next line - ++row; - col = 0; - - // Eat the character - ++p; - - // Check for \n\r sequence, and treat this as a single - // character. (Yes, this bizarre thing does occur still - // on some arcane platforms...) - if (*p == '\r') { - ++p; - } - break; - - case '\t': - // Eat the character - ++p; - - // Skip to next tab stop - col = (col / tabsize + 1) * tabsize; - break; - - case TIXML_UTF_LEAD_0: - if (encoding == TIXML_ENCODING_UTF8) { - if (*(p + 1) && *(p + 2)) { - // In these cases, don't advance the column. These are - // 0-width spaces. - if (*(pU + 1) == TIXML_UTF_LEAD_1 && *(pU + 2) == TIXML_UTF_LEAD_2) { - p += 3; - } - else if (*(pU + 1) == 0xbfU && *(pU + 2) == 0xbeU) { - p += 3; - } - else if (*(pU + 1) == 0xbfU && *(pU + 2) == 0xbfU) { - p += 3; - } - else { - p += 3; - ++col; - } // A normal character. - } - } - else { - ++p; - ++col; - } - break; - - default: - if (encoding == TIXML_ENCODING_UTF8) { - // Eat the 1 to 4 byte utf8 character. - int step = TiXmlBase::utf8ByteTable[*reinterpret_cast(p)]; - if (step == 0) { - step = 1; // Error case from bad encoding, but handle gracefully. - } - p += step; - - // Just advance one column, of course. - ++col; - } - else { - ++p; - ++col; - } - break; - } - } - cursor.row = row; - cursor.col = col; - assert(cursor.row >= -1); - assert(cursor.col >= -1); - stamp = p; - assert(stamp); -} - -const char *TiXmlBase::SkipWhiteSpace(const char *p, TiXmlEncoding encoding) -{ - if (!p || !*p) { - return nullptr; - } - if (encoding == TIXML_ENCODING_UTF8) { - while (*p) { - const auto *pU = reinterpret_cast(p); - - // Skip the stupid Microsoft UTF-8 Byte order marks - if (*(pU + 0) == TIXML_UTF_LEAD_0 && *(pU + 1) == TIXML_UTF_LEAD_1 && - *(pU + 2) == TIXML_UTF_LEAD_2) { - p += 3; - continue; - } - else if (*(pU + 0) == TIXML_UTF_LEAD_0 && *(pU + 1) == 0xbfU && *(pU + 2) == 0xbeU) { - p += 3; - continue; - } - else if (*(pU + 0) == TIXML_UTF_LEAD_0 && *(pU + 1) == 0xbfU && *(pU + 2) == 0xbfU) { - p += 3; - continue; - } - - if (IsWhiteSpace(*p) || *p == '\n' || *p == '\r') { // Still using old rules for white space. - ++p; - } - else { - break; - } - } - } - else { - while (*p && (IsWhiteSpace(*p) || *p == '\n' || *p == '\r')) { - ++p; - } - } - - return p; -} - -#ifdef TIXML_USE_STL -/*static*/ bool TiXmlBase::StreamWhiteSpace(TIXML_ISTREAM *in, TIXML_STRING *tag) -{ - for (;;) { - if (!in->good()) { - return false; - } - - const int c = in->peek(); - // At this scope, we can't get to a document. So fail silently. - if (!IsWhiteSpace(c) || c <= 0) { - return true; - } - - *tag += (char)in->get(); - } -} - -/*static*/ bool TiXmlBase::StreamTo(TIXML_ISTREAM *in, int character, TIXML_STRING *tag) -{ - // assert( character > 0 && character < 128 ); // else it won't work in utf-8 - while (in->good()) { - const int c = in->peek(); - if (c == character) { - return true; - } - if (c <= 0) { // Silent failure: can't get document at this scope - return false; - } - - in->get(); - *tag += (char)c; - } - return false; -} -#endif - -const char *TiXmlBase::ReadName(const char *p, TIXML_STRING *name, TiXmlEncoding encoding) -{ - *name = ""; - assert(p); - - // Names start with letters or underscores. - // Of course, in unicode, tinyxml has no idea what a letter *is*. The - // algorithm is generous. - // - // After that, they can be letters, underscores, numbers, - // hyphens, or colons. (Colons are valid ony for namespaces, - // but tinyxml can't tell namespaces from names.) - if (p && *p && (IsAlpha((unsigned char)*p, encoding) || *p == '_')) { - while (p && *p && - (IsAlphaNum((unsigned char)*p, encoding) || *p == '_' || *p == '-' || *p == '.' || - *p == ':')) { - (*name) += *p; - ++p; - } - return p; - } - return nullptr; -} - -const char *TiXmlBase::GetEntity(const char *p, char *value, int *length, TiXmlEncoding encoding) -{ - // Presume an entity, and pull it out. - const TIXML_STRING ent; - int i; - *length = 0; - - if (*(p + 1) && *(p + 1) == '#' && *(p + 2)) { - unsigned long ucs = 0; - ptrdiff_t delta = 0; - unsigned mult = 1; - - if (*(p + 2) == 'x') { - // Hexadecimal. - if (!*(p + 3)) { - return nullptr; - } - - const char *q = p + 3; - q = strchr(q, ';'); - - if (!q || !*q) { - return nullptr; - } - - delta = q - p; - --q; - - while (*q != 'x') { - if (*q >= '0' && *q <= '9') { - ucs += mult * (*q - '0'); - } - else if (*q >= 'a' && *q <= 'f') { - ucs += mult * (*q - 'a' + 10); - } - else if (*q >= 'A' && *q <= 'F') { - ucs += mult * (*q - 'A' + 10); - } - else { - return nullptr; - } - mult *= 16; - --q; - } - } - else { - // Decimal. - if (!*(p + 2)) { - return nullptr; - } - - const char *q = p + 2; - q = strchr(q, ';'); - - if (!q || !*q) { - return nullptr; - } - - delta = q - p; - --q; - - while (*q != '#') { - if (*q >= '0' && *q <= '9') { - ucs += mult * (*q - '0'); - } - else { - return nullptr; - } - mult *= 10; - --q; - } - } - if (encoding == TIXML_ENCODING_UTF8) { - // convert the UCS to UTF-8 - ConvertUTF32ToUTF8(ucs, value, length); - } - else { - *value = (char)ucs; - *length = 1; - } - return p + delta + 1; - } - - // Now try to match it. - for (i = 0; i < NUM_ENTITY; ++i) { - if (strncmp(entity[i].str, p, entity[i].strLength) == 0) { - assert(strlen(entity[i].str) == entity[i].strLength); - *value = entity[i].chr; - *length = 1; - return (p + entity[i].strLength); - } - } - - // So it wasn't an entity, its unrecognized, or something like that. - *value = *p; // Don't put back the last one, since we return it! - return p + 1; -} - -bool TiXmlBase::StringEqual(const char *p, const char *tag, bool ignoreCase, - TiXmlEncoding encoding) -{ - assert(p); - assert(tag); - if (!p || !*p) { - assert(0); - return false; - } - - const char *q = p; - - if (ignoreCase) { - while (*q && *tag && ToLower(*q, encoding) == ToLower(*tag, encoding)) { - ++q; - ++tag; - } - - if (*tag == 0) { - return true; - } - } - else { - while (*q && *tag && *q == *tag) { - ++q; - ++tag; - } - - if (*tag == 0) { // Have we found the end of the tag, and everything equal? - return true; - } - } - return false; -} - -const char *TiXmlBase::ReadText(const char *p, TIXML_STRING *text, bool trimWhiteSpace, - const char *endTag, bool caseInsensitive, TiXmlEncoding encoding) -{ - *text = ""; - if (!trimWhiteSpace // certain tags always keep whitespace - || !condenseWhiteSpace) // if true, whitespace is always kept - { - // Keep all the white space. - while (p && *p && !StringEqual(p, endTag, caseInsensitive, encoding)) { - int len; - char cArr[4] = {0, 0, 0, 0}; - p = GetChar(p, cArr, &len, encoding); - text->append(cArr, len); - } - } - else { - bool whitespace = false; - - // Remove leading white space: - p = SkipWhiteSpace(p, encoding); - while (p && *p && !StringEqual(p, endTag, caseInsensitive, encoding)) { - if (*p == '\r' || *p == '\n') { - whitespace = true; - ++p; - } - else if (IsWhiteSpace(*p)) { - whitespace = true; - ++p; - } - else { - // If we've found whitespace, add it before the - // new character. Any whitespace just becomes a space. - if (whitespace) { - (*text) += ' '; - whitespace = false; - } - int len; - char cArr[4] = {0, 0, 0, 0}; - p = GetChar(p, cArr, &len, encoding); - if (len == 1) { - (*text) += cArr[0]; // more efficient - } - else { - text->append(cArr, len); - } - } - } - } - return p + strlen(endTag); -} - -#ifdef TIXML_USE_STL - -void TiXmlDocument::StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) -{ - // The basic issue with a document is that we don't know what we're - // streaming. Read something presumed to be a tag (and hope), then - // identify it, and call the appropriate stream method on the tag. - // - // This "pre-streaming" will never read the closing ">" so the - // sub-tag can orient itself. - - if (!StreamTo(in, '<', tag)) { - SetError(TIXML_ERROR_PARSING_EMPTY, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - return; - } - - while (in->good()) { - const int tagIndex = (int)tag->length(); - while (in->good() && in->peek() != '>') { - const int c = in->get(); - if (c <= 0) { - SetError(TIXML_ERROR_EMBEDDED_NULL, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - break; - } - (*tag) += (char)c; - } - - if (in->good()) { - // We now have something we presume to be a node of - // some sort. Identify it, and call the node to - // continue streaming. - TiXmlNode *node = Identify(tag->c_str() + tagIndex, TIXML_DEFAULT_ENCODING); - - if (node) { - node->StreamIn(in, tag); - const bool isElement = node->ToElement() != nullptr; - delete node; - node = nullptr; - - // If this is the root element, we're done. Parsing will be - // done by the >> operator. - if (isElement) { - return; - } - } - else { - SetError(TIXML_ERROR, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - return; - } - } - } - // We should have returned sooner. - SetError(TIXML_ERROR, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); -} - -#endif - -const char *TiXmlDocument::Parse(const char *p, TiXmlParsingData *prevData, TiXmlEncoding encoding) -{ - ClearError(); - - // Parse away, at the document level. Since a document - // contains nothing but other tags, most of what happens - // here is skipping white space. - if (!p || !*p) { - SetError(TIXML_ERROR_DOCUMENT_EMPTY, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - return nullptr; - } - - // Note that, for a document, this needs to come - // before the while space skip, so that parsing - // starts from the pointer we are given. - location.Clear(); - if (prevData) { - location.row = prevData->cursor.row; - location.col = prevData->cursor.col; - } - else { - location.row = 0; - location.col = 0; - } - TiXmlParsingData data(p, TabSize(), location.row, location.col); - location = data.Cursor(); - - if (encoding == TIXML_ENCODING_UNKNOWN) { - // Check for the Microsoft UTF-8 lead bytes. - const auto *pU = reinterpret_cast(p); - if (*(pU + 0) && *(pU + 0) == TIXML_UTF_LEAD_0 && *(pU + 1) && *(pU + 1) == TIXML_UTF_LEAD_1 && - *(pU + 2) && *(pU + 2) == TIXML_UTF_LEAD_2) { - encoding = TIXML_ENCODING_UTF8; - useMicrosoftBOM = true; - } - } - - p = SkipWhiteSpace(p, encoding); - if (!p) { - SetError(TIXML_ERROR_DOCUMENT_EMPTY, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - return nullptr; - } - - while (p && *p) { - TiXmlNode *node = Identify(p, encoding); - if (node) { - p = node->Parse(p, &data, encoding); - LinkEndChild(node); - } - else { - break; - } - - // Did we get encoding info? - if (encoding == TIXML_ENCODING_UNKNOWN && node->ToDeclaration()) { - const TiXmlDeclaration *dec = node->ToDeclaration(); - const char *enc = dec->Encoding(); - assert(enc); - - if (*enc == 0) { - encoding = TIXML_ENCODING_UTF8; - } - else if (StringEqual(enc, "UTF-8", true, TIXML_ENCODING_UNKNOWN)) { - encoding = TIXML_ENCODING_UTF8; - } - else if (StringEqual(enc, "UTF8", true, TIXML_ENCODING_UNKNOWN)) { - encoding = TIXML_ENCODING_UTF8; // incorrect, but be nice - } - else { - encoding = TIXML_ENCODING_LEGACY; - } - } - - p = SkipWhiteSpace(p, encoding); - } - - // Was this empty? - if (!firstChild) { - SetError(TIXML_ERROR_DOCUMENT_EMPTY, nullptr, nullptr, encoding); - return nullptr; - } - - // All is well. - return p; -} - -void TiXmlDocument::SetError(int err, const char *pError, TiXmlParsingData *data, - TiXmlEncoding encoding) -{ - // The first error in a chain is more accurate - don't set again! - if (error) { - return; - } - - assert(err > 0 && err < TIXML_ERROR_STRING_COUNT); - error = true; - errorId = err; - errorDesc = errorString[errorId]; - - errorLocation.Clear(); - if (pError && data) { - data->Stamp(pError, encoding); - errorLocation = data->Cursor(); - } -} - -TiXmlNode *TiXmlNode::Identify(const char *p, TiXmlEncoding encoding) -{ - TiXmlNode *returnNode = nullptr; - - p = SkipWhiteSpace(p, encoding); - if (!p || !*p || *p != '<') { - return nullptr; - } - - TiXmlDocument *doc = GetDocument(); - p = SkipWhiteSpace(p, encoding); - - if (!p || !*p) { - return nullptr; - } - - // What is this thing? - // - Elements start with a letter or underscore, but xml is reserved. - // - Comments: "; - - if (!StringEqual(p, startTag, false, encoding)) { - document->SetError(TIXML_ERROR_PARSING_COMMENT, p, data, encoding); - return nullptr; - } - p += strlen(startTag); - p = ReadText(p, &value, false, endTag, false, encoding); - return p; -} - -const char *TiXmlAttribute::Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding encoding) -{ - p = SkipWhiteSpace(p, encoding); - if (!p || !*p) { - return nullptr; - } - - // int tabsize = 4; - // if ( document ) { - // tabsize = document->TabSize(); - // } - - if (data) { - data->Stamp(p, encoding); - location = data->Cursor(); - } - // Read the name, the '=' and the value. - const char *pErr = p; - p = ReadName(p, &name, encoding); - if (!p || !*p) { - if (document) { - document->SetError(TIXML_ERROR_READING_ATTRIBUTES, pErr, data, encoding); - } - return nullptr; - } - p = SkipWhiteSpace(p, encoding); - if (!p || !*p || *p != '=') { - if (document) { - document->SetError(TIXML_ERROR_READING_ATTRIBUTES, p, data, encoding); - } - return nullptr; - } - - ++p; // skip '=' - p = SkipWhiteSpace(p, encoding); - if (!p || !*p) { - if (document) { - document->SetError(TIXML_ERROR_READING_ATTRIBUTES, p, data, encoding); - } - return nullptr; - } - - const char *end; - - if (*p == '\'') { - ++p; - end = "\'"; - p = ReadText(p, &value, false, end, false, encoding); - } - else if (*p == '"') { - ++p; - end = "\""; - p = ReadText(p, &value, false, end, false, encoding); - } - else { - // All attribute values should be in single or double quotes. - // But this is such a common error that the parser will try - // its best, even without them. - value = ""; - while (p && *p // existence - && !IsWhiteSpace(*p) && *p != '\n' && *p != '\r' // whitespace - && *p != '/' && *p != '>') // tag end - { - value += *p; - ++p; - } - } - return p; -} - -#ifdef TIXML_USE_STL -void TiXmlText::StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) -{ - if (cdata) { - const int c = in->get(); - if (c <= 0) { - TiXmlDocument *document = GetDocument(); - if (document) { - document->SetError(TIXML_ERROR_EMBEDDED_NULL, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - } - return; - } - - (*tag) += (char)c; - - if (c == '>' && tag->at(tag->length() - 2) == ']' && tag->at(tag->length() - 3) == ']') { - // All is well. - return; - } - } - else { - while (in->good()) { - const int c = in->peek(); - if (c == '<') { - return; - } - if (c <= 0) { - TiXmlDocument *document = GetDocument(); - if (document) { - document->SetError(TIXML_ERROR_EMBEDDED_NULL, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - } - return; - } - - (*tag) += (char)c; - in->get(); - } - } -} -#endif - -const char *TiXmlText::Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding encoding) -{ - value = ""; - TiXmlDocument *document = GetDocument(); - - if (data) { - data->Stamp(p, encoding); - location = data->Cursor(); - } - - const char *const startTag = ""; - - if (cdata || StringEqual(p, startTag, false, encoding)) { - cdata = true; - - if (!StringEqual(p, startTag, false, encoding)) { - document->SetError(TIXML_ERROR_PARSING_CDATA, p, data, encoding); - return nullptr; - } - p += strlen(startTag); - - // Keep all the white space, ignore the encoding, etc. - while (p && *p && !StringEqual(p, endTag, false, encoding)) { - value += *p; - ++p; - } - - TIXML_STRING dummy; - p = ReadText(p, &dummy, false, endTag, false, encoding); - return p; - } - else { - const bool ignoreWhite = true; - - const char *end = "<"; - p = ReadText(p, &value, ignoreWhite, end, false, encoding); - if (p) { - return p - 1; // don't truncate the '<' - } - return nullptr; - } -} - -#ifdef TIXML_USE_STL -void TiXmlDeclaration::StreamIn(TIXML_ISTREAM *in, TIXML_STRING *tag) -{ - while (in->good()) { - const int c = in->get(); - if (c <= 0) { - TiXmlDocument *document = GetDocument(); - if (document) { - document->SetError(TIXML_ERROR_EMBEDDED_NULL, nullptr, nullptr, TIXML_ENCODING_UNKNOWN); - } - return; - } - (*tag) += (char)c; - - if (c == '>') { - // All is well. - return; - } - } -} -#endif - -const char *TiXmlDeclaration::Parse(const char *p, TiXmlParsingData *data, TiXmlEncoding _encoding) -{ - p = SkipWhiteSpace(p, _encoding); - // Find the beginning, find the end, and look for - // the stuff in-between. - TiXmlDocument *document = GetDocument(); - if (!p || !*p || !StringEqual(p, "SetError(TIXML_ERROR_PARSING_DECLARATION, nullptr, nullptr, _encoding); - } - return nullptr; - } - if (data) { - data->Stamp(p, _encoding); - location = data->Cursor(); - } - p += 5; - - version = ""; - encoding = ""; - standalone = ""; - - while (p && *p) { - if (*p == '>') { - ++p; - return p; - } - - p = SkipWhiteSpace(p, _encoding); - if (StringEqual(p, "version", true, _encoding)) { - TiXmlAttribute attrib; - p = attrib.Parse(p, data, _encoding); - version = attrib.Value(); - } - else if (StringEqual(p, "encoding", true, _encoding)) { - TiXmlAttribute attrib; - p = attrib.Parse(p, data, _encoding); - encoding = attrib.Value(); - } - else if (StringEqual(p, "standalone", true, _encoding)) { - TiXmlAttribute attrib; - p = attrib.Parse(p, data, _encoding); - standalone = attrib.Value(); - } - else { - // Read over whatever it is. - while (p && *p && *p != '>' && !IsWhiteSpace(*p)) { - ++p; - } - } - } - return nullptr; -} - -bool TiXmlText::Blank() const -{ - for (const char c : value) { - if (!IsWhiteSpace(c)) { - return false; - } - } - return true; -} diff --git a/src/games/file.cc b/src/games/file.cc index 0d772d18f..db2adc1b5 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -854,56 +854,10 @@ void ParseNode(GameFileLexer &p_state, Game p_game, GameNode p_node, TreeData &p } // end of anonymous namespace -#include "core/tinyxml.h" +#include "workspace.h" namespace Gambit { -class GameXMLSavefile { -private: - TiXmlDocument doc; - -public: - explicit GameXMLSavefile(const std::string &p_xml); - ~GameXMLSavefile() = default; - - Game GetGame() const; -}; - -GameXMLSavefile::GameXMLSavefile(const std::string &p_xml) -{ - doc.Parse(p_xml.c_str()); - if (doc.Error()) { - throw InvalidFileException("Not a valid XML document"); - } -} - -Game GameXMLSavefile::GetGame() const -{ - const TiXmlNode *docroot = doc.FirstChild("gambit:document"); - if (!docroot) { - throw InvalidFileException("Not a Gambit game savefile document"); - } - - const TiXmlNode *game = docroot->FirstChild("game"); - if (!game) { - throw InvalidFileException("No game representation found in document"); - } - - const TiXmlNode *efgfile = game->FirstChild("efgfile"); - if (efgfile) { - std::istringstream s(efgfile->FirstChild()->Value()); - return ReadGame(s); - } - - const TiXmlNode *nfgfile = game->FirstChild("nfgfile"); - if (nfgfile) { - std::istringstream s(nfgfile->FirstChild()->Value()); - return ReadGame(s); - } - - throw InvalidFileException("No game representation found in document"); -} - void NormalizeGameLabels(const Game &p_game) { const auto get_label = [](const auto &e) { return e->GetLabel(); }; @@ -988,11 +942,19 @@ Game ReadNfgFile(std::istream &p_stream) Game ReadGbtFile(std::istream &p_stream) { - std::stringstream buffer; - buffer << p_stream.rdbuf(); - auto game = GameXMLSavefile(buffer.str()).GetGame(); - NormalizeGameLabels(game); - return game; + try { + const LegacyWorkspaceFile workspace = ReadLegacyWorkspace(p_stream); + std::istringstream game_text(workspace.game); + auto game = ReadGame(game_text); + NormalizeGameLabels(game); + return game; + } + catch (const InvalidFileException &) { + throw; + } + catch (const std::runtime_error &) { + throw InvalidFileException("Not a valid .gbt document"); + } } Game ReadAggFile(std::istream &p_stream) diff --git a/src/games/workspace.cc b/src/games/workspace.cc new file mode 100644 index 000000000..a2aaaad87 --- /dev/null +++ b/src/games/workspace.cc @@ -0,0 +1,556 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/games/workspace.cc +// Reader and writer for Gambit's legacy .gbt workspace format +// +// 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 "workspace.h" + +#include +#include +#include +#include +#include +#include + +namespace Gambit { +namespace { + +class Element { + friend class Parser; + + std::string m_name; + std::string m_text; + std::map m_attributes; + std::vector> m_children; + +public: + const std::string &Name() const { return m_name; } + const std::string &Text() const { return m_text; } + const std::string *Attribute(const std::string &p_name) const; + bool IntAttribute(const std::string &p_name, int &p_value) const; + const Element *Child(const std::string &p_name) const; + std::vector Children(const std::string &p_name = {}) const; +}; + +class Parser { + const std::string &m_input; + size_t m_pos{0}; + + [[noreturn]] void Fail(const std::string &p_message) const + { + throw std::runtime_error("Invalid .gbt XML at byte " + std::to_string(m_pos) + ": " + + p_message); + } + + bool StartsWith(const std::string &p_text) const + { + return m_input.compare(m_pos, p_text.size(), p_text) == 0; + } + + void SkipWhitespace() + { + while (m_pos < m_input.size() && (m_input[m_pos] == ' ' || m_input[m_pos] == '\t' || + m_input[m_pos] == '\r' || m_input[m_pos] == '\n')) { + ++m_pos; + } + } + + void SkipMarkup() + { + if (StartsWith("", m_pos + 2); + if (end == std::string::npos) { + Fail("unterminated processing instruction"); + } + m_pos = end + 2; + } + else if (StartsWith("", m_pos + 4); + if (end == std::string::npos) { + Fail("unterminated comment"); + } + m_pos = end + 3; + } + else { + Fail("unsupported markup"); + } + } + + std::string Name() + { + const size_t begin = m_pos; + while (m_pos < m_input.size()) { + const char c = m_input[m_pos]; + if (!(std::isalnum(static_cast(c)) || c == '_' || c == '-' || c == ':' || + c == '.')) { + break; + } + ++m_pos; + } + if (begin == m_pos) { + Fail("expected a name"); + } + return m_input.substr(begin, m_pos - begin); + } + + static void AppendUtf8(std::string &p_out, unsigned long p_codepoint) + { + if (p_codepoint <= 0x7f) { + p_out.push_back(static_cast(p_codepoint)); + } + else if (p_codepoint <= 0x7ff) { + p_out.push_back(static_cast(0xc0 | (p_codepoint >> 6))); + p_out.push_back(static_cast(0x80 | (p_codepoint & 0x3f))); + } + else if (p_codepoint <= 0xffff) { + p_out.push_back(static_cast(0xe0 | (p_codepoint >> 12))); + p_out.push_back(static_cast(0x80 | ((p_codepoint >> 6) & 0x3f))); + p_out.push_back(static_cast(0x80 | (p_codepoint & 0x3f))); + } + else if (p_codepoint <= 0x10ffff) { + p_out.push_back(static_cast(0xf0 | (p_codepoint >> 18))); + p_out.push_back(static_cast(0x80 | ((p_codepoint >> 12) & 0x3f))); + p_out.push_back(static_cast(0x80 | ((p_codepoint >> 6) & 0x3f))); + p_out.push_back(static_cast(0x80 | (p_codepoint & 0x3f))); + } + else { + throw std::runtime_error("Invalid Unicode character in .gbt XML"); + } + } + + std::string Decode(const std::string &p_text) const + { + std::string result; + for (size_t pos = 0; pos < p_text.size();) { + if (p_text[pos] != '&') { + result.push_back(p_text[pos++]); + continue; + } + const auto end = p_text.find(';', pos + 1); + if (end == std::string::npos) { + throw std::runtime_error("Invalid entity in .gbt XML"); + } + const std::string entity = p_text.substr(pos + 1, end - pos - 1); + if (entity == "amp") { + result.push_back('&'); + } + else if (entity == "lt") { + result.push_back('<'); + } + else if (entity == "gt") { + result.push_back('>'); + } + else if (entity == "quot") { + result.push_back('"'); + } + else if (entity == "apos") { + result.push_back('\''); + } + else if (!entity.empty() && entity[0] == '#') { + const bool hex = entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X'); + const std::string digits = entity.substr(hex ? 2 : 1); + unsigned long value = 0; + try { + value = std::stoul(digits, nullptr, hex ? 16 : 10); + } + catch (...) { + throw std::runtime_error("Invalid numeric entity in .gbt XML"); + } + AppendUtf8(result, value); + } + else { + throw std::runtime_error("Unknown entity in .gbt XML"); + } + pos = end + 1; + } + return result; + } + +public: + explicit Parser(const std::string &p_input) : m_input(p_input) {} + + std::unique_ptr ParseElement() + { + if (m_pos >= m_input.size() || m_input[m_pos++] != '<') { + Fail("expected '<'"); + } + auto element = std::make_unique(); + element->m_name = Name(); + while (true) { + SkipWhitespace(); + if (StartsWith("/>")) { + m_pos += 2; + return element; + } + if (StartsWith(">")) { + ++m_pos; + break; + } + const std::string name = Name(); + SkipWhitespace(); + if (m_pos >= m_input.size() || m_input[m_pos++] != '=') { + Fail("expected '='"); + } + SkipWhitespace(); + if (m_pos >= m_input.size() || (m_input[m_pos] != '"' && m_input[m_pos] != '\'')) { + Fail("expected quoted attribute value"); + } + const char quote = m_input[m_pos++]; + const size_t begin = m_pos; + const auto end = m_input.find(quote, begin); + if (end == std::string::npos) { + Fail("unterminated attribute value"); + } + element->m_attributes[name] = Decode(m_input.substr(begin, end - begin)); + m_pos = end + 1; + } + + // SaveWorkspace embeds these payloads verbatim rather than XML-escaping + // them. Treat them as opaque text so game labels and descriptions that + // contain '<' or '&' remain readable. + if (element->m_name == "efgfile" || element->m_name == "nfgfile" || + element->m_name == "description" || element->m_name == "profile") { + const std::string close = "m_name + ">"; + const auto end = m_input.find(close, m_pos); + if (end == std::string::npos) { + Fail("unterminated element " + element->m_name); + } + element->m_text = m_input.substr(m_pos, end - m_pos); + m_pos = end + close.size(); + return element; + } + + while (true) { + if (m_pos >= m_input.size()) { + Fail("unterminated element " + element->m_name); + } + if (StartsWith("= m_input.size() || m_input[m_pos++] != '>') { + Fail("expected '>'"); + } + if (closing != element->m_name) { + Fail("mismatched closing element " + closing); + } + return element; + } + if (StartsWith("