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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- `gambit-ipa` now supports verbose output via the `-V` flag, reporting on intermediate progress. (#501)
- Added `-R` option to `gambit-liap`, `gambit-simpdiv`, `gambit-ipa`, and `gambit-gnm` to reproducibly seed
random starting point generation. (#448)
- Added `Game.relabel_players`, which simultaneously reassigns the labels of the game's players. (#1058)

### Fixed
- `Game.add_player`, on a game in strategic form, no longer leaves the new player's sole
Expand Down Expand Up @@ -36,6 +37,8 @@
were subsumed by `SetStrategies`, and by this point were used only in support of `pygambit`'s
`add_strategy`/`delete_strategy` and the GUI's strategy add/delete controls, which now build the
equivalent `SetStrategies` call directly. (#1056)
- Assigning to `Player.label` has been removed; use `Game.relabel_players`, which enforces
nonempty, unique labels. (#1058)


## [17.0.0-alpha.1] - 2026-08-13
Expand Down
1 change: 1 addition & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Transforming game components
:toctree: api/

Game.add_player
Game.relabel_players
Game.add_outcome
Game.delete_outcome
Game.set_outcome
Expand Down
9 changes: 8 additions & 1 deletion doc/tutorials/01_quickstart.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,14 @@
"id": "9d8203e8",
"metadata": {},
"outputs": [],
"source": "tom, jerry = g.players\ntom.label = \"Tom\"\njerry.label = \"Jerry\"\n\nfor player in g.players:\n cooperate, defect = player.strategies\n g.relabel_strategies(player, {cooperate.label: \"Cooperate\", defect.label: \"Defect\"})"
"source": [
"tom, jerry = g.players\n",
"g.relabel_players({tom.label: \"Tom\", jerry.label: \"Jerry\"})\n",
"\n",
"for player in g.players:\n",
" cooperate, defect = player.strategies\n",
" g.relabel_strategies(player, {cooperate.label: \"Cooperate\", defect.label: \"Defect\"})"
]
},
{
"cell_type": "markdown",
Expand Down
30 changes: 10 additions & 20 deletions src/games/file.cc
Original file line number Diff line number Diff line change
Expand Up @@ -375,24 +375,6 @@ template <class Container> void NormalizeLabelStrings(Container &p_labels)
[](std::string &s, const std::string &v) { s = v; });
}

template <class Container>
void RelabelWithoutCollision(Container &&p_container, const std::vector<std::string> &p_labels)
{
const std::set<std::string> forbidden(p_labels.begin(), p_labels.end());
size_t scratchIndex = 0;
for (auto &&element : p_container) {
std::string candidate;
do {
candidate = "_gambit_reader_scratch_" + std::to_string(scratchIndex++);
} while (forbidden.contains(candidate));
element->SetLabel(candidate);
}
size_t index = 0;
for (auto &&element : p_container) {
element->SetLabel(p_labels[index++]);
}
}

void ReadPlayers(GameFileLexer &p_state, TableFileGame &p_data)
{
p_state.ExpectNextToken(TOKEN_LBRACE, "'{'");
Expand Down Expand Up @@ -538,7 +520,12 @@ Game BuildNfg(GameFileLexer &p_parser, TableFileGame &p_data)
playerLabels.push_back(p_data.GetPlayer(player->GetNumber()));
}
NormalizeLabelStrings(playerLabels);
RelabelWithoutCollision(nfg->GetPlayers(), playerLabels);
std::map<std::string, std::string> playerRelabels;
size_t playerIndex = 0;
for (auto player : nfg->GetPlayers()) {
playerRelabels[player->GetLabel()] = playerLabels[playerIndex++];
}
nfg->RelabelPlayers(playerRelabels);

for (auto player : nfg->GetPlayers()) {
std::vector<std::string> strategyLabels;
Expand Down Expand Up @@ -907,7 +894,10 @@ void NormalizeGameLabels(const Game &p_game)
{
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);
const auto relabel_player = [&p_game](const auto &e, const std::string &s) {
p_game->RelabelPlayers({{e->GetLabel(), s}});
};
NormalizeLabels(p_game->GetPlayers(), get_label, relabel_player);
NormalizeLabels(p_game->GetOutcomes(), get_label, set_label);
// Action labels are not normalized here: for tree games, ParseNode/ParsePersonalNode
// already normalize each infoset's actions individually, at creation, from the raw
Expand Down
42 changes: 42 additions & 0 deletions src/games/game.cc
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,48 @@ void GameRep::WriteNfgFile(std::ostream &p_file) const
};
}

//------------------------------------------------------------------------
// GameRep: Players
//------------------------------------------------------------------------

void GameRep::RelabelPlayers(const std::map<std::string, std::string> &p_labels)
{
// Resolve each key to exactly one (personal) player of the game.
std::map<GamePlayerRep *, std::string> assignment;
std::set<const GamePlayerRep *> relabeled;
for (const auto &[old_label, new_label] : p_labels) {
GamePlayerRep *match = nullptr;
for (const auto &player : m_players) {
if (player->GetLabel() == old_label) {
if (match) {
throw ValueException("Player label '" + old_label + "' is ambiguous in this game");
}
match = player.get();
}
}
if (!match) {
if (IsTree() && old_label == GetChance()->GetLabel()) {
throw ValueException("The chance player's label cannot be changed");
}
throw ValueException("No player with label '" + old_label + "' in this game");
}
assignment[match] = new_label;
relabeled.insert(match);
}
// Replacement labels must be legal, unique against untouched players, and pairwise distinct
std::set<std::string> targets;
for (const auto &[player, new_label] : assignment) {
CheckPlayerLabel(new_label, relabeled);
if (!targets.insert(new_label).second) {
throw ValueException("Player label '" + new_label +
"' would be duplicated by the relabelling");
}
}
for (const auto &[player, new_label] : assignment) {
player->m_label = new_label;
}
}

//========================================================================
// MixedStrategyProfileRep<T>
//========================================================================
Expand Down
23 changes: 8 additions & 15 deletions src/games/game.h
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,6 @@ class GamePlayerRep : public std::enable_shared_from_this<GamePlayerRep> {
Game GetGame() const;

const std::string &GetLabel() const { return m_label; }
void SetLabel(const std::string &p_label);

bool IsChance() const { return (m_number == 0); }

Expand Down Expand Up @@ -936,7 +935,9 @@ class GameRep : public std::enable_shared_from_this<GameRep> {
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;
/// ignoring the labels of the players in p_ignore.
void CheckPlayerLabel(const std::string &p_label,
const std::set<const GamePlayerRep *> &p_ignore = {}) 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;
//@}
Expand Down Expand Up @@ -1357,6 +1358,8 @@ class GameRep : public std::enable_shared_from_this<GameRep> {
auto GetPlayersWithChance() const { return prepend_value(GetChance(), GetPlayers()); }
/// Creates a new player in the game, with no moves
virtual GamePlayer NewPlayer(const std::string &p_label) = 0;
/// Reassign player labels. Keys of p_labels are current labels; values are their replacements.
void RelabelPlayers(const std::map<std::string, std::string> &p_labels);
//@}

/// @name Dimensions of the game
Expand Down Expand Up @@ -1589,7 +1592,8 @@ inline void GameInfosetRep::SetLabel(const std::string &p_label)
m_player->CheckInfosetLabel(p_label, {this});
m_label = p_label;
}
inline void GameRep::CheckPlayerLabel(const std::string &p_label) const
inline void GameRep::CheckPlayerLabel(const std::string &p_label,
const std::set<const GamePlayerRep *> &p_ignore) const
{
if (p_label.empty()) {
throw ValueException("Player label must not be empty");
Expand All @@ -1599,7 +1603,7 @@ inline void GameRep::CheckPlayerLabel(const std::string &p_label) const
throw ValueException("Player label must not be the reserved chance player label");
}
for (const auto &player : m_players) {
if (player->GetLabel() == p_label) {
if (p_ignore.count(player.get()) == 0 && player->GetLabel() == p_label) {
throw ValueException("Player label must be unique within the game");
}
}
Expand All @@ -1619,17 +1623,6 @@ inline void GameRep::CheckOutcomeLabel(const std::string &p_label) const
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->EnsureStrategies();
Expand Down
4 changes: 2 additions & 2 deletions src/gui/dlgameprop.cc
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ PlayerLabelPanel::PlayerLabelPanel(wxWindow *p_parent, GameDocument *p_doc,
wxALIGN_CENTER_VERTICAL | wxALIGN_RIGHT);

if (player->IsChance()) {
// Chance's label is reserved and can't be changed (GamePlayerRep::SetLabel throws on
// IsChance()) -- just display it.
// Chance's label is reserved and can't be changed (GameRep::RelabelPlayers rejects it
// as a key) -- just display it.
gridSizer->Add(new wxStaticText(this, wxID_STATIC, wxString::FromUTF8(player->GetLabel())),
1, wxALIGN_CENTER_VERTICAL);
}
Expand Down
4 changes: 2 additions & 2 deletions src/gui/gamedoc.cc
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,9 @@ GamePlayer GameDocument::DoNewPlayer()
return player;
}

void GameDocument::DoSetPlayerLabel(GamePlayer p_player, const wxString &p_label)
void GameDocument::DoRelabelPlayers(const std::map<std::string, std::string> &p_labels)
{
p_player->SetLabel(p_label.ToStdString(wxConvUTF8));
m_game->RelabelPlayers(p_labels);
NotifyChanged(GameModificationType::GameLabels);
}

Expand Down
9 changes: 6 additions & 3 deletions src/gui/gamedoc.h
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,8 @@ class GameDocument {
void DoSave(const wxString &p_filename, GameSaveFormat p_format);
void DoSetTitle(const wxString &p_title, const wxString &p_comment);
GamePlayer DoNewPlayer();
void DoSetPlayerLabel(GamePlayer p_player, const wxString &p_label);
/// Reassign player labels in a single operation; see `Game::RelabelPlayers`.
void DoRelabelPlayers(const std::map<std::string, std::string> &p_labels);
/// Declare the strategies of `p_player` in a single operation, covering any combination
/// of adding, deleting, reordering, and relabeling strategies.
///
Expand Down Expand Up @@ -357,10 +358,12 @@ inline GameDocument *NewTableDocument(const std::vector<int> &p_dim)
{
const Game nfg = NewTable(p_dim);
nfg->SetTitle("Untitled Strategic Game");
std::map<std::string, std::string> labels;
int pl = 1;
for (auto player : nfg->GetPlayers()) {
player->SetLabel("Player " + std::to_string(pl++));
for (const auto &player : nfg->GetPlayers()) {
labels[player->GetLabel()] = "Player " + std::to_string(pl++);
}
nfg->RelabelPlayers(labels);
return new GameDocument(nfg);
}

Expand Down
12 changes: 11 additions & 1 deletion src/gui/gameframe.cc
Original file line number Diff line number Diff line change
Expand Up @@ -882,15 +882,25 @@ void GameFrame::OnEditGame(wxCommandEvent &)
m_doc->DoSetTitle(dialog.GetTitle(), dialog.GetDescription());

TreeRenderConfig style = m_doc->GetStyle();
std::map<std::string, std::string> labels;
for (int i = 0; i < dialog.NumRows(); i++) {
const GamePlayer player = dialog.GetPlayer(i);
if (player->IsChance()) {
style.SetChanceColor(dialog.GetPlayerColor(i));
continue; // chance's label is reserved and can't be changed
}
m_doc->DoSetPlayerLabel(player, dialog.GetPlayerLabel(i));
const std::string newLabel = dialog.GetPlayerLabel(i).ToStdString(wxConvUTF8);
if (newLabel != player->GetLabel()) {
labels[player->GetLabel()] = newLabel;
}
style.SetPlayerColor(player->GetNumber(), dialog.GetPlayerColor(i));
}
// Relabeling all players in one call, rather than one at a time, lets two players'
// labels be swapped directly without tripping the duplicate-label check on an
// intermediate state that a per-player rename would pass through.
if (!labels.empty()) {
m_doc->DoRelabelPlayers(labels);
}
m_doc->SetStyle(style);
}
catch (std::exception &ex) {
Expand Down
2 changes: 1 addition & 1 deletion src/pygambit/gambit.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ cdef extern from "games/game.h":
int IsChance() except +

string GetLabel() except +
void SetLabel(string) except +ValueError

c_GameStrategy GetStrategy(int) except +IndexError
Strategies GetStrategies() except +
Expand Down Expand Up @@ -319,6 +318,7 @@ cdef extern from "games/game.h":
Players GetPlayers() except +
c_GamePlayer GetChance() except +
c_GamePlayer NewPlayer(string) except +ValueError
void RelabelPlayers(stdmap[string, string]) except +ValueError

int NumOutcomes() except +
c_GameOutcome GetOutcome(int) except +IndexError
Expand Down
74 changes: 72 additions & 2 deletions src/pygambit/game.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -707,8 +707,9 @@ class Game:
arrays = list(payoffs.values())
shape = arrays[0].shape
g = Game.new_table(shape)
for (player, label) in zip(g.players, payoffs, strict=True):
player.label = label
g.relabel_players(
{player.label: label for player, label in zip(g.players, payoffs, strict=True)}
)
for profile in itertools.product(*(range(s) for s in shape)):
for array, player in zip(arrays, g.players, strict=True):
g[profile][player] = array[profile]
Expand Down Expand Up @@ -2819,6 +2820,75 @@ class Game:
c_labels.push_back(label.encode("utf-8"))
self.game.deref().SetStrategies(resolved_player.player, c_labels)

def relabel_players(self,
labels: typing.Mapping[str, str],
strict: bool = True) -> None:
"""Simultaneously reassign the labels of the game's players.

`labels` maps current player labels to their replacements. The reassignment
is simultaneous, so labels can be swapped directly. Players are not re-ordered: each
relabelled player keeps its position.

The chance player is not part of the operation: its label is reserved, and a
key of `labels` equal to it raises ``ValueError`` even when `strict` is `False`.

.. versionadded:: 17.0.0

Parameters
----------
labels : Mapping[str, str]
A mapping from current player labels to replacement labels.
Entries whose key equals their value are ignored.
strict : bool, default True
If `True`, every key of `labels` must be the label of a player of the game,
and unknown keys raise ``KeyError``. If `False`, unknown keys are ignored.

Raises
------
KeyError
When `strict` is `True`, if a key of `labels` matches no player of the game.
TypeError
If `labels` is not a mapping, or any key or value is not a string.
ValueError
If a key of `labels` matches more than one player; if a key of `labels`
is the label of the chance player; or if any replacement label is empty,
is not a valid label, would result in a duplicate label, or (in an
extensive game) is the reserved label of the chance player.

See Also
--------
relabel_actions : Change the labels of actions at an information set.
relabel_strategies : Change the labels of a player's strategies.
"""
if not hasattr(labels, "items"):
raise TypeError(
f"relabel_players(): labels must be a mapping, "
f"not {labels.__class__.__name__}"
)
current = [player.label for player in self.players]
chance_label = self.players.chance.label if self.is_tree else None
c_labels = stdmap[string, string]()
for old, new in labels.items():
if not isinstance(old, str) or not isinstance(new, str):
raise TypeError("relabel_players(): labels must map str to str")
if old == chance_label:
raise ValueError("relabel_players(): the chance player's label is reserved")
matches = current.count(old)
if matches > 1:
raise ValueError(
f"relabel_players(): label '{old}' is ambiguous in this game"
)
if matches == 0:
if strict:
raise KeyError(f"relabel_players(): no player with label '{old}'")
continue
if new == old:
continue
c_labels[old.encode("utf-8")] = new.encode("utf-8")
if c_labels.empty():
return
self.game.deref().RelabelPlayers(c_labels)


@dataclasses.dataclass
class NodeCoordinates:
Expand Down
Loading
Loading