Skip to content
Open
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
59 changes: 58 additions & 1 deletion kaggle_environments/envs/werewolf/game/roles.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)):
Expand Down
104 changes: 104 additions & 0 deletions kaggle_environments/envs/werewolf/game/test_roles.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions kaggle_environments/envs/werewolf/test_werewolf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion kaggle_environments/envs/werewolf/werewolf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
10 changes: 7 additions & 3 deletions kaggle_environments/envs/werewolf/werewolf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down