Skip to content

Commit 6f56275

Browse files
authored
Werewolf add randomize flag (#546)
* Add randomize_roles option to shuffle roles during agent initialization * Get player info from state instead of config Config may contain wrong order and role information due to the randomize_roles flag.
1 parent 1a58b3a commit 6f56275

File tree

6 files changed

+233
-28
lines changed

6 files changed

+233
-28
lines changed

kaggle_environments/envs/werewolf/game/roles.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import logging
33
from collections import Counter, defaultdict, deque
4+
from copy import deepcopy
45
from functools import partial
56
from typing import Deque, Dict, List, Optional
67

@@ -311,7 +312,63 @@ def report_elimination(self):
311312
}
312313

313314

314-
def create_players_from_agents_config(agents_config: List[Dict]) -> List[Player]:
315+
def get_permutation(items: List, seed: int) -> List:
316+
"""
317+
Generates a deterministic permutation of a list based on a seed.
318+
319+
This function implements a Fisher-Yates shuffle using a simple Linear
320+
Congruential Generator (LCG) for pseudo-random number generation. This
321+
ensures that the permutation is reproducible across different platforms
322+
and languages, as it does not depend on Python's built-in 'random' module.
323+
324+
The LCG parameters (m, a, c) are chosen from the glibc standard for
325+
broad compatibility.
326+
327+
Args:
328+
items: The list of items to be permuted.
329+
seed: An integer used to initialize the random number generator.
330+
331+
Returns:
332+
A new list containing the items in a permuted order.
333+
"""
334+
# LCG parameters (from glibc)
335+
m = 2**31
336+
a = 1103515245
337+
c = 12345
338+
339+
# Make a copy to avoid modifying the original list
340+
shuffled_items = list(items)
341+
n = len(shuffled_items)
342+
343+
current_seed = seed
344+
345+
for i in range(n - 1, 0, -1):
346+
# Generate a pseudo-random number
347+
current_seed = (a * current_seed + c) % m
348+
349+
# Get an index j such that 0 <= j <= i
350+
j = current_seed % (i + 1)
351+
352+
# Swap elements
353+
shuffled_items[i], shuffled_items[j] = shuffled_items[j], shuffled_items[i]
354+
355+
return shuffled_items
356+
357+
358+
def shuffle_roles(agents_config, seed):
359+
roles_config = [{'role': agent['role'], 'role_params': agent.get('role_params', {})} for agent in agents_config]
360+
permuted_roles_config = get_permutation(roles_config, seed)
361+
new_agents_config = deepcopy(agents_config)
362+
for role, agent in zip(permuted_roles_config, new_agents_config):
363+
agent['role'] = role['role']
364+
agent['role_params'] = role['role_params']
365+
return new_agents_config
366+
367+
368+
def create_players_from_agents_config(agents_config: List[Dict], randomize_roles: bool = False, seed: Optional[int] = None) -> List[Player]:
369+
if randomize_roles:
370+
assert seed is not None
371+
agents_config = shuffle_roles(agents_config, seed)
315372
# check all agents have unique ids
316373
agent_ids = [agent_config["id"] for agent_config in agents_config]
317374
if len(agent_ids) != len(set(agent_ids)):
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import pytest
2+
3+
from kaggle_environments.envs.werewolf.game.consts import RoleConst
4+
from kaggle_environments.envs.werewolf.game.roles import (
5+
create_players_from_agents_config,
6+
get_permutation,
7+
Player,
8+
Werewolf,
9+
Doctor,
10+
Seer,
11+
Villager,
12+
)
13+
14+
15+
@pytest.fixture
16+
def sample_agents_config():
17+
"""Provides a sample agent configuration list for testing."""
18+
return [
19+
{"id": "Player1", "agent_id": "random", "role": "Werewolf", "role_params": {}},
20+
{"id": "Player2", "agent_id": "random", "role": "Doctor", "role_params": {"allow_self_save": True}},
21+
{"id": "Player3", "agent_id": "random", "role": "Seer", "role_params": {}},
22+
{"id": "Player4", "agent_id": "random", "role": "Villager", "role_params": {}},
23+
]
24+
25+
26+
def test_create_players_from_agents_config_basic(sample_agents_config):
27+
"""Tests basic player creation from a valid configuration."""
28+
players = create_players_from_agents_config(sample_agents_config)
29+
30+
assert isinstance(players, list)
31+
assert len(players) == len(sample_agents_config)
32+
assert all(isinstance(p, Player) for p in players)
33+
34+
# Check player IDs
35+
assert [p.id for p in players] == ["Player1", "Player2", "Player3", "Player4"]
36+
37+
# Check role assignment and types
38+
assert isinstance(players[0].role, Werewolf)
39+
assert players[0].role.name == RoleConst.WEREWOLF
40+
41+
assert isinstance(players[1].role, Doctor)
42+
assert players[1].role.name == RoleConst.DOCTOR
43+
assert players[1].role.allow_self_save is True
44+
45+
assert isinstance(players[2].role, Seer)
46+
assert players[2].role.name == RoleConst.SEER
47+
48+
assert isinstance(players[3].role, Villager)
49+
assert players[3].role.name == RoleConst.VILLAGER
50+
51+
52+
def test_create_players_with_duplicate_ids_raises_error(sample_agents_config):
53+
"""Tests that a ValueError is raised when duplicate agent IDs are provided."""
54+
invalid_config = sample_agents_config + [{"id": "Player1", "agent_id": "random", "role": "Villager", "role_params": {}}]
55+
with pytest.raises(ValueError, match="Duplicate agent ids found: Player1"):
56+
create_players_from_agents_config(invalid_config)
57+
58+
59+
def test_get_permutation_is_deterministic():
60+
"""Tests that the get_permutation function is deterministic for the same seed."""
61+
items = ["a", "b", "c", "d", "e"]
62+
seed1 = 123
63+
seed2 = 456
64+
65+
permutation1_run1 = get_permutation(items, seed1)
66+
permutation1_run2 = get_permutation(items, seed1)
67+
permutation2 = get_permutation(items, seed2)
68+
69+
assert permutation1_run1 == permutation1_run2
70+
assert permutation1_run1 != permutation2
71+
assert sorted(permutation1_run1) == sorted(items) # Ensure it's still a permutation
72+
73+
74+
def test_create_players_with_role_shuffling(sample_agents_config):
75+
"""Tests that roles are shuffled deterministically when randomize_roles is True."""
76+
seed = 10
77+
# This is the expected order based on the LCG and Fisher-Yates implementation
78+
expected_permuted_roles = [
79+
(RoleConst.WEREWOLF, {}),
80+
(RoleConst.SEER, {}),
81+
(RoleConst.DOCTOR, {"allow_self_save": True}),
82+
(RoleConst.VILLAGER, {}),
83+
]
84+
85+
players = create_players_from_agents_config(sample_agents_config, randomize_roles=True, seed=seed)
86+
87+
# The roles should be assigned to the agents in the new permuted order
88+
for i, player in enumerate(players):
89+
expected_role_name, expected_role_params = expected_permuted_roles[i]
90+
assert player.role.name == expected_role_name
91+
# Check if role_params match, excluding defaults that might be added by pydantic
92+
for key, value in expected_role_params.items():
93+
assert getattr(player.role, key) == value
94+
95+
96+
def test_create_players_no_shuffling_without_flag(sample_agents_config):
97+
"""Tests that roles are not shuffled if randomize_roles is False, even with a seed."""
98+
seed = 42
99+
players = create_players_from_agents_config(sample_agents_config, randomize_roles=False, seed=seed)
100+
101+
original_roles = [agent["role"] for agent in sample_agents_config]
102+
assigned_roles = [p.role.name for p in players]
103+
104+
assert original_roles == assigned_roles

kaggle_environments/envs/werewolf/test_werewolf.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ def test_load_env(env):
4545
env.renderer(state, env)
4646

4747

48+
def test_randomize_role_with_seed(agents_config):
49+
env = make("werewolf", debug=True, configuration={"agents": agents_config, 'randomize_roles': True, 'seed': 123})
50+
agents = ["random"] * 7
51+
env.run(agents)
52+
53+
for i, state in enumerate(env.steps):
54+
env.render_step_ind = i
55+
env.renderer(state, env)
56+
57+
4858
def test_discussion_protocol(agents_config):
4959
env = make(
5060
"werewolf",

kaggle_environments/envs/werewolf/visualizer/default/src/legacy-renderer.js

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5995,43 +5995,72 @@ export function renderer(context, parent) {
59955995
playerThreatLevels: new Map(),
59965996
};
59975997

5998+
// 1. Create a Metadata Lookup Map from configuration (Key: ID -> Value: Agent Config)
5999+
// We do NOT use the role from here because it may have been shuffled.
6000+
const agentConfigMap = new Map();
6001+
if (environment.configuration && environment.configuration.agents) {
6002+
environment.configuration.agents.forEach((agent) => {
6003+
if (agent && agent.id) {
6004+
agentConfigMap.set(agent.id, agent);
6005+
}
6006+
});
6007+
}
6008+
59986009
const firstObs = originalSteps[0]?.[0]?.observation?.raw_observation;
5999-
let allPlayerNamesList;
6010+
let allPlayerNamesList = [];
60006011
let playerThumbnails = {};
60016012

6013+
// 2. Get the authoritative list of Player IDs from the Observation (State)
60026014
if (firstObs && firstObs.all_player_ids) {
60036015
allPlayerNamesList = firstObs.all_player_ids;
6016+
// Use thumbnails from observation if available
60046017
playerThumbnails = firstObs.player_thumbnails || {};
6005-
playerNamesFor3D = [...allPlayerNamesList];
6006-
playerThumbnailsFor3D = { ...playerThumbnails };
6007-
} else if (environment.configuration && environment.configuration.agents) {
6008-
// console.warn("Renderer: Initial observation missing or incomplete. Reconstructing players from configuration.");
6009-
allPlayerNamesList = environment.configuration.agents.map((agent) => agent.id);
6010-
environment.configuration.agents.forEach((agent) => {
6011-
if (agent.id && agent.thumbnail) {
6012-
playerThumbnails[agent.id] = agent.thumbnail;
6013-
}
6014-
});
6015-
playerNamesFor3D = [...allPlayerNamesList];
6016-
playerThumbnailsFor3D = { ...playerThumbnails };
6018+
} else {
6019+
// Fallback: If observation is missing ID list, extract keys from the config map
6020+
console.warn("Renderer: all_player_ids missing in first observation. Falling back to configuration IDs.");
6021+
allPlayerNamesList = Array.from(agentConfigMap.keys());
60176022
}
60186023

6024+
// 3. Sync 3D globals
6025+
playerNamesFor3D = [...allPlayerNamesList];
6026+
playerThumbnailsFor3D = { ...playerThumbnails };
6027+
6028+
// Ensure every player has a thumbnail (fallback to config if not in obs)
6029+
allPlayerNamesList.forEach(id => {
6030+
if (!playerThumbnailsFor3D[id]) {
6031+
const conf = agentConfigMap.get(id);
6032+
if (conf && conf.thumbnail) playerThumbnailsFor3D[id] = conf.thumbnail;
6033+
}
6034+
});
6035+
60196036
if (!allPlayerNamesList || allPlayerNamesList.length === 0) {
60206037
const tempContainer = document.createElement('div');
6021-
tempContainer.textContent = 'Waiting for game data: No players found in observation or configuration.';
6038+
tempContainer.textContent = 'Waiting for game data: No players found in observation.';
60226039
parent.appendChild(tempContainer);
60236040
return;
60246041
}
60256042

6026-
gameState.players = environment.configuration.agents.map((agent) => ({
6027-
name: agent.id,
6028-
is_alive: true,
6029-
role: agent.role,
6030-
team: 'Unknown',
6031-
status: 'Alive',
6032-
thumbnail: agent.thumbnail || `https://via.placeholder.com/40/2c3e50/ecf0f1?text=${agent.id.charAt(0)}`,
6033-
display_name: agent.display_name,
6034-
}));
6043+
// 4. Construct gameState.players iterating over the AUTHORITATIVE ID list
6044+
gameState.players = allPlayerNamesList.map((playerId) => {
6045+
const configAgent = agentConfigMap.get(playerId) || {};
6046+
6047+
// Determine thumbnail: Observation -> Config -> Placeholder
6048+
const thumbnail = playerThumbnails[playerId] ||
6049+
configAgent.thumbnail ||
6050+
`https://via.placeholder.com/40/2c3e50/ecf0f1?text=${playerId.charAt(0)}`;
6051+
6052+
return {
6053+
name: playerId,
6054+
is_alive: true,
6055+
// Initialize role as 'Unknown'. The moderator log parsing (immediately below in the file)
6056+
// will assign the correct shuffled role from 'GameStartRoleDataEntry'.
6057+
role: 'Unknown',
6058+
team: 'Unknown',
6059+
status: 'Alive',
6060+
thumbnail: thumbnail,
6061+
display_name: configAgent.display_name || playerId,
6062+
};
6063+
});
60356064
const playerMap = new Map(gameState.players.map((p) => [p.name, p]));
60366065

60376066
// Initialize and cache the replacer function if it doesn't exist

kaggle_environments/envs/werewolf/werewolf.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"episodeSteps": { "type": "integer", "default": 1000 },
99
"actTimeout": { "type": "number", "default": 3 },
1010
"runTimeout": { "type": "number", "default": 1800 },
11-
"seed": { "type": "integer", "description": "Seed to use for episodes" },
11+
"seed": { "type": "integer", "description": "Seed to use for episodes", "default": 123 },
12+
"randomize_roles": { "type": "boolean", "default": false, "description": "If set to true, the roles would be randomized according to seed." },
1213
"night_elimination_reveal_level": {
1314
"type": "string",
1415
"enum": ["no_reveal", "team", "role"],

kaggle_environments/envs/werewolf/werewolf.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,11 @@ def log_error(status_code, state, env):
328328
logger.error(f"{status_code} DETECTED")
329329
for i, player_state in enumerate(state):
330330
if player_state["status"] == status_code:
331-
agent_config = env.configuration["agents"][i]
332-
logger.error(f"agent_id={agent_config['id']} returns action with status code {status_code}.")
331+
player = env.game_state.players[i]
332+
logger.error(
333+
f"player.id={player.id}, player.agent.agent_id={player.agent.agent_id} "
334+
f"returns action with status code {status_code}."
335+
)
333336
return invalid_action
334337

335338

@@ -535,7 +538,8 @@ def initialize_moderator(state, env):
535538
f"Configuration has {len(agents_from_config)} agents, but {num_players} kaggle agents are present."
536539
)
537540

538-
players = create_players_from_agents_config(agents_from_config)
541+
players = create_players_from_agents_config(
542+
agents_from_config, randomize_roles=env.configuration.randomize_roles, seed=env.configuration.seed)
539543

540544
env.game_state = GameState(
541545
players=players,

0 commit comments

Comments
 (0)