From 9151bd39b89184096a5e99d900f08aaa8ca9d15f Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 2 Dec 2025 09:11:31 +0000 Subject: [PATCH 1/6] Implement minpay properly for trees. --- src/games/gameexpl.h | 8 ++++---- src/games/gametree.cc | 27 +++++++++++++++++++++++++++ src/games/gametree.h | 3 +++ tests/test_players.py | 12 ++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/games/gameexpl.h b/src/games/gameexpl.h index fc77947ae..4593a3f3b 100644 --- a/src/games/gameexpl.h +++ b/src/games/gameexpl.h @@ -33,13 +33,13 @@ class GameExplicitRep : public GameRep { public: /// @name General data access //@{ - /// Returns the smallest payoff to any player in any outcome of the game + /// Returns the smallest payoff to any player in any play of the game Rational GetMinPayoff() const override; - /// Returns the smallest payoff to the player in any outcome of the game + /// Returns the smallest payoff to the player in any play of the game Rational GetMinPayoff(const GamePlayer &) const override; - /// Returns the largest payoff to any player in any outcome of the game + /// Returns the largest payoff to any player in any play of the game Rational GetMaxPayoff() const override; - /// Returns the largest payoff to the player in any outcome of the game + /// Returns the largest payoff to the player in any play of the game Rational GetMaxPayoff(const GamePlayer &) const override; //@} diff --git a/src/games/gametree.cc b/src/games/gametree.cc index a98c5b216..1a8f31fd7 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -762,6 +762,33 @@ bool GameTreeRep::IsPerfectRecall() const [](const auto &pair) { return pair.second.size() <= 1; }); } +namespace { +Rational GetSubtreeMinPayoff(const GamePlayer &p_player, const GameNode &p_node) +{ + if (p_node->IsTerminal()) { + if (p_node->GetOutcome()) { + return p_node->GetOutcome()->GetPayoff(p_player); + } + return Rational(0); + } + const auto &children = p_node->GetChildren(); + auto subtree = std::accumulate(std::next(children.begin()), children.end(), + GetSubtreeMinPayoff(p_player, children.front()), + [&p_player](const Rational &r, const GameNode &c) { + return std::min(r, GetSubtreeMinPayoff(p_player, c)); + }); + if (p_node->GetOutcome()) { + return subtree + p_node->GetOutcome()->GetPayoff(p_player); + } + return subtree; +} +} // namespace + +Rational GameTreeRep::GetMinPayoff(const GamePlayer &p_player) const +{ + return GetSubtreeMinPayoff(p_player, m_root); +} + //------------------------------------------------------------------------ // GameTreeRep: Managing the representation //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index e5249488b..42b720ad2 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -75,6 +75,9 @@ class GameTreeRep : public GameExplicitRep { bool IsTree() const override { return true; } bool IsConstSum() const override; bool IsPerfectRecall() const override; + + /// Returns the smallest payoff to the player in any play of the game + Rational GetMinPayoff(const GamePlayer &) const override; //@} /// @name Players diff --git a/tests/test_players.py b/tests/test_players.py index 5409d2c4e..32e5b4294 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -143,6 +143,18 @@ def test_player_get_min_payoff(): assert game.players["Player 2"].min_payoff == 1 +def test_player_get_min_payoff_nonterminal_outcomes(): + """Test whether `min_payoff` correctly reports minimum payoffs + when there are non-terminal 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])) + assert game.players["Alice"].min_payoff == -3 + assert game.players["Bob"].min_payoff == -3 + + def test_player_get_max_payoff(): game = games.read_from_file("payoff_game.nfg") assert game.players[0].max_payoff == 10 From 6f6f43345bc9ab2a520f1d2d1e6c6f501fe5a543 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 2 Dec 2025 09:20:15 +0000 Subject: [PATCH 2/6] Implement maxpay properly for trees. --- src/games/gametree.cc | 27 ++++++++++++++++++++------- src/games/gametree.h | 2 ++ tests/test_players.py | 12 ++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 1a8f31fd7..cfdf1c483 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -763,7 +763,9 @@ bool GameTreeRep::IsPerfectRecall() const } namespace { -Rational GetSubtreeMinPayoff(const GamePlayer &p_player, const GameNode &p_node) +Rational +AggregateSubtreePayoff(const GamePlayer &p_player, const GameNode &p_node, + std::function p_aggregator) { if (p_node->IsTerminal()) { if (p_node->GetOutcome()) { @@ -772,21 +774,32 @@ Rational GetSubtreeMinPayoff(const GamePlayer &p_player, const GameNode &p_node) return Rational(0); } const auto &children = p_node->GetChildren(); - auto subtree = std::accumulate(std::next(children.begin()), children.end(), - GetSubtreeMinPayoff(p_player, children.front()), - [&p_player](const Rational &r, const GameNode &c) { - return std::min(r, GetSubtreeMinPayoff(p_player, c)); - }); + auto subtree = + std::accumulate(std::next(children.begin()), children.end(), + AggregateSubtreePayoff(p_player, children.front(), p_aggregator), + [&p_aggregator, &p_player](const Rational &r, const GameNode &c) { + return p_aggregator(r, AggregateSubtreePayoff(p_player, c, p_aggregator)); + }); if (p_node->GetOutcome()) { return subtree + p_node->GetOutcome()->GetPayoff(p_player); } return subtree; } + } // namespace Rational GameTreeRep::GetMinPayoff(const GamePlayer &p_player) const { - return GetSubtreeMinPayoff(p_player, m_root); + return AggregateSubtreePayoff( + p_player, m_root, [](const Rational &a, const Rational &b) { return std::min(a, b); }); + ; +} + +Rational GameTreeRep::GetMaxPayoff(const GamePlayer &p_player) const +{ + return AggregateSubtreePayoff( + p_player, m_root, [](const Rational &a, const Rational &b) { return std::max(a, b); }); + ; } //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index 42b720ad2..a28611a7c 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -78,6 +78,8 @@ class GameTreeRep : public GameExplicitRep { /// Returns the smallest payoff to the player in any play of the game Rational GetMinPayoff(const GamePlayer &) const override; + /// Returns the largest payoff to the player in any play of the game + Rational GetMaxPayoff(const GamePlayer &) const override; //@} /// @name Players diff --git a/tests/test_players.py b/tests/test_players.py index 32e5b4294..11481c150 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -161,3 +161,15 @@ def test_player_get_max_payoff(): assert game.players["Player 1"].max_payoff == 10 assert game.players[1].max_payoff == 8 assert game.players["Player 2"].max_payoff == 8 + + +def test_player_get_max_payoff_nonterminal_outcomes(): + """Test whether `max_payoff` correctly reports maximum payoffs + when there are non-terminal 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])) + assert game.players["Alice"].max_payoff == 1 + assert game.players["Bob"].max_payoff == 1 From e83c11bda44b0f2204b21ac9d9d097bacbaf1808 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 2 Dec 2025 09:52:44 +0000 Subject: [PATCH 3/6] Implement minpay/maxpay properly for normal forms. --- src/games/game.h | 12 ++++---- src/games/gameagg.h | 4 +-- src/games/gamebagg.h | 4 +-- src/games/gameexpl.cc | 36 +++++------------------ src/games/gameexpl.h | 4 --- src/games/gametable.cc | 23 ++++++++++++++- src/games/gametable.h | 6 ++++ src/games/gametree.cc | 65 +++++++++++++++++++---------------------- src/games/gametree.h | 4 +-- src/pygambit/gambit.pxd | 4 +-- src/pygambit/game.pxi | 24 +++++++++++++-- src/pygambit/player.pxi | 28 +++++++++++++++--- tests/test_players.py | 26 +++++++++++++++++ 13 files changed, 152 insertions(+), 88 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index 9dc724967..250bf4c7f 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -738,14 +738,14 @@ class GameRep : public std::enable_shared_from_this { /// Returns true if the game is constant-sum virtual bool IsConstSum() const = 0; - /// Returns the smallest payoff to any player in any outcome of the game + /// Returns the smallest payoff to any player in any play of the game virtual Rational GetMinPayoff() const = 0; - /// Returns the smallest payoff to the player in any outcome of the game - virtual Rational GetMinPayoff(const GamePlayer &p_player) const = 0; - /// Returns the largest payoff to any player in any outcome of the game + /// Returns the smallest payoff to the player in any play of the game + virtual Rational GetPlayerMinPayoff(const GamePlayer &p_player) const = 0; + /// Returns the largest payoff to any player in any play of the game virtual Rational GetMaxPayoff() const = 0; - /// Returns the largest payoff to the player in any outcome of the game - virtual Rational GetMaxPayoff(const GamePlayer &p_player) const = 0; + /// Returns the largest payoff to the player in any play of the game + virtual Rational GetPlayerMaxPayoff(const GamePlayer &p_player) const = 0; /// Returns the set of terminal nodes which are descendants of node virtual std::vector GetPlays(GameNode node) const { throw UndefinedException(); } diff --git a/src/games/gameagg.h b/src/games/gameagg.h index d829dbab1..6ae344161 100644 --- a/src/games/gameagg.h +++ b/src/games/gameagg.h @@ -89,11 +89,11 @@ class GameAGGRep : public GameRep { /// Returns the smallest payoff to any player in any outcome of the game Rational GetMinPayoff() const override { return Rational(aggPtr->getMinPayoff()); } /// Returns the smallest payoff to the player in any outcome of the game - Rational GetMinPayoff(const GamePlayer &) const override { throw UndefinedException(); } + Rational GetPlayerMinPayoff(const GamePlayer &) const override { throw UndefinedException(); } /// Returns the largest payoff to any player in any outcome of the game Rational GetMaxPayoff() const override { return Rational(aggPtr->getMaxPayoff()); } /// Returns the largest payoff to the player in any outcome of the game - Rational GetMaxPayoff(const GamePlayer &) const override { throw UndefinedException(); } + Rational GetPlayerMaxPayoff(const GamePlayer &) const override { throw UndefinedException(); } //@} /// @name Modification diff --git a/src/games/gamebagg.h b/src/games/gamebagg.h index a3deffc9d..1ea6eb722 100644 --- a/src/games/gamebagg.h +++ b/src/games/gamebagg.h @@ -96,11 +96,11 @@ class GameBAGGRep : public GameRep { /// Returns the smallest payoff to any player in any outcome of the game Rational GetMinPayoff() const override { return Rational(baggPtr->getMinPayoff()); } /// Returns the smallest payoff to the player in any outcome of the game - Rational GetMinPayoff(const GamePlayer &) const override { throw UndefinedException(); } + Rational GetPlayerMinPayoff(const GamePlayer &) const override { throw UndefinedException(); } /// Returns the largest payoff to any player in any outcome of the game Rational GetMaxPayoff() const override { return Rational(baggPtr->getMaxPayoff()); } /// Returns the largest payoff to the player in any outcome of the game - Rational GetMaxPayoff(const GamePlayer &) const override { throw UndefinedException(); } + Rational GetPlayerMaxPayoff(const GamePlayer &) const override { throw UndefinedException(); } //@} /// @name Writing data files diff --git a/src/games/gameexpl.cc b/src/games/gameexpl.cc index 32203faeb..7b1f6b6a1 100644 --- a/src/games/gameexpl.cc +++ b/src/games/gameexpl.cc @@ -38,39 +38,19 @@ namespace Gambit { Rational GameExplicitRep::GetMinPayoff() const { - return std::accumulate( - std::next(m_players.begin()), m_players.end(), GetMinPayoff(m_players.front()), - [this](const Rational &r, const GamePlayer &p) { return std::min(r, GetMinPayoff(p)); }); -} - -Rational GameExplicitRep::GetMinPayoff(const GamePlayer &p_player) const -{ - if (m_outcomes.empty()) { - return Rational(0); - } - return std::accumulate(std::next(m_outcomes.begin()), m_outcomes.end(), - m_outcomes.front()->GetPayoff(p_player), - [&p_player](const Rational &r, const std::shared_ptr &c) { - return std::min(r, c->GetPayoff(p_player)); + return std::accumulate(std::next(m_players.begin()), m_players.end(), + GetPlayerMinPayoff(m_players.front()), + [this](const Rational &r, const GamePlayer &p) { + return std::min(r, GetPlayerMinPayoff(p)); }); } Rational GameExplicitRep::GetMaxPayoff() const { - return std::accumulate( - std::next(m_players.begin()), m_players.end(), GetMaxPayoff(m_players.front()), - [this](const Rational &r, const GamePlayer &p) { return std::max(r, GetMaxPayoff(p)); }); -} - -Rational GameExplicitRep::GetMaxPayoff(const GamePlayer &p_player) const -{ - if (m_outcomes.empty()) { - return Rational(0); - } - return std::accumulate(std::next(m_outcomes.begin()), m_outcomes.end(), - m_outcomes.front()->GetPayoff(p_player), - [&p_player](const Rational &r, const std::shared_ptr &c) { - return std::max(r, c->GetPayoff(p_player)); + return std::accumulate(std::next(m_players.begin()), m_players.end(), + GetPlayerMaxPayoff(m_players.front()), + [this](const Rational &r, const GamePlayer &p) { + return std::max(r, GetPlayerMaxPayoff(p)); }); } diff --git a/src/games/gameexpl.h b/src/games/gameexpl.h index 4593a3f3b..808ab50bb 100644 --- a/src/games/gameexpl.h +++ b/src/games/gameexpl.h @@ -35,12 +35,8 @@ class GameExplicitRep : public GameRep { //@{ /// Returns the smallest payoff to any player in any play of the game Rational GetMinPayoff() const override; - /// Returns the smallest payoff to the player in any play of the game - Rational GetMinPayoff(const GamePlayer &) const override; /// Returns the largest payoff to any player in any play of the game Rational GetMaxPayoff() const override; - /// Returns the largest payoff to the player in any play of the game - Rational GetMaxPayoff(const GamePlayer &) const override; //@} /// @name Dimensions of the game diff --git a/src/games/gametable.cc b/src/games/gametable.cc index b4c518d86..252e5790e 100644 --- a/src/games/gametable.cc +++ b/src/games/gametable.cc @@ -307,7 +307,8 @@ bool GameTableRep::IsConstSum() const sum += profile->GetPayoff(player); } - for (auto iter : StrategyContingencies(std::const_pointer_cast(shared_from_this()))) { + for (const auto iter : + StrategyContingencies(std::const_pointer_cast(shared_from_this()))) { Rational newsum(0); for (const auto &player : m_players) { newsum += iter->GetPayoff(player); @@ -319,6 +320,26 @@ bool GameTableRep::IsConstSum() const return true; } +Rational GameTableRep::GetPlayerMinPayoff(const GamePlayer &p_player) const +{ + Rational minpay = NewPureStrategyProfile()->GetPayoff(p_player); + for (const auto &profile : + StrategyContingencies(std::const_pointer_cast(shared_from_this()))) { + minpay = std::min(minpay, profile->GetPayoff(p_player)); + } + return minpay; +} + +Rational GameTableRep::GetPlayerMaxPayoff(const GamePlayer &p_player) const +{ + Rational maxpay = NewPureStrategyProfile()->GetPayoff(p_player); + for (const auto &profile : + StrategyContingencies(std::const_pointer_cast(shared_from_this()))) { + maxpay = std::max(maxpay, profile->GetPayoff(p_player)); + } + return maxpay; +} + //------------------------------------------------------------------------ // GameTableRep: Writing data files //------------------------------------------------------------------------ diff --git a/src/games/gametable.h b/src/games/gametable.h index 874851d70..3aab1dd38 100644 --- a/src/games/gametable.h +++ b/src/games/gametable.h @@ -57,6 +57,12 @@ class GameTableRep : public GameExplicitRep { //@{ bool IsTree() const override { return false; } bool IsConstSum() const override; + + /// Returns the smallest payoff to the player in any play of the game + Rational GetPlayerMinPayoff(const GamePlayer &) const override; + /// Returns the largest payoff to the player in any play of the game + Rational GetPlayerMaxPayoff(const GamePlayer &) const override; + bool IsPerfectRecall() const override { return true; } //@} diff --git a/src/games/gametree.cc b/src/games/gametree.cc index cfdf1c483..973ced66d 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -719,10 +719,10 @@ Rational SubtreeSum(GameNode p_node) Rational sum(0); if (!p_node->IsTerminal()) { - auto children = p_node->GetChildren(); + const auto children = p_node->GetChildren(); sum = SubtreeSum(children.front()); if (std::any_of(std::next(children.begin()), children.end(), - [sum](GameNode n) { return SubtreeSum(n) != sum; })) { + [sum](const GameNode &n) { return SubtreeSum(n) != sum; })) { throw NotZeroSumException(); } } @@ -735,34 +735,6 @@ Rational SubtreeSum(GameNode p_node) return sum; } -} // end anonymous namespace - -bool GameTreeRep::IsConstSum() const -{ - try { - SubtreeSum(m_root); - return true; - } - catch (NotZeroSumException &) { - return false; - } -} - -bool GameTreeRep::IsPerfectRecall() const -{ - if (m_infosetParents.empty() && !m_root->IsTerminal()) { - const_cast(this)->BuildInfosetParents(); - } - - if (GetRoot()->IsTerminal()) { - return true; - } - - return std::all_of(m_infosetParents.cbegin(), m_infosetParents.cend(), - [](const auto &pair) { return pair.second.size() <= 1; }); -} - -namespace { Rational AggregateSubtreePayoff(const GamePlayer &p_player, const GameNode &p_node, std::function p_aggregator) @@ -786,20 +758,43 @@ AggregateSubtreePayoff(const GamePlayer &p_player, const GameNode &p_node, return subtree; } -} // namespace +} // end anonymous namespace + +bool GameTreeRep::IsConstSum() const +{ + try { + SubtreeSum(m_root); + return true; + } + catch (NotZeroSumException &) { + return false; + } +} -Rational GameTreeRep::GetMinPayoff(const GamePlayer &p_player) const +Rational GameTreeRep::GetPlayerMinPayoff(const GamePlayer &p_player) const { return AggregateSubtreePayoff( p_player, m_root, [](const Rational &a, const Rational &b) { return std::min(a, b); }); - ; } -Rational GameTreeRep::GetMaxPayoff(const GamePlayer &p_player) const +Rational GameTreeRep::GetPlayerMaxPayoff(const GamePlayer &p_player) const { return AggregateSubtreePayoff( p_player, m_root, [](const Rational &a, const Rational &b) { return std::max(a, b); }); - ; +} + +bool GameTreeRep::IsPerfectRecall() const +{ + if (m_infosetParents.empty() && !m_root->IsTerminal()) { + const_cast(this)->BuildInfosetParents(); + } + + if (GetRoot()->IsTerminal()) { + return true; + } + + return std::all_of(m_infosetParents.cbegin(), m_infosetParents.cend(), + [](const auto &pair) { return pair.second.size() <= 1; }); } //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index a28611a7c..923253241 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -77,9 +77,9 @@ class GameTreeRep : public GameExplicitRep { bool IsPerfectRecall() const override; /// Returns the smallest payoff to the player in any play of the game - Rational GetMinPayoff(const GamePlayer &) const override; + Rational GetPlayerMinPayoff(const GamePlayer &) const override; /// Returns the largest payoff to the player in any play of the game - Rational GetMaxPayoff(const GamePlayer &) const override; + Rational GetPlayerMaxPayoff(const GamePlayer &) const override; //@} /// @name Players diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index b3ef8faf2..d71cca3e1 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -290,9 +290,9 @@ cdef extern from "games/game.h": bool IsConstSum() except + c_Rational GetMinPayoff() except + - c_Rational GetMinPayoff(c_GamePlayer) except + + c_Rational GetPlayerMinPayoff(c_GamePlayer) except + c_Rational GetMaxPayoff() except + - c_Rational GetMaxPayoff(c_GamePlayer) except + + c_Rational GetPlayerMaxPayoff(c_GamePlayer) except + stdvector[c_GameNode] GetPlays(c_GameNode) except + stdvector[c_GameNode] GetPlays(c_GameInfoset) except + stdvector[c_GameNode] GetPlays(c_GameAction) except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 4dd1de5b8..adb728e5e 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -790,12 +790,32 @@ class Game: @property def min_payoff(self) -> typing.Union[decimal.Decimal, Rational]: - """The minimum payoff in the game.""" + """The minimum payoff to any player in any play of the game. + + .. versionchanged:: 16.5.0 + Changed from reporting minimum payoff in any (non-null) outcome to the minimum + payoff in any play of the game. + + See also + -------- + Game.max_payoff + Player.min_payoff + """ return rat_to_py(self.game.deref().GetMinPayoff()) @property def max_payoff(self) -> typing.Union[decimal.Decimal, Rational]: - """The maximum payoff in the game.""" + """The maximum payoff to any player in any play of the game. + + .. versionchanged:: 16.5.0 + Changed from reporting maximum payoff in any (non-null) outcome to the maximum + payoff in any play of the game. + + See also + -------- + Game.min_payoff + Player.max_payoff + """ return rat_to_py(self.game.deref().GetMaxPayoff()) def set_chance_probs(self, infoset: typing.Union[Infoset, str], probs: typing.Sequence): diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 13264fbe6..b72b136c9 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -241,10 +241,30 @@ class Player: @property def min_payoff(self) -> Rational: - """Returns the smallest payoff for the player in any outcome of the game.""" - return rat_to_py(self.player.deref().GetGame().deref().GetMinPayoff(self.player)) + """Returns the smallest payoff for the player in any play of the game. + + .. versionchanged:: 16.5.0 + Changed from reporting minimum payoff in any (non-null) outcome to the minimum + payoff in any play of the game. + + See also + -------- + Player.max_payoff + Game.min_payoff + """ + return rat_to_py(self.player.deref().GetGame().deref().GetPlayerMinPayoff(self.player)) @property def max_payoff(self) -> Rational: - """Returns the largest payoff for the player in any outcome of the game.""" - return rat_to_py(self.player.deref().GetGame().deref().GetMaxPayoff(self.player)) + """Returns the largest payoff for the player in any play of the game. + + .. versionchanged:: 16.5.0 + Changed from reporting maximum payoff in any (non-null) outcome to the maximum + payoff in any play of the game. + + See also + -------- + Player.min_payoff + Game.max_payoff + """ + return rat_to_py(self.player.deref().GetGame().deref().GetPlayerMaxPayoff(self.player)) diff --git a/tests/test_players.py b/tests/test_players.py index 11481c150..3a51c0ced 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -155,6 +155,19 @@ def test_player_get_min_payoff_nonterminal_outcomes(): assert game.players["Bob"].min_payoff == -3 +def test_player_get_min_payoff_null_outcome(): + """Test whether `min_payoff` correctly reports minimum payoffs + in a strategic game with a null outcome.""" + game = gbt.Game.from_arrays([[1, 1], [1, 1]], [[2, 2], [2, 2]]) + assert game.players[0].min_payoff == 1 + assert game.players[1].min_payoff == 2 + game.add_strategy(game.players[0]) + # 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.min_payoff == 0 + + def test_player_get_max_payoff(): game = games.read_from_file("payoff_game.nfg") assert game.players[0].max_payoff == 10 @@ -173,3 +186,16 @@ def test_player_get_max_payoff_nonterminal_outcomes(): game.set_outcome(game.root, game.add_outcome([-1, -1])) assert game.players["Alice"].max_payoff == 1 assert game.players["Bob"].max_payoff == 1 + + +def test_player_get_max_payoff_null_outcome(): + """Test whether `max_payoff` correctly reports maximum payoffs + in a strategic game with a null outcome.""" + game = gbt.Game.from_arrays([[-1, -1], [-1, -1]], [[-2, -2], [-2, -2]]) + assert game.players[0].max_payoff == -1 + assert game.players[1].max_payoff == -2 + game.add_strategy(game.players[0]) + # 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 From a667fa390c8b5009e8a00f28bdadc4db5fe70ba8 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 2 Dec 2025 09:57:01 +0000 Subject: [PATCH 4/6] Updated ChangeLog --- ChangeLog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ChangeLog b/ChangeLog index 6a0fe36e2..d0a581bab 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,8 @@ are always drawn and indicators are always drawn if an information set spans multiple levels. - In `pygambit`, indexing the children of a node by a string inteprets the string as an action label, not a label of a child node. In addition, indexing by an action object is now supported. (#587) +- In `pygambit`, `min_payoff` and `max_payoff` (for both games and players) now refers to payoffs in + any play of the game; previously this referred only to the set of outcomes. (#498) ### Added - Tests for EFG Nash solvers -- `enumpoly_solve`, `lp_solve`, `lcp_solve` -- in behavior stratgegies From 44ed576c3b1be68edf446682cd74775b57c4c79c Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 2 Dec 2025 10:00:11 +0000 Subject: [PATCH 5/6] Added header for std::function --- src/games/gametree.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 973ced66d..4380b8f78 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -22,6 +22,7 @@ #include #include +#include #include #include #include From 1cf0920b7eda3e71d5738834386f397b93d9f0bf Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Tue, 2 Dec 2025 19:09:38 +0000 Subject: [PATCH 6/6] new tests for {min,max}_payoff, {lp,lcp}_solve with EFGs w/ non-terminal/missing terminal outcomes --- tests/games.py | 244 +++++++++++++++++++++++++++++-- tests/test_extensive.py | 19 +++ tests/test_games/payoff_game.nfg | 4 - tests/test_nash.py | 156 ++++++++++++++++++-- tests/test_players.py | 37 +++-- tests/test_strategic.py | 8 +- 6 files changed, 419 insertions(+), 49 deletions(-) delete mode 100644 tests/test_games/payoff_game.nfg diff --git a/tests/games.py b/tests/games.py index d08178be2..c08e6497d 100644 --- a/tests/games.py +++ b/tests/games.py @@ -100,6 +100,49 @@ def create_coord_4x4_nfg(outcome_version: bool = False) -> gbt.Game: # Extensive-form games (efg) +def create_2x2_zero_sum_efg(missing_term_outcome=False) -> gbt.Game: + """ + EFG corresponding to 2x2 zero-sum game (I,-I). + If missing_term_outcome, the terminal node after "T" then "r" does not have an outcome. + """ + g = gbt.Game.new_tree( + players=["Alice", "Bob"], title="2x2 matrix games (I,-I)") + g.append_move(g.root, "Alice", ["T", "B"]) + g.append_move(g.root.children, "Bob", ["l", "r"]) + + alice_win = g.add_outcome([1, -1], label="Alice win") + draw = g.add_outcome([0, 0], label="Draw") + + g.set_outcome(g.root.children["T"].children["l"], alice_win) + g.set_outcome(g.root.children["B"].children["r"], alice_win) + g.set_outcome(g.root.children["B"].children["l"], draw) + + if not missing_term_outcome: + g.set_outcome(g.root.children["T"].children["r"], draw) + + return g + + +def create_matching_pennies_efg(with_neutral_outcome=False) -> gbt.Game: + """ + The version with_neutral_outcome adds a (0,0) payoff outcomes at a non-terminal node. + """ + g = gbt.Game.new_tree( + players=["Alice", "Bob"], title="Matching pennies") + g.append_move(g.root, "Alice", ["H", "T"]) + g.append_move(g.root.children, "Bob", ["h", "t"]) + alice_lose = g.add_outcome([-1, 1], label="Alice lose") + alice_win = g.add_outcome([1, -1], label="Alice win") + if with_neutral_outcome: + neutral = g.add_outcome([0, 0], label="neutral") + g.set_outcome(g.root.children["H"], neutral) + g.set_outcome(g.root.children["H"].children["h"], alice_win) + g.set_outcome(g.root.children["T"].children["t"], alice_win) + g.set_outcome(g.root.children["H"].children["t"], alice_lose) + g.set_outcome(g.root.children["T"].children["h"], alice_lose) + return g + + def create_mixed_behav_game_efg() -> gbt.Game: """ Returns @@ -113,7 +156,7 @@ def create_mixed_behav_game_efg() -> gbt.Game: return read_from_file("mixed_behavior_game.efg") -def create_stripped_down_poker_efg() -> gbt.Game: +def create_stripped_down_poker_efg(nonterm_outcomes=False) -> gbt.Game: """ Returns ------- @@ -121,20 +164,61 @@ def create_stripped_down_poker_efg() -> gbt.Game: Stripped-Down Poker: A Classroom Game with Signaling and Bluffing Reiley et al (2008) - Two-player extensive-form poker game between Fred and Alice + Two-player extensive-form poker game between Alice and Bob Chance deals King or Queen to Fred - Fred can then Bet or Fold; after raising Alice is in an infoset with two nodes + Alice can then Bet or Fold; after raising Bob is in an infoset with two nodes and can choose to Call or Fold """ - return read_from_file("stripped_down_poker.efg") + if not nonterm_outcomes: + return read_from_file("stripped_down_poker.efg") + + g = gbt.Game.new_tree( + players=["Alice", "Bob"], title="Stripped-Down Poker: a simple game of one-card\ + poker from Reiley et al (2008)." + ) + deals = ["King", "Queen"] + g.append_move(g.root, g.players.chance, deals) + + ante_outcome = g.add_outcome([-1, -1], label="Ante") + 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") -def create_kuhn_poker_efg() -> gbt.Game: + for node in g.root.children: + g.append_move( + node, + player="Alice", + actions=["Bet", "Fold"] + ) + g.set_outcome(node.children["Fold"], alice_folds_outcome) + g.set_outcome(node.children["Bet"], alice_bets_outcome) + + alice_bets_nodes = [g.root.children["King"].children["Bet"], + g.root.children["Queen"].children["Bet"]] + g.append_move( + alice_bets_nodes, + player="Bob", + actions=["Call", "Fold"] + ) + for node in alice_bets_nodes: + g.set_outcome(node.children["Fold"], bob_folds_outcome) + + bob_calls_and_loses_node = g.root.children["King"].children["Bet"].children["Call"] + g.set_outcome(bob_calls_and_loses_node, bob_calls_and_loses_outcome) + bob_calls_and_wins_node = g.root.children["Queen"].children["Bet"].children["Call"] + g.set_outcome(bob_calls_and_wins_node, bob_calls_and_wins_outcome) + # g.to_efg("stripped_down_poker_nonterminal_outcomes.efg") + + return g + + +def _create_kuhn_poker_efg_without_outcomes(): """ - Returns - ------- - Game - Kuhn poker with 3 cards and 2 players + Used in create_kuhn_poker_efg() """ g = gbt.Game.new_tree( players=["Alice", "Bob"], title="Three-card poker (J, Q, K), two-player" @@ -167,6 +251,26 @@ def deals_by_infoset(player, card): term_nodes = [g.root.children[d].children["Bet"] for d in deals_by_infoset("Bob", bob_card)] g.append_move(term_nodes, "Bob", ["Fold", "Call"]) + return g + + +def _kuhn_showdown_winner(deal: str): + """ + Used in: + _create_kuhn_poker_efg_only_term_outcomes(); + _create_kuhn_poker_efg_nonterm_outcomes() + """ + # deal is an element of deals = ["JQ", "JK", "QJ", "QK", "KJ", "KQ"] + card_values = dict(J=0, Q=1, K=2) + a, b = deal + return "Alice" if card_values[a] > card_values[b] else "Bob" + + +def _create_kuhn_poker_efg_only_term_outcomes() -> gbt.Game: + """ + Used in create_kuhn_poker_efg() + """ + g = _create_kuhn_poker_efg_without_outcomes() def calculate_payoffs(term_node): @@ -177,14 +281,8 @@ def get_path(node): node = node.parent return path - def showdown_winner(deal): - # deal is an element of deals = ["JQ", "JK", "QJ", "QK", "KJ", "KQ"] - card_values = dict(J=0, Q=1, K=2) - a, b = deal - return "Alice" if card_values[a] > card_values[b] else "Bob" - def showdown(deal, payoffs, pot): - payoffs[showdown_winner(deal)] += pot + payoffs[_kuhn_showdown_winner(deal)] += pot return payoffs def bet(player, payoffs, pot): @@ -226,11 +324,125 @@ def bet(player, payoffs, pot): outcome = payoffs_to_outcomes[calculate_payoffs(term_node)] g.set_outcome(term_node, outcome) + return g + + +def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: + """ + Used in create_kuhn_poker_efg() + """ + g = _create_kuhn_poker_efg_without_outcomes() + + ante_outcome = g.add_outcome([-1, -1], label="Ante") + g.set_outcome(g.root, ante_outcome) + + outcomes_dict = dict() + for player in ["Alice", "Bob"]: + # 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) + + # 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) + + # 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) + + # 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) + + # 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) + + def add_outcomes(term_node): + + def get_path(node): + path = [] + while node.parent: + path.append((node, node.prior_action.label)) + node = node.parent + return path + + path = get_path(term_node) + _, deal = path.pop() + winner = _kuhn_showdown_winner(deal) # needed if there is a showdown + + n, label = path.pop() + if label == "Check": # Alice checks + n, label = path.pop() + if label == "Check": # Bob checks + g.set_outcome(n, outcomes_dict[f"{winner} wins showdown for pot of 2"]) + else: # Bob bets + g.set_outcome(n, outcomes_dict["Bob bets"]) + n, label = path.pop() + if label == "Fold": # Alice folds + g.set_outcome(n, outcomes_dict["Alice folds"]) + else: # Alice calls + tmp = "wins" if winner == "Alice" else "loses" + g.set_outcome(n, outcomes_dict[f"Alice calls and {tmp}"]) + else: # Alice bets + g.set_outcome(n, outcomes_dict["Alice bets"]) + n, label = path.pop() + if label == "Fold": # Bob + g.set_outcome(n, outcomes_dict["Bob folds"]) + else: # Bob calls + tmp = "wins" if winner == "Bob" else "loses" + g.set_outcome(n, outcomes_dict[f"Bob calls and {tmp}"]) + + for term_node in [n for n in g.nodes if n.is_terminal]: + add_outcomes(term_node) + + return g + + +def create_kuhn_poker_efg(nonterm_outcomes=False) -> gbt.Game: + """ + Returns + ------- + Game + Kuhn poker with 3 cards and 2 players + + If nonterm_outcomes is True then the ante and bets are captured with nonterminal + outcomes; else the only outcomes are at terminal nodes. + In both cases, all terminal nodes have outcomes. + """ + if nonterm_outcomes: + g = _create_kuhn_poker_efg_nonterm_outcomes() + else: + g = _create_kuhn_poker_efg_only_term_outcomes() + # Ensure infosets are in the same order as if game was written to efg and read back in g.sort_infosets() return g +def kuhn_poker_lp_mixed_strategy_prof(): + """ + Returns + ------- + Data for the extreme equilibrium in mixed stategies for Kuhn poker found by lp_solve + """ + alice = [0] * 27 + alice[2] = "6/15" + alice[4] = "7/15" + alice[19] = "2/15" + + bob = [0] * 64 + bob[12] = "1/3" + bob[14] = "1/3" + bob[28] = "1/3" + return [alice, bob] + + def kuhn_poker_lcp_first_mixed_strategy_prof(): """ Returns diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 416621732..2f943ace5 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -171,6 +171,17 @@ def test_outcome_index_exception_label(): np.array([[[1, 1], [0, 1]], [[2, 0], [2, 0]]]), ], ), + # EFG for 2x2 zero-sum game (I,-I) where the second version is missing a terminal outcome + ( + games.create_2x2_zero_sum_efg(), + [["1", "2"], ["1", "2"]], + [np.array([[1, 0], [0, 1]]), np.array([[-1, 0], [0, -1]])] + ), + ( + games.create_2x2_zero_sum_efg(missing_term_outcome=True), + [["1", "2"], ["1", "2"]], + [np.array([[1, 0], [0, 1]]), np.array([[-1, 0], [0, -1]])] + ), # 2-player (zero-sum) game; reduction for both players; generic payoffs ( games.create_reduction_generic_payoffs_efg(), @@ -292,6 +303,14 @@ def test_outcome_index_exception_label(): np.array([[0, -1], ["-1/2", 0], ["3/2", 0], [1, 1]]), ], ), + ( + games.create_stripped_down_poker_efg(nonterm_outcomes=True), + [["11", "12", "21", "22"], ["1", "2"]], + [ + np.array([[0, 1], ["1/2", 0], ["-3/2", 0], [-1, -1]]), + np.array([[0, -1], ["-1/2", 0], ["3/2", 0], [1, 1]]), + ], + ), # Nature playing at the root, 2 players, no reduction, non-generic payoffs ( games.read_from_file("nature_rooted_nongeneric.efg"), diff --git a/tests/test_games/payoff_game.nfg b/tests/test_games/payoff_game.nfg deleted file mode 100644 index 96d3b1b94..000000000 --- a/tests/test_games/payoff_game.nfg +++ /dev/null @@ -1,4 +0,0 @@ -NFG 1 R "Test payoff game" -{ "Player 1" "Player 2" } { 2 2 } - -7 3 4 1 5 4 10 8 diff --git a/tests/test_nash.py b/tests/test_nash.py index 7fbf7c02b..375169db8 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -246,8 +246,28 @@ def test_lcp_strategy_double(): "game,mixed_strategy_prof_data,stop_after", [ # Zero-sum games + ( + games.create_2x2_zero_sum_efg(), + [[["1/2", "1/2"], ["1/2", "1/2"]]], + None + ), + ( + games.create_2x2_zero_sum_efg(missing_term_outcome=True), + [[["1/2", "1/2"], ["1/2", "1/2"]]], + None + ), (games.create_stripped_down_poker_efg(), [[["1/3", "2/3", 0, 0], ["2/3", "1/3"]]], None), + ( + games.create_stripped_down_poker_efg(nonterm_outcomes=True), + [[["1/3", "2/3", 0, 0], ["2/3", "1/3"]]], + None + ), (games.create_kuhn_poker_efg(), [games.kuhn_poker_lcp_first_mixed_strategy_prof()], 1), + ( + games.create_kuhn_poker_efg(nonterm_outcomes=True), + [games.kuhn_poker_lcp_first_mixed_strategy_prof()], + 1 + ), # Non-zero-sum games (games.create_one_shot_trust_efg(), [[[0, 1], ["1/2", "1/2"]]], None), ( @@ -316,7 +336,28 @@ def test_lcp_behavior_double(): "game,mixed_behav_prof_data", [ # Zero-sum games (also tested with lp solve) + ( + games.create_2x2_zero_sum_efg(), + [[["1/2", "1/2"]], [["1/2", "1/2"]]] + ), + pytest.param( + games.create_2x2_zero_sum_efg(missing_term_outcome=True), + [[["1/2", "1/2"]], [["1/2", "1/2"]]], + marks=pytest.mark.xfail(reason="Problem with missing terminal outcome in LP/LCP") + ), + (games.create_matching_pennies_efg(), + [[["1/2", "1/2"]], [["1/2", "1/2"]]]), + pytest.param( + games.create_matching_pennies_efg(with_neutral_outcome=True), + [[["1/2", "1/2"]], [["1/2", "1/2"]]], + marks=pytest.mark.xfail(reason="Problem with nonterminal nodes in LP/LCP") + ), (games.create_stripped_down_poker_efg(), [[[1, 0], ["1/3", "2/3"]], [["2/3", "1/3"]]]), + pytest.param( + games.create_stripped_down_poker_efg(nonterm_outcomes=True), + [[[1, 0], ["1/3", "2/3"]], [["2/3", "1/3"]]], + marks=pytest.mark.xfail(reason="Problem with missing terminal outcome in LP/LCP") + ), ( games.create_kuhn_poker_efg(), [ @@ -331,6 +372,21 @@ def test_lcp_behavior_double(): [[1, 0], ["2/3", "1/3"], [0, 1], [0, 1], ["2/3", "1/3"], [1, 0]], ], ), + pytest.param( + games.create_kuhn_poker_efg(nonterm_outcomes=True), + [ + [ + ["2/3", "1/3"], + [1, 0], + [1, 0], + ["1/3", "2/3"], + [0, 1], + ["1/2", "1/2"], + ], + [[1, 0], ["2/3", "1/3"], [0, 1], [0, 1], ["2/3", "1/3"], [1, 0]], + ], + marks=pytest.mark.xfail(reason="Problem with missing terminal outcome in LP/LCP") + ), # In the next test case: # 1/2-1/2 for l/r is determined by MixedBehaviorProfile.UndefinedToCentroid() ( @@ -380,19 +436,37 @@ def test_lp_strategy_double(): @pytest.mark.nash @pytest.mark.nash_lp_strategy -def test_lp_strategy_rational(): +@pytest.mark.parametrize( + "game,mixed_strategy_prof_data", + [ + ( + games.create_2x2_zero_sum_efg(), + [["1/2", "1/2"], ["1/2", "1/2"]], + ), + ( + games.create_2x2_zero_sum_efg(missing_term_outcome=True), + [["1/2", "1/2"], ["1/2", "1/2"]], + ), + (games.create_stripped_down_poker_efg(), [["1/3", "2/3", 0, 0], ["2/3", "1/3"]]), + ( + games.create_stripped_down_poker_efg(nonterm_outcomes=True), + [["1/3", "2/3", 0, 0], ["2/3", "1/3"]] + ), + (games.create_kuhn_poker_efg(), games.kuhn_poker_lp_mixed_strategy_prof()), + ( + games.create_kuhn_poker_efg(nonterm_outcomes=True), + games.kuhn_poker_lp_mixed_strategy_prof() + ), + ], +) +def test_lp_strategy_rational(game: gbt.Game, mixed_strategy_prof_data: list): """Test calls of LP for mixed strategy equilibria, rational precision.""" - game = games.read_from_file("stripped_down_poker.efg") result = gbt.nash.lp_solve(game, use_strategic=True, rational=True) assert len(result.equilibria) == 1 - expected = game.mixed_strategy_profile( - rational=True, - data=[ - [gbt.Rational(1, 3), gbt.Rational(2, 3), gbt.Rational(0), gbt.Rational(0)], - [gbt.Rational(2, 3), gbt.Rational(1, 3)], - ], - ) - assert result.equilibria[0] == expected + eq = result.equilibria[0] + assert eq.max_regret() == 0 + expected = game.mixed_strategy_profile(rational=True, data=mixed_strategy_prof_data) + assert eq == expected def test_lp_behavior_double(): @@ -412,10 +486,31 @@ def test_lp_behavior_double(): games.create_two_player_perfect_info_win_lose_efg(), [[[0, 1], [1, 0]], [[1, 0], [1, 0]]], ), + ( + games.create_2x2_zero_sum_efg(missing_term_outcome=False), + [[["1/2", "1/2"]], [["1/2", "1/2"]]] + ), + pytest.param( + games.create_2x2_zero_sum_efg(missing_term_outcome=True), + [[["1/2", "1/2"]], [["1/2", "1/2"]]], + marks=pytest.mark.xfail(reason="Problem with missing terminal outcome in LP/LCP") + ), + (games.create_matching_pennies_efg(with_neutral_outcome=False), + [[["1/2", "1/2"]], [["1/2", "1/2"]]]), + pytest.param( + games.create_matching_pennies_efg(with_neutral_outcome=True), + [[["1/2", "1/2"]], [["1/2", "1/2"]]], + marks=pytest.mark.xfail(reason="Problem with nonterminal nodes in LP/LCP") + ), ( games.create_stripped_down_poker_efg(), [[[1, 0], ["1/3", "2/3"]], [["2/3", "1/3"]]], ), + pytest.param( + games.create_stripped_down_poker_efg(nonterm_outcomes=True), + [[[1, 0], ["1/3", "2/3"]], [["2/3", "1/3"]]], + marks=pytest.mark.xfail(reason="Problem with nonterminal nodes in LP/LCP") + ), ( games.create_kuhn_poker_efg(), [ @@ -423,6 +518,21 @@ def test_lp_behavior_double(): [[1, 0], ["2/3", "1/3"], [0, 1], [0, 1], ["2/3", "1/3"], [1, 0]], ], ), + pytest.param( + games.create_kuhn_poker_efg(nonterm_outcomes=True), + [ + [ + ["2/3", "1/3"], + [1, 0], + [1, 0], + ["1/3", "2/3"], + [0, 1], + ["1/2", "1/2"], + ], + [[1, 0], ["2/3", "1/3"], [0, 1], [0, 1], ["2/3", "1/3"], [1, 0]], + ], + marks=pytest.mark.xfail(reason="Problem with nonterminal nodes in LP/LCP") + ), ( games.create_seq_form_STOC_paper_zero_sum_2_player_efg(), [ @@ -542,3 +652,29 @@ def test_logit_solve_lambda(): game = games.read_from_file("const_sum_game.nfg") assert len(gbt.qre.logit_solve_lambda( game=game, lam=[1, 2, 3], first_step=0.2, max_accel=1)) > 0 + + +def test_kuhn(): + """ + TEMPORARY + + Check that the reduced strategic forms match for the versions with and without + nonterminal nodes + """ + old = games.create_kuhn_poker_efg(nonterm_outcomes=False) + new = games.create_kuhn_poker_efg(nonterm_outcomes=True) + for i in [0, 1]: + assert (old.to_arrays()[i] == new.to_arrays()[i]).all() + + +def test_stripped(): + """ + TEMPORARY + + Check that the reduced strategic forms match for the versions with and without + nonterminal nodes + """ + old = games.create_stripped_down_poker_efg() + new = games.create_stripped_down_poker_efg(nonterm_outcomes=True) + for i in [0, 1]: + assert (old.to_arrays()[i] == new.to_arrays()[i]).all() diff --git a/tests/test_players.py b/tests/test_players.py index 3a51c0ced..9b02d0e8d 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -39,7 +39,6 @@ def test_player_index_by_string(): def test_player_index_out_of_range(): game = gbt.Game.new_table([2, 2]) - print(f"Number of players: {len(game.players)}") assert len(game.players) == 2 with pytest.raises(IndexError): _ = game.players[2] @@ -135,12 +134,28 @@ def test_player_strategy_bad_type(): _ = game.players[0].strategies[1.3] -def test_player_get_min_payoff(): - game = games.read_from_file("payoff_game.nfg") - assert game.players[0].min_payoff == 4 - assert game.players["Player 1"].min_payoff == 4 - assert game.players[1].min_payoff == 1 - assert game.players["Player 2"].min_payoff == 1 +@pytest.mark.parametrize( + "game,exp_min_payoffs,exp_max_payoffs", + [ + # NFGs + ( + games.read_from_file("2x2x2_nfg_with_two_pure_one_mixed_eq.nfg"), + [-1, 0, -1], + [2, 4, 2] + ), + (games.read_from_file("mixed_strategy.nfg"), [0, 0], [2, 3]), + # EFGs only terminal outcomes + (games.create_kuhn_poker_efg(), [-2, -2], [2, 2]), + (games.create_stripped_down_poker_efg(), [-2, -2], [2, 2]), + # with non-terminal outcomes + (games.create_kuhn_poker_efg(nonterm_outcomes=True), [-2, -2], [2, 2]), + (games.create_stripped_down_poker_efg(nonterm_outcomes=True), [-2, -2], [2, 2]), + ], +) +def test_player_get_min_max_payoff(game: gbt.Game, exp_min_payoffs: list, exp_max_payoffs: list): + for i in range(len(game.players)): + assert game.players[i].min_payoff == exp_min_payoffs[i] + assert game.players[i].max_payoff == exp_max_payoffs[i] def test_player_get_min_payoff_nonterminal_outcomes(): @@ -168,14 +183,6 @@ def test_player_get_min_payoff_null_outcome(): assert player.min_payoff == 0 -def test_player_get_max_payoff(): - game = games.read_from_file("payoff_game.nfg") - assert game.players[0].max_payoff == 10 - assert game.players["Player 1"].max_payoff == 10 - assert game.players[1].max_payoff == 8 - assert game.players["Player 2"].max_payoff == 8 - - def test_player_get_max_payoff_nonterminal_outcomes(): """Test whether `max_payoff` correctly reports maximum payoffs when there are non-terminal outcomes. diff --git a/tests/test_strategic.py b/tests/test_strategic.py index 5b453ce78..f1c025509 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -45,10 +45,10 @@ def test_game_is_not_const_sum(): def test_game_get_min_payoff(): - game = games.read_from_file("payoff_game.nfg") - assert game.min_payoff == 1 + game = games.read_from_file("mixed_strategy.nfg") + assert game.min_payoff == 0 def test_game_get_max_payoff(): - game = games.read_from_file("payoff_game.nfg") - assert game.max_payoff == 10 + game = games.read_from_file("mixed_strategy.nfg") + assert game.max_payoff == 3