From 184c19e6c8acfee30d50355280e52b2d9eadf0d4 Mon Sep 17 00:00:00 2001 From: Hann Wang Date: Tue, 11 Nov 2025 11:47:27 -0800 Subject: [PATCH 1/2] Add randomize_roles option to shuffle roles during agent initialization --- .../envs/werewolf/game/roles.py | 59 +++++++++- .../envs/werewolf/game/test_roles.py | 104 ++++++++++++++++++ .../envs/werewolf/test_werewolf.py | 10 ++ .../envs/werewolf/werewolf.json | 3 +- kaggle_environments/envs/werewolf/werewolf.py | 10 +- 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 kaggle_environments/envs/werewolf/game/test_roles.py diff --git a/kaggle_environments/envs/werewolf/game/roles.py b/kaggle_environments/envs/werewolf/game/roles.py index e8d32b57..539b3143 100644 --- a/kaggle_environments/envs/werewolf/game/roles.py +++ b/kaggle_environments/envs/werewolf/game/roles.py @@ -1,6 +1,7 @@ import json import logging from collections import Counter, defaultdict, deque +from copy import deepcopy from functools import partial from typing import Deque, Dict, List, Optional @@ -311,7 +312,63 @@ def report_elimination(self): } -def create_players_from_agents_config(agents_config: List[Dict]) -> List[Player]: +def get_permutation(items: List, seed: int) -> List: + """ + Generates a deterministic permutation of a list based on a seed. + + This function implements a Fisher-Yates shuffle using a simple Linear + Congruential Generator (LCG) for pseudo-random number generation. This + ensures that the permutation is reproducible across different platforms + and languages, as it does not depend on Python's built-in 'random' module. + + The LCG parameters (m, a, c) are chosen from the glibc standard for + broad compatibility. + + Args: + items: The list of items to be permuted. + seed: An integer used to initialize the random number generator. + + Returns: + A new list containing the items in a permuted order. + """ + # LCG parameters (from glibc) + m = 2**31 + a = 1103515245 + c = 12345 + + # Make a copy to avoid modifying the original list + shuffled_items = list(items) + n = len(shuffled_items) + + current_seed = seed + + for i in range(n - 1, 0, -1): + # Generate a pseudo-random number + current_seed = (a * current_seed + c) % m + + # Get an index j such that 0 <= j <= i + j = current_seed % (i + 1) + + # Swap elements + shuffled_items[i], shuffled_items[j] = shuffled_items[j], shuffled_items[i] + + return shuffled_items + + +def shuffle_roles(agents_config, seed): + roles_config = [{'role': agent['role'], 'role_params': agent.get('role_params', {})} for agent in agents_config] + permuted_roles_config = get_permutation(roles_config, seed) + new_agents_config = deepcopy(agents_config) + for role, agent in zip(permuted_roles_config, new_agents_config): + agent['role'] = role['role'] + agent['role_params'] = role['role_params'] + return new_agents_config + + +def create_players_from_agents_config(agents_config: List[Dict], randomize_roles: bool = False, seed: Optional[int] = None) -> List[Player]: + if randomize_roles: + assert seed is not None + agents_config = shuffle_roles(agents_config, seed) # check all agents have unique ids agent_ids = [agent_config["id"] for agent_config in agents_config] if len(agent_ids) != len(set(agent_ids)): diff --git a/kaggle_environments/envs/werewolf/game/test_roles.py b/kaggle_environments/envs/werewolf/game/test_roles.py new file mode 100644 index 00000000..51871d12 --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/test_roles.py @@ -0,0 +1,104 @@ +import pytest + +from kaggle_environments.envs.werewolf.game.consts import RoleConst +from kaggle_environments.envs.werewolf.game.roles import ( + create_players_from_agents_config, + get_permutation, + Player, + Werewolf, + Doctor, + Seer, + Villager, +) + + +@pytest.fixture +def sample_agents_config(): + """Provides a sample agent configuration list for testing.""" + return [ + {"id": "Player1", "agent_id": "random", "role": "Werewolf", "role_params": {}}, + {"id": "Player2", "agent_id": "random", "role": "Doctor", "role_params": {"allow_self_save": True}}, + {"id": "Player3", "agent_id": "random", "role": "Seer", "role_params": {}}, + {"id": "Player4", "agent_id": "random", "role": "Villager", "role_params": {}}, + ] + + +def test_create_players_from_agents_config_basic(sample_agents_config): + """Tests basic player creation from a valid configuration.""" + players = create_players_from_agents_config(sample_agents_config) + + assert isinstance(players, list) + assert len(players) == len(sample_agents_config) + assert all(isinstance(p, Player) for p in players) + + # Check player IDs + assert [p.id for p in players] == ["Player1", "Player2", "Player3", "Player4"] + + # Check role assignment and types + assert isinstance(players[0].role, Werewolf) + assert players[0].role.name == RoleConst.WEREWOLF + + assert isinstance(players[1].role, Doctor) + assert players[1].role.name == RoleConst.DOCTOR + assert players[1].role.allow_self_save is True + + assert isinstance(players[2].role, Seer) + assert players[2].role.name == RoleConst.SEER + + assert isinstance(players[3].role, Villager) + assert players[3].role.name == RoleConst.VILLAGER + + +def test_create_players_with_duplicate_ids_raises_error(sample_agents_config): + """Tests that a ValueError is raised when duplicate agent IDs are provided.""" + invalid_config = sample_agents_config + [{"id": "Player1", "agent_id": "random", "role": "Villager", "role_params": {}}] + with pytest.raises(ValueError, match="Duplicate agent ids found: Player1"): + create_players_from_agents_config(invalid_config) + + +def test_get_permutation_is_deterministic(): + """Tests that the get_permutation function is deterministic for the same seed.""" + items = ["a", "b", "c", "d", "e"] + seed1 = 123 + seed2 = 456 + + permutation1_run1 = get_permutation(items, seed1) + permutation1_run2 = get_permutation(items, seed1) + permutation2 = get_permutation(items, seed2) + + assert permutation1_run1 == permutation1_run2 + assert permutation1_run1 != permutation2 + assert sorted(permutation1_run1) == sorted(items) # Ensure it's still a permutation + + +def test_create_players_with_role_shuffling(sample_agents_config): + """Tests that roles are shuffled deterministically when randomize_roles is True.""" + seed = 10 + # This is the expected order based on the LCG and Fisher-Yates implementation + expected_permuted_roles = [ + (RoleConst.WEREWOLF, {}), + (RoleConst.SEER, {}), + (RoleConst.DOCTOR, {"allow_self_save": True}), + (RoleConst.VILLAGER, {}), + ] + + players = create_players_from_agents_config(sample_agents_config, randomize_roles=True, seed=seed) + + # The roles should be assigned to the agents in the new permuted order + for i, player in enumerate(players): + expected_role_name, expected_role_params = expected_permuted_roles[i] + assert player.role.name == expected_role_name + # Check if role_params match, excluding defaults that might be added by pydantic + for key, value in expected_role_params.items(): + assert getattr(player.role, key) == value + + +def test_create_players_no_shuffling_without_flag(sample_agents_config): + """Tests that roles are not shuffled if randomize_roles is False, even with a seed.""" + seed = 42 + players = create_players_from_agents_config(sample_agents_config, randomize_roles=False, seed=seed) + + original_roles = [agent["role"] for agent in sample_agents_config] + assigned_roles = [p.role.name for p in players] + + assert original_roles == assigned_roles diff --git a/kaggle_environments/envs/werewolf/test_werewolf.py b/kaggle_environments/envs/werewolf/test_werewolf.py index 93a3a220..97e69b25 100644 --- a/kaggle_environments/envs/werewolf/test_werewolf.py +++ b/kaggle_environments/envs/werewolf/test_werewolf.py @@ -45,6 +45,16 @@ def test_load_env(env): env.renderer(state, env) +def test_randomize_role_with_seed(agents_config): + env = make("werewolf", debug=True, configuration={"agents": agents_config, 'randomize_roles': True, 'seed': 123}) + agents = ["random"] * 7 + env.run(agents) + + for i, state in enumerate(env.steps): + env.render_step_ind = i + env.renderer(state, env) + + def test_discussion_protocol(agents_config): env = make( "werewolf", diff --git a/kaggle_environments/envs/werewolf/werewolf.json b/kaggle_environments/envs/werewolf/werewolf.json index 24d04392..6bcf5234 100644 --- a/kaggle_environments/envs/werewolf/werewolf.json +++ b/kaggle_environments/envs/werewolf/werewolf.json @@ -8,7 +8,8 @@ "episodeSteps": { "type": "integer", "default": 1000 }, "actTimeout": { "type": "number", "default": 3 }, "runTimeout": { "type": "number", "default": 1800 }, - "seed": { "type": "integer", "description": "Seed to use for episodes" }, + "seed": { "type": "integer", "description": "Seed to use for episodes", "default": 123 }, + "randomize_roles": { "type": "boolean", "default": false, "description": "If set to true, the roles would be randomized according to seed." }, "night_elimination_reveal_level": { "type": "string", "enum": ["no_reveal", "team", "role"], diff --git a/kaggle_environments/envs/werewolf/werewolf.py b/kaggle_environments/envs/werewolf/werewolf.py index ddc0b092..b6a6f7b9 100644 --- a/kaggle_environments/envs/werewolf/werewolf.py +++ b/kaggle_environments/envs/werewolf/werewolf.py @@ -328,8 +328,11 @@ def log_error(status_code, state, env): logger.error(f"{status_code} DETECTED") for i, player_state in enumerate(state): if player_state["status"] == status_code: - agent_config = env.configuration["agents"][i] - logger.error(f"agent_id={agent_config['id']} returns action with status code {status_code}.") + player = env.game_state.players[i] + logger.error( + f"player.id={player.id}, player.agent.agent_id={player.agent.agent_id} " + f"returns action with status code {status_code}." + ) return invalid_action @@ -535,7 +538,8 @@ def initialize_moderator(state, env): f"Configuration has {len(agents_from_config)} agents, but {num_players} kaggle agents are present." ) - players = create_players_from_agents_config(agents_from_config) + players = create_players_from_agents_config( + agents_from_config, randomize_roles=env.configuration.randomize_roles, seed=env.configuration.seed) env.game_state = GameState( players=players, From 97dd0e1ee6cf87398f99370d5f95a618a2bcf7b0 Mon Sep 17 00:00:00 2001 From: Hann Wang Date: Tue, 11 Nov 2025 16:01:13 -0800 Subject: [PATCH 2/2] Get player info from state instead of config Config may contain wrong order and role information due to the randomize_roles flag. --- .../visualizer/default/src/legacy-renderer.js | 75 +++++++++++++------ 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/kaggle_environments/envs/werewolf/visualizer/default/src/legacy-renderer.js b/kaggle_environments/envs/werewolf/visualizer/default/src/legacy-renderer.js index bc7bcea3..37a1f896 100644 --- a/kaggle_environments/envs/werewolf/visualizer/default/src/legacy-renderer.js +++ b/kaggle_environments/envs/werewolf/visualizer/default/src/legacy-renderer.js @@ -5995,43 +5995,72 @@ export function renderer(context, parent) { playerThreatLevels: new Map(), }; + // 1. Create a Metadata Lookup Map from configuration (Key: ID -> Value: Agent Config) + // We do NOT use the role from here because it may have been shuffled. + const agentConfigMap = new Map(); + if (environment.configuration && environment.configuration.agents) { + environment.configuration.agents.forEach((agent) => { + if (agent && agent.id) { + agentConfigMap.set(agent.id, agent); + } + }); + } + const firstObs = originalSteps[0]?.[0]?.observation?.raw_observation; - let allPlayerNamesList; + let allPlayerNamesList = []; let playerThumbnails = {}; + // 2. Get the authoritative list of Player IDs from the Observation (State) if (firstObs && firstObs.all_player_ids) { allPlayerNamesList = firstObs.all_player_ids; + // Use thumbnails from observation if available playerThumbnails = firstObs.player_thumbnails || {}; - playerNamesFor3D = [...allPlayerNamesList]; - playerThumbnailsFor3D = { ...playerThumbnails }; - } else if (environment.configuration && environment.configuration.agents) { - // console.warn("Renderer: Initial observation missing or incomplete. Reconstructing players from configuration."); - allPlayerNamesList = environment.configuration.agents.map((agent) => agent.id); - environment.configuration.agents.forEach((agent) => { - if (agent.id && agent.thumbnail) { - playerThumbnails[agent.id] = agent.thumbnail; - } - }); - playerNamesFor3D = [...allPlayerNamesList]; - playerThumbnailsFor3D = { ...playerThumbnails }; + } else { + // Fallback: If observation is missing ID list, extract keys from the config map + console.warn("Renderer: all_player_ids missing in first observation. Falling back to configuration IDs."); + allPlayerNamesList = Array.from(agentConfigMap.keys()); } + // 3. Sync 3D globals + playerNamesFor3D = [...allPlayerNamesList]; + playerThumbnailsFor3D = { ...playerThumbnails }; + + // Ensure every player has a thumbnail (fallback to config if not in obs) + allPlayerNamesList.forEach(id => { + if (!playerThumbnailsFor3D[id]) { + const conf = agentConfigMap.get(id); + if (conf && conf.thumbnail) playerThumbnailsFor3D[id] = conf.thumbnail; + } + }); + if (!allPlayerNamesList || allPlayerNamesList.length === 0) { const tempContainer = document.createElement('div'); - tempContainer.textContent = 'Waiting for game data: No players found in observation or configuration.'; + tempContainer.textContent = 'Waiting for game data: No players found in observation.'; parent.appendChild(tempContainer); return; } - gameState.players = environment.configuration.agents.map((agent) => ({ - name: agent.id, - is_alive: true, - role: agent.role, - team: 'Unknown', - status: 'Alive', - thumbnail: agent.thumbnail || `https://via.placeholder.com/40/2c3e50/ecf0f1?text=${agent.id.charAt(0)}`, - display_name: agent.display_name, - })); + // 4. Construct gameState.players iterating over the AUTHORITATIVE ID list + gameState.players = allPlayerNamesList.map((playerId) => { + const configAgent = agentConfigMap.get(playerId) || {}; + + // Determine thumbnail: Observation -> Config -> Placeholder + const thumbnail = playerThumbnails[playerId] || + configAgent.thumbnail || + `https://via.placeholder.com/40/2c3e50/ecf0f1?text=${playerId.charAt(0)}`; + + return { + name: playerId, + is_alive: true, + // Initialize role as 'Unknown'. The moderator log parsing (immediately below in the file) + // will assign the correct shuffled role from 'GameStartRoleDataEntry'. + role: 'Unknown', + team: 'Unknown', + status: 'Alive', + thumbnail: thumbnail, + display_name: configAgent.display_name || playerId, + }; + }); const playerMap = new Map(gameState.players.map((p) => [p.name, p])); // Initialize and cache the replacer function if it doesn't exist