diff --git a/assets/logo/chatgpt.png b/assets/logo/chatgpt.png new file mode 100644 index 00000000..da7787be Binary files /dev/null and b/assets/logo/chatgpt.png differ diff --git a/assets/logo/claude.png b/assets/logo/claude.png new file mode 100644 index 00000000..3bd984ed Binary files /dev/null and b/assets/logo/claude.png differ diff --git a/assets/logo/deepseek.png b/assets/logo/deepseek.png new file mode 100644 index 00000000..7ee99e74 Binary files /dev/null and b/assets/logo/deepseek.png differ diff --git a/assets/logo/gemini.png b/assets/logo/gemini.png new file mode 100644 index 00000000..7af1cdcf Binary files /dev/null and b/assets/logo/gemini.png differ diff --git a/assets/logo/grok.png b/assets/logo/grok.png new file mode 100644 index 00000000..fa4c8537 Binary files /dev/null and b/assets/logo/grok.png differ diff --git a/assets/logo/kimi.png b/assets/logo/kimi.png new file mode 100644 index 00000000..c486020d Binary files /dev/null and b/assets/logo/kimi.png differ diff --git a/assets/logo/qwen.png b/assets/logo/qwen.png new file mode 100644 index 00000000..1c7ed403 Binary files /dev/null and b/assets/logo/qwen.png differ diff --git a/assets/moon4.png b/assets/moon4.png new file mode 100644 index 00000000..c5ac34b4 Binary files /dev/null and b/assets/moon4.png differ diff --git a/assets/stickman.fbx b/assets/stickman.fbx new file mode 100644 index 00000000..8dcf386b Binary files /dev/null and b/assets/stickman.fbx differ diff --git a/assets/stickman.glb b/assets/stickman.glb new file mode 100644 index 00000000..98c30ec6 Binary files /dev/null and b/assets/stickman.glb differ diff --git a/kaggle_environments/__init__.py b/kaggle_environments/__init__.py index 55456db8..04ca9bcc 100644 --- a/kaggle_environments/__init__.py +++ b/kaggle_environments/__init__.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os from importlib import import_module from os import listdir from .agent import Agent @@ -26,6 +27,9 @@ "make", "register", "utils", "__version__", "get_episode_replay", "list_episodes", "list_episodes_for_team", "list_episodes_for_submission"] +_script_dir = os.path.dirname(os.path.realpath(__file__)) +PROJECT_ROOT = os.path.abspath(os.path.join('..', _script_dir)) + # Register Environments. for name in listdir(utils.envs_path): diff --git a/kaggle_environments/agent.py b/kaggle_environments/agent.py index b199583d..18b54ad4 100644 --- a/kaggle_environments/agent.py +++ b/kaggle_environments/agent.py @@ -105,6 +105,11 @@ def build_agent(raw, builtin_agents, environment_name): Returns the agent and whether the agent is parallelizable. """ if raw in builtin_agents: + agent = builtin_agents[raw] + # TODO: Below is a hack. Assuming an agent is a global callable is not enough to guarantee it is stateless. + # Kaggle environment should allow more scalable agent initialization and proper agent interface design. + if hasattr(agent, "reset"): + agent.reset() return builtin_agents[raw], False # Already callable. @@ -163,16 +168,23 @@ def act(self, observation): # Start the timer. - with StringIO() as out_buffer, StringIO() as err_buffer, redirect_stdout(out_buffer), redirect_stderr(err_buffer): - try: - start = perf_counter() - action = self.agent(*args) - except Exception as e: - traceback.print_exc(file=err_buffer) - action = e - - out = out_buffer.getvalue() - err = err_buffer.getvalue() + if self.debug: + # Adding a debugging branch here, since the context manager and try except would prevent + # debugger from functioning properly. + start = perf_counter() + action = self.agent(*args) + out = "" + err = "" + else: + with StringIO() as out_buffer, StringIO() as err_buffer, redirect_stdout(out_buffer), redirect_stderr(err_buffer): + try: + start = perf_counter() + action = self.agent(*args) + except Exception as e: + traceback.print_exc(file=err_buffer) + action = e + out = out_buffer.getvalue() + err = err_buffer.getvalue() # Get the maximum log length # Allow up to 10k (default) log characters per step which is ~10MB per 600 step episode max_log_length = self.configuration.get('maxLogLength', 10000) diff --git a/kaggle_environments/core.py b/kaggle_environments/core.py index f941ed1d..1b0ade31 100644 --- a/kaggle_environments/core.py +++ b/kaggle_environments/core.py @@ -572,31 +572,34 @@ def update_props(props): ) return data - def __run_interpreter(self, state, logs): + def __loop_through_interpreter(self, state, logs): + args = [structify(state), self, logs] + new_state = structify(self.interpreter( + *args[:self.interpreter.__code__.co_argcount])) + new_state[0].observation.step = ( + 0 if self.done + else len(self.steps) + ) + + for index, agent in enumerate(new_state): + if index < len(logs) and "duration" in logs[index]: + duration = logs[index]["duration"] + overage_time_consumed = max(0, duration - self.configuration.actTimeout) + agent.observation.remainingOverageTime -= overage_time_consumed + if agent.status not in self.__state_schema.properties.status.enum: + self.debug_print(f"Invalid Action: {agent.status}") + agent.status = "INVALID" + if agent.status in ["ERROR", "INVALID", "TIMEOUT"]: + agent.reward = None + return new_state + + def __run_interpreter_prod(self, state, logs): out = None err = None - # Append any environmental logs to any agent logs we collected. try: with StringIO() as out_buffer, StringIO() as err_buffer, redirect_stdout(out_buffer), redirect_stderr(err_buffer): try: - args = [structify(state), self, logs] - new_state = structify(self.interpreter( - *args[:self.interpreter.__code__.co_argcount])) - new_state[0].observation.step = ( - 0 if self.done - else len(self.steps) - ) - - for index, agent in enumerate(new_state): - if index < len(logs) and "duration" in logs[index]: - duration = logs[index]["duration"] - overage_time_consumed = max(0, duration - self.configuration.actTimeout) - agent.observation.remainingOverageTime -= overage_time_consumed - if agent.status not in self.__state_schema.properties.status.enum: - self.debug_print(f"Invalid Action: {agent.status}") - agent.status = "INVALID" - if agent.status in ["ERROR", "INVALID", "TIMEOUT"]: - agent.reward = None + new_state = self.__loop_through_interpreter(state, logs) return new_state except Exception as e: # Print the exception stack trace to our log @@ -629,6 +632,13 @@ def __run_interpreter(self, state, logs): err = err[:-1] self.debug_print(err) + def __run_interpreter(self, state, logs): + # Append any environmental logs to any agent logs we collected. + if self.debug: + return self.__loop_through_interpreter(state, logs) + else: + return self.__run_interpreter_prod(state, logs) + def __process_specification(self, spec): if has(spec, path=["reward"]): reward = spec["reward"] diff --git a/kaggle_environments/envs/connectx/test_connectx.py b/kaggle_environments/envs/connectx/test_connectx.py index 81045ba0..637b1b79 100644 --- a/kaggle_environments/envs/connectx/test_connectx.py +++ b/kaggle_environments/envs/connectx/test_connectx.py @@ -21,7 +21,7 @@ def before_each(state=None, configuration=None): global env steps = [] if state == None else [state] env = make("connectx", steps=steps, - configuration=configuration, debug=True) + configuration=configuration, debug=False) def test_has_correct_timeouts(): diff --git a/kaggle_environments/envs/llm_20_questions/test_llm_20_questions.py b/kaggle_environments/envs/llm_20_questions/test_llm_20_questions.py index fea0aabe..a5f8d4bd 100644 --- a/kaggle_environments/envs/llm_20_questions/test_llm_20_questions.py +++ b/kaggle_environments/envs/llm_20_questions/test_llm_20_questions.py @@ -25,14 +25,14 @@ def error_agent(): raise ValueError def test_llm_20_q_completes(): - env = make("llm_20_questions", debug=True) + env = make("llm_20_questions", debug=False) env.run([custom_questioner, custom_answerer, custom_questioner, custom_answerer]) json = env.toJSON() assert json["name"] == "llm_20_questions" assert json["statuses"] == ["DONE", "DONE", "DONE", "DONE"] def test_llm_20_q_errors_on_bad_answer(): - env = make("llm_20_questions", debug=True) + env = make("llm_20_questions", debug=False) env.run([custom_questioner, custom_answerer, custom_questioner, bad_answerer]) json = env.toJSON() assert json["name"] == "llm_20_questions" @@ -42,7 +42,7 @@ def test_llm_20_q_errors_on_bad_answer(): assert len(json["steps"]) == 3 def test_llm_20_q_errors_on_error_answer(): - env = make("llm_20_questions", debug=True) + env = make("llm_20_questions", debug=False) env.run([custom_questioner, custom_answerer, custom_questioner, error_agent]) json = env.toJSON() assert json["name"] == "llm_20_questions" @@ -51,7 +51,7 @@ def test_llm_20_q_errors_on_error_answer(): assert len(json["steps"]) == 3 def test_llm_20_q_errors_on_error_question(): - env = make("llm_20_questions", debug=True) + env = make("llm_20_questions", debug=False) env.run([custom_questioner, custom_answerer, error_agent, custom_answerer]) json = env.toJSON() assert json["name"] == "llm_20_questions" @@ -60,7 +60,7 @@ def test_llm_20_q_errors_on_error_question(): assert len(json["steps"]) == 2 def test_llm_20_q_errors_on_error_last_guess(): - env = make("llm_20_questions", debug=True) + env = make("llm_20_questions", debug=False) env.run([custom_questioner, custom_answerer, last_round_guesser_error, custom_answerer]) json = env.toJSON() assert json["name"] == "llm_20_questions" diff --git a/kaggle_environments/envs/tictactoe/test_tictactoe.py b/kaggle_environments/envs/tictactoe/test_tictactoe.py index 32939db2..13c6624f 100644 --- a/kaggle_environments/envs/tictactoe/test_tictactoe.py +++ b/kaggle_environments/envs/tictactoe/test_tictactoe.py @@ -51,7 +51,7 @@ def custom6(obs): def before_each(state=None): global env steps = [] if state == None else [state] - env = make("tictactoe", steps=steps, debug=True) + env = make("tictactoe", steps=steps, debug=False) def test_to_json(): @@ -201,14 +201,14 @@ def test_can_run_custom_agents(): def test_agents_can_timeout_on_init(): - env = make("tictactoe", debug=True) + env = make("tictactoe", debug=False) state = env.run([custom1, custom3])[-1] assert state[1]["status"] == "TIMEOUT" assert state[1]["observation"]["remainingOverageTime"] < 0 def test_agents_can_timeout_on_act(): - env = make("tictactoe", debug=True) + env = make("tictactoe", debug=False) state = env.run([custom1, custom6])[-1] print(state) assert state[1]["status"] == "TIMEOUT" @@ -216,7 +216,7 @@ def test_agents_can_timeout_on_act(): def test_run_timeout(): - env = make("tictactoe", debug=True, configuration={"actTimeout": 10, "runTimeout": 1}) + env = make("tictactoe", debug=False, configuration={"actTimeout": 10, "runTimeout": 1}) try: state = env.run([custom1, custom3])[-1] except DeadlineExceeded: diff --git a/kaggle_environments/envs/werewolf/GAME_RULE.md b/kaggle_environments/envs/werewolf/GAME_RULE.md new file mode 100644 index 00000000..d5de09ed --- /dev/null +++ b/kaggle_environments/envs/werewolf/GAME_RULE.md @@ -0,0 +1,75 @@ +# Werewolf: Game Rules + +Welcome to Werewolf, a game of social deduction, team collaboration, deception, and survival. Players are secretly assigned roles on one of two teams: the Village or the Werewolves. + +## Roles + +Each player is assigned one of the following roles: + +### Village Team + +The goal of the Village team is to exile all the werewolves. + +* **Villager:** You have no special abilities other than your power of observation and your voice. Use the discussion phase to identify suspicious behavior and vote to exile suspected werewolves. +* **Seer:** Each night, you may choose one player to investigate. You will learn if that player is a Werewolf or not. Your goal is to share this information strategically to help the village without revealing your identity too soon. +* **Doctor:** Each night, you may choose one player to protect. The player you protect cannot be eliminated by the werewolves that night. + +### Werewolf Team + +* **Werewolf:** Your goal is to eliminate villagers until the number of werewolves equals the number of remaining village members. Each night, you and your fellow werewolves will secretly agree on one player to eliminate. + +## Game Phases + +The game alternates between a Night phase and a Day phase. + +### Night Phase 🐺 + +During the night, all players close their eyes. The moderator will ask players with special roles to wake up and perform their actions in this order: + +1. **Doctor:** Chooses one player to protect. +2. **Seer:** Chooses one player to investigate their alignment. +3. **Werewolves:** Silently vote on one player to eliminate. + +### Day Phase ☀️ + +1. **Announcement:** The moderator announces which player, if any, was eliminated during the night. That player is removed from the game and may not speak or participate further. +2. **Discussion:** The surviving players discuss who they think the werewolves are. +3. **Exile Vote:** Players vote on who to exile from the village. The player who receives the most votes is exiled, removed from the game, and their role is revealed. + +The game continues with another Night phase until a winning condition is met. + +## Customizable Rules + +Before the game begins, the following options must be decided. + +### 1. Doctor's Self-Save + +* **Option A (Self-Save Allowed):** The Doctor is allowed to choose themselves as the target of their protection. +* **Option B (No Self-Save):** The Doctor must choose another player to protect. + +### 2. Discussion Protocol + +* **Option A (Parallel Discussion):** All players may speak simultaneously for a number of rounds. +* **Option B (Round Robin):** Each player speak one after another following a predefined order for a number of rounds. + +### 3. Voting Protocol +The night wolf target election and the day exile election are both configurable. All voting protocols follow a random +tie breaking mechanism, where a random draw is used when there multiple candidates with the same votes. + +* **Option A (Sequential Voting):** Voters cast their votes one after another, where each voter has visibility to all earlier vote. +* **Option B (Parallel Voting):** All voters cast their votes simultaneously. + +## Winning the Game + +A team wins as soon as their winning condition is met. + +* **The Village Team wins** when all werewolves have been successfully exiled. +* **The Werewolf Team wins** when the number of werewolves is equal to the number of remaining Village team members. + +### Rewards + +All members of the winning team will receive **1 reward**. This includes players who were eliminated before the end of the game. + +### Tie Game (Forfeit) + +If any back-end inference fails during the game, the match will immediately end. The game will be declared a **tie**, and no players will receive a reward. \ No newline at end of file diff --git a/kaggle_environments/envs/werewolf/README.md b/kaggle_environments/envs/werewolf/README.md new file mode 100644 index 00000000..34d93cf3 --- /dev/null +++ b/kaggle_environments/envs/werewolf/README.md @@ -0,0 +1,181 @@ +# Quickstart to run werewolf and get visualization + +Very quick guide for internal developers to run the kaggle werewolf code for debugging exploration +This example only uses models from vertexai for simplicity of auth + +Checkout the werewolf_harness branch +```bash +git clone https://github.com/Kaggle/kaggle-environments.git +cd kaggle-environments +git checkout werewolf_harness +``` + +Set up preferred venv environment + +Install the requirements for kaggle env +```bash +pip install -e kaggle-environments +``` + +[Optional] Set up authentication for connecting to vertex +```bash +gcloud auth application-default login +gcloud config set project YOUR_PROJECT_ID +``` + +Set up `.env` under project root for auth, used in base.py +``` + +OPENAI_API_KEY=... +ANTHROPIC_API_KEY=... +TOGETHERAI_API_KEY=... +XAI_API_KEY=... +GEMINI_MODEL="gemini-2.5-pro" +# Note for gemini can access via Google Labs API via GEMNI_API_KEY or +# via Google Cloud Vertex API by setting VERTEXAI_PROJECT and VERTEXAI_LOCATION +# and authenticating to vertex project with gcloud command above. +GOOGLE_APPLICATION_CREDENTIALS="/my/path/xxx.json" # Optional if different from default location +VERTEXAI_PROJECT=MY_PROJECT_ID # name of your poject +VERTEXAI_LOCATION=LOCATION # e.g. us-central1 +GEMINI_API_KEY=.. +``` + +## Running a Game + +The primary way to run a game is by using the `run.py` script, which uses a YAML configuration file to define all the game parameters, including the agents. + +To run a game with the default configuration (`run_config.yaml`): +```bash +python kaggle_environments/envs/werewolf/scripts/run.py +``` +The output, including a log file and an HTML replay, will be saved in a timestamped subdirectory inside `werewolf_run/`. + +### Customizing a Run + +- **Use a different configuration file:** + ```bash + python kaggle_environments/envs/werewolf/scripts/run.py -c path/to/your/config.yaml + ``` + +- **Use random agents for a quick test:** + ```bash + python kaggle_environments/envs/werewolf/scripts/run.py -r + ``` + +- **Enable debug mode for more verbose logging:** + ```bash + python kaggle_environments/envs/werewolf/scripts/run.py -d + ``` + +### Configuring Agents +Each agent's configuration looks like the following +```yaml + - role: "Villager" + id: "gemini-2.5-pro" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini/gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" +``` +- `id`: is the unique id of the agent. In the werewolf game, the player will be uniquely +refereed to by the moderator as this id as well as all natural language and structured text logs. +It can be a human name like "Alex" or the model's name or any unique string. +- `thumbnail`: a thumbnail url that will be rendered by the `html_renderer` as avatar for the agent. +- `agent_id`: this is the agent identifier used by kaggle environment to initialize an agent instance, e.g. `"random"` for random agent. +We prepared LLM based harness compatible with `litellm` library. You can use `"llm/"` to specify the LLM you want e.g. `"llm/gemini/gemini-2.5-pro"`. +The supported LLMs can be found at `kaggle_environments/envs/werewolf/werewolf.py`. +- `display_name`: this is a name you want to show in the player card that's visible only in the html rendered by `html_renderer`. +If left blank there will be no separate display name shown. This is used primarily to disambiguate id and the underlying model, e.g. id -> `Alex (gemini-2.5-pro)` <- display_name. Not used in game logic. +- `agent_harness_name`: a placeholder for you to record the agent harness name. Not used in game logic. +- `chat_mode`: This only impact instruction sets for the agent harness. +If set to `audio`, a different instruction will be given to the LLM agent to generate audio friendly messages. +- `enable_bid_reasoning`: only useful for `BidDrivenDiscussion` protocol. If enabled, the LLM agents will use reasoning for all bid actions. +- `llms`: This is only for recording the models used in the harness. It's an array to support multi LLM setup in the future. + +## Running an Experiment Block + +For more rigorous testing, the `run_block.py` script allows you to run a series of games in a structured block experiment. This is useful for evaluating agent performance across different role assignments and player rotations. + +Each game is run as an independent process, ensuring that experiments are clean and logs are separated. + +To run a block experiment with the default configuration: +```bash +python kaggle_environments/envs/werewolf/scripts/run_block.py +``` +The output will be saved in `werewolf_block_experiment/`, with subdirectories for each block and game. + +### Customizing an Experiment + +- **Use a different configuration file:** + ```bash + python kaggle_environments/envs/werewolf/scripts/run_block.py -c path/to/your/config.yaml + ``` + +- **Specify the number of blocks:** + Each block runs a full rotation of roles for the given players. + ```bash + python kaggle_environments/envs/werewolf/scripts/run_block.py -b 5 # Runs 5 blocks + ``` + +- **Shuffle player IDs to mitigate name bias:** + ```bash + python kaggle_environments/envs/werewolf/scripts/run_block.py -s + ``` + +- **Use random agents for a quick test:** + ```bash + python kaggle_environments/envs/werewolf/scripts/run_block.py -r + ``` + +### Parallel Execution + +- **Run games in parallel to speed up the experiment:** + ```bash + python kaggle_environments/envs/werewolf/scripts/run_block.py -p + ``` + +- **Specify the number of parallel processes:** + If not specified, the script will calculate a reasonable default. + ```bash + python kaggle_environments/envs/werewolf/scripts/run_block.py -p -n 4 + ``` + +Note that kaggle environment by default use multiprocessing to run each agent in a separate process if debug mode is disabled. This means that the main processes you can use for each game would be greatly reduced. If you use sequential protocols e.g. round robin discussion, sequential voting, etc, we would recommend to enable debug mode `-d` to have sequential execution of each game and enable parallel processing of `run_block.py` script. + +### Debugging + +- **Enable debug mode to run games sequentially in the main process:** + This is useful for stepping through code with a debugger. + ```bash + python kaggle_environments/envs/werewolf/scripts/run_block.py -d + ``` + +## Simple Self Play (Legacy) + +Run example program. Should be able to view out.html in a standard web browser + +To use random agents for quick game engine troubleshooting, +```bash +python kaggle_environments/envs/werewolf/scripts/self_play.py --use_random_agent --output_dir my/path/to/replay/dir +# or equivalently +python kaggle_environments/envs/werewolf/scripts/self_play.py -r -o my/path/to/replay +``` + +To use gemini for quick self-play simulation, +```bash +python kaggle_environments/envs/werewolf/scripts/self_play.py +# or if you want to use a different model and output_path versus default +python kaggle_environments/envs/werewolf/scripts/self_play.py --litellm_model_path gemini/gemini-2.5-pro --brand gemini --output_dir my/path/to/replay/dir +``` + +## End to End Generate Game Play and Audio +```bash +# simple testing with debug audio +python kaggle_environments/envs/werewolf/scripts/dump_audio.py -o werewolf_replay_audio --debug-audio -r -s +# full llm game play and audio +python kaggle_environments/envs/werewolf/scripts/dump_audio.py --output_dir werewolf_replay_audio --shuffle_roles +``` \ No newline at end of file diff --git a/kaggle_environments/envs/werewolf/__init__.py b/kaggle_environments/envs/werewolf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kaggle_environments/envs/werewolf/game/__init__.py b/kaggle_environments/envs/werewolf/game/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kaggle_environments/envs/werewolf/game/actions.py b/kaggle_environments/envs/werewolf/game/actions.py new file mode 100644 index 00000000..cefc7af9 --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/actions.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import re +from functools import lru_cache +from typing import Optional, Tuple + +from pydantic import Field, field_validator, create_model + +from .base import BaseAction, BaseState, PlayerID +from .consts import PerceivedThreatLevel, EventName, Phase +from .records import SeerInspectActionDataEntry, DoctorHealActionDataEntry + + +ACTION_EVENT_MAP = {} + + +def register_event(event_name: EventName): + """A class decorator to register an EventName for an Action class.""" + def decorator(cls): + ACTION_EVENT_MAP[cls.__name__] = event_name + setattr(cls, 'event_name', event_name) + return cls + return decorator + + +_REPLACEMENT_MAP = { + # 'kill' variations + 'kill': 'eliminate', + 'kills': 'eliminates', + 'killed': 'eliminated', + 'killing': 'eliminating', + 'killer': 'eliminator', + + # 'lynch' variations + 'lynch': 'exile', + 'lynches': 'exiles', + 'lynched': 'exiled', + 'lynching': 'exiling', + + # 'mislynch' variations + 'mislynch': 'mis-exile', + 'mislynches': 'mis-exiles', + 'mislynched': 'mis-exiled', + 'mislynching': 'mis-exiling', + + # 'murder' variations + 'murder': 'remove', + 'murders': 'removes', + 'murdered': 'removed', + 'murdering': 'removing', + 'murderer': 'remover' +} + +_CENSOR_PATTERN = re.compile(r'\b(' + '|'.join(_REPLACEMENT_MAP.keys()) + r')\b', re.IGNORECASE) + + +# Create a single, case-insensitive regex pattern from all map keys. +def replacer(match): + """ + Finds the correct replacement and applies case based on a specific heuristic. + """ + original_word = match.group(0) + replacement = _REPLACEMENT_MAP[original_word.lower()] + + # Rule 1: Preserve ALL CAPS. + if original_word.isupper(): + return replacement.upper() + + # Rule 2: Handle title-cased words with a more specific heuristic. + if original_word.istitle(): + # Preserve title case if it's the first word of the string OR + # if it's a form like "-ing" which can start a new clause. + return replacement.title() + + # Rule 3: For all other cases (e.g., "Kill" mid-sentence), default to lowercase. + return replacement.lower() + + +def filter_language(text): + """Remove inappropriate/violent language.""" + return _CENSOR_PATTERN.sub(replacer, text) + + +# ------------------------------------------------------------------ # +class Action(BaseAction): + """Root of the discriminated-union tree.""" + day: int + phase: Phase + actor_id: PlayerID + reasoning: Optional[str] = Field( + default=None, max_length=4096, + description="The self monologue that illustrate how you arrived at the action. " + "It will be invisible to other players.") + + perceived_threat_level: PerceivedThreatLevel = Field( + default=PerceivedThreatLevel.SAFE, + description="The self perceived threat level you are currently experiencing from other players. " + "The assessment will be invisible to other players." + ) + error: Optional[str] = None + raw_prompt: Optional[str] = None + raw_completion: Optional[str] = None + + @field_validator('reasoning', mode='before') + @classmethod + def filter_reasoning(cls, v): + if v is None: + return v + return filter_language(v) + + def serialize(self): + return {'action_type': self.__class__.__name__, 'kwargs': self.model_dump()} + + @classmethod + def schema_for_player(cls, fields: Tuple = None, new_cls_name=None): + """Many of the fields are for internal game record. This method is used to convert the response schema + to a format friendly for players. + """ + fields = fields or [] + if not new_cls_name: + new_cls_name = cls.__name__ + 'Data' + field_definitions = { + field: ( + cls.model_fields[field].annotation, + # Pass the entire FieldInfo object, not just the default value + cls.model_fields[field] + ) + for field in fields + if field in cls.model_fields + } + sub_cls = create_model(new_cls_name, **field_definitions) + subset_schema = sub_cls.model_json_schema() + return subset_schema + + @property + def action_field(self) -> Optional[str]: + return None + + def push_event(self, state: BaseState): + # The following is just for internal record keeping. + data = self.model_dump() + state.push_event( + description=f"Player {self.actor_id}, you submitted {data}", + event_name=ACTION_EVENT_MAP[self.__class__.__name__], + public=False, + visible_to=[], + data=data + ) + + +# ——— Mix-in for actions that need a target ------------------------ # +class TargetedAction(Action): + target_id: PlayerID = Field(description="The target player's id.") + + @classmethod + @lru_cache(maxsize=10) + def schema_for_player(cls, fields=None, new_cls_name=None): + fields = fields or ['perceived_threat_level', 'reasoning', 'target_id'] + return super(TargetedAction, cls).schema_for_player(fields, new_cls_name) + + @property + def action_field(self): + return "target_id" + + +# ——— Concrete leaf classes --------------------------------------- # +@register_event(EventName.HEAL_ACTION) +class HealAction(TargetedAction): + def push_event(self, state: BaseState): + action_data = DoctorHealActionDataEntry( + actor_id=self.actor_id, + target_id=self.target_id, + reasoning=self.reasoning, + perceived_threat_level=self.perceived_threat_level, + action=self + ) + state.push_event( + description=f"Player {self.actor_id}, you chose to heal player {self.target_id}.", + event_name=EventName.HEAL_ACTION, + public=False, + visible_to=[self.actor_id], + data=action_data + ) + + +@register_event(EventName.INSPECT_ACTION) +class InspectAction(TargetedAction): + def push_event(self, state: BaseState): + action_data = SeerInspectActionDataEntry( + actor_id=self.actor_id, + target_id=self.target_id, + reasoning=self.reasoning, + perceived_threat_level=self.perceived_threat_level, + action=self + ) + state.push_event( + description=f"Player {self.actor_id}, you chose to inspect player {self.target_id}.", + event_name=EventName.INSPECT_ACTION, + public=False, + visible_to=[self.actor_id], + data=action_data + ) + + +@register_event(EventName.VOTE_ACTION) +class VoteAction(TargetedAction): + pass + + +@register_event(EventName.ELIMINATE_PROPOSAL_ACTION) +class EliminateProposalAction(VoteAction): + pass + + +@register_event(EventName.DISCUSSION) +class ChatAction(Action): + message: str = Field(default="", max_length=4096) + + @field_validator('message', mode='before') + @classmethod + def filter_message(cls, v): + return filter_language(v) + + @classmethod + @lru_cache(maxsize=10) + def schema_for_player(cls, fields=None, new_cls_name=None): + fields = fields or ['perceived_threat_level', 'reasoning', 'message'] + return super(ChatAction, cls).schema_for_player(fields, new_cls_name) + + @property + def action_field(self): + return "message" + + +@register_event(EventName.NOOP_ACTION) +class NoOpAction(Action): + pass + + +# ------------------------------------------------------------ # +@register_event(EventName.BID_ACTION) +class BidAction(Action): + """ + An amount the actor is willing to pay this round. + Currency unit can be generic 'chips' or role-specific. + """ + amount: int = Field(ge=0) + + @classmethod + @lru_cache(maxsize=10) + def schema_for_player(cls, fields=None, new_cls_name=None): + fields = fields or ['perceived_threat_level', 'reasoning', 'amount'] + return super(BidAction, cls).schema_for_player(fields, new_cls_name) + + @property + def action_field(self): + return "amount" + + +ACTIONS = [ + EliminateProposalAction, + HealAction, + InspectAction, + VoteAction, + ChatAction, + BidAction, + NoOpAction +] + +ACTION_REGISTRY = { + action.__name__: action for action in ACTIONS +} + +def create_action(serialized): + return ACTION_REGISTRY[serialized['action_type']](**serialized.get('kwargs', {})) \ No newline at end of file diff --git a/kaggle_environments/envs/werewolf/game/base.py b/kaggle_environments/envs/werewolf/game/base.py new file mode 100644 index 00000000..00a462e5 --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/base.py @@ -0,0 +1,106 @@ +from abc import ABC, abstractmethod +from typing import Type, Dict, Protocol, Any, Annotated, Optional, List + +from pydantic import BaseModel, StringConstraints + +from .consts import EVENT_HANDLER_FOR_ATTR_NAME, EventName, MODERATOR_ID + + +# The ID regex supports Unicode letters (\p{L}), numbers (\p{N}) and common symbol for ID. +ROBUST_ID_REGEX = r'^[\p{L}\p{N} _.-]+$' + +PlayerID = Annotated[str, StringConstraints(pattern=ROBUST_ID_REGEX, min_length=1, max_length=128)] + + +class BasePlayer(BaseModel, ABC): + id: PlayerID + """The unique id of the player. Also, how the player is referred to in the game.""" + + alive: bool = True + + @abstractmethod + def set_role_state(self, key, value): + """Set role related state, which is a dict.""" + + @abstractmethod + def get_role_state(self, key, default=None): + """Get role related state.""" + + +class BaseAction(BaseModel): + pass + + +class BaseState(BaseModel): + @abstractmethod + def push_event(self, + description: str, + event_name: EventName, + public: bool, + visible_to: Optional[List[PlayerID]] = None, + data: Any = None, source=MODERATOR_ID): + """Publish an event.""" + + +class BaseEvent(BaseModel): + event_name: EventName + + +class BaseModerator(ABC): + @abstractmethod + def advance(self, player_actions: Dict[PlayerID, BaseAction]): + """Move one Kaggle environment step further. This is to be used within Kaggle 'interpreter'.""" + + @abstractmethod + def request_action( + self, action_cls: Type[BaseAction], player_id: PlayerID, prompt: str, data=None, + event_name=EventName.MODERATOR_ANNOUNCEMENT + ): + """This can be used by event handler to request action from a player.""" + + @abstractmethod + def record_night_save(self, doctor_id: str, target_id: str): + """To be used by a special Role to perform night save. This is implemented in moderator level, since + coordinating between safe and night elimination is cross role activity. + """ + + @property + @abstractmethod + def state(self) -> BaseState: + """Providing current state of the game, including player info, event messaging and caching.""" + + +def on_event(event_type: EventName): + def decorator(func): + setattr(func, EVENT_HANDLER_FOR_ATTR_NAME, event_type) + return func + return decorator + + +class EventHandler(Protocol): + """A callable triggered by an event.""" + def __call__(self, event: BaseEvent) -> Any: + pass + + +class RoleEventHandler(Protocol): + """A role specific event handler.""" + def __call__(self, me: BasePlayer, moderator: BaseModerator, event: BaseEvent) -> Any: + pass + + + +class BaseRole(BaseModel, ABC): + """Special abilities should be implemented as RoleEventHandler in each subclass of BaseRole, so that Moderator + doesn't need to be overwhelmed by role specific logic. + """ + def get_event_handlers(self) -> Dict[EventName, RoleEventHandler]: + """Inspects the role instance and collects all methods decorated with @on_event""" + handlers = {} + for attr_name in dir(self): + if not attr_name.startswith('__'): + attr = getattr(self, attr_name) + if callable(attr) and hasattr(attr, EVENT_HANDLER_FOR_ATTR_NAME): + event_type = getattr(attr, EVENT_HANDLER_FOR_ATTR_NAME) + handlers[event_type] = attr + return handlers diff --git a/kaggle_environments/envs/werewolf/game/consts.py b/kaggle_environments/envs/werewolf/game/consts.py new file mode 100644 index 00000000..0fab4f8d --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/consts.py @@ -0,0 +1,156 @@ +from enum import Enum + + +MODERATOR_ID = "MODERATOR" + + +class StrEnum(str, Enum): + def __str__(self): + return str(self.value) + + def __repr__(self): + return str(self.value) + + +class Phase(StrEnum): + DAY = "Day" + NIGHT = "Night" + GAME_OVER = "Game Over" + +DAY, NIGHT, GAME_OVER = Phase + + +class PhaseDivider(StrEnum): + NIGHT_START = "NIGHT START" + NIGHT_END = "NIGHT END" + DAY_START = "DAY START" + DAY_END = "DAY END" + NIGHT_VOTE_START = "NIGHT VOTE START" + NIGHT_VOTE_END = "NIGHT VOTE END" + DAY_CHAT_START = "DAY CHAT START" + DAY_CHAT_END = "DAY CHAT END" + DAY_VOTE_START = "DAY VOTE START" + DAY_VOTE_END = "DAY VOTE END" + + +class Team(StrEnum): + VILLAGERS = "Villagers" + WEREWOLVES = "Werewolves" + + +class RoleConst(StrEnum): + VILLAGER = "Villager" + WEREWOLF = "Werewolf" + DOCTOR = "Doctor" + SEER = "Seer" + + +class ActionType(StrEnum): + NO_OP = "NO_OP" + NIGHT_KILL_VOTE = "NIGHT_KILL_VOTE" + NIGHT_SAVE_TARGET = "NIGHT_SAVE_TARGET" + NIGHT_INSPECT_TARGET = "NIGHT_INSPECT_TARGET" + DAY_DISCUSS = "DAY_DISCUSS" + DAY_LYNCH_VOTE = "DAY_LYNCH_VOTE" + + +class PerceivedThreatLevel(StrEnum): + SAFE = "SAFE" + UNEASY = "UNEASY" + DANGER = "DANGER" + + +class EnvInfoKeys: + MODERATOR_OBS = "MODERATOR_OBSERVATION" + GAME_END = "GAME_END" + + +class ObsKeys: + RAW_OBSERVATION = "raw_observation" + + +class DetailedPhase(StrEnum): + def __new__(cls, value, category: Phase): + # This creates the string object from the value + obj = str.__new__(cls, value) + # This sets the _value_ attribute, which is what Enum uses internally + obj._value_ = value + # Now, attach your custom category attribute + obj.category = category + return obj + + # Night Phases + NIGHT_START = "NIGHT_START", NIGHT + NIGHT_AWAIT_ACTIONS = "NIGHT_AWAIT_ACTIONS", NIGHT + NIGHT_CONCLUDE = "NIGHT_CONCLUDE", NIGHT + + # Day Phases + DAY_START = "DAY_START", DAY + + DAY_BIDDING_AWAIT = "DAY_BIDDING_AWAIT", DAY + DAY_BIDDING_CONCLUDE = "DAY_BIDDING_CONCLUDE", DAY + + DAY_CHAT_AWAIT = "DAY_CHAT_AWAIT", DAY + DAY_CHAT_CONCLUDE = "DAY_CHAT_CONCLUDE", DAY + + DAY_VOTING_START = "DAY_VOTING_START", DAY + DAY_VOTING_AWAIT = "DAY_VOTING_AWAIT", DAY + DAY_VOTING_CONCLUDE = "DAY_VOTING_CONCLUDE", DAY + + # Game Over + GAME_OVER = "GAME_OVER", GAME_OVER + + +EVENT_HANDLER_FOR_ATTR_NAME = '_event_handler_for' + + +class EventName(str, Enum): + GAME_START = "game_start" + PHASE_CHANGE = "phase_change" + PHASE_DIVIDER = "phase_divider" + ELIMINATION = "elimination" + + VOTE_REQUEST = "vote_request" + VOTE_ACTION = "vote_action" + VOTE_RESULT = "vote_result" + VOTE_ORDER = "vote_order" + + HEAL_REQUEST = "heal_request" + HEAL_ACTION = "heal_action" + HEAL_RESULT = "heal_result" + + INSPECT_REQUEST = "inspect_request" + INSPECT_ACTION = "inspect_action" + INSPECT_RESULT = "inspect_result" + + CHAT_REQUEST = "chat_request" + DISCUSSION = "discussion" + DISCUSSION_ORDER = "discussion_order" + + BID_REQEUST = "bid_request" + BID_RESULT = "bid_result" + BID_ACTION = "bid_action" + BIDDING_INFO = "bidding_info" + + ELIMINATE_PROPOSAL_ACTION = "eliminate_proposal_action" + NOOP_ACTION = "no_op_action" + + GAME_END = "game_end" + MODERATOR_ANNOUNCEMENT = "moderator_announcement" + ACTION_CONFIRMATION = "action_confirmation" + ERROR = "error" + NIGHT_START = "night_start" + DAY_START = "day_start" + NIGHT_END = "night_end" + DAY_END = "day_end" + + +class RevealLevel(StrEnum): + NO_REVEAL = "no_reveal" + """No reveal during elimination.""" + + TEAM = "team" + """Only reveal team during elimination.""" + + ROLE = "role" + """Reveal detailed role information during elimination.""" diff --git a/kaggle_environments/envs/werewolf/game/engine.py b/kaggle_environments/envs/werewolf/game/engine.py new file mode 100644 index 00000000..46080fd8 --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/engine.py @@ -0,0 +1,583 @@ +import json +from typing import List, Dict, Type, Sequence, Protocol + +from .actions import Action, VoteAction, ChatAction, BidAction +from .base import BaseModerator, PlayerID +from .consts import Team, RoleConst, PhaseDivider, DetailedPhase, RevealLevel +from .night_elimination_manager import NightEliminationManager +from .protocols.base import VotingProtocol, DiscussionProtocol +from .protocols.chat import BiddingDiscussion +from .records import ( + EventName, GameStartDataEntry, GameStartRoleDataEntry, RequestWerewolfVotingDataEntry, + WerewolfNightEliminationElectedDataEntry, DayExileElectedDataEntry, + GameEndResultsDataEntry +) +from .roles import Player +from .states import GameState + + +class ActionQueue: + """A data structure for managing player ids in action specific queues.""" + + def __init__(self): + self._action_queue: Dict[str, List[PlayerID]] = {} + + def clear(self): + self._action_queue = {} + + def append(self, action_cls: Type[Action], player_id: PlayerID): + action_type = action_cls.__name__ + self._action_queue.setdefault(action_type, []) + if player_id in self._action_queue[action_type]: + raise ValueError(f'player {player_id} is already in the action queue. ') + self._action_queue[action_type].append(player_id) + + def extend(self, action_cls: Type[Action], player_ids: Sequence[PlayerID]): + for player_id in player_ids: + self.append(action_cls, player_id) + + def get(self, action_cls: Type[Action]) -> List[str]: + """return a list of player_id for the selected action.""" + return self._action_queue.get(action_cls.__name__, []) + + def get_active_player_ids(self) -> List[PlayerID]: + all_players = set() + for players in self._action_queue.values(): + all_players.update(players) + return list(all_players) + + +def phase_handler(phase: DetailedPhase): + """Decorator to register a method as a handler for a specific game phase.""" + def decorator(func): + setattr(func, '_phase_handler_for', phase) + return func + return decorator + + +class PhaseHandler(Protocol): + def __call__(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + pass + + +class Moderator(BaseModerator): + """Drives the finite-state machine for the game.""" + + def __init__( + self, + state: GameState, + discussion: DiscussionProtocol, + day_voting: VotingProtocol, # Renamed for clarity + night_voting: VotingProtocol, + night_elimination_reveal_level: RevealLevel = RevealLevel.ROLE, + day_exile_reveal_level: RevealLevel = RevealLevel.ROLE + ): + self._state = state + self.discussion = discussion + self.day_voting = day_voting + self.night_voting = night_voting + + self._night_elimination_reveal_level = night_elimination_reveal_level + self._day_exile_reveal_level = day_exile_reveal_level + + self._active_night_roles_queue: List[Player] = [] + self._night_elimination_manager = NightEliminationManager( + self._state, reveal_level=self._night_elimination_reveal_level) + self._action_queue = ActionQueue() + + # This is for registering role specific event handling + self._register_player_handlers() + + # below is the state transition function table + # each transition function has the signature tr_func(actions: List[Action]) where the input is a list of actions + # with the length the same as the number of agents + self.detailed_phase = DetailedPhase.NIGHT_START + self._phase_handlers: Dict[DetailedPhase, PhaseHandler] = {} + self._register_phase_handlers() + + self._make_initial_announcements() + + @property + def state(self) -> GameState: + return self._state + + def _make_initial_announcements(self): + data = GameStartDataEntry( + player_ids=[p.id for p in self.state.alive_players()], + number_of_players=len(self.state.alive_players()), + role_counts=self.state.alive_player_counts_per_role(), + team_member_counts=self.state.alive_player_counts_per_team(), + day_discussion_protocol_name=self.discussion.__class__.__name__, + day_discussion_display_name=self.discussion.display_name, + day_discussion_protocol_rule=self.discussion.rule, + night_werewolf_discussion_protocol_name=self.night_voting.__class__.__name__, + night_werewolf_discussion_display_name=self.night_voting.display_name, + night_werewolf_discussion_protocol_rule=self.night_voting.rule, + day_voting_protocol_name=self.day_voting.__class__.__name__, + day_voting_display_name=self.day_voting.display_name, + day_voting_protocol_rule=self.day_voting.rule + ) + + role_msg = "\n".join( + ["The following explain the function of each role."] + + [f" * Role name {role.name.value} - team {role.team.value} - {role.descriptions}" + for role in self.state.all_unique_roles]) + + if self._day_exile_reveal_level == RevealLevel.ROLE: + day_exile_reveal_msg = "If a player is exiled in the day, their role will be revealed." + elif self._day_exile_reveal_level == RevealLevel.TEAM: + day_exile_reveal_msg = "If a player is exiled in the day, their team will be revealed." + elif self._day_exile_reveal_level == RevealLevel.NO_REVEAL: + day_exile_reveal_msg = "If a player is exiled in the day, their team and role will NOT be revealed." + else: + raise ValueError(f'Unsupported day_exile_reveal_level = {self._day_exile_reveal_level}.') + + if self._night_elimination_reveal_level == RevealLevel.ROLE: + night_elimination_reveal_msg = "If a player is eliminated at night, their role will be revealed." + elif self._night_elimination_reveal_level == RevealLevel.TEAM: + night_elimination_reveal_msg = "If a player is eliminated at night, their team will be revealed." + elif self._night_elimination_reveal_level == RevealLevel.NO_REVEAL: + night_elimination_reveal_msg = "If a player is eliminated at night, their team and role will NOT be revealed." + else: + raise ValueError(f'Unsupported night_elimination_reveal_level = {self._night_elimination_reveal_level}.') + + description = "\n - ".join([ + "Werewolf game begins.", + f"**Player Roster:** {data.player_ids}", + f"**Alive Players:** {data.number_of_players}.", + f"**Role Counts:** {data.role_counts}.", + f"**Alive Team Member:** {data.team_member_counts}", + f"**Day Discussion:** {data.day_discussion_display_name}. {data.day_discussion_protocol_rule}", + f"**Day Exile Vote:** {data.day_voting_display_name}. {data.day_voting_protocol_rule}", + f"**Night Werewolf Vote:** {data.night_werewolf_discussion_display_name}. {data.night_werewolf_discussion_protocol_rule}", + role_msg, + day_exile_reveal_msg, + night_elimination_reveal_msg + ]) + self.state.push_event( + description=description, + event_name=EventName.MODERATOR_ANNOUNCEMENT, + public=True, + data=data + ) + # add role specific announcements + for player in self.state.alive_players(): + data = GameStartRoleDataEntry( + player_id=player.id, + team=player.role.team, + role=player.role.name, + rule_of_role=player.role.descriptions + ) + self.state.push_event( + description=f'Your player id is "{data.player_id}". Your team is "{data.team}". Your role is "{data.role}".\n' + f"The rule of your role: {data.rule_of_role}", + event_name=EventName.GAME_START, + public=False, + visible_to=[player.id], + data=data + ) + + def _register_phase_handlers(self): + """Collects all methods decorated with @phase_handler.""" + for attr_name in dir(self): + attr = getattr(self, attr_name) + if callable(attr) and hasattr(attr, '_phase_handler_for'): + phase = getattr(attr, '_phase_handler_for') + self._phase_handlers[phase] = attr + + def _register_player_handlers(self): + for player in self.state.players: + for event_name, handlers in player.get_event_handlers(self).items(): + for handler in handlers: + self.state.register_event_handler(event_name, handler) + + def request_action( + self, + action_cls: Type[Action], player_id: PlayerID, prompt: str, data=None, + event_name=EventName.MODERATOR_ANNOUNCEMENT + ): + """A public method for listeners to add a player to the action queue.""" + self._action_queue.append(action_cls, player_id) + # Create the corresponding data entry to prompt the player + self.state.push_event( + description=prompt, + event_name=event_name, + public=False, + visible_to=[player_id], + data=data + ) + + def confirm_action(self, player_actions: Dict[PlayerID, Action]): + for action in player_actions.values(): + # moderator confirming the action with players + action.push_event(state=self.state) + + def set_next_phase(self, new_detailed_phase: DetailedPhase, add_one_day: bool = False): + """Note: phase change is not the same as phase start, still need phase start at each block""" + old_detailed_phase = self.detailed_phase + self.detailed_phase = new_detailed_phase + self.state.detailed_phase = new_detailed_phase + self.state.phase = new_detailed_phase.category + + if add_one_day: + self.state.day_count += 1 + + self.state.push_event( + description=f"Transitioning from {old_detailed_phase} to {new_detailed_phase}.", + event_name=EventName.PHASE_CHANGE, + public=False + ) + + def get_active_player_ids(self) -> List[PlayerID]: + return self._action_queue.get_active_player_ids() + + def record_night_save(self, doctor_id: PlayerID, target_id: PlayerID): + self._night_elimination_manager.record_save(doctor_id, target_id) + + def _call_handler(self, player_actions: Dict[PlayerID, Action]): + current_handler = self._phase_handlers.get(self.detailed_phase) + if current_handler: + next_detailed_phase = current_handler(player_actions) + else: + raise ValueError(f"Unhandled detailed_phase: {self.detailed_phase}") + add_one_day = True if next_detailed_phase == DetailedPhase.DAY_START else False + self.set_next_phase(next_detailed_phase, add_one_day=add_one_day) + + def advance(self, player_actions: Dict[PlayerID, Action]): + self.confirm_action(player_actions) + # Process the incoming actions for the current phase. + self._call_handler(player_actions) + + # Loop through automatic state transitions (those that don't need agent actions) + # This continues until the game is over or requires new agent input. + # this logic is required since Environments in core.py requires that there are some players being ACTIVE to + # continue. Otherwise, if all INACTIVE the game is marked done. + while not self.get_active_player_ids() and not self.is_game_over(): + self._call_handler({}) + + # After all transitions, check for game over. + if self.is_game_over() and self.detailed_phase != DetailedPhase.GAME_OVER: + # clear action queue + self._action_queue.clear() + self.set_next_phase(DetailedPhase.GAME_OVER) + self._determine_and_log_winner() + + @phase_handler(DetailedPhase.NIGHT_START) + def _handle_night_start(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + self._action_queue.clear() + self.state.add_phase_divider(PhaseDivider.NIGHT_START) + self.state.push_event( + description=f"Night {self.state.day_count} begins!", + event_name=EventName.NIGHT_START, + public=True + ) + + # initialize werewolves voting + self.state.add_phase_divider(PhaseDivider.NIGHT_VOTE_START) + alive_werewolves = self.state.alive_players_by_role(RoleConst.WEREWOLF) + alive_werewolf_ids = list({p.id for p in alive_werewolves}) + potential_targets = self.state.alive_players_by_team(Team.VILLAGERS) # Target non-werewolves + + data = RequestWerewolfVotingDataEntry( + valid_targets=[f"{p.id}" for p in potential_targets], + alive_werewolve_player_ids=[f"{p.id}" for p in alive_werewolves], + voting_protocol_name=self.night_voting.__class__.__name__, + voting_protocol_rule=self.night_voting.rule, + action_json_schema=json.dumps(VoteAction.schema_for_player()), + ) + self.state.push_event( + description=f"Wake up Werewolves. Your fellow alive werewolves are: {data.alive_werewolve_player_ids}. " + f"Choose one target player to eliminate tonight. " + f"The voting rule ({data.voting_protocol_name}): {data.voting_protocol_rule} " + f"Who would you like to eliminate tonight? Options: {data.valid_targets}.", + event_name=EventName.VOTE_REQUEST, + public=False, + visible_to=alive_werewolf_ids, + data=data + ) + self.night_voting.begin_voting( + state=self.state, + alive_voters=alive_werewolves, + potential_targets=potential_targets + ) + return DetailedPhase.NIGHT_AWAIT_ACTIONS + + @phase_handler(DetailedPhase.NIGHT_AWAIT_ACTIONS) + def _handle_night_await_actions(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + # Process werewolf votes + werewolf_voters_expected = self._action_queue.get(VoteAction) + if werewolf_voters_expected: + self.night_voting.collect_votes(player_actions, self.state, werewolf_voters_expected) + + self._action_queue.clear() + + if not self.night_voting.done(): + next_ww_voters = self.night_voting.get_next_voters() + self._action_queue.extend(VoteAction, next_ww_voters) + vote_action_queue = self._action_queue.get(VoteAction) + alive_werewolves_still_to_vote = [p for p in self.state.alive_players_by_role(RoleConst.WEREWOLF) if + p.id in vote_action_queue] + if alive_werewolves_still_to_vote: + for ww_voter in alive_werewolves_still_to_vote: + prompt = self.night_voting.get_voting_prompt(self.state, ww_voter.id) + self.state.push_event( + description=prompt, + event_name=EventName.VOTE_REQUEST, + public=False, + visible_to=[ww_voter.id] + ) + return DetailedPhase.NIGHT_AWAIT_ACTIONS + else: + return DetailedPhase.NIGHT_CONCLUDE + + @phase_handler(DetailedPhase.NIGHT_CONCLUDE) + def _handle_night_conclude(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + werewolf_target_id = self.night_voting.get_elected() + + data = WerewolfNightEliminationElectedDataEntry(elected_target_player_id=werewolf_target_id) + self.state.push_event( + description=f'Werewolves elected to eliminate player "{data.elected_target_player_id}".', + event_name=EventName.VOTE_RESULT, + public=False, + visible_to=[p.id for p in self.state.alive_players_by_team(Team.WEREWOLVES)], + data=data + ) + + self._night_elimination_manager.resolve_elimination(werewolf_target_id) + + self.night_voting.reset() + self._night_elimination_manager.reset() + + self.state.add_phase_divider(PhaseDivider.NIGHT_VOTE_END) + self.state.add_phase_divider(PhaseDivider.NIGHT_END) + return DetailedPhase.DAY_START + + @phase_handler(DetailedPhase.DAY_START) + def _handle_day_start(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + self.state.add_phase_divider(PhaseDivider.DAY_START) + self._action_queue.clear() + self.night_step = 0 # Reset night step counter + + self.state.push_event( + description=f"Day {self.state.day_count} begins.", + event_name=EventName.DAY_START, + public=True + ) + + self.state.push_event( + description=f"Villagers, let's decide who to exile. The discussion rule is: {self.discussion.rule}", + event_name=EventName.MODERATOR_ANNOUNCEMENT, + public=True, + data={'discussion_rule': self.discussion.rule} + ) + + self.state.add_phase_divider(PhaseDivider.DAY_CHAT_START) + self.discussion.begin(self.state) + + # Check if the protocol starts with bidding + if isinstance(self.discussion, BiddingDiscussion): + return DetailedPhase.DAY_BIDDING_AWAIT + else: + return DetailedPhase.DAY_CHAT_AWAIT + + @phase_handler(DetailedPhase.DAY_BIDDING_AWAIT) + def _handle_day_bidding_await(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + current_bidders = self._action_queue.get(BidAction) + self._action_queue.clear() + + # The protocol processes bid actions + self.discussion.process_actions(list(player_actions.values()), current_bidders, self.state) + + # We need to explicitly check if the bidding sub-phase is over + # This requires a reference to the bidding protocol within BiddingDiscussion + assert isinstance(self.discussion, BiddingDiscussion) + bidding_protocol = self.discussion.bidding + if bidding_protocol.is_finished(self.state): + return DetailedPhase.DAY_BIDDING_CONCLUDE + else: + # Bidding is not over (e.g., sequential auction), get next bidders + next_bidders = self.discussion.speakers_for_tick(self.state) + self._action_queue.extend(BidAction, next_bidders) + self.discussion.prompt_speakers_for_tick(self.state, next_bidders) + return DetailedPhase.DAY_BIDDING_AWAIT + + @phase_handler(DetailedPhase.DAY_BIDDING_CONCLUDE) + def _handle_day_bidding_conclude(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + self.state.push_event( + description="Bidding has concluded. The discussion will now begin.", + event_name=EventName.PHASE_CHANGE, + public=True + ) + self.discussion.bidding.reset() + return DetailedPhase.DAY_CHAT_AWAIT + + @phase_handler(DetailedPhase.DAY_CHAT_AWAIT) + def _handle_day_chat_await(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + speaker_ids = self._action_queue.get(ChatAction) + self._action_queue.clear() + self.discussion.process_actions(list(player_actions.values()), speaker_ids, self.state) + + if self.discussion.is_discussion_over(self.state): + return DetailedPhase.DAY_CHAT_CONCLUDE + else: + # Discussion is not over. Check if we need to go back to bidding action and phase. + if isinstance(self.discussion, BiddingDiscussion) and self.discussion.is_bidding_phase(): + return DetailedPhase.DAY_BIDDING_AWAIT + # Get the next active players (either bidders or the next speaker) + next_actors = self.discussion.speakers_for_tick(self.state) + self._action_queue.extend(ChatAction, next_actors) + self.discussion.prompt_speakers_for_tick(self.state, next_actors) + return DetailedPhase.DAY_CHAT_AWAIT + + @phase_handler(DetailedPhase.DAY_CHAT_CONCLUDE) + def _handle_day_chat_conclude(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + self.state.push_event( + description="Daytime discussion has concluded. Moving to day vote.", + event_name=EventName.PHASE_CHANGE, + public=True + ) + self.discussion.reset() + self.state.add_phase_divider(PhaseDivider.DAY_CHAT_END) + return DetailedPhase.DAY_VOTING_START + + @phase_handler(DetailedPhase.DAY_VOTING_START) + def _handle_day_voting_start(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + self.state.add_phase_divider(PhaseDivider.DAY_VOTE_START) + alive_players = self.state.alive_players() + self.day_voting.begin_voting(self.state, alive_players, alive_players) + self.state.push_event( + description="Voting phase begins. We will decide who to exile today." + f"\nDay voting Rule: {self.day_voting.rule}" + f"\nCurrent alive players are: {[player.id for player in alive_players]}", + event_name=EventName.MODERATOR_ANNOUNCEMENT, + public=True, + data={"voting_rule": self.day_voting.rule} + ) + return DetailedPhase.DAY_VOTING_AWAIT + + @phase_handler(DetailedPhase.DAY_VOTING_AWAIT) + def _handle_day_voting_await(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + vote_queue = self._action_queue.get(VoteAction) + self.day_voting.collect_votes(player_actions, self.state, vote_queue) + self._action_queue.clear() # Clear previous voters + + if self.day_voting.done(): + return DetailedPhase.DAY_VOTING_CONCLUDE + else: + next_voters_ids = self.day_voting.get_next_voters() + self._action_queue.extend(VoteAction, next_voters_ids) + if next_voters_ids: + for voter_id in next_voters_ids: + player = self.state.get_player_by_id(voter_id) + if player and player.alive: + prompt = self.day_voting.get_voting_prompt(self.state, voter_id) + self.state.push_event( + description=prompt, event_name=EventName.VOTE_REQUEST, + public=False, visible_to=[voter_id] + ) + return DetailedPhase.DAY_VOTING_AWAIT + + @phase_handler(DetailedPhase.DAY_VOTING_CONCLUDE) + def _handle_day_voting_conclude(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase: + exiled_player_id = self.day_voting.get_elected() + if exiled_player_id: + exiled_player = self.state.get_player_by_id(exiled_player_id) + if exiled_player: + self.state.eliminate_player(exiled_player_id) + + role = None + team = None + description = f'Player "{exiled_player_id}" is exiled by vote.' + if self._day_exile_reveal_level == RevealLevel.ROLE: + role = exiled_player.role.name + team = exiled_player.role.team + description = f'Player "{exiled_player_id}" in team {team} is exiled by vote. The player is a {role}.' + elif self._day_exile_reveal_level == RevealLevel.TEAM: + team = exiled_player.role.team + description = f'Player "{exiled_player_id}" in team {team} is exiled by vote.' + + data = DayExileElectedDataEntry( + elected_player_id=exiled_player_id, + elected_player_role_name=role, + elected_player_team_name=team + ) + self.state.push_event( + description=description, + event_name=EventName.ELIMINATION, + public=True, + data=data + ) + else: + self.state.push_event( + description="The vote resulted in no exile (e.g., a tie, no majority, or all abstained).", + event_name=EventName.VOTE_RESULT, + public=True, + data={ + "vote_type": "day_exile", + "outcome": "no_exile", + "reason": "tie_or_no_majority" + } + ) + + self.day_voting.reset() + self.state.add_phase_divider(PhaseDivider.DAY_VOTE_END) + self.state.add_phase_divider(PhaseDivider.DAY_END) + return DetailedPhase.NIGHT_START + + def _determine_and_log_winner(self): + # Check if a GAME_END entry already exists + game_end_event = self.state.get_event_by_name(EventName.GAME_END) + if game_end_event: + return # Winner already logged for this day count + + wolves = [p for p in self.state.alive_players() if p.role.team == Team.WEREWOLVES] + villagers = [p for p in self.state.alive_players() if p.role.team == Team.VILLAGERS] + + if not wolves: + winner_team = Team.VILLAGERS.value + winner_message = "Game Over: Villagers Win!" + reason = "Reason: All werewolves exiled." + scores = {p.id: 1 for p in self.state.get_players_by_team(team=Team.VILLAGERS)} + scores.update({p.id: 0 for p in self.state.get_players_by_team(team=Team.WEREWOLVES)}) + winner_ids = [p.id for p in self.state.get_players_by_team(Team.VILLAGERS)] + loser_ids = [p.id for p in self.state.get_players_by_team(Team.WEREWOLVES)] + else: + winner_team = Team.WEREWOLVES.value + winner_message = "Game Over: Werewolves Win!" + reason = f"Reason: len(werewolves) >= len(villagers). Final counts: len(werewolves)={len(wolves)}, len(villagers)={len(villagers)})." + scores = {p.id: 1 for p in self.state.get_players_by_team(team=Team.WEREWOLVES)} + scores.update({p.id: 0 for p in self.state.get_players_by_team(team=Team.VILLAGERS)}) + loser_ids = [p.id for p in self.state.get_players_by_team(Team.VILLAGERS)] + winner_ids = [p.id for p in self.state.get_players_by_team(Team.WEREWOLVES)] + + data = GameEndResultsDataEntry( + winner_team=winner_team, + winner_ids=winner_ids, + loser_ids=loser_ids, + scores=scores, + reason=reason, + last_day=self.state.day_count, + last_phase=self.state.phase.value, + survivors_until_last_round_and_role={p.id: p.role.name.value for p in self.state.alive_players()}, + all_players_and_role={p.id: p.role.name.value for p in self.state.players}, + elimination_info=self.state.get_elimination_info(), + all_players=[p.model_dump() for p in self.state.players] + ) + + self.state.push_event( + description=f"{winner_message}\n{reason}\nScores: {scores}\n" + f"Survivors: {data.survivors_until_last_round_and_role}\n" + f"All player roles: {data.all_players_and_role}", + event_name=EventName.GAME_END, + public=True, + data=data + ) + + def is_game_over(self) -> bool: + if self.detailed_phase == DetailedPhase.GAME_OVER: + return True + wolves = self.state.alive_players_by_team(Team.WEREWOLVES) + villagers = self.state.alive_players_by_team(Team.VILLAGERS) + if not wolves and villagers: return True + if wolves and len(wolves) >= len(villagers): return True + return False diff --git a/kaggle_environments/envs/werewolf/game/night_elimination_manager.py b/kaggle_environments/envs/werewolf/game/night_elimination_manager.py new file mode 100644 index 00000000..71b2f21d --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/night_elimination_manager.py @@ -0,0 +1,106 @@ +from typing import Dict, List, Optional + +from .base import PlayerID +from .records import DoctorSaveDataEntry, WerewolfNightEliminationDataEntry, EventName +from .states import GameState +from .consts import Team, RevealLevel + + +class NightEliminationManager: + """ + Manages the state and resolution of nighttime eliminations. + """ + + def __init__(self, state: GameState, reveal_level: RevealLevel = RevealLevel.ROLE): + self._state = state + self._reveal_level = reveal_level + self._saves: Dict[PlayerID, List[PlayerID]] = {} # Key: target_id, Value: [doctor_id] + + def reset(self): + """Clears all recorded actions for the start of a new night.""" + self._saves.clear() + + def record_save(self, doctor_id: PlayerID, target_id: PlayerID): + """Records a save action from a Doctor.""" + self._saves.setdefault(target_id, []).append(doctor_id) + + def resolve_elimination(self, werewolf_target_id: Optional[PlayerID]): + """ + Resolves the werewolf attack against any saves, eliminates a player + if necessary, and pushes the resulting events to the game state. + """ + if not werewolf_target_id: + self._state.push_event( + description="Last night, the werewolves did not reach a consensus (or no valid target was chosen)." + " No one was eliminated by werewolves.", + event_name=EventName.MODERATOR_ANNOUNCEMENT, + public=False, + visible_to=self._state.get_players_by_team(Team.WEREWOLVES), + ) + self._state.push_event( + description="Last night, No one was eliminated.", + event_name=EventName.MODERATOR_ANNOUNCEMENT, + public=True + ) + return + + target_player = self._state.get_player_by_id(werewolf_target_id) + if not target_player: + self._state.push_event( + description=f'Last night, werewolves targeted player "{werewolf_target_id}", but this player ' + f'could not be found. No one was eliminated by werewolves.', + event_name=EventName.ERROR, + public=False, + visible_to=self._state.get_players_by_team(Team.WEREWOLVES), + ) + self._state.push_event( + description="Last night, no one was eliminated.", + event_name=EventName.MODERATOR_ANNOUNCEMENT, + public=True + ) + return + + if werewolf_target_id in self._saves: + # The player was saved. + saving_doctor_ids = self._saves[werewolf_target_id] + save_data = DoctorSaveDataEntry(saved_player_id=werewolf_target_id) + self._state.push_event( + description=f'Your heal on player "{werewolf_target_id}" was successful!', + event_name=EventName.HEAL_RESULT, + public=False, + data=save_data, + visible_to=saving_doctor_ids + ) + self._state.push_event( + description="Last night, no one was eliminated.", + event_name=EventName.MODERATOR_ANNOUNCEMENT, + public=True + ) + else: + # The player is eliminated. + original_role_name = target_player.role.name + self._state.eliminate_player(werewolf_target_id) + + team = None + role = None + descriptions = [f'Last night, player "{werewolf_target_id}" was eliminated by werewolves.'] + if self._reveal_level == RevealLevel.ROLE: + team = target_player.role.team + role = target_player.role.name + descriptions.append(f'Their role was a "{original_role_name}".') + elif self._reveal_level == RevealLevel.TEAM: + team = target_player.role.team + descriptions.append(f'Their team was "{team}".') + + data = WerewolfNightEliminationDataEntry( + eliminated_player_id=werewolf_target_id, + eliminated_player_role_name=role, + eliminated_player_team_name=team + ) + description = ' '.join(descriptions) + self._state.push_event( + description=description, + event_name=EventName.ELIMINATION, + public=True, + data=data + ) diff --git a/kaggle_environments/envs/werewolf/game/protocols/__init__.py b/kaggle_environments/envs/werewolf/game/protocols/__init__.py new file mode 100644 index 00000000..f83f3b50 --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/protocols/__init__.py @@ -0,0 +1,3 @@ + +# The line below register the protocols +from . import factory, bid, chat, vote diff --git a/kaggle_environments/envs/werewolf/game/protocols/base.py b/kaggle_environments/envs/werewolf/game/protocols/base.py new file mode 100644 index 00000000..b846e841 --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/protocols/base.py @@ -0,0 +1,241 @@ +import json +import re +from abc import ABC, abstractmethod +from typing import Sequence, Dict, List, Optional, Tuple + +from kaggle_environments.envs.werewolf.game.actions import Action, BidAction, ChatAction +from kaggle_environments.envs.werewolf.game.base import PlayerID +from kaggle_environments.envs.werewolf.game.consts import EventName +from kaggle_environments.envs.werewolf.game.records import ChatDataEntry, RequestVillagerToSpeakDataEntry +from kaggle_environments.envs.werewolf.game.roles import Player +from kaggle_environments.envs.werewolf.game.states import GameState + + +def _extract_player_ids_from_string(text: str, all_player_ids: List[PlayerID]) -> List[PlayerID]: + """Extracts player IDs mentioned in a string.""" + if not all_player_ids: + return [] + # Create a regex pattern to find any of the player IDs as whole words + # Using a set for faster lookups and to handle duplicates from the regex + pattern = r'\b(' + '|'.join(re.escape(pid) for pid in all_player_ids) + r')\b' + # Use a set to automatically handle duplicates found by the regex + found_ids = set(re.findall(pattern, text)) + return sorted(list(found_ids)) # sorted for deterministic order + + +def _find_mentioned_players(text: str, all_player_ids: List[PlayerID]) -> List[PlayerID]: + """ + Finds player IDs mentioned in a string of text, ordered by their first appearance. + Player IDs are treated as whole words. + Example: "I think gpt-4 is suspicious, what do you think John?" -> ["gpt-4", "John"] + """ + if not text or not all_player_ids: + return [] + + # Sort by length descending to handle substrings correctly. + sorted_player_ids = sorted(all_player_ids, key=len, reverse=True) + pattern = r'\b(' + '|'.join(re.escape(pid) for pid in sorted_player_ids) + r')\b' + + matches = re.finditer(pattern, text) + + # Deduplicate while preserving order of first appearance + ordered_mentioned_ids = [] + seen = set() + for match in matches: + player_id = match.group(1) + if player_id not in seen: + ordered_mentioned_ids.append(player_id) + seen.add(player_id) + + return ordered_mentioned_ids + + +class GameProtocol(ABC): + @property + def display_name(self) -> str: + return self.__class__.__name__ + + @property + @abstractmethod + def rule(self) -> str: + """Human-readable format of rule.""" + + + +class VotingProtocol(GameProtocol): + """Collects, validates, and tallies votes.""" + + @abstractmethod + def begin_voting(self, state: GameState, alive_voters: Sequence[Player], potential_targets: Sequence[Player]): + """Initialize for a new voting round.""" + + @abstractmethod + def get_voting_prompt(self, state: GameState, player_id: PlayerID) -> str: + """ + Returns a string prompt for the specified player, potentially including current tally. + """ + + @abstractmethod + def collect_vote(self, vote_action: Action, state: GameState): # Changed to Action, will check type + """Collect an individual vote.""" + + @abstractmethod + def collect_votes(self, player_actions: Dict[str, Action], state: GameState, expected_voters: List[PlayerID]): + """Collect a batch of votes.""" + + @abstractmethod + def get_current_tally_info(self, state: GameState) -> Dict[PlayerID, PlayerID]: + """ + Return the current tally by a map, where key is player, value is target. + """ + + @abstractmethod + def get_next_voters(self) -> List[PlayerID]: + """get the next batch of voters""" + + @abstractmethod + def done(self): + """Check if voting is done.""" + + @abstractmethod + def get_valid_targets(self) -> List[PlayerID]: + """get a list of targets""" + + @abstractmethod + def get_elected(self) -> Optional[PlayerID]: + """get the final elected individual, or None if no one was elected.""" + + @abstractmethod + def reset(self) -> None: + """Resets the protocol to its initial state.""" + pass + + +class BiddingProtocol(GameProtocol): + """Drives one auction round and returns the winner(s).""" + @property + @abstractmethod + def bids(self) -> Dict[PlayerID, int]: + """return a snapshot of the current bids""" + + @staticmethod + def get_last_mentioned(state: GameState) -> Tuple[List[PlayerID], str]: + """get the players that were mentioned in last player message.""" + last_chat_message = "" + sorted_days = sorted(state.history.keys(), reverse=True) + for day in sorted_days: + for entry in reversed(state.history[day]): + if entry.event_name == EventName.DISCUSSION and isinstance(entry.data, ChatDataEntry): + last_chat_message = entry.data.message + break + if last_chat_message: + break + players = _find_mentioned_players(last_chat_message, state.all_player_ids) + return players, last_chat_message + + @abstractmethod + def begin(self, state: GameState) -> None: ... + + @abstractmethod + def accept(self, bid: BidAction, state: GameState) -> None: ... + + @abstractmethod + def process_incoming_bids(self, actions: List[Action], state: GameState) -> None: + """Processes a batch of actions, handling BidActions by calling self.accept().""" + + @abstractmethod + def is_finished(self, state: GameState) -> bool: ... + + @abstractmethod + def outcome(self, state: GameState) -> list[PlayerID]: + """ + Return list of player-ids, ordered by bid strength. + Could be 1 winner (sealed-bid) or a full ranking (Dutch auction). + """ + + @abstractmethod + def reset(self) -> None: + """Resets the protocol to its initial state.""" + + +class DiscussionProtocol(GameProtocol): + """Drives the order/shape of daytime conversation.""" + + @abstractmethod + def begin(self, state: GameState) -> None: + """Optional hook – initialise timers, round counters…""" + + @abstractmethod + def speakers_for_tick(self, state: GameState) -> Sequence[PlayerID]: + """ + Return the IDs that are *allowed to send a chat action* this tick. + Return an empty sequence when the discussion phase is over. + """ + + @abstractmethod + def is_discussion_over(self, state: GameState) -> bool: + """Returns True if the entire discussion (including any preliminary phases like bidding) is complete.""" + pass + + @abstractmethod + def reset(self) -> None: + """Resets the protocol to its initial state.""" + pass + + def process_actions(self, actions: List[Action], expected_speakers: Sequence[PlayerID], state: GameState) -> None: + """ + Processes a batch of actions. Depending on the protocol's state (e.g., bidding or chatting), + it will handle relevant actions (like BidAction or ChatAction) from expected_speakers. + """ + for act in actions: + if isinstance(act, ChatAction): + all_player_ids = [p.id for p in state.players] + mentioned_ids = _extract_player_ids_from_string(act.message, all_player_ids) + if expected_speakers and act.actor_id in expected_speakers: + data = ChatDataEntry( + actor_id=act.actor_id, + message=act.message, + reasoning=act.reasoning, + mentioned_player_ids=mentioned_ids, + perceived_threat_level=act.perceived_threat_level, + action=act + ) + state.push_event( + description=f'Player "{act.actor_id}" (chat): {act.message}', + # Make public for general discussion + event_name=EventName.DISCUSSION, + public=True, + source=act.actor_id, + data=data + ) + else: + state.push_event( + description=f'Player "{act.actor_id}" (chat, out of turn): {act.message}', + event_name=EventName.DISCUSSION, # Or a specific "INVALID_CHAT" type + visible_to=[act.actor_id], + public=False, + source=act.actor_id + ) + + def call_for_actions(self, speakers: Sequence[PlayerID]) -> List[str]: + """prepare moderator call for action for each player.""" + return [f'Player "{speaker_id}", it is your turn to speak.' for speaker_id in speakers] + + def prompt_speakers_for_tick(self, state: GameState, speakers: Sequence[PlayerID]) -> None: + """ + Allows the protocol to make specific announcements or prompts to the current speakers for this tick. + This method is called by the Moderator after speakers_for_tick() returns a non-empty list of speakers, + and before process_actions(). + Implementations should use state.push_event() to make announcements. + These announcements are typically visible only to the speakers, unless they are general status updates. + """ + call_for_actions = self.call_for_actions(speakers) + for speaker_id, call_for_action in zip(speakers, call_for_actions): + data = RequestVillagerToSpeakDataEntry(action_json_schema=json.dumps(ChatAction.schema_for_player())) + state.push_event( + description=call_for_action, + event_name=EventName.CHAT_REQUEST, + public=False, + visible_to=[speaker_id], + data=data + ) diff --git a/kaggle_environments/envs/werewolf/game/protocols/bid.py b/kaggle_environments/envs/werewolf/game/protocols/bid.py new file mode 100644 index 00000000..6e133876 --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/protocols/bid.py @@ -0,0 +1,243 @@ +from collections import Counter +from typing import Dict, List + +from kaggle_environments.envs.werewolf.game.actions import BidAction, Action +from kaggle_environments.envs.werewolf.game.base import PlayerID +from kaggle_environments.envs.werewolf.game.consts import EventName +from kaggle_environments.envs.werewolf.game.protocols.base import BiddingProtocol +from kaggle_environments.envs.werewolf.game.records import BidDataEntry, ChatDataEntry +from kaggle_environments.envs.werewolf.game.states import GameState +from .factory import register_protocol + + +@register_protocol() +class SimpleBiddingProtocol(BiddingProtocol): + """ + A straightforward bidding protocol where speaking priority is determined + solely by the bid amount. + - Agents bid with a numerical amount. + - Higher bids result in earlier speaking turns. + - Ties are broken deterministically by player ID (ascending). + """ + + def __init__(self): + self._bids: Dict[PlayerID, int] = {} + self._max_bid = 4 + self.reset() + + def reset(self) -> None: + """Resets the bids for a new round.""" + self._bids = {} + + @property + def display_name(self): + return "Simple Bidding" + + @property + def rule(self) -> str: + """Provides a description of the bidding rules.""" + return "\n".join(( + "Players bid with an urgency level (0-4) to determine speaking order.", + "0: I would like to observe and listen for now.", + "1: I have some general thoughts to share with the group.", + "2: I have something critical and specific to contribute to this discussion.", + "3: It is absolutely urgent for me to speak next.", + "4: I must respond.", + "Higher bids speak earlier. Ties are broken by player name (A-Z)." + )) + + @property + def bids(self) -> Dict[PlayerID, int]: + """Returns a copy of the current bids.""" + return dict(**self._bids) + + def begin(self, state: GameState) -> None: + """Initializes a new bidding round.""" + self.reset() + + def accept(self, bid: BidAction, state: GameState) -> None: + """Accepts and records a single bid from a player.""" + bid_amount = min(max(0, bid.amount), self._max_bid) + self._bids[bid.actor_id] = bid_amount + + data = BidDataEntry( + actor_id=bid.actor_id, + reasoning=bid.reasoning, + perceived_threat_level=bid.perceived_threat_level, + bid_amount=bid_amount, + action=bid + ) + state.push_event( + description=f"Player {bid.actor_id} submitted a bid of {bid_amount}.", + event_name=EventName.BID_ACTION, + public=False, # Bids are private until the outcome is announced + visible_to=[bid.actor_id], + data=data, + source=bid.actor_id + ) + + def process_incoming_bids(self, actions: List[Action], state: GameState) -> None: + """Processes a list of actions, handling any BidActions.""" + for act in actions: + if isinstance(act, BidAction): + self.accept(act, state) + + def is_finished(self, state: GameState) -> bool: + """ + Checks if the bidding phase is complete (i.e., all alive players have bid). + """ + return len(self._bids) >= len(state.alive_players()) + + def outcome(self, state: GameState) -> list[str]: + """ + Determines the final speaking order based on bids. + + Returns: + A list of player IDs sorted by bid (descending) and then player ID (ascending). + """ + if not self._bids: + # If no bids were made, return alive players in their default order. + return sorted([p.id for p in state.alive_players()]) + + # Sort by bid amount (descending) and then by player ID (ascending) for tie-breaking. + sorted_bidders = sorted(self._bids.items(), key=lambda item: (-item[1], item[0])) + return [player_id for player_id, bid_amount in sorted_bidders] + + +@register_protocol() +class UrgencyBiddingProtocol(BiddingProtocol): + """ + A bidding protocol based on the Werewolf Arena paper. + - Agents bid with an urgency level (0-4). + - Highest bidder wins. + - Ties are broken by prioritizing players mentioned in the previous turn. + """ + @property + def display_name(self) -> str: + return "Urgency Bidding" + + @property + def rule(self) -> str: + return "\n".join([ + "Urgency-based bidding. Players bid with an urgency level (0-4).", + "0: I would like to observe and listen for now.", + "1: I have some general thoughts to share with the group.", + "2: I have something critical and specific to contribute to this discussion.", + "3: It is absolutely urgent for me to speak next.", + "4: Someone has addressed me directly and I must respond.", + "Highest bidder wins." + "Ties are broken by the following priority: (1) players mentioned in the previous turn's chat, " + "(2) the least spoken player, (3) round robin order of the player list." + ]) + + @property + def bids(self) -> Dict[PlayerID, int]: + return dict(**self._bids) + + def __init__(self): + self._bids: Dict[PlayerID, int] = {} + self._mentioned_last_turn: List[PlayerID] = [] + + def reset(self) -> None: + self._bids = {} + self._mentioned_last_turn = [] + + def begin(self, state: GameState) -> None: + """Called at the start of a bidding round to identify recently mentioned players.""" + self.reset() + # Find the very last chat entry in the history to check for mentions + self._mentioned_last_turn, last_chat_message = self.get_last_mentioned(state) + + if last_chat_message: + if self._mentioned_last_turn: + state.push_event( + description=f"Players mentioned last turn (priority in ties): {self._mentioned_last_turn}", + event_name=EventName.BIDDING_INFO, + public=True # So everyone knows who has priority + ) + + def accept(self, bid: BidAction, state: GameState) -> None: + if 0 <= bid.amount <= 4: + self._bids[bid.actor_id] = bid.amount + data = BidDataEntry( + actor_id=bid.actor_id, + reasoning=bid.reasoning, + perceived_threat_level=bid.perceived_threat_level, + bid_amount=bid.amount, + action=bid + ) + state.push_event( + description=f"Player {bid.actor_id} submitted bid=({bid.amount}).", + event_name=EventName.BID_ACTION, + public=False, + visible_to=[bid.actor_id], + data=data, + source=bid.actor_id + ) + else: + # Invalid bid amount is treated as a bid of 0 + self._bids[bid.actor_id] = 0 + state.push_event( + description=f"Player {bid.actor_id} submitted an invalid bid amount ({bid.amount}). Treated as 0.", + event_name=EventName.ERROR, + public=False, + visible_to=[bid.actor_id] + ) + + def process_incoming_bids(self, actions: List[Action], state: GameState) -> None: + for act in actions: + if isinstance(act, BidAction): + self.accept(act, state) + + def is_finished(self, state: GameState) -> bool: + # This bidding round is considered "finished" when all alive players have bid. + return len(self._bids) >= len(state.alive_players()) + + def outcome(self, state: GameState) -> list[str]: + if not self._bids: + # If no one bids, deterministically pick the first alive player to speak. + alive_players = state.alive_players() + return [alive_players[0].id] if alive_players else [] + + max_bid = max(self._bids.values()) + highest_bidders = sorted([pid for pid, amt in self._bids.items() if amt == max_bid]) + + if len(highest_bidders) == 1: + return highest_bidders + + # Tie-breaking logic + candidates = highest_bidders + + # Rule 1: Players mentioned in the last turn + mentioned_in_tie = [pid for pid in candidates if pid in self._mentioned_last_turn] + if mentioned_in_tie: + candidates = mentioned_in_tie + + if len(candidates) == 1: + return candidates + + # Rule 2: The least spoken individual + speech_counts = Counter( + entry.data.actor_id + for day_events in state.history.values() + for entry in day_events + if entry.event_name == EventName.DISCUSSION and isinstance(entry.data, ChatDataEntry) + ) + + candidate_speech_counts = {pid: speech_counts.get(pid, 0) for pid in candidates} + min_spoken = min(candidate_speech_counts.values()) + least_spoken_candidates = sorted( + [pid for pid, count in candidate_speech_counts.items() if count == min_spoken]) + + if len(least_spoken_candidates) == 1: + return least_spoken_candidates + + candidates = least_spoken_candidates + + # Rule 3: Round robin order of the player list in state + for pid in state.all_player_ids: + if pid in candidates: + return [pid] + + # This part should be unreachable if candidates is a subset of all_player_ids + return [candidates[0]] if candidates else [] diff --git a/kaggle_environments/envs/werewolf/game/protocols/chat.py b/kaggle_environments/envs/werewolf/game/protocols/chat.py new file mode 100644 index 00000000..ca5e4d3f --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/protocols/chat.py @@ -0,0 +1,453 @@ +import itertools +import json +import random +from abc import ABC +from collections import deque +from typing import Sequence, List, Optional + +from kaggle_environments.envs.werewolf.game.actions import Action, BidAction +from kaggle_environments.envs.werewolf.game.base import PlayerID +from kaggle_environments.envs.werewolf.game.consts import EventName, StrEnum +from kaggle_environments.envs.werewolf.game.protocols.base import DiscussionProtocol, BiddingProtocol +from kaggle_environments.envs.werewolf.game.records import DiscussionOrderDataEntry, BidResultDataEntry +from kaggle_environments.envs.werewolf.game.states import GameState +from .bid import SimpleBiddingProtocol +from .factory import register_protocol + + +@register_protocol(default_params={"max_rounds": 2, "assign_random_first_speaker": True}) +class RoundRobinDiscussion(DiscussionProtocol): + def __init__(self, max_rounds: int = 1, assign_random_first_speaker: bool = True): + """ + + Args: + max_rounds: rounds of discussion + assign_random_first_speaker: If true, the first speaker will be determined at the beginning of + the game randomly, while the order follow that of the player list. Otherwise, will start from the + 0th player from player list. + """ + self.max_rounds = max_rounds + self._queue: deque[str] = deque() + self._assign_random_first_speaker = assign_random_first_speaker + self._player_ids = None + self._first_player_idx = None + + def reset(self) -> None: + self._queue = deque() + + @property + def display_name(self) -> str: + return "Roundrobin" + + @property + def rule(self) -> str: + return f"Players speak in round-robin order for {self.max_rounds} round(s)." + + def begin(self, state): + if self._player_ids is None: + # initialize player_ids once. + self._player_ids = deque(state.all_player_ids) + if self._assign_random_first_speaker: + self._player_ids.rotate(random.randrange(len(self._player_ids))) + + # Reset queue + player_order = [pid for pid in self._player_ids if state.is_alive(pid)] + self._queue = deque(player_order * self.max_rounds) + if self.max_rounds > 0 and self._queue: + data = DiscussionOrderDataEntry(chat_order_of_player_ids=player_order) + state.push_event( + description="Discussion phase begins. Players will speak in round-robin order. " + f"Starting from player {player_order[0]} with the following order: {player_order} " + f"for {self.max_rounds} round(s).", + event_name=EventName.DISCUSSION_ORDER, + public=True, + data=data + ) + + def speakers_for_tick(self, state): + return [self._queue.popleft()] if self._queue else [] + + def is_discussion_over(self, state: GameState) -> bool: + return not self._queue # Over if queue is empty + + +@register_protocol() +class RandomOrderDiscussion(DiscussionProtocol): + def __init__(self): + self._iters = None + self._steps = 0 + + def reset(self) -> None: + self._iters = None + self._steps = 0 + + @property + def display_name(self) -> str: + return "Random Order Discussion" + + @property + def rule(self) -> str: + return "Players speak in a random order for one full round." + + def begin(self, state): + self._iters = itertools.cycle(random.sample( + [p.id for p in state.alive_players()], + k=len(state.alive_players()) + )) + self._steps = len(state.alive_players()) # one full round + if self._steps > 0: + state.push_event( + description="Discussion phase begins. Players will speak in random order.", + event_name=EventName.PHASE_CHANGE, + public=True + ) + + def speakers_for_tick(self, state): + if self._steps == 0: + return [] + self._steps -= 1 + return [next(self._iters)] + + def is_discussion_over(self, state: GameState) -> bool: + return self._steps == 0 + + +@register_protocol() +class ParallelDiscussion(DiscussionProtocol): + """ + Everyone may talk for `ticks` chat turns. + Useful when you want simultaneous / overlapping chat. + """ + + def __init__(self, ticks: int = 3): + self.ticks = ticks + self._remaining = 0 + + def reset(self) -> None: + self._remaining = 0 + + @property + def display_name(self) -> str: + return "Parallel Discussion" + + @property + def rule(self) -> str: + return f"All players may speak simultaneously for {self.ticks} tick(s)." + + def begin(self, state): + self._remaining = self.ticks + if self.ticks > 0: + state.push_event( + description="Parallel discussion phase begins. All players may speak.", + event_name=EventName.PHASE_CHANGE, + public=True + ) + + def speakers_for_tick(self, state): + if self._remaining == 0: + return [] + self._remaining -= 1 + return [p.id for p in state.alive_players()] + + def call_for_actions(self, speakers: Sequence[str]) -> List[str]: + return [f"Parallel discussion: All designated players may speak now or remain silent. " + f"({self._remaining + 1} speaking opportunities remaining, including this one)."] * len(speakers) + + def is_discussion_over(self, state: GameState) -> bool: + return self._remaining == 0 + + +class BiddingDiscussionPhase(StrEnum): + BIDDING_PHASE = 'bidding_phase' + SPEAKING_PHASE = 'speaking_phase' + + +class BiddingDiscussion(DiscussionProtocol, ABC): + def __init__(self, bidding: Optional[BiddingProtocol] = None): + bidding = bidding or SimpleBiddingProtocol() + self._bidding = bidding + self._phase = BiddingDiscussionPhase.BIDDING_PHASE + + @property + def bidding(self): + return self._bidding + + @property + def phase(self): + return self._phase + + def is_bidding_phase(self): + return self._phase == BiddingDiscussionPhase.BIDDING_PHASE + + def is_speaking_phase(self): + return self._phase == BiddingDiscussionPhase.SPEAKING_PHASE + + def set_phase(self, phase: BiddingDiscussionPhase): + self._phase = phase + + +@register_protocol(default_params={"max_turns": 8, "bid_result_public": True}) +class TurnByTurnBiddingDiscussion(BiddingDiscussion): + """ + A discussion protocol where players bid for the right to speak each turn. + This protocol manages the entire bid-speak-bid-speak loop. + """ + def __init__(self, bidding: Optional[BiddingProtocol] = None, + max_turns: int = 8, bid_result_public: bool = True): + super().__init__(bidding=bidding) + self.max_turns = max_turns + self._turns_taken = 0 + self._speaker: Optional[str] = None + self._all_passed = False + self._bid_result_public = bid_result_public + + def reset(self) -> None: + self.bidding.reset() + self.set_phase(BiddingDiscussionPhase.BIDDING_PHASE) + self._turns_taken = 0 + self._speaker = None + self._all_passed = False + + @property + def display_name(self) -> str: + return "Turn-by-turn Bidding Driven Discussion" + + @property + def rule(self) -> str: + return "\n".join([ + f"Players bid for the right to speak each turn for up to {self.max_turns} turns.", + f"**Bidding Rule:** {self.bidding.display_name}. {self.bidding.rule}", + f"If everyone bids 0, moderator will directly move on to day voting and no one speaks." + ]) + + def begin(self, state: GameState) -> None: + self.reset() + self.bidding.begin(state) # Initial setup for the first bidding round + + def is_discussion_over(self, state: GameState) -> bool: + return self._turns_taken >= self.max_turns or self._all_passed + + def speakers_for_tick(self, state: GameState) -> Sequence[PlayerID]: + if self.is_discussion_over(state): + return [] + + if self.is_bidding_phase(): + return [p.id for p in state.alive_players()] + elif self.is_speaking_phase(): + return [self._speaker] if self._speaker else [] + return [] + + def process_actions(self, actions: List[Action], expected_speakers: Sequence[PlayerID], state: GameState) -> None: + if self.is_bidding_phase(): + self.bidding.process_incoming_bids(actions, state) + + # Handle players who didn't bid (timed out) by assuming a bid of 0 + all_alive_player_ids = [p.id for p in state.alive_players()] + if hasattr(self.bidding, '_bids'): + for player_id in all_alive_player_ids: + if player_id not in self.bidding._bids: + default_bid = BidAction(actor_id=player_id, amount=0, day=state.day_count, phase=state.phase.value) + self.bidding.accept(default_bid, state) + + bids = getattr(self.bidding, '_bids', {}) + if len(bids) >= len(all_alive_player_ids) and all(amount == 0 for amount in bids.values()): + self._all_passed = True + state.push_event( + description="All players passed on speaking. Discussion ends.", + event_name=EventName.MODERATOR_ANNOUNCEMENT, + public=True + ) + return + + # Once all bids are in (or a timeout, handled by moderator's single tick), determine the winner + winner_list = self.bidding.outcome(state) + self._speaker = winner_list[0] if winner_list else None + + if self._speaker: + data = BidResultDataEntry( + winner_player_ids=[self._speaker], + bid_overview=self.bidding.bids, + mentioned_players_in_previous_turn=self.bidding.get_last_mentioned(state)[0] + ) + overview_text = ', '.join([f'{k}: {v}' for k, v in self.bidding.bids.items()]) + state.push_event( + description=f"Player {self._speaker} won the bid and will speak next.\n" + f"Bid overview - {overview_text}.", + event_name=EventName.BID_RESULT, + public=self._bid_result_public, + data=data + ) + self.set_phase(BiddingDiscussionPhase.SPEAKING_PHASE) + else: + # No one to speak, advance turn count and bid again + self._turns_taken += 1 + if not self.is_discussion_over(state): + self.bidding.begin(state) # Prepare for next bidding round + + elif self.is_speaking_phase(): + # Process the chat action from the designated speaker + super().process_actions(actions, expected_speakers, state) + self._turns_taken += 1 + + # After speaking, transition back to bidding for the next turn + if not self.is_discussion_over(state): + self.set_phase(BiddingDiscussionPhase.BIDDING_PHASE) + self._speaker = None + self.bidding.begin(state) # Reset bids and find new mentioned players + + def prompt_speakers_for_tick(self, state: GameState, speakers: Sequence[PlayerID]) -> None: + if self.is_bidding_phase(): + data = {"action_json_schema": json.dumps(BidAction.schema_for_player())} + state.push_event( + description=( + f"A new round of discussion begins. Place bid for a chance to speak. " + f"{self.max_turns - self._turns_taken} turns left to speak." + ), + event_name=EventName.BID_REQEUST, + public=True, + data=data + ) + elif self.is_speaking_phase() and self._speaker: + super().prompt_speakers_for_tick(state, speakers) + + +@register_protocol(default_params={"max_rounds": 2, "bid_result_public": True}) +class RoundByRoundBiddingDiscussion(BiddingDiscussion): + """ + A discussion protocol where players bid at the start of each round to + determine the speaking order for that round. + + In each of the N rounds: + 1. A bidding phase occurs where all alive players submit a bid (0-4). + 2. The speaking order is determined by sorting players by their bid amount + (descending) and then by player ID (ascending) as a tie-breaker. + 3. A speaking phase occurs where each player speaks once according to the + determined order. + """ + def __init__(self, bidding: Optional[BiddingProtocol] = None, max_rounds: int = 2, bid_result_public: bool = True): + """ + Args: + bidding: The bidding protocol to use for determining speaking order. + max_rounds: The total number of discussion rounds. + bid_result_public: Whether to make the bidding results public. + """ + super().__init__(bidding=bidding) + self.max_rounds = max_rounds + self._bid_result_public = bid_result_public + self._current_round = 0 + self._speaking_queue: deque[str] = deque() + self.reset() + + def reset(self) -> None: + """Resets the protocol to its initial state.""" + self.bidding.reset() + self.set_phase(BiddingDiscussionPhase.BIDDING_PHASE) + self._current_round = 0 + self._speaking_queue = deque() + + @property + def display_name(self) -> str: + return "Round-by-round Bidding Driven Discussion" + + @property + def rule(self) -> str: + """A string describing the discussion rule in effect.""" + return "\n".join([ + "Players speak in an order determined by bidding at the beginning of each round. " + f"There will be {self.max_rounds} round(s) per day.", + "In each round, all players may speak once.", + f"**Bidding Rule:** {self.bidding.display_name}. {self.bidding.rule}" + ]) + + def begin(self, state: GameState) -> None: + """Initializes the protocol for the first round.""" + self.reset() + self.bidding.begin(state) + + def is_discussion_over(self, state: GameState) -> bool: + """Checks if all rounds have been completed.""" + return self._current_round >= self.max_rounds + + def speakers_for_tick(self, state: GameState) -> Sequence[PlayerID]: + """Returns the players who are allowed to act in the current tick.""" + if self.is_discussion_over(state): + return [] + + if self.is_bidding_phase(): + # In the bidding phase, all alive players can bid. + return [p.id for p in state.alive_players()] + elif self.is_speaking_phase(): + # In the speaking phase, the next player in the queue speaks. + return [self._speaking_queue.popleft()] if self._speaking_queue else [] + return [] + + def process_actions(self, actions: List[Action], expected_speakers: Sequence[PlayerID], state: GameState) -> None: + """Processes incoming actions from players.""" + if self.is_bidding_phase(): + self.bidding.process_incoming_bids(actions, state) + + # Assume a bid of 0 for any players who timed out. + all_alive_player_ids = [p.id for p in state.alive_players()] + if hasattr(self.bidding, '_bids'): + for player_id in all_alive_player_ids: + if player_id not in self.bidding.bids: + default_bid = BidAction(actor_id=player_id, amount=0, day=state.day_count, + phase=state.phase.value) + self.bidding.accept(default_bid, state) + + # Determine speaking order based on bids. + # Sort by bid amount (desc) and then player ID (asc). + bids = self.bidding.bids + sorted_bidders = sorted(bids.items(), key=lambda item: (-item[1], item[0])) + + self._speaking_queue = deque([player_id for player_id, bid_amount in sorted_bidders]) + + # Announce the speaking order for the round. + data = DiscussionOrderDataEntry(chat_order_of_player_ids=list(self._speaking_queue)) + speaking_order_text = ", ".join([f"{pid} ({amount})" for pid, amount in sorted_bidders]) + + state.push_event( + description=f"Bidding for round {self._current_round + 1} has concluded. The speaking order, " + f"with bid amounts in parentheses, is: {speaking_order_text}.", + event_name=EventName.BID_RESULT, + public=self._bid_result_public, + data=data, + ) + + # Transition to the speaking phase. + self.set_phase(BiddingDiscussionPhase.SPEAKING_PHASE) + + elif self.is_speaking_phase(): + # Process the chat action from the current speaker. + super().process_actions(actions, expected_speakers, state) + + # Check if the round is over (i.e., the speaking queue is empty). + if not self._speaking_queue: + self._current_round += 1 + state.push_event( + description=f"End of discussion round {self._current_round}.", + event_name=EventName.PHASE_CHANGE, + public=True + ) + + # If the game isn't over, prepare for the next round's bidding. + if not self.is_discussion_over(state): + self.set_phase(BiddingDiscussionPhase.BIDDING_PHASE) + self.bidding.begin(state) + + def prompt_speakers_for_tick(self, state: GameState, speakers: Sequence[PlayerID]) -> None: + """Prompts the active players for their next action.""" + if self.is_bidding_phase(): + data = {"action_json_schema": json.dumps(BidAction.schema_for_player())} + state.push_event( + description=( + f"Round {self._current_round + 1} of {self.max_rounds} begins. " + "Place your bid to determine speaking order." + ), + event_name=EventName.BID_REQEUST, + public=True, + data=data + ) + elif self.is_speaking_phase(): + # The default prompt from the base class is sufficient for speaking. + super().prompt_speakers_for_tick(state, speakers) diff --git a/kaggle_environments/envs/werewolf/game/protocols/factory.py b/kaggle_environments/envs/werewolf/game/protocols/factory.py new file mode 100644 index 00000000..364e4abe --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/protocols/factory.py @@ -0,0 +1,62 @@ +from typing import Dict, Type, Callable, Any + +# The new unified, flat registry. Maps class names to class objects and default params. +PROTOCOL_REGISTRY: Dict[str, Dict[str, Any]] = {} + + +def register_protocol(default_params: Dict = None) -> Callable: + """ + A decorator to register a protocol class in the central unified registry. + The protocol is registered using its class name. + """ + if default_params is None: + default_params = {} + + def decorator(cls: Type) -> Type: + name = cls.__name__ + if name in PROTOCOL_REGISTRY: + raise TypeError(f"Protocol '{name}' is already registered.") + + PROTOCOL_REGISTRY[name] = { + "class": cls, + "default_params": default_params + } + return cls + + return decorator + + +def create_protocol(config: Dict, default_name: str = None) -> Any: + """ + Factory function to recursively create protocol instances from a configuration dictionary. + """ + if not config and default_name: + config = {"name": default_name} + elif not config and not default_name: + # If no config and no default, we cannot proceed. + raise ValueError("Cannot create protocol from an empty configuration without a default name.") + + # Fallback to default_name if 'name' is not in the config + name = config.get("name", default_name) + if not name: + raise ValueError("Protocol name must be provided in config or as a default.") + + params = config.get("params", {}) + + protocol_info = PROTOCOL_REGISTRY.get(name) + if not protocol_info: + raise ValueError(f"Protocol '{name}' not found in the registry.") + + protocol_class = protocol_info["class"] + # Start with the protocol's defaults, then override with config params + final_params = {**protocol_info["default_params"], **params} + + # --- Recursive Instantiation for Nested Protocols --- + for param_name, param_value in final_params.items(): + # If a parameter's value is a dictionary that looks like a protocol config + # (i.e., it has a "name" key), we recursively create it. + if isinstance(param_value, dict) and "name" in param_value: + # The nested protocol's config is the param_value itself. + final_params[param_name] = create_protocol(param_value) + + return protocol_class(**final_params) diff --git a/kaggle_environments/envs/werewolf/game/protocols/vote.py b/kaggle_environments/envs/werewolf/game/protocols/vote.py new file mode 100644 index 00000000..d96525dd --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/protocols/vote.py @@ -0,0 +1,460 @@ +import random +from collections import Counter, deque +from typing import Dict, List, Optional, Sequence + +from kaggle_environments.envs.werewolf.game.actions import Action, VoteAction, NoOpAction +from kaggle_environments.envs.werewolf.game.base import PlayerID +from kaggle_environments.envs.werewolf.game.consts import StrEnum, EventName, Phase +from kaggle_environments.envs.werewolf.game.protocols.base import VotingProtocol +from kaggle_environments.envs.werewolf.game.records import (WerewolfNightVoteDataEntry, DayExileVoteDataEntry, + VoteOrderDataEntry) +from kaggle_environments.envs.werewolf.game.roles import Player +from kaggle_environments.envs.werewolf.game.states import GameState +from .factory import register_protocol + + +class TieBreak(StrEnum): + RANDOM = 'random' + """Randomly select from top ties.""" + + NO_EXILE = 'no_elected' + """Tie result in no one elected.""" + + +ABSTAIN_VOTE = "-1" + + +class Ballot: + def __init__(self, tie_selection: TieBreak = TieBreak.RANDOM): + self._ballots: Dict[PlayerID, PlayerID] = {} + self._tie_selection = tie_selection + + def reset(self): + self._ballots = {} + + def add_vote(self, voter_id: PlayerID, target_id: PlayerID): + """Records a vote from a voter for a target.""" + self._ballots[voter_id] = target_id + + def get_tally(self) -> Counter: + """Returns a Counter of votes for each target, excluding abstained votes.""" + return Counter(v for v in self._ballots.values() if v is not None and v != ABSTAIN_VOTE) + + def get_elected(self, potential_targets: List[PlayerID]) -> Optional[PlayerID]: + """ + Tallies the votes and determines the elected player based on the tie-breaking rule. + """ + counts = self.get_tally().most_common() + elected: Optional[PlayerID] = None + + if not counts: + # No valid votes were cast. + if self._tie_selection == TieBreak.RANDOM and potential_targets: + elected = random.choice(potential_targets) + # If NO_EXILE, elected remains None. + else: + _, top_votes = counts[0] + top_candidates = [v for v, c in counts if c == top_votes] + + if len(top_candidates) == 1: + elected = top_candidates[0] + else: # It's a tie. + if self._tie_selection == TieBreak.RANDOM: + elected = random.choice(top_candidates) + # If NO_EXILE, elected remains None. + + return elected + + def get_all_votes(self) -> Dict[PlayerID, PlayerID]: + """Returns a copy of all recorded ballots.""" + return self._ballots.copy() + + +@register_protocol() +class SimultaneousMajority(VotingProtocol): + def __init__(self, tie_break=TieBreak.RANDOM): + self._expected_voters: List[PlayerID] = [] + self._potential_targets: List[PlayerID] = [] + self._current_game_state: Optional[GameState] = None # To store state from begin_voting + self._elected: Optional[PlayerID] = None + self._done_tallying = False + self._tie_break = tie_break + self._ballot = Ballot(tie_selection=self._tie_break) + + if tie_break not in TieBreak: + raise ValueError(f"Invalid tie_break value: {tie_break}. Must be one of {TieBreak}.") + + def reset(self) -> None: + self._ballot.reset() + self._expected_voters = [] + self._potential_targets = [] + self._current_game_state = None + self._elected = None + self._done_tallying = False + + @property + def display_name(self) -> str: + return "Simultaneous Majority Voting" + + @property + def rule(self) -> str: + rule = "Player with the most votes is exiled. " + if self._tie_break == TieBreak.RANDOM: + rule += ("Ties result in random selection amongst the top ties. " + "If no valid vote available (if all casted abstained votes), " + "will result in random elimination of one player.") + elif self._tie_break == TieBreak.NO_EXILE: + rule += "Ties result in no exile." + return rule + + def begin_voting(self, state: GameState, alive_voters: Sequence[Player], potential_targets: Sequence[Player]): + self._ballot.reset() + # Ensure voters and targets are alive at the start of voting + self._expected_voters = [p.id for p in alive_voters if p.alive] + self._potential_targets = [p.id for p in potential_targets if p.alive] + self._current_game_state = state # Store the game state reference + + def collect_votes(self, player_actions: Dict[PlayerID, Action], state: GameState, expected_voters: List[PlayerID]): + for actor_id, action in player_actions.items(): + if actor_id in expected_voters: + self.collect_vote(action, state) + + # For any expected voter who didn't act, record an abstain vote. + all_votes = self._ballot.get_all_votes() + for player_id in expected_voters: + if player_id not in all_votes: + self._ballot.add_vote(player_id, ABSTAIN_VOTE) + + def collect_vote(self, vote_action: Action, state: GameState): + actor_player = state.get_player_by_id(vote_action.actor_id) + if not isinstance(vote_action, VoteAction): + state.push_event( + description=f'Invalid vote attempt by player "{vote_action.actor_id}". ' + f'Not a VoteAction; submitted {vote_action.__class__.__name__} instead. ' + f'Cast as abstained vote.', + event_name=EventName.ERROR, + public=False, + visible_to=self._expected_voters, + data={} + ) + self._ballot.add_vote(vote_action.actor_id, ABSTAIN_VOTE) + return + + if state.phase == Phase.NIGHT: + data_entry_class = WerewolfNightVoteDataEntry + else: + data_entry_class = DayExileVoteDataEntry + + data = data_entry_class( + actor_id=vote_action.actor_id, + target_id=vote_action.target_id, + reasoning=vote_action.reasoning, + perceived_threat_level=vote_action.perceived_threat_level, + action=vote_action + ) + + # Voter must be expected and alive at the moment of casting vote + if actor_player and actor_player.alive and vote_action.actor_id in self._expected_voters: + # Prevent re-voting + if vote_action.actor_id in self._ballot.get_all_votes(): + state.push_event( + description=f'Invalid vote attempt by "{vote_action.actor_id}", already voted.', + event_name=EventName.ERROR, + public=False, + visible_to=self._expected_voters, + data=data + ) + return + + if vote_action.target_id in self._potential_targets: + self._ballot.add_vote(vote_action.actor_id, vote_action.target_id) + + # Determine DataEntry type based on game phase + state.push_event( + description=f'Player "{data.actor_id}" voted to eliminate "{data.target_id}". ', + event_name=EventName.VOTE_ACTION, + public=False, + visible_to=self._expected_voters, + data=data, + source=vote_action.actor_id + ) + else: + self._ballot.add_vote(vote_action.actor_id, ABSTAIN_VOTE) + state.push_event( + description=f'Invalid vote attempt by "{vote_action.actor_id}".', + event_name=EventName.ERROR, + public=False, + visible_to=self._expected_voters, + data=data + ) + return + else: + state.push_event( + description=f"Invalid vote attempt by {vote_action.actor_id}.", + event_name=EventName.ERROR, + public=False, + data=data + ) + + def get_voting_prompt(self, state: GameState, player_id: PlayerID) -> str: + target_options = [p_id for p_id in self._potential_targets if + state.get_player_by_id(p_id) and state.get_player_by_id(p_id).alive] + return f'Player "{player_id}", please cast your vote. Options: {target_options} or Abstain ("{ABSTAIN_VOTE}").' + + def get_current_tally_info(self, state: GameState) -> Dict[PlayerID, int]: + return self._ballot.get_tally() + + def get_next_voters(self) -> List[PlayerID]: + # For simultaneous, all expected voters vote at once, and only once. + return [voter for voter in self._expected_voters if voter not in self._ballot.get_all_votes()] + + def done(self) -> bool: + # The voting is considered "done" after one tick where voters were requested. + # The moderator will then call tally_votes. + return all(voter in self._ballot.get_all_votes() for voter in self._expected_voters) + + def get_valid_targets(self) -> List[PlayerID]: + # Return a copy of targets that were valid (alive) at the start of voting. + return list(self._potential_targets) + + def get_elected(self) -> PlayerID | None: # Return type matches tally_votes + if not self.done(): + raise Exception("Voting is not done yet.") + if self._elected is None and not self._done_tallying: + self._elected = self._ballot.get_elected(self._potential_targets) + self._done_tallying = True + return self._elected + + +@register_protocol() +class SequentialVoting(VotingProtocol): + """ + Players vote one by one in a sequence. Each player is shown the current + tally before casting their vote. All players in the initial list of + voters get a turn. + """ + + def __init__(self, assign_random_first_voter: bool = True, tie_break: TieBreak = TieBreak.RANDOM): + self._potential_targets: List[PlayerID] = [] + self._voter_queue: List[PlayerID] = [] # Order of players to vote + self._expected_voters: List[PlayerID] = [] + self._current_voter_index: int = 0 # Index for _voter_queue + self._current_game_state: Optional[GameState] = None # To store state from begin_voting + self._elected: Optional[PlayerID] = None + self._done_tallying = False + self._assign_random_first_voter = assign_random_first_voter + self._player_ids = None + self._ballot = Ballot(tie_selection=tie_break) + + def reset(self) -> None: + self._ballot.reset() + self._potential_targets = [] + self._expected_voters = [] + self._voter_queue = [] + self._current_voter_index = 0 + self._current_game_state = None + self._elected = None + self._done_tallying = False + + @property + def display_name(self) -> str: + return "Sequential Voting" + + @property + def rule(self) -> str: + return ("Players vote one by one. Player with the most votes after all have voted is exiled." + " Ties are broken randomly.") + + def begin_voting(self, state: GameState, alive_voters: Sequence[Player], potential_targets: Sequence[Player]): + if self._player_ids is None: + # initialize player_ids once. + self._player_ids = deque(state.all_player_ids) + if self._assign_random_first_voter: + self._player_ids.rotate(random.randrange(len(self._player_ids))) + alive_voter_ids = [p.id for p in alive_voters] + alive_voter_ids_set = set(alive_voter_ids) + self._ballot.reset() + self._expected_voters = [pid for pid in self._player_ids if pid in alive_voter_ids_set] + self._potential_targets = [p.id for p in potential_targets] + # The order of voting can be based on player ID, a random shuffle, or the order in alive_voters + # For simplicity, using the order from alive_voters. + self._voter_queue = list(self._expected_voters) + self._current_voter_index = 0 + self._current_game_state = state # Store the game state reference + + if self._expected_voters: + data = VoteOrderDataEntry(vote_order_of_player_ids=self._expected_voters) + state.push_event( + description=f"Voting starts from player {self._expected_voters[0]} " + f"with the following order: {self._expected_voters}", + event_name=EventName.VOTE_ORDER, + public=False, + visible_to=alive_voter_ids, + data=data + ) + + def get_voting_prompt(self, state: GameState, player_id: PlayerID) -> str: + """ + Generates a prompt for the given player_id, assuming it's their turn. + """ + current_tally = self.get_current_tally_info(state) + + # Sort for consistent display + tally_str_parts = [] + for target_id, votes in sorted(current_tally.items(), key=lambda x: x[1], reverse=True): + tally_str_parts.append(f"{target_id}: {votes} vote(s)") + + tally_str = "; ".join(tally_str_parts) if tally_str_parts else "No votes cast yet." + + options_str_parts = [] + for p_target in state.alive_players(): # Iterate through all alive players for options + if p_target.id in self._potential_targets: + options_str_parts.append(f"{p_target.id}") + options_str = ", ".join(options_str_parts) + + return ( + f"{player_id}, it is your turn to vote. " + f"Current tally: {tally_str}. " + f"Options: {options_str} or Abstain (vote for {ABSTAIN_VOTE})." + ) + + def collect_votes(self, player_actions: Dict[PlayerID, Action], state: GameState, expected_voters: List[PlayerID]): + if self.done(): + return + + # In sequential voting, expected_voters should contain exactly one player. + if not expected_voters: + # This case should ideally not be reached if `done()` is false. + # If it is, advancing the turn might be a safe way to prevent a stall. + self._current_voter_index += 1 + return + + expected_voter_id = expected_voters[0] + action = player_actions.get(expected_voter_id) + + if action: + self.collect_vote(action, state) + else: + # This block handles timeout for the expected voter. + # The player did not submit an action. Treat as NoOp/Abstain. + self.collect_vote(NoOpAction(actor_id=expected_voter_id, day=state.day_count, phase=state.phase), state) + + def collect_vote(self, vote_action: Action, state: GameState): + if not isinstance(vote_action, (VoteAction, NoOpAction)): + # Silently ignore if not a VoteAction or NoOpAction. + # Consider logging an "unexpected action type" error if more verbosity is needed. + return + + if self.done(): + state.push_event( + description=f"Action ({vote_action.kind}) received from {vote_action.actor_id}, " + f"but voting is already complete.", + event_name=EventName.ERROR, + public=False, + visible_to=[vote_action.actor_id] + ) + return + + expected_voter_id = self._voter_queue[self._current_voter_index] + if vote_action.actor_id != expected_voter_id: + state.push_event( + description=f"Action ({vote_action.kind}) received from {vote_action.actor_id}, " + f"but it is {expected_voter_id}'s turn.", + event_name=EventName.ERROR, + public=False, # Or public if strict turn enforcement is announced + visible_to=[vote_action.actor_id, expected_voter_id] + ) + return + + actor_player = next((p for p in state.players if p.id == vote_action.actor_id), None) + if actor_player and actor_player.alive: + description_for_event = "" + involved_players_list = [vote_action.actor_id] # Actor is always involved + data = None + if isinstance(vote_action, NoOpAction): + self._ballot.add_vote(vote_action.actor_id, ABSTAIN_VOTE) # Treat NoOp as abstain + description_for_event = f"{vote_action.actor_id} chose to NoOp (treated as Abstain)." + + elif isinstance(vote_action, VoteAction): # This must be true if not NoOpAction + target_display: str + recorded_target_id = vote_action.target_id + if vote_action.target_id != ABSTAIN_VOTE and vote_action.target_id not in self._potential_targets: + # Invalid target chosen for VoteAction + state.push_event( + description=f"{vote_action.actor_id} attempted to vote for {vote_action.target_id} " + f"(invalid target). Vote recorded as Abstain.", + event_name=EventName.ERROR, + public=False, + visible_to=[vote_action.actor_id] + ) + recorded_target_id = ABSTAIN_VOTE # Treat invalid target as abstain + target_display = f"Invalid Target ({vote_action.target_id}), recorded as Abstain" + elif vote_action.target_id == ABSTAIN_VOTE: + # Explicit Abstain via VoteAction + target_display = "Abstain" + # recorded_target_id is already ABSTAIN_VOTE + else: + # Valid target chosen for VoteAction + target_display = f"{vote_action.target_id}" + involved_players_list.append(vote_action.target_id) # Add valid target to involved + + self._ballot.add_vote(vote_action.actor_id, recorded_target_id) + description_for_event = f"{vote_action.actor_id} has voted for {target_display}." + + # Add data entry for the vote + data_entry_class = DayExileVoteDataEntry if state.phase == Phase.DAY else WerewolfNightVoteDataEntry + data = data_entry_class( + actor_id=vote_action.actor_id, + target_id=recorded_target_id, + reasoning=vote_action.reasoning, + perceived_threat_level=vote_action.perceived_threat_level, + action=vote_action + ) + + state.push_event( + description=description_for_event, + event_name=EventName.VOTE_ACTION, + public=False, + visible_to=self._expected_voters, + data=data, + source=vote_action.actor_id + ) + self._current_voter_index += 1 + else: # Player not found, not alive, or (redundantly) not their turn + state.push_event( + description=f"Invalid action ({vote_action.kind}) attempt by {vote_action.actor_id} (player not found," + f" not alive, or not their turn). Action not counted.", + event_name=EventName.ERROR, + public=False, + visible_to=[vote_action.actor_id] + ) + # If voter was expected but found to be not alive, advance turn to prevent stall + if vote_action.actor_id == expected_voter_id: # Implies actor_player was found but not actor_player.alive + self._current_voter_index += 1 + + def get_current_tally_info(self, state: GameState) -> Dict[str, int]: + # Returns counts of non-abstain votes for valid targets + return self._ballot.get_tally() + + def get_next_voters(self) -> List[PlayerID]: + if not self.done(): + # Ensure _current_voter_index is within bounds before accessing + if self._current_voter_index < len(self._voter_queue): + return [self._voter_queue[self._current_voter_index]] + return [] + + def done(self) -> bool: + if not self._voter_queue: # No voters were ever in the queue + return True + return self._current_voter_index >= len(self._voter_queue) + + def get_valid_targets(self) -> List[PlayerID]: + return list(self._potential_targets) + + def get_elected(self) -> Optional[PlayerID]: + if not self.done(): + raise Exception("Voting is not done yet.") + if self._elected is None and not self._done_tallying: + self._elected = self._ballot.get_elected(self._potential_targets) + self._done_tallying = True + return self._elected diff --git a/kaggle_environments/envs/werewolf/game/records.py b/kaggle_environments/envs/werewolf/game/records.py new file mode 100644 index 00000000..99eac91d --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/records.py @@ -0,0 +1,323 @@ +import json +from abc import ABC +from datetime import datetime +from enum import IntEnum +from typing import Optional, List, Dict +from zoneinfo import ZoneInfo + +from pydantic import BaseModel, Field, field_serializer, ConfigDict, model_serializer + +from .base import BaseEvent, BaseAction, PlayerID +from .consts import Phase, ObsKeys, DetailedPhase, RoleConst, Team, EventName, PerceivedThreatLevel, PhaseDivider + + +def get_utc_now(): + return str(datetime.now(ZoneInfo("UTC"))) + + +class DataAccessLevel(IntEnum): + PUBLIC = 0 + PERSONAL = 1 + + +class DataEntry(BaseModel, ABC): + """Abstract base class for all data entry types.""" + pass + + +class ActionDataMixin(BaseModel): + """ + A mixin for action-related DataEntry models. + Includes the actor performing the action and their private reasoning. + """ + actor_id: PlayerID + reasoning: Optional[str] = Field( + default=None, description="Private reasoning for moderator analysis.", access=DataAccessLevel.PERSONAL) + perceived_threat_level: Optional[PerceivedThreatLevel] = Field( + default=PerceivedThreatLevel.SAFE, access=DataAccessLevel.PERSONAL) + action: Optional[BaseAction] = Field(default=None, access=DataAccessLevel.PERSONAL) + + +class VisibleRawData(BaseModel): + data_type: str + json_str: str + + +class PlayerEventView(BaseModel): + day: int + phase: Phase + detailed_phase: DetailedPhase + event_name: EventName + description: str + data: Optional[dict | DataEntry] = None + source: str + created_at: str + + @model_serializer + def serialize(self) -> dict: + if isinstance(self.data, DataEntry): + data = self.data.model_dump() + else: + data = self.data + return dict( + day=self.day, + phase=self.phase, + detailed_phase=self.detailed_phase, + event_name=self.event_name, + description=self.description, + data=data, + source=self.source, + created_at=self.created_at + ) + + +class Event(BaseEvent): + day: int # Day number, 0 for initial night + phase: Phase + detailed_phase: DetailedPhase + event_name: EventName + description: str + public: bool = False + visible_to: List[str] = Field(default_factory=list) + data: Optional[dict | DataEntry] = None + source: str + created_at: str = Field(default_factory=get_utc_now) + + @field_serializer('data') + def serialize_data(self, data): + if data is None: return None + if isinstance(data, dict): + return data + if isinstance(data, BaseModel): + return data.model_dump() + return None + + def serialize(self): + # TODO: this is purely constructed for compatibility with html renderer. Need to refactor werewolf.js to handle + # a direct model_dump of Event + data_dict = self.model_dump() + return VisibleRawData(data_type=self.data.__class__.__name__, json_str=json.dumps(data_dict)).model_dump() + + def view_by_access(self, user_level: DataAccessLevel) -> PlayerEventView: + if isinstance(self.data, ActionDataMixin): + fields_to_include = set() + fields_to_exclude = set() + for name, info in self.data.__class__.model_fields.items(): + if info.json_schema_extra: + if user_level >= info.json_schema_extra.get('access', DataAccessLevel.PUBLIC): + fields_to_include.add(name) + else: + fields_to_exclude.add(name) + else: + fields_to_include.add(name) + data = self.data.model_dump(include=fields_to_include, exclude=fields_to_exclude) + else: + data = self.data + out = PlayerEventView( + day=self.day, + phase=self.phase, + detailed_phase=self.detailed_phase, + event_name=self.event_name, + description=self.description, + data=data, + source=self.source, + created_at=self.created_at + ) + return out + + +# --- Game State and Setup Data Entries --- +class GameStartDataEntry(DataEntry): + player_ids: List[PlayerID] + number_of_players: int + role_counts: Dict[RoleConst, int] + team_member_counts: Dict[Team, int] + day_discussion_protocol_name: str + day_discussion_display_name: str + day_discussion_protocol_rule: str + night_werewolf_discussion_protocol_name: str + night_werewolf_discussion_display_name: str + night_werewolf_discussion_protocol_rule: str + day_voting_protocol_name: str + day_voting_display_name: str + day_voting_protocol_rule: str + + +class GameStartRoleDataEntry(DataEntry): + player_id: PlayerID + team: Team + role: RoleConst + rule_of_role: str + + +class SetNewPhaseDataEntry(DataEntry): + new_detailed_phase: DetailedPhase + + +class PhaseDividerDataEntry(DataEntry): + divider_type: PhaseDivider + + +# --- Request for Action Data Entries --- +class RequestForActionDataEntry(DataEntry): + action_json_schema: str + + +class RequestDoctorSaveDataEntry(RequestForActionDataEntry): + valid_candidates: List[PlayerID] + + +class RequestSeerRevealDataEntry(RequestForActionDataEntry): + valid_candidates: List[PlayerID] + + +class RequestWerewolfVotingDataEntry(RequestForActionDataEntry): + valid_targets: List[PlayerID] + alive_werewolve_player_ids: List[PlayerID] + voting_protocol_name: str + voting_protocol_rule: str + + +class RequestVillagerToSpeakDataEntry(RequestForActionDataEntry): + pass + + +# --- Action and Result Data Entries --- +class SeerInspectResultDataEntry(DataEntry): + actor_id: PlayerID + target_id: PlayerID + role: Optional[RoleConst] + team: Optional[Team] + + +class TargetedActionDataEntry(ActionDataMixin, DataEntry): + target_id: PlayerID + + +class SeerInspectActionDataEntry(TargetedActionDataEntry): + """This records the Seer's choice of target to inspect.""" + + +class DoctorHealActionDataEntry(TargetedActionDataEntry): + """This records the Doctor's choice of target to heal.""" + + +class WerewolfNightVoteDataEntry(TargetedActionDataEntry): + """Records a werewolf's vote, including private reasoning.""" + + +class DayExileVoteDataEntry(TargetedActionDataEntry): + """Records a player's vote to exile, including private reasoning.""" + + +class DoctorSaveDataEntry(DataEntry): + """This records that a player was successfully saved by a doctor.""" + saved_player_id: PlayerID + + +class VoteOrderDataEntry(DataEntry): + vote_order_of_player_ids: List[PlayerID] + + +class WerewolfNightEliminationElectedDataEntry(DataEntry): + """This record the elected elimination target by werewolves.""" + elected_target_player_id: PlayerID + + +class WerewolfNightEliminationDataEntry(DataEntry): + """This record the one eventually got eliminated by werewolves without doctor safe.""" + eliminated_player_id: PlayerID + eliminated_player_role_name: Optional[RoleConst] = None + eliminated_player_team_name: Optional[Team] = None + + +class DayExileElectedDataEntry(DataEntry): + elected_player_id: PlayerID + elected_player_role_name: Optional[RoleConst] = None + elected_player_team_name: Optional[Team] = None + + +class DiscussionOrderDataEntry(DataEntry): + chat_order_of_player_ids: List[PlayerID] + + +class ChatDataEntry(ActionDataMixin, DataEntry): + """Records a chat message from a player, including private reasoning.""" + # actor_id and reasoning are inherited from ActionDataMixin + message: str + mentioned_player_ids: List[PlayerID] = Field(default_factory=list) + + +class BidDataEntry(ActionDataMixin, DataEntry): + bid_amount: int + + +class BidResultDataEntry(DataEntry): + winner_player_ids: List[PlayerID] + bid_overview: Dict[PlayerID, int] + mentioned_players_in_previous_turn: List[PlayerID] = [] + + +# --- Game End and Observation Models (Unchanged) --- +class GameEndResultsDataEntry(DataEntry): + model_config = ConfigDict(use_enum_values=True) + + winner_team: Team + winner_ids: List[PlayerID] + loser_ids: List[PlayerID] + scores: Dict[str, int | float] + reason: str + last_day: int + last_phase: Phase + survivors_until_last_round_and_role: Dict[PlayerID, RoleConst] + all_players_and_role: Dict[PlayerID, RoleConst] + elimination_info: List[Dict] + """list each player's elimination status, see GameState.get_elimination_info""" + + all_players: List[Dict] + """provide the info dump for each player""" + + +class WerewolfObservationModel(BaseModel): + player_id: PlayerID + role: RoleConst + team: Team + is_alive: bool + day: int + detailed_phase: DetailedPhase + all_player_ids: List[PlayerID] + player_thumbnails: Dict[PlayerID, str] = {} + alive_players: List[PlayerID] + revealed_players: Dict[PlayerID, RoleConst | Team | None] = {} + new_visible_announcements: List[str] + new_player_event_views: List[PlayerEventView] + game_state_phase: Phase + + def get_human_readable(self) -> str: + # This is a placeholder implementation. A real implementation would format this nicely. + return json.dumps(self.model_dump(), indent=2) + + +def set_raw_observation(kaggle_player_state, raw_obs: WerewolfObservationModel): + """Persist raw observations for players in kaggle's player state + + Args: + kaggle_player_state: Kaggle's interpreter state is a list of player state. This arg is one player state item. + raw_obs: the raw observation for a player extracted from game engine. + + Note: using raw_obs.model_dump_json() will greatly increase rendering speed (due to kaggle environment's use + of deepcopy for serialization) at the expense of harder to parse JSON rendering, since we are getting a json + string instead of human-readable dump. We choose raw_obs.model_dump() for clarity. + """ + kaggle_player_state.observation[ObsKeys.RAW_OBSERVATION] = raw_obs.model_dump() + + +def get_raw_observation(kaggle_observation) -> WerewolfObservationModel: + """ + + Args: + kaggle_observation: + + Returns: a dict of WerewolfObservationModel dump + """ + return WerewolfObservationModel(**kaggle_observation[ObsKeys.RAW_OBSERVATION]) diff --git a/kaggle_environments/envs/werewolf/game/roles.py b/kaggle_environments/envs/werewolf/game/roles.py new file mode 100644 index 00000000..f4a10a3b --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/roles.py @@ -0,0 +1,325 @@ +import json +import logging +from collections import deque, Counter, defaultdict +from functools import partial +from typing import List, Deque, Optional, Dict + +from pydantic import BaseModel, Field, PrivateAttr, ConfigDict, field_validator, model_validator + +from .actions import HealAction, InspectAction +from .base import BasePlayer, BaseModerator, BaseRole, EventHandler, on_event, PlayerID +from .consts import Team, RoleConst, Phase, EventName, RevealLevel +from .records import ( + Event, + PlayerEventView, RequestDoctorSaveDataEntry, RequestSeerRevealDataEntry, SeerInspectResultDataEntry +) + +logger = logging.getLogger(__name__) + + +class Role(BaseRole): + model_config = ConfigDict(use_enum_values=True) + + name: RoleConst = Field(..., frozen=True) + team: Team + night_priority: int = 100 # lower number acts earlier + descriptions: str + + +class Werewolf(Role): + name: RoleConst = RoleConst.WEREWOLF + team: Team = Team.WEREWOLVES + night_priority: int = 2 + descriptions: str = "Each night, collaborates with fellow werewolves to vote on eliminating one player." + + +class Villager(Role): + name: RoleConst = RoleConst.VILLAGER + team: Team = Team.VILLAGERS + descriptions: str = "No special abilities. Participates in the daily vote to eliminate a suspected werewolf." + + +class DoctorDescription: + ALLOW_SELF_SAVE = "Each night, may protect one player from a werewolf attack. Doctor is allowed to save themselves during night time." + NO_SELF_SAVE = "Each night, may protect one player from a werewolf attack. Doctor is NOT allowed to save themselves during night time." + NO_CONSECUTIVE_SAVE = " Doctor is NOT allowed to save the same player on consecutive nights." + + +class DoctorStateKey: + LAST_SAVED_DAY = 'last_saved_day' + LAST_SAVED_PLAYER_ID = "last_saved_player_id" + + +class Doctor(Role): + name: RoleConst = RoleConst.DOCTOR + team: Team = Team.VILLAGERS + allow_self_save: bool = False + allow_consecutive_saves: bool = True + descriptions: str = "" + + @model_validator(mode='after') + def set_descriptions_default(self) -> 'Doctor': + if self.descriptions == "": + if self.allow_self_save: + self.descriptions = DoctorDescription.ALLOW_SELF_SAVE + else: + self.descriptions = DoctorDescription.NO_SELF_SAVE + if not self.allow_consecutive_saves: + self.descriptions += DoctorDescription.NO_CONSECUTIVE_SAVE + return self + + @on_event(EventName.NIGHT_START) + def on_night_starts(self, me: BasePlayer, moderator: BaseModerator, event: Event): + if me.alive: + current_day = moderator.state.day_count + last_saved_day = me.get_role_state(DoctorStateKey.LAST_SAVED_DAY, default=-1) + last_saved_player_id = me.get_role_state(DoctorStateKey.LAST_SAVED_PLAYER_ID) + + # Reset consecutive save memory if a night was skipped + if not self.allow_consecutive_saves and last_saved_day != -1 and current_day > last_saved_day + 1: + me.set_role_state(DoctorStateKey.LAST_SAVED_PLAYER_ID, None) + last_saved_player_id = None + + valid_candidates = [p.id for p in moderator.state.alive_players()] + + if not self.allow_self_save: + valid_candidates = [p_id for p_id in valid_candidates if p_id != me.id] + + prompt = f"Wake up Doctor. Who would you like to save? " + if not self.allow_consecutive_saves and last_saved_player_id: + valid_candidates = [p_id for p_id in valid_candidates if p_id != last_saved_player_id] + prompt += f'You cannot save the same player on consecutive nights. Player "{last_saved_player_id}" is not a valid target this night. ' + + data_entry = RequestDoctorSaveDataEntry( + valid_candidates=valid_candidates, + action_json_schema=json.dumps(HealAction.schema_for_player()) + ) + prompt += f"The options are {data_entry.valid_candidates}." + + moderator.request_action( + action_cls=HealAction, + player_id=me.id, + prompt=prompt, + data=data_entry, + event_name=EventName.HEAL_REQUEST, + ) + + @on_event(EventName.HEAL_ACTION) + def on_heal_action(self, me: BasePlayer, moderator: BaseModerator, event: Event): + if not me.alive or event.data.actor_id != me.id: + return + + action = event.data.action + if isinstance(action, HealAction): + if not self.allow_self_save and action.target_id == me.id: + moderator.state.push_event( + description=f'Player "{me.id}", doctor is not allowed to self save. ' + f'Your target is {action.target_id}, which is your own id.', + event_name=EventName.ERROR, + public=False, + visible_to=[me.id] + ) + return + + if not self.allow_consecutive_saves and action.target_id == me.get_role_state(DoctorStateKey.LAST_SAVED_PLAYER_ID): + moderator.state.push_event( + description=f'Player "{me.id}", you cannot save the same player on consecutive nights. ' + f'Your target "{action.target_id}" was also saved last night.', + event_name=EventName.ERROR, + public=False, + visible_to=[me.id] + ) + return + + moderator.record_night_save(me.id, action.target_id) + me.set_role_state(DoctorStateKey.LAST_SAVED_PLAYER_ID, action.target_id) + me.set_role_state(DoctorStateKey.LAST_SAVED_DAY, moderator.state.day_count) + + +class SeerDescription: + REVEAL_ROLE = "Each night, may inspect one player to learn their true role." + REVEAL_TEAM = "Each night, may inspect one player's team but not their role." + + +class Seer(Role): + name: RoleConst = RoleConst.SEER + team: Team = Team.VILLAGERS + descriptions: str = "" + reveal_level: RevealLevel = RevealLevel.ROLE + + @field_validator('reveal_level') + @classmethod + def validate_reveal_level(cls, v): + if v == RevealLevel.NO_REVEAL: + raise ValueError(f"Setting reveal_level of Seer as {v}. Seer will become useless.") + return v + + @model_validator(mode='after') + def set_descriptions_default(self) -> 'Seer': + if self.descriptions == "": + if self.reveal_level == RevealLevel.ROLE: + self.descriptions = SeerDescription.REVEAL_ROLE + elif self.reveal_level == RevealLevel.TEAM: + self.descriptions = SeerDescription.REVEAL_TEAM + else: + raise ValueError(f"reveal_level {self.reveal_level} not supported.") + return self + + @on_event(EventName.NIGHT_START) + def on_night_starts(self, me: BasePlayer, moderator: BaseModerator, event: Event): + if me.alive: + data_entry = RequestSeerRevealDataEntry( + valid_candidates=[p.id for p in moderator.state.alive_players() if p != me], + action_json_schema=json.dumps(InspectAction.schema_for_player()) + ) + moderator.request_action( + action_cls=InspectAction, + player_id=me.id, + prompt=f"Wake up Seer. Who would you like to see their true {self.reveal_level}? " + f"The options are {data_entry.valid_candidates}.", + data=data_entry, + event_name=EventName.INSPECT_REQUEST, + ) + + @on_event(EventName.INSPECT_ACTION) + def on_inspect_action(self, me: BasePlayer, moderator: BaseModerator, event: Event): + action = event.data.action + if not me.alive or action.actor_id != me.id: + return + actor_id = me.id + target_player = moderator.state.get_player_by_id(action.target_id) + if target_player: # Ensure target exists + role = None + team = None + reveal_text = "" + if self.reveal_level == RevealLevel.ROLE: + role = target_player.role.name + team = target_player.role.team + reveal_text = f'Their role is a "{target_player.role.name}" in team "{target_player.role.team.value}".' + elif self.reveal_level == RevealLevel.TEAM: + team = target_player.role.team + reveal_text = f"Their team is {team}." + + data = SeerInspectResultDataEntry( + actor_id=actor_id, + target_id=action.target_id, + role=role, + team=team + ) + moderator.state.push_event( + description=f'Player "{actor_id}", you inspected {target_player.id}. ' + reveal_text, + event_name=EventName.INSPECT_RESULT, + public=False, + visible_to=[actor_id], + data=data + ) + else: + moderator.state.push_event( + description=f'Player "{actor_id}", you inspected player "{action.target_id}",' + f' but this player could not be found.', + event_name=EventName.ERROR, + public=False, + visible_to=[actor_id] + ) + + +class LLM(BaseModel): + model_name: str + properties: Dict = {} + + +class Agent(BaseModel): + id: PlayerID + """The unique name of the player.""" + + agent_id: str + """Id of the agent. Might not be unique (many players might be using the same underlying agent).""" + + display_name: str = "" + """Agent name shown in the UI and only visible to spectator but not the players. e.g. Pete (base_harness-gemini-2.5-pro) + base_harness-gemini-2.5-pro is the display_name while Pete is the id. It maybe different from agent_id, + e.g. base_harness_v2-gemini-2.5-pro-0506, to reduce the cognitive load of the spectators. + """ + + role: RoleConst + role_params: Dict = Field(default_factory=dict) + """Parameters to the Role constructor""" + + thumbnail: Optional[str] = "" + agent_harness_name: str = "basic_llm" + llms: List[LLM] = [] + + def get_agent_name(self): + return f"{self.agent_harness_name}({', '.join([llm.model_name for llm in self.llms])})" + + +class Player(BasePlayer): + model_config = ConfigDict(use_enum_values=True) + + id: PlayerID + """The unique name of the player.""" + + agent: Agent + role: BaseRole + alive: bool = True + eliminated_during_day: int = -1 + """game starts at night 0, then day 1, night 1, day 2, ...""" + + eliminated_during_phase: Optional[Phase] = None + + _message_queue: Deque[PlayerEventView] = PrivateAttr(default_factory=deque) + _role_state: Dict = PrivateAttr(default_factory=dict) + + def set_role_state(self, key, value): + self._role_state[key] = value + + def get_role_state(self, key, default=None): + return self._role_state.get(key, default) + + def get_event_handlers(self, moderator: BaseModerator) -> Dict[EventName, List[EventHandler]]: + handlers = defaultdict(list) + for event_type, handler in self.role.get_event_handlers().items(): + event_handler = partial(handler, self, moderator) + handlers[event_type].append(event_handler) + return handlers + + def update(self, entry: PlayerEventView): + self._message_queue.append(entry) + + def consume_messages(self) -> List[PlayerEventView]: + messages = list(self._message_queue) + self._message_queue.clear() + return messages + + def eliminate(self, day: int, phase: Phase): + self.alive = False + self.eliminated_during_day = day + self.eliminated_during_phase = phase.value + + def report_elimination(self): + return { + "player_id": self.id, + "eliminated_during_day": self.eliminated_during_day, + "eliminated_during_phase": self.eliminated_during_phase + } + + +ROLE_CLASS_MAP = { + RoleConst.WEREWOLF.value: Werewolf, + RoleConst.DOCTOR.value: Doctor, + RoleConst.SEER.value: Seer, + RoleConst.VILLAGER.value: Villager, +} + + +def create_players_from_agents_config(agents_config: List[Dict]) -> List[Player]: + # 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)): + counts = Counter(agent_ids) + duplicates = [item for item, count in counts.items() if count > 1 and item is not None] + if duplicates: + raise ValueError(f"Duplicate agent ids found: {', '.join(duplicates)}") + agents = [Agent(**agent_config) for agent_config in agents_config] + players = [Player(id=agent.id, agent=agent, role=ROLE_CLASS_MAP[agent.role](**agent.role_params)) for agent in agents] + return players \ No newline at end of file diff --git a/kaggle_environments/envs/werewolf/game/states.py b/kaggle_environments/envs/werewolf/game/states.py new file mode 100644 index 00000000..f1bb433d --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/states.py @@ -0,0 +1,218 @@ +import logging +from collections import defaultdict, deque +from functools import cached_property +from typing import List, Dict, Optional, Any, Union, Deque, Sequence, DefaultDict + +from pydantic import PrivateAttr, Field, computed_field, ConfigDict + +from .base import BaseState, EventHandler, PlayerID, BaseRole +from .consts import Phase, Team, RoleConst, MODERATOR_ID, PhaseDivider, DetailedPhase, EventName, RevealLevel +from .records import DataEntry, Event, PhaseDividerDataEntry, DataAccessLevel, \ + PlayerEventView +from .roles import Player + +logger = logging.getLogger(__name__) + + +class EventBus: + def __init__(self): + self._subs: DefaultDict[EventName, List[EventHandler]] = defaultdict(list) + + def register( + self, + event_name: EventName, + handler: EventHandler + ): + self._subs[event_name].append(handler) + + def dispatch(self, entry: Event): + for handler in self._subs[entry.event_name]: + handler(entry) + + +class GameState(BaseState): + model_config = ConfigDict(use_enum_values=True) + + players: List[Player] + phase: Phase = Phase.NIGHT + detailed_phase: DetailedPhase = DetailedPhase.NIGHT_START + day_count: int = 0 + history: Dict[int, List[Event]] = Field(default_factory=dict) + wallet: dict[PlayerID, int] = Field(default_factory=dict) + night_elimination_reveal_level: RevealLevel = RevealLevel.ROLE + day_exile_reveal_level: RevealLevel = RevealLevel.ROLE + _id_to_player: Dict[PlayerID, Player] = PrivateAttr(default_factory=dict) + _event_by_type: Dict[EventName, List[Event]] = PrivateAttr( + default_factory=lambda: defaultdict(list)) + _event_queue: Deque[Event] = PrivateAttr(default_factory=deque) + _night_elimination_player_ids: List[PlayerID] = PrivateAttr(default_factory=list) + _day_exile_player_ids: List[PlayerID] = PrivateAttr(default_factory=list) + _event_bus: EventBus = PrivateAttr(default_factory=EventBus) + + @computed_field + @cached_property + def all_player_ids(self) -> List[str]: + return [player.id for player in self.players] + + @computed_field + @cached_property + def all_unique_roles(self) -> List[BaseRole]: + role_dict = {player.role.name: player.role for player in self.players} + return list(role_dict.values()) + + def model_post_init(self, context: Any, /) -> None: + self._id_to_player = {p.id: p for p in self.players} + + def get_player_by_id(self, pid: PlayerID): + return self._id_to_player.get(pid) + + def get_players_by_role(self, role: RoleConst): + return [p for p in self.players if p.role.name == role] + + def get_players_by_team(self, team: Team): + return [p for p in self.players if p.role.team == team] + + def alive_players(self): + return [p for p in self.players if p.alive] + + def eliminated_players(self): + return [p for p in self.players if not p.alive] + + def revealed_players(self) -> Dict[PlayerID, RoleConst | Team | None]: + revealed = {} + if self.night_elimination_reveal_level == RevealLevel.ROLE: + revealed.update({pid: self.get_player_by_id(pid).role.name for pid in self._night_elimination_player_ids}) + elif self.night_elimination_reveal_level == RevealLevel.TEAM: + revealed.update({pid: self.get_player_by_id(pid).role.team for pid in self._night_elimination_player_ids}) + elif self.night_elimination_reveal_level == RevealLevel.NO_REVEAL: + revealed.update({pid: None for pid in self._night_elimination_player_ids}) + + if self.day_exile_reveal_level == RevealLevel.ROLE: + revealed.update({pid: self.get_player_by_id(pid).role.name for pid in self._day_exile_player_ids}) + elif self.day_exile_reveal_level == RevealLevel.TEAM: + revealed.update({pid: self.get_player_by_id(pid).role.team for pid in self._day_exile_player_ids}) + elif self.day_exile_reveal_level == RevealLevel.NO_REVEAL: + revealed.update({pid: None for pid in self._day_exile_player_ids}) + return revealed + + def is_alive(self, player_id: PlayerID): + return self.get_player_by_id(player_id).alive + + def alive_players_by_role(self, role: RoleConst): + return [p for p in self.alive_players() if p.role.name == role] + + def alive_players_by_team(self, team: Team): + return [p for p in self.alive_players() if p.role.team == team] + + def alive_player_counts_per_role(self): + counts = {role: len(self.alive_players_by_role(role)) for role in RoleConst} + return counts + + def alive_player_counts_per_team(self): + return {team: len(self.alive_players_by_team(team)) for team in Team} + + _night_eliminate_queue: List[PlayerID] = PrivateAttr(default_factory=list) + + def queue_eliminate(self, target: Player): + self._night_eliminate_queue.append(target.id) + + def clear_eliminate_queue(self): + self._night_eliminate_queue.clear() + + _night_doctor_save_queue: List[PlayerID] = PrivateAttr(default_factory=list) + + def queue_doctor_save(self, target: Player): + self._night_doctor_save_queue.append(target.id) + + def get_event_by_name(self, event_name: EventName) -> List[Event]: + return self._event_by_type[event_name] + + def push_event(self, + description: str, + event_name: EventName, + public: bool, + visible_to: Optional[List[PlayerID]] = None, + data: Optional[Union[DataEntry, Dict[str, Any]]] = None, source=MODERATOR_ID): + visible_to = visible_to or [] + # Night 0 will use day_count 0, Day 1 will use day_count 1, etc. + day_key = self.day_count + self.history.setdefault(day_key, []) + sys_entry = Event( + day=day_key, + phase=self.phase, + detailed_phase=self.detailed_phase, + event_name=event_name, + description=description, + public=public, + visible_to=visible_to or [], + data=data, + source=source + ) + + self.history[day_key].append(sys_entry) + self._event_by_type[event_name].append(sys_entry) + self._event_queue.append(sys_entry) + + public_view = sys_entry.view_by_access(user_level=DataAccessLevel.PUBLIC) + personal_view = sys_entry.view_by_access(user_level=DataAccessLevel.PERSONAL) + + # observers message pushing below + if public: + for player in self.players: + if player.id == source: + player.update(personal_view) + else: + player.update(public_view) + else: + for player_id in visible_to: + player = self.get_player_by_id(player_id) + if player: + if player.id == source: + player.update(personal_view) + else: + player.update(public_view) + + # publish events + self._event_bus.dispatch(sys_entry) + + def add_phase_divider(self, divider: PhaseDivider): + """The phase divider is used to clearly separate phase boundary. This is very useful + for visualizer updates, where some updates naturally takes a time slice of events as input. + """ + self.push_event( + description=divider.value, + event_name=EventName.PHASE_DIVIDER, + public=False, + data=PhaseDividerDataEntry(divider_type=divider.value) + ) + + def eliminate_player(self, pid: PlayerID): + if pid not in self.all_player_ids: + logger.warning(f"Tried to eliminate {pid} who is not within valid player ids {self.all_player_ids}.") + return + player = self.get_player_by_id(pid) + if self.phase == Phase.NIGHT: + self._night_elimination_player_ids.append(pid) + else: + self._day_exile_player_ids.append(pid) + if player: + player.eliminate(day=self.day_count, phase=self.phase) + + def consume_messages(self) -> List[Event]: + messages = list(self._event_queue) + self._event_queue.clear() + return messages + + def get_elimination_info(self): + return [player.report_elimination() for player in self.players] + + def register_event_handler(self, event_name: EventName, handler: EventHandler): + self._event_bus.register(event_name, handler) + + +def get_last_action_request( + event_views: Sequence[PlayerEventView], + event_name: EventName +) -> None | PlayerEventView: + """Get the action request from the new player history entry view updates.""" + return next((entry for entry in event_views if entry.event_name == event_name), None) diff --git a/kaggle_environments/envs/werewolf/game/test_actions.py b/kaggle_environments/envs/werewolf/game/test_actions.py new file mode 100644 index 00000000..21a1f942 --- /dev/null +++ b/kaggle_environments/envs/werewolf/game/test_actions.py @@ -0,0 +1,53 @@ + +import pytest + + +from kaggle_environments.envs.werewolf.game.actions import filter_language + + +test_data = [ + # Test 1: Basic lowercase substitution + ("We must kill the monster.", "We must eliminate the monster."), + + # Test 2: Title case substitution + ("Killing is wrong.", "Eliminating is wrong."), + + # Test 3: Uppercase substitution + ("The town should not LYNCH anyone.", "The town should not EXILE anyone."), + + # Test 4: Word boundary check (should not affect "skill") + ("His skill is unparalleled.", "His skill is unparalleled."), + + # Test 5: Mixed case and multiple substitutions + ("The Mob will lynch the player they think will Kill them.", + "The Mob will exile the player they think will Eliminate them."), + + # Test 6: Handling different word endings ('-ed', '-s') + ("He killed the dragon, and she kills the goblin.", "He eliminated the dragon, and she eliminates the goblin."), + + # Test 7: No inappropriate words, should return original string + ("This is a perfectly safe sentence.", "This is a perfectly safe sentence."), + + # Test 8: A more complex sentence with a third rule ('murder') + ("The detective solved the Murder, preventing the killer from killing again.", + "The detective solved the Remove, preventing the eliminator from eliminating again."), + + # Test 9: A tricky title case that isn't at the start of a sentence + ("I think Killing is not the answer.", "I think Eliminating is not the answer.") +] + + +@pytest.mark.parametrize("input_text, expected_text", test_data) +def test_clean_script_scenarios(input_text, expected_text): + """ + Tests the clean_script_preserve_case function with various scenarios. + """ + assert filter_language(input_text) == expected_text + + +def test_empty_string(): + """ + Tests that an empty string input results in an empty string output. + """ + assert filter_language("") == "" + diff --git a/kaggle_environments/envs/werewolf/harness/__init__.py b/kaggle_environments/envs/werewolf/harness/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kaggle_environments/envs/werewolf/harness/base.py b/kaggle_environments/envs/werewolf/harness/base.py new file mode 100644 index 00000000..e889456a --- /dev/null +++ b/kaggle_environments/envs/werewolf/harness/base.py @@ -0,0 +1,716 @@ +import functools +import json +import logging +import os +import re +import traceback +from abc import ABC, abstractmethod +from collections import namedtuple +from typing import List, Optional + +import litellm +import pyjson5 +import tenacity +import yaml +from dotenv import load_dotenv +from litellm import completion, cost_per_token +from litellm.types.utils import Usage +from pydantic import BaseModel, Field + +from kaggle_environments.envs.werewolf.game.actions import ( + NoOpAction, EliminateProposalAction, HealAction, InspectAction, ChatAction, VoteAction, TargetedAction, BidAction +) +from kaggle_environments.envs.werewolf.game.consts import RoleConst, ActionType, DetailedPhase, EventName +from kaggle_environments.envs.werewolf.game.records import get_raw_observation +from kaggle_environments.envs.werewolf.game.states import get_last_action_request + +_LITELLM_CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'litellm_models.yaml') +litellm.config_path = _LITELLM_CONFIG_PATH +with open(_LITELLM_CONFIG_PATH, 'r') as _file: + _MODEL_COST_DICT = yaml.safe_load(_file) +litellm.register_model(_MODEL_COST_DICT) + + +logger = logging.getLogger(__name__) + +litellm.drop_params = True + +# Load environment variables from a .env file in the same directory +load_dotenv() + + +class LLMActionException(Exception): + """Custom exception to carry context from a failed LLM action.""" + def __init__(self, message, original_exception, raw_out=None, prompt=None): + super().__init__(message) + self.original_exception = original_exception + self.raw_out = raw_out + self.prompt = prompt + + def __str__(self): + return f"{super().__str__()} | Raw Output: '{self.raw_out}'" + + +def _log_retry_warning(retry_state: tenacity.RetryCallState): + assert retry_state.outcome is not None + exception = retry_state.outcome.exception() + traceback_str = ''.join(traceback.format_exception(exception)) + if retry_state.attempt_number < 1: + loglevel = logging.INFO + else: + loglevel = logging.WARNING + logging.log( + loglevel, + 'Retrying: $s attempt # %s ended with: $s Traceback: %s Retry state: %s', + retry_state.fn, + retry_state.attempt_number, + retry_state.outcome, + traceback_str, + retry_state, + ) + + +def _is_rate_limit_error(exception) -> bool: + """ + Checks if an exception is a RateLimitError that warrants a context reduction retry. + This checks for both OpenAI's specific error and the generic HTTP 429 status code. + """ + is_openai_rate_limit = "RateLimitError" in str(type(exception)) + is_http_429 = hasattr(exception, 'status_code') and exception.status_code == 429 + return is_openai_rate_limit or is_http_429 + + +def _is_context_window_exceeded_error(exception) -> bool: + """""" + is_error = "ContextWindowExceededError" in str(type(exception)) + return is_error + + +def _is_json_parsing_error(exception) -> bool: + out = True if isinstance(exception, pyjson5.Json5Exception) else False + return out + + +def _truncate_and_log_on_retry(retry_state: tenacity.RetryCallState): + """ + Tenacity hook called before a retry. It reduces the context size if a + RateLimitError was detected. + """ + # The first argument of the retried method is the class instance 'self' + agent_instance = retry_state.args[0] + + if _is_rate_limit_error(retry_state.outcome.exception()): + # Reduce the number of history items to keep by 25% on each attempt + original_count = agent_instance._event_log_items_to_keep + agent_instance._event_log_items_to_keep = int(original_count * 0.75) + + logger.warning( + 'ContextWindowExceededError detected. Retrying with smaller context. ' + 'Reducing event log from %d to %d itms.', + original_count, + agent_instance._event_log_items_to_keep, + ) + + # Also call the original logging function for general retry logging + _log_retry_warning(retry_state) + + +def _add_error_entry_on_retry(retry_state: tenacity.RetryCallState): + last_exception_wrapper = retry_state.outcome.exception() + if isinstance(last_exception_wrapper, LLMActionException): + last_exception = last_exception_wrapper.original_exception + # You can also access the failed output here if needed for logging + raw_out = last_exception_wrapper.raw_out + prompt = last_exception_wrapper.prompt + logger.warning(f"Retrying due to JSON parsing error. Failed output: {raw_out} Failed prompt: {prompt}") + else: + last_exception = last_exception_wrapper + + stack_trace_list = traceback.format_exception(last_exception) + stack_trace_str = "".join(stack_trace_list) + retry_state.kwargs['error_stack_trace'] = stack_trace_str + _log_retry_warning(retry_state) + + +TARGETED_ACTION_SCHEMA = TargetedAction.schema_for_player() +CHAT_ACTION_SCHEMA = ChatAction.schema_for_player() + +BID_ACTION_SCHEMA = BidAction.schema_for_player() +BID_ACTION_SCHEMA_REASONING = BidAction.schema_for_player(('perceived_threat_level', 'reasoning', 'target_id')) + + +TARGETED_ACTION_EXEMPLAR = f"""```json +{json.dumps(dict(perceived_threat_level="SAFE", reasoning="I chose this target randomly.", target_id="some_player_id"))} +```""" + +BID_ACTION_EXEMPLAR = f"""```json +{json.dumps(dict(perceived_threat_level="UNEASY", amount=4))} +```""" +BID_ACTION_EXEMPLAR_REASONING = f"""```json +{json.dumps(dict(perceived_threat_level="UNEASY", reasoning="I have important information to share, so I am bidding high.", amount=4))} +```""" + +AUDIO_EXAMPLE = 'Say in an spooky whisper: "By the pricking of my thumbs... Something wicked this way comes!"' +AUDIO_EXAMPLE_2 = 'Deliver in a thoughtful tone: "I was stunned. I really suspect John\'s intent of bringing up Tim."' +AUDIO_EXAMPLE_3 = 'Read this in as fast as possible while remaining intelligible: "My nomination for Jack was purely incidental."' +AUDIO_EXAMPLE_4 = 'Sound amused and relaxed: "that was a very keen observation, AND a classic wolf play.\n(voice: curious)\nI\'m wondering what the seer might say."' +CHAT_AUDIO_DICT = {"perceived_threat_level": "SAFE", "reasoning": "To draw attention to other players ...", "message": AUDIO_EXAMPLE} +CHAT_AUDIO_DICT_2 = {"perceived_threat_level": "DANGER", "reasoning": "This accusation is uncalled for ...", "message": AUDIO_EXAMPLE_2} +CHAT_AUDIO_DICT_3 = {"perceived_threat_level": "UNEASY", "reasoning": "I sense there are some suspicion directed towards me ...", "message": AUDIO_EXAMPLE_3} +CHAT_AUDIO_DICT_4 = {"perceived_threat_level": "UNEASY", "reasoning": "I am redirecting the attention to other leads ...", "message": AUDIO_EXAMPLE_4} +CHAT_ACTION_EXEMPLAR_2 = f"```json\n{json.dumps(CHAT_AUDIO_DICT)}\n```" +CHAT_ACTION_EXEMPLAR_3 = f"```json\n{json.dumps(CHAT_AUDIO_DICT_2)}\n```" +CHAT_ACTION_EXEMPLAR = f"```json\n{json.dumps(CHAT_AUDIO_DICT_3)}\n```" +CHAT_ACTION_EXEMPLAR_4 = f"```json\n{json.dumps(CHAT_AUDIO_DICT_4)}\n```" + + +CHAT_ACTION_ADDITIONAL_CONSTRAINTS_AUDIO = [ + f'- The "message" will be rendered to TTS and shown to other players, so make sure to control the style, tone, ' + f'accent and pace of your message using natural language prompt. e.g.\n{CHAT_ACTION_EXEMPLAR_2}', + "- Since this is a social game, the script in the message should sound conversational.", + '- Be Informal: Use contractions (like "it\'s," "gonna"), and simple language.', + '- Be Spontaneous: Vary your sentence length. It\'s okay to have short, incomplete thoughts or to restart a sentence.', + '- [Optional] If appropriate, you could add natural sounds in (sound: ...) e.g. (sound: chuckles), or (sound: laughs), etc.', + '- [Optional] Be Dynamic: A real chat is never monotonous. Use (voice: ...) instructions to constantly and subtly shift the tone to match the words.', + # f'- Be Expressive: Use a variety of descriptive tones. Don\'t just use happy or sad. Try tones like amused, ' + # f'thoughtful, curious, energetic, sarcastic, or conspiratorial. e.g. \n{CHAT_ACTION_EXEMPLAR_4}' +] + + +CHAT_TEXT_DICT = {"perceived_threat_level": "UNEASY", "reasoning": "I want to put pressure on Player3 and see how they react. A quiet player is often a werewolf.", "message": "I'm suspicious of Player3. They've been too quiet. What do you all think?"} +CHAT_ACTION_EXEMPLAR_TEXT = f"```json\n{json.dumps(CHAT_TEXT_DICT)}\n```" + + +CHAT_ACTION_ADDITIONAL_CONSTRAINTS_TEXT = [ + '- The "message" will be displayed as text to other players. Focus on being clear and persuasive', + '- Your goal is to win the game as a team. Think about how to reach that goal strategically.', + '- Refer to players by their ID (e.g., "Player1", "Player3") to avoid ambiguity.', + '- Keep your messages concise and to the point. ', + '- You can simply say "Pass!", if you have nothing valuable you would like to share.' +] + + +class WerewolfAgentBase(ABC): + @abstractmethod + def __call__(self, obs): + """The instance is meant to be used as callable for kaggle environments.""" + + +DEFAULT_PROMPT_TEMPLATE = """{system_prompt} + +### Current Game State +{current_state} + +### Game Timeline +This is the complete, chronological timeline of all public events and your private actions. +{event_log} + +### Your Instruction +Based on the game state and event log, please respond to the following instruction. + +{instruction}{error_instruction} +""" + +INSTRUCTION_TEMPLATE = """#### ROLE +{role} + +#### TASK +{task} + +#### CONSTRAINTS +- Your response MUST be a single, valid JSON object. +- generate the "reasoning" key first to think through your response. Your "reasoning" is invisible to other players. +{additional_constraints} + +#### JSON SCHEMA +Your JSON output must conform to the following schema. Do NOT include this schema in your response. +```json +{json_schema} +``` + +#### EXAMPLE OUTPUT +Please format your response as a Markdown JSON code block, which should include the fences. Here's a valid example: +{exemplar} +""" + + +class TokenCost(BaseModel): + total_tokens: int = 0 + total_costs_usd: float = 0. + token_count_history: List[int] = [] + cost_history_usd: List[float] = [] + + def update(self, token_count, cost): + self.total_tokens += token_count + self.total_costs_usd += cost + self.token_count_history.append(token_count) + self.cost_history_usd.append(cost) + + +class LLMCostTracker(BaseModel): + model_name: str + query_token_cost: TokenCost = Field(default_factory=TokenCost) + prompt_token_cost: TokenCost = Field(default_factory=TokenCost) + completion_token_cost: TokenCost = Field(default_factory=TokenCost) + usage_history: List[Usage] = [] + """example item from gemini flash model dump: response.usage = {'completion_tokens': 579, 'prompt_tokens': 1112, + 'total_tokens': 1691, 'completion_tokens_details': {'accepted_prediction_tokens': None, + 'audio_tokens': None, 'reasoning_tokens': 483, 'rejected_prediction_tokens': None, + 'text_tokens': 96}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': None, + 'text_tokens': 1112, 'image_tokens': None}}""" + + def update(self, response): + completion_tokens = response['usage']['completion_tokens'] + prompt_tokens = response['usage']['prompt_tokens'] + response_cost = response._hidden_params["response_cost"] + + try: + prompt_cost, completion_cost = cost_per_token( + model=self.model_name, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens) + logger.info(f"Used litellm cost for {self.model_name}") + except Exception as exception: + raise Exception(f"Could not find cost for {self.model_name} in litellm or custom dict. " + f"You can register the cost in \"litellm_models.yaml\"") from exception + + self.query_token_cost.update(token_count=prompt_tokens + completion_tokens, cost=response_cost) + self.prompt_token_cost.update(token_count=prompt_tokens, cost=prompt_cost) + self.completion_token_cost.update(token_count=completion_tokens, cost=completion_cost) + self.usage_history.append(response.usage) + + +class ActionRegistry: + """A registry for action handler based on phase and role.""" + def __init__(self): + self._registry = {} + + def register(self, phase: DetailedPhase, role: Optional[RoleConst] = None): + """If an action is not role specific, role can be left as None, in which case all roles will be + pointing to the same handler. + """ + def decorator(func): + self._registry.setdefault(phase, {}) + if role is not None: + self._registry[phase][role] = func + else: + for item in RoleConst: + self._registry[phase][item] = func + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + return decorator + + def get(self, phase: DetailedPhase, role: RoleConst): + func = self._registry[phase][role] + return func + + +class EventLogKeys: + PUBLIC_EVENT = "public_event" + PRIVATE_ACTION = "private_action" + + +EventLogItem = namedtuple('EventLogItem', ['event_log_key', 'day', 'phase', 'log_item']) + + +class LLMWerewolfAgent(WerewolfAgentBase): + action_registry = ActionRegistry() + + def __init__( + self, model_name: str, agent_config: dict = None, system_prompt: str = "", + prompt_template: str = DEFAULT_PROMPT_TEMPLATE, kaggle_config=None, + ): + """This wrapper only support 1 LLM. + """ + agent_config = agent_config or {} + decoding_kwargs = agent_config.get("llms", [{}])[0].get('parameters') + self._decoding_kwargs = decoding_kwargs or {} + self._kaggle_config = kaggle_config or {} + self._chat_mode = agent_config.get("chat_mode", "audio") + self._enable_bid_reasoning = agent_config.get("enable_bid_reasoning", False) + self._cost_tracker = LLMCostTracker(model_name=model_name) + + self._model_name = model_name + self._system_prompt = system_prompt + self._prompt_template = prompt_template + self._is_vertex_ai = "vertex_ai" in self._model_name + + # storing all events including internal and external + self._event_logs: List[EventLogItem] = [] + + # This new attribute will track how much history to include for each retry attempt + self._event_log_items_to_keep = 0 + + if self._is_vertex_ai: + self._decoding_kwargs.update({ + "vertex_ai_project": os.environ.get("VERTEXAI_PROJECT",""), + "vertex_ai_location": os.environ.get("VERTEXAI_LOCATION",""), + }) + + @property + def cost_tracker(self) -> LLMCostTracker: + return self._cost_tracker + + def log_token_usage(self): + cost_history = self._cost_tracker.query_token_cost.cost_history_usd + query_cost = cost_history[-1] if cost_history else None + logger.info( + ", ".join([ + f"*** Total prompt tokens: {self._cost_tracker.prompt_token_cost.total_tokens}", + f"total completion_tokens: {self._cost_tracker.completion_token_cost.total_tokens}", + f"total query cost: $ {self._cost_tracker.query_token_cost.total_costs_usd}", + f"current query cost: $ {query_cost}" + ]) + ) + + def __del__(self): + logger.info( + f"Instance '{self._model_name}' is being deleted. " + f"Prompt tokens: '{self._cost_tracker.prompt_token_cost.total_tokens}' " + f"completion_tokens: '{self._cost_tracker.completion_token_cost.total_tokens}'." + ) + + @tenacity.retry( + retry=tenacity.retry_if_exception(_is_rate_limit_error), + stop=tenacity.stop_after_attempt(5), + wait=tenacity.wait_random_exponential(multiplier=1, min=2, max=10), + reraise=True + ) + def query(self, prompt): + logger.info(f"prompt for {self._model_name}: {prompt}") + response = completion( + model=self._model_name, + messages=[{"content": prompt, "role": "user"}], + **self._decoding_kwargs + ) + msg = response["choices"][0]["message"]["content"] + self._cost_tracker.update(response) + logger.info(f"message from {self._model_name}: {msg}") + return msg + + def parse(self, out: str) -> dict: + """ + Parses the string output from an LLM into a dictionary. + + This method implements best practices for parsing potentially-malformed + JSON output from a large language model. + 1. It looks for JSON within Markdown code blocks (```json ... ```). + 2. It attempts to clean the extracted string to fix common LLM mistakes. + 3. It uses a robust JSON parser. + 4. If standard parsing fails, it falls back to a regular expression search + for the most critical fields as a last resort. + + Args: + out: The raw string output from the LLM. + + Returns: + A dictionary parsed from the JSON, or an empty dictionary if all parsing attempts fail. + """ + try: + # 1. Extract JSON string from Markdown code blocks + if '```json' in out: + # Find the start and end of the json block + start = out.find('```json') + len('```json') + end = out.find('```', start) + json_str = out[start:end].strip() + elif '```' in out: + start = out.find('```') + len('```') + end = out.find('```', start) + json_str = out[start:end].strip() + else: + # If no code block, assume the whole output might be JSON + json_str = out + + # 2. Clean the JSON string + # Remove trailing commas from objects and arrays which is a common mistake + json_str = re.sub(r',\s*([\}\]])', r'\1', json_str) + + # 3. Parse the cleaned string + return pyjson5.loads(json_str) + except Exception: + # Catch any other unexpected errors during string manipulation or parsing + error_trace = traceback.format_exc() + logger.error("An error occurred:\n%s", error_trace) + logger.error(f"The model out failed to parse is model_name=\"{self._model_name}\".") + logger.error(f"Failed to parse out={out}") + # reraise the error + raise + + def render_prompt( + self, instruction: str, obs, max_log_items: int = -1, + error_stack_trace=None, error_prompt=None): + """ + Renders the final prompt, optionally truncating the event log + to include only the last 'max_log_items' events. + """ + current_state = self.current_state(obs) + + # Greedily take the last n items from the event log if a limit is set + if 0 <= max_log_items < len(self._event_logs): + event_logs = self._event_logs[-max_log_items:] + else: + event_logs = self._event_logs + + # Build the unified, tagged event logs + log_parts = [] + day_phase = (None, None) + for log_key, day, phase, log_item in event_logs: + if (day, phase) != day_phase: + day_phase = (day, phase) + log_parts.append(f"**--- {phase} {day} ---**") + if log_key == EventLogKeys.PUBLIC_EVENT: + log_parts.append(f"[EVENT] {log_item.description}") + elif log_key == EventLogKeys.PRIVATE_ACTION: + text_parts = [f"[YOUR ACTION & REASONING] You decided to use {type(log_item).__name__} "] + # account for NOOP + if log_item.action_field: + action_field_item = f" - {log_item.action_field.capitalize()}: {getattr(log_item, log_item.action_field)}" + text_parts.append(action_field_item) + text_parts.append(f" - Reasoning: {log_item.reasoning}") + text_parts.append(f" - Perceived threat level: {log_item.perceived_threat_level}") + log_parts.append("\n".join(text_parts)) + + event_log = "\n\n".join(log_parts) + + error_instruction = "" + if error_stack_trace: + error_instruction = \ + f"\n\nYour previous attempt resulted in the following error:\n{error_stack_trace}\n\n{error_prompt}" + + content = { + "system_prompt": self._system_prompt, + "current_state": json.dumps(current_state, sort_keys=True), + "event_log": event_log, + "instruction": instruction, + "error_instruction": error_instruction, + } + return self._prompt_template.format(**content) + + @staticmethod + def current_state(obs): + obs_model = get_raw_observation(obs) + content = { + "your_name": obs_model.player_id, + "your_team": obs_model.team, + "your_role_name": obs_model.role, + "all_player_ids": obs_model.all_player_ids, + "alive_players": obs_model.alive_players, + "revealed_players": obs_model.revealed_players, + } + return content + + @tenacity.retry( + retry=tenacity.retry_if_exception(_is_context_window_exceeded_error), + stop=tenacity.stop_after_attempt(5), + wait=tenacity.wait_random_exponential(multiplier=1, min=2, max=10), + before_sleep=_truncate_and_log_on_retry, + reraise=True, + ) + def render_prompt_query(self, instruction, obs, error_stack_trace=None, error_prompt=None): + prompt = self.render_prompt( + instruction=instruction, + obs=obs, + max_log_items=self._event_log_items_to_keep, + error_stack_trace=error_stack_trace, + error_prompt=error_prompt + ) + out = self.query(prompt) + return out, prompt + + @tenacity.retry( + retry=tenacity.retry_if_exception(_is_json_parsing_error), + stop=tenacity.stop_after_attempt(3), + wait=tenacity.wait_random_exponential(multiplier=1, min=2, max=10), + before_sleep=_add_error_entry_on_retry, + reraise=True, + ) + def query_parse(self, instruction, obs, error_stack_trace=None, error_prompt=None): + raw_out, prompt = self.render_prompt_query(instruction, obs, error_stack_trace, error_prompt) + try: + parsed_out = self.parse(raw_out) + # Add the raw_out and prompt to the output dict + parsed_out['raw_prompt'] = prompt + parsed_out['raw_completion'] = raw_out + return parsed_out + except pyjson5.Json5Exception as e: + # Catch the parsing error, wrap it with context, and re-raise. + # Tenacity will catch this and decide whether to retry. + raise LLMActionException( + message="Failed to parse LLM output.", + original_exception=e, + raw_out=raw_out, + prompt=prompt + ) + + @action_registry.register(DetailedPhase.NIGHT_AWAIT_ACTIONS, RoleConst.WEREWOLF) + def _night_werewolf_vote(self, entries, obs, common_args): + # Werewolves target other alive players. + history_entry = get_last_action_request(entries, EventName.VOTE_REQUEST) + action = NoOpAction(**common_args, reasoning="There's nothing to be done.") + if history_entry: + valid_targets = history_entry.data.get('valid_targets') + instruction = INSTRUCTION_TEMPLATE.format(**{ + "role": "You are a Werewolf.", + "task": "Vote for a player to eliminate.", + "additional_constraints": f"- Valid targets are: `{valid_targets}`.", + "json_schema": json.dumps(TARGETED_ACTION_SCHEMA), + "exemplar": TARGETED_ACTION_EXEMPLAR + }) + parsed_out = self.query_parse( + instruction, obs, + error_prompt="Your previous attempt failed. Please vote again." + ) + action = EliminateProposalAction(**common_args, **parsed_out) + return action + + @action_registry.register(DetailedPhase.NIGHT_AWAIT_ACTIONS, RoleConst.SEER) + def _night_seer_inspect(self, entries, obs, common_args): + # Seers can inspect any alive player. + history_entry = get_last_action_request(entries, EventName.INSPECT_REQUEST) + action = NoOpAction(**common_args, reasoning="There's nothing to be done.") + if history_entry: + valid_targets = history_entry.data['valid_candidates'] + instruction = INSTRUCTION_TEMPLATE.format(**{ + "role": "You are a Seer.", + "task": "Choose a player to inspect and reveal their role.", + "additional_constraints": f'- The "target_id" must be in this list: `{valid_targets}`.', + "json_schema": json.dumps(TARGETED_ACTION_SCHEMA), + "exemplar": TARGETED_ACTION_EXEMPLAR + }) + parsed_out = self.query_parse( + instruction, obs, + error_prompt="Your previous attempt failed. Please choose one player to inspect again." + ) + action = InspectAction(**common_args, **parsed_out) + return action + + @action_registry.register(DetailedPhase.NIGHT_AWAIT_ACTIONS, RoleConst.DOCTOR) + def _night_doctor_heal(self, entries, obs, common_args): + action = NoOpAction(**common_args, reasoning="There's nothing to be done.") + history_entry = get_last_action_request(entries, EventName.HEAL_REQUEST) + if history_entry: + valid_targets = history_entry.data['valid_candidates'] + instruction = INSTRUCTION_TEMPLATE.format(**{ + "role": "You are a Doctor.", + "task": "Choose a player to save from the werewolf attack.", + "additional_constraints": f'- The "target_id" must be in this list: `{valid_targets}`.', + "json_schema": json.dumps(TARGETED_ACTION_SCHEMA), + "exemplar": TARGETED_ACTION_EXEMPLAR + }) + parsed_out = self.query_parse( + instruction, obs, + error_prompt="Your previous attempt failed. Please choose one player to heal again." + ) + action = HealAction(**common_args, **parsed_out) + return action + + @action_registry.register(DetailedPhase.DAY_BIDDING_AWAIT) + def _day_bid(self, entries, obs, common_args): + instruction = INSTRUCTION_TEMPLATE.format(**{ + "role": "It is bidding time. You can bid to get a chance to speak.", + "task": 'Decide how much to bid for a speaking turn. A higher bid increases your chance of speaking. You can bid from 0 to 4.', + "additional_constraints": "- The 'amount' must be an integer between 0 and 4.", + "json_schema": json.dumps(BID_ACTION_SCHEMA), + "exemplar": BID_ACTION_EXEMPLAR_REASONING if self._enable_bid_reasoning else BID_ACTION_EXEMPLAR + }) + parsed_out = self.query_parse( + instruction, obs, + error_prompt="Your previous attempt failed. Please place your bid again." + ) + action = BidAction(**common_args, **parsed_out) + return action + + @action_registry.register(DetailedPhase.DAY_CHAT_AWAIT) + def _day_chat(self, entries, obs, common_args): + # All alive players can discuss. + if self._chat_mode == 'text': + constraints = CHAT_ACTION_ADDITIONAL_CONSTRAINTS_TEXT + exemplar = CHAT_ACTION_EXEMPLAR_TEXT + elif self._chat_mode == 'audio': # audio mode + constraints = CHAT_ACTION_ADDITIONAL_CONSTRAINTS_AUDIO + exemplar = CHAT_ACTION_EXEMPLAR + else: + raise ValueError( + f'Can only select between "text" mode and "audio" mode to prompt the LLM. "{self._chat_mode}" mode detected.') + instruction = INSTRUCTION_TEMPLATE.format(**{ + "role": "It is day time. Participate in the discussion.", + "task": 'Discuss with other players to decide who to vote out. Formulate a "message" to persuade others.', + "additional_constraints": "\n".join(constraints), + "json_schema": json.dumps(CHAT_ACTION_SCHEMA), + "exemplar": exemplar + }) + parsed_out = self.query_parse( + instruction, obs, + error_prompt="Your previous attempt failed. Please prepare your message again." + ) + action = ChatAction(**common_args, **parsed_out) + return action + + @action_registry.register(DetailedPhase.DAY_VOTING_AWAIT) + def _day_vote(self, entries, obs, common_args): + raw_obs = get_raw_observation(obs) + alive_players = raw_obs.alive_players + my_id = raw_obs.player_id + valid_targets = [p for p in alive_players if p != my_id] + instruction = INSTRUCTION_TEMPLATE.format(**{ + "role": "It is day time. It is time to vote.", + "task": 'Choose a player to exile.', + "additional_constraints": f'- The "target_id" must be in this list: `{valid_targets}`.', + "json_schema": json.dumps(TARGETED_ACTION_SCHEMA), + "exemplar": TARGETED_ACTION_EXEMPLAR + }) + parsed_out = self.query_parse( + instruction, obs, + error_prompt="Your previous attempt failed. Please cast your vote again." + ) + action = VoteAction(**common_args, **parsed_out) + return action + + def __call__(self, obs): + raw_obs = get_raw_observation(obs) + entries = raw_obs.new_player_event_views + + for entry in entries: + self._event_logs.append( + EventLogItem(EventLogKeys.PUBLIC_EVENT, day=entry.day, phase=entry.phase, log_item=entry) + ) + + # Default to NO_OP if observation is missing or agent cannot act + if not raw_obs or not entries: + return {"action_type": ActionType.NO_OP.value, "target_idx": None, "message": None} + + self._event_log_items_to_keep = len(self._event_logs) + + current_phase = DetailedPhase(raw_obs.detailed_phase) + my_role = RoleConst(raw_obs.role) + + common_args = {"day": raw_obs.day, "phase": raw_obs.game_state_phase, "actor_id": raw_obs.player_id} + + handler = self.action_registry.get(phase=current_phase, role=my_role) + + try: + action = handler(self, entries, obs, common_args) + except LLMActionException as e: + # Catch the specific exception after all retries have failed + error_trace = traceback.format_exc() + logger.error("An LLMActionException occurred after all retries:\n%s", error_trace) + logger.error(f"The model failed to act is model_name=\"{self._model_name}\".") + + # Now you can access the preserved data! + action = NoOpAction( + **common_args, + reasoning="Fell back to NoOp after multiple parsing failures.", + error=error_trace, + raw_completion=e.raw_out, # <-- Preserved data + raw_prompt=e.prompt # <-- Preserved data + ) + except Exception: + error_trace = traceback.format_exc() + logger.error("An error occurred:\n%s", error_trace) + logger.error(f"The model failed to act is model_name=\"{self._model_name}\".") + action = NoOpAction(**common_args, reasoning="", error=error_trace) + self.log_token_usage() + # record self action + self._event_logs.append( + EventLogItem(EventLogKeys.PRIVATE_ACTION, day=raw_obs.day, phase=raw_obs.game_state_phase, log_item=action)) + return action.serialize() diff --git a/kaggle_environments/envs/werewolf/harness/litellm_models.yaml b/kaggle_environments/envs/werewolf/harness/litellm_models.yaml new file mode 100644 index 00000000..5ecb05ff --- /dev/null +++ b/kaggle_environments/envs/werewolf/harness/litellm_models.yaml @@ -0,0 +1,51 @@ +openrouter/deepseek/deepseek-chat-v3.1: + input_cost_per_token: 2e-7 + output_cost_per_token: 8e-7 +openrouter/openai/gpt-4o-mini: + input_cost_per_token: 1.5e-7 + output_cost_per_token: 6e-7 +openrouter/qwen/qwen3-235b-a22b-2507: + input_cost_per_token: 7.8e-8 + output_cost_per_token: 3.12e-7 +openrouter/z-ai/glm-4.5: + input_cost_per_token: 2e-7 + output_cost_per_token: 8e-7 +openrouter/openai/gpt-oss-120b: + input_cost_per_token: 7.2e-8 + output_cost_per_token: 2.8e-7 +openrouter/openai/gpt-oss-20b: + input_cost_per_token: 4e-8 + output_cost_per_token: 1.5e-7 +openrouter/qwen/qwen3-30b-a3b: + input_cost_per_token: 1e-7 + output_cost_per_token: 3e-7 +openrouter/openai/gpt-5: + input_cost_per_token: 1.25e-6 + output_cost_per_token: 1e-5 +openrouter/openai/gpt-4.1: + input_cost_per_token: 2e-6 + output_cost_per_token: 8e-6 +openrouter/anthropic/claude-sonnet-4: + input_cost_per_token: 3e-6 + output_cost_per_token: 1.5e-5 +openrouter/x-ai/grok-4: + input_cost_per_token: 3e-6 + output_cost_per_token: 1.5e-5 +openrouter/google/gemini-2.5-flash-lite: + input_cost_per_token: 1e-7 + output_cost_per_token: 4e-7 +openrouter/google/gemini-2.5-pro: + input_cost_per_token: 1.25e-6 + output_cost_per_token: 1e-5 +openrouter/google/gemini-2.5-flash: + input_cost_per_token: 3e-7 + output_cost_per_token: 2.5e-6 +vertex_ai/gemini-2.5-pro: + input_cost_per_token: 1.25e-6 + output_cost_per_token: 1e-5 +vertex_ai/gemini-2.5-flash: + input_cost_per_token: 3e-7 + output_cost_per_token: 2.5e-6 +vertex_ai/gemini-2.5-flash-lite: + input_cost_per_token: 1e-7 + output_cost_per_token: 4e-7 diff --git a/kaggle_environments/envs/werewolf/harness/test_base.py b/kaggle_environments/envs/werewolf/harness/test_base.py new file mode 100644 index 00000000..3d50a58a --- /dev/null +++ b/kaggle_environments/envs/werewolf/harness/test_base.py @@ -0,0 +1,38 @@ +import json +import os + +import litellm +import pytest +from dotenv import load_dotenv + +load_dotenv() + + +@pytest.mark.skip('Require the key to run test.') +def test_vertex_ai(): + model = "vertex_ai/deepseek-ai/deepseek-r1-0528-maas" + file_path = os.environ["GOOGLE_APPLICATION_CREDENTIALS"] + with open(file_path, 'r') as file: + vertex_credentials = json.load(file) + + vertex_credentials_json = json.dumps(vertex_credentials) + + response = litellm.completion( + model=model, + messages=[{"role": "user", "content": "hi"}], + temperature=0.7, + vertex_ai_project=os.environ["VERTEXAI_PROJECT"], + vertex_ai_location=os.environ["VERTEXAI_LOCATION"], + vertex_credentials=vertex_credentials_json + ) + print(response) + + +@pytest.mark.skip('Require the key to run test.') +def test_together(): + model = "together_ai/deepseek-ai/DeepSeek-R1" + response = litellm.completion( + model=model, + messages=[{"role": "user", "content": "hi"}] + ) + print(response) diff --git a/kaggle_environments/envs/werewolf/runner.py b/kaggle_environments/envs/werewolf/runner.py new file mode 100644 index 00000000..ee20478a --- /dev/null +++ b/kaggle_environments/envs/werewolf/runner.py @@ -0,0 +1,148 @@ +import logging +import os +import random +import subprocess +import time +from datetime import datetime + +from kaggle_environments import make, PROJECT_ROOT + +logger = logging.getLogger(__name__) + + +class LogExecutionTime: + """ + A context manager to log the execution time of a code block. + The elapsed time is stored in the `elapsed_time` attribute. + + Example: + logger = logging.getLogger(__name__) + with LogExecutionTime(logger, "My Task") as timer: + # Code to be timed + time.sleep(1) + print(f"Task took {timer.elapsed_time:.2f} seconds.") + print(f"Formatted time: {timer.elapsed_time_formatted()}") + """ + def __init__(self, logger_obj: logging.Logger, task_str: str): + """ + Initializes the context manager. + + Args: + logger_obj: The logger instance to use for output. + task_str: A descriptive string for the task being timed. + """ + self.logger = logger_obj + self.task_str = task_str + self.start_time = None + self.elapsed_time = 0.0 + + def __enter__(self): + """Records the start time when entering the context.""" + self.start_time = time.time() + self.logger.info(f"Starting: {self.task_str}") + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Calculates and logs the elapsed time upon exiting the context.""" + end_time = time.time() + self.elapsed_time = end_time - self.start_time + self.logger.info(f"Finished: {self.task_str} in {self.elapsed_time_formatted()}.") + + def elapsed_time_formatted(self) -> str: + """Returns the elapsed time as a formatted string (HH:MM:SS).""" + return time.strftime("%H:%M:%S", time.gmtime(self.elapsed_time)) + + +def append_timestamp_to_dir(dir_path, append=True): + if not append: return dir_path + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + out = dir_path + f"_{timestamp}" + return out + + +def shuffle_roles_inplace(config): + agents = config['agents'] + roles = [agent['role'] for agent in agents] + random.shuffle(roles) + for new_role, agent in zip(roles, agents): + agent['role'] = new_role + + +def run_werewolf(output_dir, base_name, config, agents, debug): + """ + Runs a game of Werewolf, saves the replay, and logs the execution time. + + Args: + output_dir (str): The directory where the output files will be saved. + base_name (str): The base name for the output files (HTML, JSON). + config (dict): The configuration for the Werewolf environment. + agents (list): A list of agents to participate in the game. + debug (bool): A flag to enable or disable debug mode. + """ + start_time = time.time() + logger.info(f"Results saved to {output_dir}.") + os.makedirs(output_dir, exist_ok=True) + html_file = os.path.join(output_dir, f"{base_name}.html") + json_file = os.path.join(output_dir, f"{base_name}.json") + + with LogExecutionTime(logger_obj=logger, task_str="env run") as timer: + env = make( + 'werewolf', + debug=debug, + configuration=config + ) + env.run(agents) + + env.info['total_run_time'] = timer.elapsed_time + env.info['total_run_time_formatted'] = timer.elapsed_time_formatted() + + logger.info("Game finished") + env_out = env.render(mode='html') + with open(html_file, 'w') as out: + out.write(env_out) + logger.info(f"HTML replay written to {html_file}") + env_out = env.render(mode='json') + with open(json_file, 'w') as out: + out.write(env_out) + logger.info(f"JSON replay written to {json_file}") + end_time = time.time() + elapsed_time = end_time - start_time + formatted_time = time.strftime("%H:%M:%S", time.gmtime(elapsed_time)) + logger.info(f"Script finished in {formatted_time}.") + return env + + +def setup_logger(output_dir, base_name): + """ + Sets up a logger to output to both the console and a log file. + + Args: + output_dir (str): The directory where the log file will be saved. + base_name (str): The base name for the log file. + """ + log_file = os.path.join(output_dir, f"{base_name}.log") + os.makedirs(output_dir, exist_ok=True) + handlers = [logging.StreamHandler(), logging.FileHandler(log_file, mode='w')] + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=handlers, + ) + + +def log_git_hash(): + try: + result = subprocess.run( + ['git', 'rev-parse', 'HEAD'], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False # Don't raise exception on non-zero exit code + ) + if result.returncode == 0: + git_hash = result.stdout.strip() + logger.info(f"Running from git commit: {git_hash}") + else: + logger.info("Not a git repository or git command failed.") + except: + logger.info("Git command not found.") diff --git a/kaggle_environments/envs/werewolf/scripts/__init__.py b/kaggle_environments/envs/werewolf/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kaggle_environments/envs/werewolf/scripts/configs/audio/standard.yaml b/kaggle_environments/envs/werewolf/scripts/configs/audio/standard.yaml new file mode 100644 index 00000000..36c6ce3e --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/audio/standard.yaml @@ -0,0 +1,100 @@ +# Settings for the dump_audio.py script +script_settings: + server: + port: 7999 + paths: + audio_dir_name: "audio" + debug_audio_dir_name: "debug_audio" + output_html_filename: "replay.html" + voices: + moderator: "enceladus" + players: + gemini-2.5-flash: 'Kore' + deepseek-r1: 'Charon' + gpt-oss-120b: 'Leda' + qwen3: 'Despina' + "gpt-4.1": 'Erinome' + "o4-mini": 'Gacrux' + "gemini-2.5-pro": 'Achird' + "grok-4": 'Puck' + audio: + static_moderator_messages: + night_begins: "(rate=\"fast\", volume=\"soft\", voice=\"mysterious\")[As darkness descends, the village falls silent.](rate=\"medium\", pitch=\"-2st\")[Everyone, close your eyes.]" + day_begins: "(rate=\"fast\", volume=\"loud\")[Wake up, villagers!] (rate=\"medium\", voice=\"neutral\")[The sun rises on a new day.] (break=\"50ms\") (rate=\"medium\", voice=\"somber\")[Let's see who survived the night.]" + discussion_begins: "(voice=\"authoritative\")[The town meeting now begins.] (voice=\"neutral\")[You have a few minutes to discuss and find the werewolves among you.] (voice=\"authoritative\")[Begin.]" + voting_begins: "(rate=\"slow\", voice=\"serious\")[The time for talk is over.] (break=\"50ms\") (rate=\"medium\", volume=\"loud\", voice=\"dramatic\")[Now, you must cast your votes!]" + +# Configuration for the Werewolf game environment +game_config: + actTimeout: 300 + runTimeout: 3600 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 1 + agents: + - role: "Werewolf" + id: "gemini-2.5-flash" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm_harness/gemini/gemini-2.5-flash" + display_name: "gemini/gemini-2.5-flash" + agent_harness_name: "llm_harness" + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "deepseek-r1" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm_harness/together_ai/deepseek-ai/DeepSeek-R1" + display_name: "together_ai/deepseek-ai/DeepSeek-R1" + agent_harness_name: "llm_harness" + llms: + - model_name: "together_ai/deepseek-ai/DeepSeek-R1" + parameters: { "max_tokens": 163839 } + - role: "Doctor" + id: "gpt-oss-120b" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm_harness/together_ai/openai/gpt-oss-120b" + display_name: "together_ai/openai/gpt-oss-120b" + agent_harness_name: "llm_harness" + llms: + - model_name: "together_ai/openai/gpt-oss-120b" + - role: "Seer" + id: "qwen3" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm_harness/together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + display_name: "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + agent_harness_name: "llm_harness" + llms: + - model_name: "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + - role: "Villager" + id: "gpt-4.1" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm_harness/gpt-4.1" + display_name: "gpt-4.1" + agent_harness_name: "llm_harness" + llms: + - model_name: "gpt-4.1" + - role: "Villager" + id: "o4-mini" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm_harness/o4-mini" + display_name: "o4-mini" + agent_harness_name: "llm_harness" + llms: + - model_name: "o4-mini" + - role: "Villager" + id: "gemini-2.5-pro" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm_harness/gemini/gemini-2.5-pro" + display_name: "gemini/gemini-2.5-pro" + agent_harness_name: "llm_harness" + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "grok-4" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/grok-logo-png_seeklogo-613403.png" + agent_id: "llm_harness/xai/grok-4-latest" + display_name: "xai/grok-4-latest" + agent_harness_name: "llm_harness" + llms: + - model_name: "xai/grok-4-latest" \ No newline at end of file diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/block_basic.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/block_basic.yaml new file mode 100644 index 00000000..e98b3d00 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/block_basic.yaml @@ -0,0 +1,102 @@ +# Settings for the dump_audio.py script +script_settings: + server: + port: 7999 + paths: + audio_dir_name: "audio" + debug_audio_dir_name: "debug_audio" + output_html_filename: "replay.html" + voices: + moderator: "enceladus" + players: + gemini-2.5-flash: 'Kore' + deepseek-r1: 'Charon' + gpt-oss-120b: 'Leda' + qwen3: 'Despina' + "gpt-4.1": 'Erinome' + "o4-mini": 'Gacrux' + "gemini-2.5-pro": 'Achird' + "grok-4": 'Puck' + audio: + static_moderator_messages: + night_begins: "(rate=\"fast\", volume=\"soft\", voice=\"mysterious\")[As darkness descends, the village falls silent.](rate=\"medium\", pitch=\"-2st\")[Everyone, close your eyes.]" + day_begins: "(rate=\"fast\", volume=\"loud\")[Wake up, villagers!] (rate=\"medium\", voice=\"neutral\")[The sun rises on a new day.] (break=\"50ms\") (rate=\"medium\", voice=\"somber\")[Let's see who survived the night.]" + discussion_begins: "(voice=\"authoritative\")[The town meeting now begins.] (voice=\"neutral\")[You have a few minutes to discuss and find the werewolves among you.] (voice=\"authoritative\")[Begin.]" + voting_begins: "(rate=\"slow\", voice=\"serious\")[The time for talk is over.] (break=\"50ms\") (rate=\"medium\", volume=\"loud\", voice=\"dramatic\")[Now, you must cast your votes!]" + +# Configuration for the Werewolf game environment +game_config: + actTimeout: 300 + runTimeout: 3600 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 1 + agents: + - role: "Werewolf" + id: "gemini-2.5-pro" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini/gemini-2.5-pro" + agent_harness_name: "llm_harness" + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Werewolf" + id: "deepseek-r1" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/together_ai/deepseek-ai/DeepSeek-R1" + display_name: "together_ai/deepseek-ai/DeepSeek-R1" + agent_harness_name: "llm_harness" + llms: + - model_name: "together_ai/deepseek-ai/DeepSeek-R1" + parameters: { "max_tokens": 163839 } + - role: "Doctor" + id: "gpt-5" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/gpt-5" + display_name: "gpt-5" + agent_harness_name: "llm_harness" + llms: + - model_name: "gpt-5" + - role: "Seer" + id: "qwen3" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + display_name: "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + agent_harness_name: "llm_harness" + llms: + - model_name: "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + - role: "Villager" + id: "claude-4-sonnet" + thumbnail: "https://images.seeklogo.com/logo-png/55/1/claude-logo-png_seeklogo-554534.png" + agent_id: "llm/claude-4-sonnet-20250514" + display_name: "claude-4-sonnet-20250514" + agent_harness_name: "llm_harness" + llms: + - model_name: "claude-4-sonnet-20250514" + - role: "Villager" + id: "zai-glm-4.5-air" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/together_ai/zai-org/GLM-4.5-Air-FP8" + display_name: "zai-glm-4.5-air" + agent_harness_name: "llm_harness" + llms: + - model_name: "together_ai/zai-org/GLM-4.5-Air-FP8" + parameters: { "max_tokens": 100000 } + - role: "Villager" + id: "kimi-k2" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/kimi-logo-png_seeklogo-611650.png" + agent_id: "llm/together_ai/moonshotai/Kimi-K2-Instruct" + display_name: "kimi-k2" + agent_harness_name: "llm_harness" + llms: + - model_name: "together_ai/moonshotai/Kimi-K2-Instruct" + parameters: { "max_tokens": 100000 } + - role: "Villager" + id: "grok-4" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/grok-logo-png_seeklogo-613403.png" + agent_id: "llm/xai/grok-4-latest" + display_name: "xai/grok-4-latest" + agent_harness_name: "llm_harness" + llms: + - model_name: "xai/grok-4-latest" \ No newline at end of file diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/comprehensive.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/comprehensive.yaml new file mode 100644 index 00000000..942368a1 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/comprehensive.yaml @@ -0,0 +1,100 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 300 + runTimeout: 3600 + discussion_protocol: + name: "TurnByTurnBiddingDiscussion" + params: + max_turns: 16 + bid_result_public: false + day_voting_protocol: + name: "SequentialVoting" + werewolf_night_vote_protocol: + name: "SequentialVoting" + night_elimination_reveal_level: no_reveal + day_exile_reveal_level: no_reveal + agents: + - role: "Werewolf" + id: "gemini-2.5-flash" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm_harness/gemini/gemini-2.5-flash" + display_name: "gemini/gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "deepseek-r1" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm_harness/together_ai/deepseek-ai/DeepSeek-R1" + display_name: "together_ai/deepseek-ai/DeepSeek-R1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "together_ai/deepseek-ai/DeepSeek-R1" + parameters: { "max_tokens": 163839 } + - role: "Doctor" + role_params: + allow_self_save: true + id: "gpt-oss-120b" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm_harness/together_ai/openai/gpt-oss-120b" + display_name: "together_ai/openai/gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "together_ai/openai/gpt-oss-120b" + - role: "Seer" + id: "qwen3" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm_harness/together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + display_name: "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + - role: "Villager" + id: "gpt-4.1" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm_harness/gpt-4.1" + display_name: "gpt-4.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gpt-4.1" + - role: "Villager" + id: "o4-mini" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm_harness/o4-mini" + display_name: "o4-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "o4-mini" + - role: "Villager" + id: "gemini-2.5-pro" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm_harness/gemini/gemini-2.5-pro" + display_name: "gemini/gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "grok-4" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/grok-logo-png_seeklogo-613403.png" + agent_id: "llm_harness/xai/grok-4-latest" + display_name: "xai/grok-4-latest" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "xai/grok-4-latest" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/roundrobin_discussion_large.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/roundrobin_discussion_large.yaml new file mode 100644 index 00000000..e6d80348 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/roundrobin_discussion_large.yaml @@ -0,0 +1,103 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: true + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-5" + display_name: "gpt-5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-5" + - role: "Seer" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4.1" + display_name: "gpt-4.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4.1" + - role: "Villager" + id: "Quinn" + thumbnail: "https://images.seeklogo.com/logo-png/55/1/claude-logo-png_seeklogo-554534.png" + agent_id: "llm/openrouter/anthropic/claude-sonnet-4" + display_name: "claude-sonnet-4" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/claude-sonnet-4" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/grok-logo-png_seeklogo-613403.png" + agent_id: "llm/openrouter/x-ai/grok-4" + display_name: "grok-4" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/x-ai/grok-4" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/roundrobin_discussion_small.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/roundrobin_discussion_small.yaml new file mode 100644 index 00000000..c17cdec1 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/roundrobin_discussion_small.yaml @@ -0,0 +1,103 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: true + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard.yaml new file mode 100644 index 00000000..c17cdec1 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard.yaml @@ -0,0 +1,103 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: true + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_DisableDoctorConsecutiveSave.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_DisableDoctorConsecutiveSave.yaml new file mode 100644 index 00000000..254b9933 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_DisableDoctorConsecutiveSave.yaml @@ -0,0 +1,104 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: false + allow_consecutive_saves: false + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam.yaml new file mode 100644 index 00000000..7d16822b --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam.yaml @@ -0,0 +1,105 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: false + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + role_params: + reveal_level: "team" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam_NightEliminationNoReveal_DayExileNoReveal.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam_NightEliminationNoReveal_DayExileNoReveal.yaml new file mode 100644 index 00000000..4d181427 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam_NightEliminationNoReveal_DayExileNoReveal.yaml @@ -0,0 +1,105 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: no_reveal + day_exile_reveal_level: no_reveal + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: false + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + role_params: + reveal_level: "team" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam_NightEliminationRevealTeam_DayExileRevealTeam.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam_NightEliminationRevealTeam_DayExileRevealTeam.yaml new file mode 100644 index 00000000..226a49f1 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_DisableDoctorSelfSave_SeerRevealTeam_NightEliminationRevealTeam_DayExileRevealTeam.yaml @@ -0,0 +1,105 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: team + day_exile_reveal_level: team + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: false + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + role_params: + reveal_level: "team" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_disable_doctor_self_save.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_disable_doctor_self_save.yaml new file mode 100644 index 00000000..a3c1b089 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_disable_doctor_self_save.yaml @@ -0,0 +1,103 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: false + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting.yaml new file mode 100644 index 00000000..907e03bb --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting.yaml @@ -0,0 +1,103 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SimultaneousMajority" + params: + tie_break: 'random' + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: true + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting_no_tie_exile.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting_no_tie_exile.yaml new file mode 100644 index 00000000..13cc0d47 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting_no_tie_exile.yaml @@ -0,0 +1,103 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundRobinDiscussion" + params: + max_rounds: 2 + assign_random_first_speaker: true + day_voting_protocol: + name: "SimultaneousMajority" + params: + tie_break: 'no_elected' + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: true + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting_roundbiddiscussion.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting_roundbiddiscussion.yaml new file mode 100644 index 00000000..95cf4164 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/rule_experiment/standard_parallel_voting_roundbiddiscussion.yaml @@ -0,0 +1,105 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 123 + actTimeout: 900 + runTimeout: 7200 + discussion_protocol: + name: "RoundByRoundBiddingDiscussion" + params: + bidding: + name: "SimpleBiddingProtocol" + max_rounds: 2 + bid_result_public: true + day_voting_protocol: + name: "SimultaneousMajority" + params: + tie_break: 'random' + werewolf_night_vote_protocol: + name: "SequentialVoting" + params: + assign_random_first_voter: true + night_elimination_reveal_level: role + day_exile_reveal_level: role + agents: + - role: "Werewolf" + id: "Kai" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "gemini-2.5-flash" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Jordan" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/deepseek-ai-icon-logo-png_seeklogo-611473.png" + agent_id: "llm/openrouter/deepseek/deepseek-chat-v3.1" + display_name: "deepseek-chat-v3.1" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/deepseek/deepseek-chat-v3.1" + - role: "Doctor" + role_params: + allow_self_save: true + id: "Charlie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-4o-mini" + display_name: "gpt-4o-mini" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-4o-mini" + - role: "Seer" + id: "Taylor" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-235b-a22b-2507" + display_name: "qwen3-235b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-235b-a22b-2507" + - role: "Villager" + id: "Alex" + thumbnail: "https://pbs.twimg.com/profile_images/1911947416678350848/USaKwZgh_400x400.jpg" + agent_id: "llm/openrouter/z-ai/glm-4.5" + display_name: "glm-4.5" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/z-ai/glm-4.5" + - role: "Villager" + id: "Jamie" + thumbnail: "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png" + agent_id: "llm/openrouter/openai/gpt-oss-120b" + display_name: "gpt-oss-120b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/openai/gpt-oss-120b" + - role: "Villager" + id: "Quinn" + thumbnail: "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "gemini-2.5-pro" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Villager" + id: "Casey" + thumbnail: "https://images.seeklogo.com/logo-png/61/1/qwen-icon-logo-png_seeklogo-611724.png" + agent_id: "llm/openrouter/qwen/qwen3-30b-a3b" + display_name: "qwen3-30b" + agent_harness_name: "llm_harness" + chat_mode: "text" + enable_bid_reasoning: false + llms: + - model_name: "openrouter/qwen/qwen3-30b-a3b" diff --git a/kaggle_environments/envs/werewolf/scripts/configs/run/run_config.yaml b/kaggle_environments/envs/werewolf/scripts/configs/run/run_config.yaml new file mode 100644 index 00000000..0f306f55 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/configs/run/run_config.yaml @@ -0,0 +1,58 @@ +# Configuration for the Werewolf game environment +game_config: + seed: 42 + actTimeout: 300 + runTimeout: 3600 + discussion_protocol: + name: "TurnByTurnBiddingDiscussion" + params: + max_turns: 16 + day_voting_protocol: + name: "SequentialVoting" + werewolf_night_vote_protocol: + name: "SequentialVoting" + night_elimination_reveal_level: no_reveal + day_exile_reveal_level: no_reveal + agents: + - role: "Werewolf" + id: "Player1" + agent_id: "llm/gemini/gemini-2.5-flash" + display_name: "Player1 (Flash)" + agent_harness_name: "llm_harness" + chat_mode: "text" + llms: + - model_name: "gemini/gemini-2.5-flash" + - role: "Werewolf" + id: "Player2" + agent_id: "llm/gemini/gemini-2.5-pro" + display_name: "Player2 (Pro)" + agent_harness_name: "llm_harness" + chat_mode: "text" + llms: + - model_name: "gemini/gemini-2.5-pro" + - role: "Doctor" + role_params: + allow_self_save: true + id: "Player3" + agent_id: "random" + display_name: "Player3 (Random)" + - role: "Seer" + id: "Player4" + agent_id: "random" + display_name: "Player4 (Random)" + - role: "Villager" + id: "Player5" + agent_id: "random" + display_name: "Player5 (Random)" + - role: "Villager" + id: "Player6" + agent_id: "random" + display_name: "Player6 (Random)" + - role: "Villager" + id: "Player7" + agent_id: "random" + display_name: "Player7 (Random)" + - role: "Villager" + id: "Player8" + agent_id: "random" + display_name: "Player8 (Random)" diff --git a/kaggle_environments/envs/werewolf/scripts/dump_audio.py b/kaggle_environments/envs/werewolf/scripts/dump_audio.py new file mode 100644 index 00000000..3486aea2 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/dump_audio.py @@ -0,0 +1,298 @@ +import argparse +import hashlib +import http.server +import json +import os +import shutil +import socketserver +import wave + +import yaml +from dotenv import load_dotenv +from google import genai +from google.genai import types + +from kaggle_environments.envs.werewolf.runner import run_werewolf, setup_logger, shuffle_roles_inplace + + +def load_config(config_path): + """Loads the configuration from a YAML file.""" + with open(config_path, 'r') as f: + return yaml.safe_load(f) + + +def wave_file(filename, pcm, channels=1, rate=24000, sample_width=2): + """Saves PCM audio data to a WAV file.""" + with wave.open(filename, "wb") as wf: + wf.setnchannels(channels) + wf.setsampwidth(sample_width) + wf.setframerate(rate) + wf.writeframes(pcm) + + +def get_tts_audio(client, text: str, voice_name: str) -> bytes | None: + """Fetches TTS audio from Gemini API.""" + if not text or not client: + return None + try: + response = client.models.generate_content( + model="gemini-2.5-flash-preview-tts", + contents=text, + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_name) + ) + ), + ) + ) + return response.candidates[0].content.parts[0].inline_data.data + except Exception as e: + print(f" - Error generating audio for '{text[:30]}...': {e}") + return None + + +def copy_assets(output_dir): + """Copies the assets directory for 3D model rendering.""" + source_assets_dir = "assets" + dest_assets_dir = os.path.join(output_dir, "assets") + if os.path.exists(source_assets_dir): + if os.path.exists(dest_assets_dir): + shutil.rmtree(dest_assets_dir) + shutil.copytree(source_assets_dir, dest_assets_dir) + print(f"Copied '{source_assets_dir}' to '{dest_assets_dir}' for 3D rendering.") + + +def setup_environment(game_config, script_config): + """Sets up the Werewolf game environment and agent configurations.""" + print("1. Setting up Werewolf environment...") + + player_voices = script_config['voices']['players'] + + for agent_config in game_config['agents']: + agent_id = agent_config['id'] + agent_config['voice'] = player_voices.get(agent_id) + player_voice_map = {agent["id"]: agent.get("voice") for agent in game_config['agents']} + return player_voice_map + + +def extract_game_data(env): + """Extracts dialogue and events from the game log.""" + unique_speaker_messages = set() + dynamic_moderator_messages = set() + moderator_log_steps = env.info.get("MODERATOR_OBSERVATION", []) + + for step_log in moderator_log_steps: + for data_entry in step_log: + json_str = data_entry.get('json_str') + if not json_str: continue + try: + history_event = json.loads(json_str) + data = history_event.get('data', {}) + data_type = data_entry.get("data_type") + + if data_type == "ChatDataEntry": + if data.get("actor_id") and data.get("message"): + unique_speaker_messages.add((data["actor_id"], data["message"])) + elif data_type == "DayExileElectedDataEntry": + dynamic_moderator_messages.add( + f"Player {data['elected_player_id']} was exiled by vote. Their role was a {data['elected_player_role_name']}.") + elif data_type == "WerewolfNightEliminationDataEntry": + dynamic_moderator_messages.add( + f"Player {data['eliminated_player_id']} was eliminated. Their role was a {data['eliminated_player_role_name']}.") + elif data_type == "DoctorSaveDataEntry": + dynamic_moderator_messages.add( + f"Player {data['saved_player_id']} was attacked but saved by a Doctor!") + elif data_type == "GameEndResultsDataEntry": + dynamic_moderator_messages.add(f"The game is over. The {data['winner_team']} team has won!") + elif data_type == "WerewolfNightEliminationElectedDataEntry": + dynamic_moderator_messages.add( + f"The werewolves have chosen to eliminate player {data['elected_target_player_id']}.") + except json.JSONDecodeError: + print(f" - Warning: Could not decode JSON: {json_str}") + + return unique_speaker_messages, dynamic_moderator_messages + + +def generate_audio_files(client, unique_speaker_messages, dynamic_moderator_messages, player_voice_map, script_config): + """Generates and saves all required audio files, returning a map for the HTML.""" + print("3. Extracting dialogue and generating audio files...") + audio_map = {} + paths = script_config['paths'] + audio_dir = os.path.join(paths['output_dir'], paths['audio_dir_name']) + moderator_voice = script_config['voices']['moderator'] + static_moderator_messages = script_config['audio']['static_moderator_messages'] + + messages_to_generate = [] + for key, message in static_moderator_messages.items(): + messages_to_generate.append(("moderator", key, message, moderator_voice)) + for message in dynamic_moderator_messages: + messages_to_generate.append(("moderator", message, message, moderator_voice)) + for speaker_id, message in unique_speaker_messages: + voice = player_voice_map.get(speaker_id) + if voice: + messages_to_generate.append((speaker_id, message, message, voice)) + else: + print(f" - Warning: No voice found for speaker: {speaker_id}") + + for speaker, key, message, voice in messages_to_generate: + map_key = f"{speaker}:{key}" + filename = hashlib.md5(map_key.encode()).hexdigest() + ".wav" + audio_path_on_disk = os.path.join(audio_dir, filename) + audio_path_for_html = os.path.join(paths['audio_dir_name'], filename) + + if not os.path.exists(audio_path_on_disk): + print(f" - Generating audio for {speaker} ({voice}): \"{message[:40]}...\" ") + audio_content = get_tts_audio(client, message, voice_name=voice) + if audio_content: + wave_file(audio_path_on_disk, audio_content) + audio_map[map_key] = audio_path_for_html + else: + audio_map[map_key] = audio_path_for_html + + return audio_map + + +def generate_debug_audio_files(output_dir, client, unique_speaker_messages, dynamic_moderator_messages, script_config): + """Generates a single debug audio file and maps all events to it.""" + print("3. Generating single debug audio for UI testing...") + paths = script_config['paths'] + debug_audio_dir = os.path.join(output_dir, paths['debug_audio_dir_name']) + os.makedirs(debug_audio_dir, exist_ok=True) + audio_map = {} + + debug_message = "Testing start, testing end." + debug_voice = "achird" + + filename = "debug_audio.wav" + audio_path_on_disk = os.path.join(debug_audio_dir, filename) + audio_path_for_html = os.path.join(paths['debug_audio_dir_name'], filename) + + if not os.path.exists(audio_path_on_disk): + print(f" - Generating debug audio: \"{debug_message}\"") + audio_content = get_tts_audio(client, debug_message, voice_name=debug_voice) + if audio_content: + wave_file(audio_path_on_disk, audio_content) + else: + print(" - Failed to generate debug audio. The map will be empty.") + return {} + else: + print(f" - Using existing debug audio file: {audio_path_on_disk}") + + static_moderator_messages = script_config['audio']['static_moderator_messages'] + + messages_to_map = [] + for key in static_moderator_messages: + messages_to_map.append(("moderator", key)) + for message in dynamic_moderator_messages: + messages_to_map.append(("moderator", message)) + for speaker_id, message in unique_speaker_messages: + messages_to_map.append((speaker_id, message)) + + for speaker, key in messages_to_map: + map_key = f"{speaker}:{key}" + audio_map[map_key] = audio_path_for_html + + print(f" - Mapped all {len(audio_map)} audio events to '{audio_path_for_html}'") + return audio_map + + +def render_html(env, audio_map, output_file): + """Renders the game to HTML and injects the audio map.""" + print("4. Rendering the game to an HTML file...") + html_content = env.render(mode="html") + + print("5. Injecting the local audio map into the HTML...") + audio_map_json = json.dumps(audio_map) + injection_script = f"" + html_content = html_content.replace('', f'{injection_script}') + + with open(output_file, "w", encoding="utf-8") as f: + f.write(html_content) + + +def start_server(directory, port, filename): + """Starts a local HTTP server to serve the replay.""" + print(f"\n6. Starting local server to serve from the '{directory}' directory...") + + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=directory, **kwargs) + + with socketserver.TCPServer(('', port), Handler) as httpd: + print(f"\nServing replay at: http://localhost:{port}/{filename}") + print("Open this URL in your web browser.") + print(f"Or you can zip the '{directory}' directory and share it.") + print("Press Ctrl+C to stop the server.") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nServer stopped.") + + +def main(output_dir, config, debug_audio, use_random_agents, shuffle_roles): + """Main function to generate and serve the Werewolf replay.""" + script_config = config['script_settings'] + game_config = config['game_config'] + if shuffle_roles: + shuffle_roles_inplace(game_config) + + paths = script_config['paths'] + audio_dir = os.path.join(output_dir, paths['audio_dir_name']) + output_html_file = os.path.join(output_dir, paths['output_html_filename']) + + os.makedirs(audio_dir, exist_ok=True) + copy_assets(output_dir) + + player_voice_map = setup_environment(game_config, script_config) + + agents = [agent['agent_id'] for agent in game_config['agents']] + if use_random_agents: + agents = ['random'] * len(agents) + + load_dotenv() + GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + if not GEMINI_API_KEY: + print("Error: GEMINI_API_KEY not found. Audio generation requires it.") + print("Run with --no-audio to generate a replay without sound.") + return + client = genai.Client() + + env = run_werewolf(output_dir=output_dir, base_name="replay", config=game_config, agents=agents, debug=True) + + unique_speaker_messages, dynamic_moderator_messages = extract_game_data(env) + if debug_audio: + audio_map = generate_debug_audio_files(output_dir, client, unique_speaker_messages, dynamic_moderator_messages, + script_config) + else: + audio_map = generate_audio_files( + client, unique_speaker_messages, dynamic_moderator_messages, player_voice_map, script_config + ) + + render_html(env, audio_map, output_html_file) + start_server(output_dir, script_config['server']['port'], paths['output_html_filename']) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description="Generate a Werewolf game replay.") + parser.add_argument("-o", "--output_dir", type=str, help="output directory", default="werewolf_replay") + parser.add_argument("-n", "--base_name", type=str, help="the base file name of .html, .json and .log", + default="out") + parser.add_argument("-c", '--config', type=str, + default='kaggle_environments/envs/werewolf/scripts/configs/audio/standard.yaml', + help="Path to the configuration YAML file.") + parser.add_argument('--debug-audio', action='store_true', + help="Generate a single debug audio file for UI testing.") + parser.add_argument("-r", "--use_random_agents", action="store_true", + help='Use random agent for fast testing.') + parser.add_argument('-s', "--shuffle_roles", action='store_true', + help="shuffle the roles of the agents defined in config.") + + args = parser.parse_args() + + setup_logger(output_dir=args.output_dir, base_name=args.base_name) + + config = load_config(args.config) + main(output_dir=args.output_dir, config=config, debug_audio=args.debug_audio, + use_random_agents=args.use_random_agents, shuffle_roles=args.shuffle_roles) diff --git a/kaggle_environments/envs/werewolf/scripts/measure_cost.py b/kaggle_environments/envs/werewolf/scripts/measure_cost.py new file mode 100644 index 00000000..02eb57bf --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/measure_cost.py @@ -0,0 +1,239 @@ + +import argparse +import json +import logging +import os +import random +import yaml +import numpy as np +import matplotlib.pyplot as plt +from datetime import datetime + +from kaggle_environments.envs.werewolf.runner import run_werewolf, setup_logger +from kaggle_environments.envs.werewolf.werewolf import LLM_MODEL_NAMES, CostSummary + +logger = logging.getLogger(__name__) + +AGENT_NAMES = ["Alex", "Jordan", "Taylor", "Casey", "Riley", "Jamie", "Morgan", "Skyler"] +DEFAULT_MODEL = "gemini/gemini-2.5-flash" + + +def setup_game_config(max_turns: int, base_config: dict, model_name: str): + """ + Sets up the game configuration for a single run. + """ + config = base_config.copy() + + # Define roles and shuffle them + roles = ["Werewolf", "Werewolf", "Doctor", "Seer", "Villager", "Villager", "Villager", "Villager"] + random.shuffle(roles) + random.shuffle(AGENT_NAMES) + + # Create agent configurations + agents_config = [] + for i, role in enumerate(roles): + player_name = AGENT_NAMES[i] + agents_config.append({ + "role": role, + "id": player_name, + "agent_id": f"llm/{model_name}", + "display_name": f"{model_name}/{player_name}", + "agent_harness_name": "llm_harness", + "chat_mode": "text", + "llms": [{"model_name": model_name}] + }) + + config['agents'] = agents_config + + # Update discussion protocol with the specified max_turns + if 'discussion_protocol' in config and config['discussion_protocol']['name'] == 'TurnByTurnBiddingDiscussion': + config['discussion_protocol']['params']['max_turns'] = max_turns + else: + logger.warning("Could not find 'TurnByTurnBiddingDiscussion' protocol to set max_turns.") + + # Set a new random seed for each game to ensure role/name shuffling is different + config['seed'] = random.randint(0, 2**32 - 1) + + agent_harnesses = [f'llm/{model_name}'] * len(roles) + + return config, agent_harnesses + + +def plot_results(summary_data, output_dir): + """ + Plots the results and saves them to files. + """ + max_turns = sorted([int(k) for k in summary_data.keys()]) + metrics = ['total_cost', 'total_tokens', 'total_prompt_tokens', 'total_completion_tokens'] + + for metric in metrics: + means = [summary_data[str(t)][metric]['mean'] for t in max_turns] + stds = [summary_data[str(t)][metric]['std'] for t in max_turns] + + plt.figure(figsize=(10, 6)) + plt.errorbar(max_turns, means, yerr=stds, fmt='-o', capsize=5, ecolor='red', markeredgecolor='black') + plt.xlabel("Maximum Turns in Discussion") + plt.ylabel(metric.replace('_', ' ').title()) + plt.title(f"{metric.replace('_', ' ').title()} vs. Maximum Turns") + plt.grid(True, which='both', linestyle='--', linewidth=0.5) + plt.xticks(max_turns) + + plot_filename = os.path.join(output_dir, f"{metric}_vs_max_turns.png") + plt.savefig(plot_filename) + plt.close() + logger.info(f"Saved plot: {plot_filename}") + + +def plot_token_trajectories(trajectories_data, output_dir): + """ + Plots token usage trajectories, grouped by max_turns, and saves them to files. + """ + for metric, trajectories_by_turns in trajectories_data.items(): + if not trajectories_by_turns: + continue + + plt.figure(figsize=(12, 8)) + + # Create a color map for the different turn settings + turn_keys = sorted(trajectories_by_turns.keys(), key=int) + colors = plt.cm.viridis(np.linspace(0, 1, len(turn_keys))) + color_map = {turns: color for turns, color in zip(turn_keys, colors)} + + for turns, trajectories in sorted(trajectories_by_turns.items(), key=lambda item: int(item[0])): + for i, traj in enumerate(trajectories): + # Only add a label to the first trajectory of each group for a clean legend + label = f'Max Turns: {turns}' if i == 0 else None + plt.plot(np.arange(len(traj)), traj, linestyle='-', alpha=0.4, color=color_map[turns], label=label) + + plt.title(f'{metric.replace("_", " ").title()} per Query Step Trajectories') + plt.xlabel("Query Step") + plt.ylabel(f'{metric.replace("_", " ").title()} per Query Step') + plt.grid(True, which='both', linestyle='--', linewidth=0.5) + plt.legend() + + plot_filename = os.path.join(output_dir, f"{metric}_trajectories.png") + plt.savefig(plot_filename) + plt.close() + logger.info(f"Saved trajectory plot: {plot_filename}") + + +def main(): + parser = argparse.ArgumentParser(description="Measure LLM cost for the Werewolf game.") + parser.add_argument( + "-c", "--config_path", type=str, + default=os.path.join(os.path.dirname(__file__), "configs/run/comprehensive.yaml"), + help="Path to the base YAML configuration file." + ) + parser.add_argument("-o", "--output_dir", type=str, default="cost_measurement", help="Output directory for logs, replays, and results.") + parser.add_argument( + "-m", "--model_name", type=str, default=DEFAULT_MODEL, + choices=LLM_MODEL_NAMES, help="LiteLLM model name to use for all agents." + ) + parser.add_argument("-d", "--disable_debug_mode", action="store_true", help='Disable debug mode.') + + args = parser.parse_args() + + # Create a unique subdirectory for this run + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + run_output_dir = os.path.join(args.output_dir, f"run_{timestamp}") + os.makedirs(run_output_dir, exist_ok=True) + + log_filename = f"measure_cost_{timestamp}" + setup_logger(output_dir=run_output_dir, base_name=log_filename) + logger.info(f"Starting cost measurement script. Results will be saved in: {run_output_dir}") + + # Load base game configuration + with open(args.config_path, 'r') as f: + base_config = yaml.safe_load(f).get('game_config', {}) + + max_turns_to_test = [8, 12, 16, 20, 24] + runs_per_setting = 3 + results = {str(t): { + 'total_cost': [], 'total_tokens': [], 'total_prompt_tokens': [], 'total_completion_tokens': [] + } for t in max_turns_to_test} + all_trajectories = { + 'total_tokens': {str(t): [] for t in max_turns_to_test}, + 'reasoning_tokens': {str(t): [] for t in max_turns_to_test}, + 'text_tokens': {str(t): [] for t in max_turns_to_test} + } + + for turns in max_turns_to_test: + logger.info(f"--- Starting runs for max_turns = {turns} ---") + for run in range(runs_per_setting): + base_name = f"game_turns_{turns}_run_{run + 1}" + logger.info(f"Starting {base_name}...") + + game_config, agent_harnesses = setup_game_config(turns, base_config, args.model_name) + + try: + final_env = run_werewolf( + output_dir=run_output_dir, + base_name=base_name, + config=game_config, + agents=agent_harnesses, + debug=not args.disable_debug_mode + ) + + # Extract cost summary + cost_summary_dict = final_env.info.get('GAME_END', {}).get('cost_summary', {}) + if cost_summary_dict: + cost_summary = CostSummary(**cost_summary_dict) + results[str(turns)]['total_cost'].append(cost_summary.total_cost) + results[str(turns)]['total_tokens'].append(cost_summary.total_tokens) + results[str(turns)]['total_prompt_tokens'].append(cost_summary.total_prompt_tokens) + results[str(turns)]['total_completion_tokens'].append(cost_summary.total_completion_tokens) + logger.info(f"Finished {base_name}. Total Cost: ${cost_summary.total_cost:.4f}") + + for agent_summary in cost_summary.cost_per_agent: + if agent_summary.data and agent_summary.data.usage_history: + usage_history_dicts = [usage.model_dump() for usage in agent_summary.data.usage_history] + + total_tokens_traj = [usage.get('total_tokens', 0) or 0 for usage in usage_history_dicts] + all_trajectories['total_tokens'][str(turns)].append(total_tokens_traj) + + reasoning_tokens_traj = [ + usage.get('completion_tokens_details', {}).get('reasoning_tokens', 0) or 0 + for usage in usage_history_dicts + ] + all_trajectories['reasoning_tokens'][str(turns)].append(reasoning_tokens_traj) + + text_tokens_traj = [ + (u.get('completion_tokens', 0) or 0) - (u.get('completion_tokens_details', {}).get('reasoning_tokens', 0) or 0) + for u in usage_history_dicts + ] + all_trajectories['text_tokens'][str(turns)].append(text_tokens_traj) + else: + logger.error(f"Could not find cost summary for {base_name}.") + + except Exception as e: + logger.error(f"An error occurred during {base_name}: {e}", exc_info=True) + + # Calculate mean and standard deviation + summary_data = {} + for turns, metrics in results.items(): + summary_data[turns] = {} + for metric, values in metrics.items(): + if values: + summary_data[turns][metric] = { + 'mean': np.mean(values), + 'std': np.std(values), + 'raw_values': values + } + else: + summary_data[turns][metric] = {'mean': 0, 'std': 0, 'raw_values': []} + + # Save summary to JSON + summary_filename = os.path.join(run_output_dir, "cost_analysis_summary.json") + with open(summary_filename, 'w') as f: + json.dump(summary_data, f, indent=4) + logger.info(f"Saved summary results to {summary_filename}") + + # Plot results + plot_results(summary_data, run_output_dir) + plot_token_trajectories(all_trajectories, run_output_dir) + + logger.info("--- Cost measurement script finished ---") + + +if __name__ == "__main__": + main() diff --git a/kaggle_environments/envs/werewolf/scripts/plot_existing_trajectories.py b/kaggle_environments/envs/werewolf/scripts/plot_existing_trajectories.py new file mode 100644 index 00000000..c95bebac --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/plot_existing_trajectories.py @@ -0,0 +1,133 @@ +import argparse +import json +import logging +import os +import re +import glob +import sys +import numpy as np +import matplotlib.pyplot as plt + +# Add the project root to the Python path to allow importing from kaggle_environments +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..')) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from kaggle_environments.envs.werewolf.werewolf import CostSummary + + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +def plot_token_trajectories(trajectories_data, output_dir): + """ + Plots token usage trajectories, grouped by max_turns, and saves them to files. + """ + for metric, trajectories_by_turns in trajectories_data.items(): + if not trajectories_by_turns: + logger.warning(f"No data found for metric '{metric}'. Skipping plot.") + continue + + plt.figure(figsize=(12, 8)) + + # Create a color map for the different turn settings + turn_keys = sorted(trajectories_by_turns.keys(), key=int) + colors = plt.cm.viridis(np.linspace(0, 1, len(turn_keys))) + color_map = {turns: color for turns, color in zip(turn_keys, colors)} + + for turns, trajectories in sorted(trajectories_by_turns.items(), key=lambda item: int(item[0])): + for i, traj in enumerate(trajectories): + if not all(isinstance(x, (int, float)) for x in traj): + logger.error(f"Trajectory for metric '{metric}' (turns={turns}) contains non-numeric data. Skipping.") + continue + # Only add a label to the first trajectory of each group for a clean legend + label = f'Max Turns: {turns}' if i == 0 else None + plt.plot(np.arange(len(traj)), traj, linestyle='-', alpha=0.4, color=color_map[turns], label=label) + + plt.title(f'{metric.replace("_", " ").title()} per Query Step Trajectories') + plt.xlabel("Query Step") + plt.ylabel(f'{metric.replace("_", " ").title()} per Query Step') + plt.grid(True, which='both', linestyle='--', linewidth=0.5) + plt.legend() + + plot_filename = os.path.join(output_dir, f"{metric}_trajectories.png") + plt.savefig(plot_filename) + plt.close() + logger.info(f"Saved trajectory plot: {plot_filename}") + + +def main(): + parser = argparse.ArgumentParser( + description="Load data from a measure_cost.py output directory and generate token trajectory plots." + ) + parser.add_argument( + "-i", "--input_dir", type=str, required=True, + help="Path to the output directory of a previous measure_cost.py run." + ) + args = parser.parse_args() + + if not os.path.isdir(args.input_dir): + logger.error(f"Input directory not found: {args.input_dir}") + return + + logger.info(f"Loading data from: {args.input_dir}") + + all_trajectories = { + 'total_tokens': {}, + 'reasoning_tokens': {}, + 'text_tokens': {} + } + + # Find all game replay JSON files + game_files = glob.glob(os.path.join(args.input_dir, "game_*_run_*.json")) + if not game_files: + logger.error(f"No game replay files (game_*_run_*.json) found in {args.input_dir}.") + return + + logger.info(f"Found {len(game_files)} game replay files to process.") + + for game_file in game_files: + # Extract max_turns from filename + match = re.search(r'game_turns_(\d+)_run_', os.path.basename(game_file)) + if not match: + logger.warning(f"Could not parse max_turns from filename: {game_file}. Skipping.") + continue + turns = match.group(1) + + with open(game_file, 'r') as f: + game_data = json.load(f) + + cost_summary_dict = game_data.get('info', {}).get('GAME_END', {}).get('cost_summary') + if not cost_summary_dict: + logger.warning(f"No cost_summary found in {game_file}. Skipping.") + continue + + cost_summary = CostSummary(**cost_summary_dict) + + for agent_summary in cost_summary.cost_per_agent: + if agent_summary.data and agent_summary.data.usage_history: + usage_history_dicts = [usage.model_dump() for usage in agent_summary.data.usage_history] + + total_tokens_traj = [usage.get('total_tokens', 0) or 0 for usage in usage_history_dicts] + all_trajectories['total_tokens'].setdefault(turns, []).append(total_tokens_traj) + + reasoning_tokens_traj = [ + usage.get('completion_tokens_details', {}).get('reasoning_tokens', 0) or 0 + for usage in usage_history_dicts + ] + all_trajectories['reasoning_tokens'].setdefault(turns, []).append(reasoning_tokens_traj) + + text_tokens_traj = [ + (u.get('completion_tokens', 0) or 0) - (u.get('completion_tokens_details', {}).get('reasoning_tokens', 0) or 0) + for u in usage_history_dicts + ] + all_trajectories['text_tokens'].setdefault(turns, []).append(text_tokens_traj) + + logger.info("Finished processing all files. Generating plots...") + plot_token_trajectories(all_trajectories, args.input_dir) + logger.info(f"--- Script finished. Plots saved in {args.input_dir} ---") + + +if __name__ == "__main__": + main() diff --git a/kaggle_environments/envs/werewolf/scripts/run.py b/kaggle_environments/envs/werewolf/scripts/run.py new file mode 100644 index 00000000..51d3f5e0 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/run.py @@ -0,0 +1,92 @@ +import argparse +import logging +import os +import random + +import yaml + +from kaggle_environments.envs.werewolf.harness.base import LLMWerewolfAgent +from kaggle_environments.envs.werewolf.runner import ( + run_werewolf, setup_logger, append_timestamp_to_dir, LogExecutionTime, log_git_hash +) +from kaggle_environments.envs.werewolf.werewolf import AgentFactoryWrapper, LLM_SYSTEM_PROMPT, register_agents + +logger = logging.getLogger(__name__) + + +def main(): + parser = argparse.ArgumentParser(description="Run a single Werewolf game.") + parser.add_argument( + "-c", "--config_path", type=str, + default=os.path.join(os.path.dirname(__file__), "configs/run/run_config.yaml"), + help="Path to the YAML configuration file." + ) + parser.add_argument( + "-o", "--output_dir", type=str, + default="werewolf_run", + help="Output directory for the log and replay file." + ) + parser.add_argument("-d", "--debug", action="store_true", help='Enable debug mode.') + parser.add_argument("-r", "--random_agents", action="store_true", + help='Use random agents for all players for fast testing.') + parser.add_argument("-a", "--append_timestamp_to_dir", action="store_true", + help="Append a timestamp to the output directory.") + parser.add_argument("-s", "--shuffle_roles", action="store_true", + help="If provided, shuffle the roles provided in the config.") + + args = parser.parse_args() + + # Create a unique subdirectory for this run + run_output_dir = append_timestamp_to_dir(args.output_dir, append=args.append_timestamp_to_dir) + + os.makedirs(run_output_dir, exist_ok=True) + + base_name = "werewolf_game" + setup_logger(output_dir=run_output_dir, base_name=base_name) + + log_git_hash() + + # Load game configuration + with open(args.config_path, 'r') as f: + config = yaml.safe_load(f) + game_config = config.get('game_config', {}) + + # shuffle roles + if args.shuffle_roles: + role_and_params = [(agent['role'], agent.get('role_params', {})) for agent in game_config['agents']] + random.shuffle(role_and_params) + for agent, (new_role, new_role_params) in zip(game_config['agents'], role_and_params): + agent['role'] = new_role + agent['role_params'] = new_role_params + + # Extract agent harnesses from the config and register the agents + agents_ = [agent.get('agent_id', 'random') for agent in game_config.get('agents', [])] + agent_dict = {} + for agent_name in agents_: + if agent_name.startswith('llm/'): + model_name = agent_name.lstrip('llm/') + agent_dict[agent_name] = AgentFactoryWrapper( + LLMWerewolfAgent, + model_name=model_name, + system_prompt=LLM_SYSTEM_PROMPT + ) + register_agents(agent_dict) + + if args.random_agents: + logger.info("Using random agents for all players.") + agents_ = ['random'] * len(agents_) + + logger.info(f"Starting Werewolf game run. Output will be saved to: {run_output_dir}") + with LogExecutionTime(logger_obj=logger, task_str="single game"): + run_werewolf( + output_dir=run_output_dir, + base_name=base_name, + config=game_config, + agents=agents_, + debug=args.debug + ) + logger.info(f"Game finished. Replay and log saved in: {run_output_dir}") + + +if __name__ == "__main__": + main() diff --git a/kaggle_environments/envs/werewolf/scripts/run_block.py b/kaggle_environments/envs/werewolf/scripts/run_block.py new file mode 100644 index 00000000..20068690 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/run_block.py @@ -0,0 +1,217 @@ +import argparse +import collections +import logging +import math +import multiprocessing +import os +import random +from itertools import permutations +from typing import List, Dict, Any + +import tenacity +import yaml +from tqdm import tqdm + +from kaggle_environments.envs.werewolf.runner import setup_logger, append_timestamp_to_dir, LogExecutionTime +from kaggle_environments.envs.werewolf.scripts.utils import run_single_game_cli + +# Initialize a placeholder logger +logger = logging.getLogger(__name__) + + +def load_config(config_path): + """Loads the configuration from a YAML file.""" + with open(config_path, 'r') as f: + return yaml.safe_load(f) + + +def get_all_unique_role_configs(role_configs: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]: + """ + Generates all unique permutations of role configurations. + A role configuration is a dict with 'role' and 'role_params'. + """ + def make_hashable(config): + role = config['role'] + params = config.get('role_params', {}) + if params: + return role, frozenset(params.items()) + return role, frozenset() + + def make_unhashable(hashable_config): + role, params_frozenset = hashable_config + return {'role': role, 'role_params': dict(params_frozenset)} + + hashable_configs = [make_hashable(c) for c in role_configs] + all_perms_hashable = list(set(permutations(hashable_configs))) + all_perms = [[make_unhashable(c) for c in p] for p in all_perms_hashable] + return all_perms + + +run_single_game_with_retry = tenacity.retry( + wait=tenacity.wait_random_exponential(multiplier=1, min=2, max=10), + stop=tenacity.stop_after_attempt(3), + before_sleep=tenacity.before_sleep_log(logger, logging.INFO) +)(run_single_game_cli) + + +def game_runner_wrapper(args): + """Wrapper to unpack arguments for the multiprocessing pool.""" + game_dir, game_config, use_random_agents, debug, _, _ = args + run_single_game_with_retry(game_dir, game_config, use_random_agents, debug) + + +def generate_game_tasks(output_dir, num_blocks, config, use_random_agents, debug, shuffle_player_ids): + """ + Generates all game configurations for the entire experiment. + """ + base_game_config = config['game_config'] + players_data = base_game_config['agents'] + base_role_configs = [{'role': agent['role'], 'role_params': agent.get('role_params', {})} for agent in players_data] + + logger.info("Generating all unique role configurations...") + all_role_configs = get_all_unique_role_configs(base_role_configs) + logger.info(f"Found {len(all_role_configs)} unique arrangements.") + + available_role_configs = [] + + for block_index in range(num_blocks): + block_dir = os.path.join(output_dir, f"block_{block_index}") + os.makedirs(block_dir, exist_ok=True) + + if not available_role_configs: + if num_blocks > len(all_role_configs): + logger.warning("Sampling with replacement as num_blocks > unique configurations.") + available_role_configs = list(all_role_configs) + random.shuffle(available_role_configs) + + block_role_config = available_role_configs.pop() + random.shuffle(players_data) + current_players_deque = collections.deque(players_data) + + for game_in_block in range(len(players_data)): + game_dir = os.path.join(block_dir, f"game_{game_in_block}") + os.makedirs(game_dir, exist_ok=True) + + current_players = list(current_players_deque) + game_agents_config = [ + {**player_config, **block_role_config[i]} + for i, player_config in enumerate(current_players) + ] + + if shuffle_player_ids: + player_ids = [agent['id'] for agent in game_agents_config] + random.shuffle(player_ids) + for i, agent in enumerate(game_agents_config): + agent['id'] = player_ids[i] + + game_config = {**base_game_config, 'agents': game_agents_config} + yield (game_dir, game_config, use_random_agents, debug, block_index, game_in_block) + current_players_deque.rotate(1) + + +def run_experiment( + output_dir, num_blocks, config, use_random_agents, debug, parallel, num_processes, shuffle_player_ids): + """ + Runs a tournament by generating all game tasks and processing them, + potentially in parallel. + """ + if debug: + logger.warning("Debug mode is enabled. Forcing sequential execution.") + + base_game_config = config['game_config'] + players_data = base_game_config['agents'] + total_games = num_blocks * len(players_data) + + if parallel: + logger.info(f"Running games in parallel with up to {num_processes} processes.") + + game_tasks = generate_game_tasks( + output_dir, num_blocks, config, use_random_agents, debug, shuffle_player_ids + ) + + with tqdm(total=total_games, desc="Processing Games") as pbar: + if parallel: + with multiprocessing.Pool(processes=num_processes) as pool: + for _ in pool.imap_unordered(game_runner_wrapper, game_tasks): + pbar.update(1) + else: + for task_args in game_tasks: + game_runner_wrapper(task_args) + pbar.update(1) + + logger.info("All game tasks have been processed.") + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + default_config_path = os.path.join(script_dir, 'configs', 'run', 'run_config.yaml') + + parser = argparse.ArgumentParser( + description="Run a block-design experiment for the Werewolf game, " + "where each block is a complete role rotation amongst the players." + ) + parser.add_argument("-o", "--output_dir", type=str, help="Output directory for game replays and logs.", + default="werewolf_block_experiment") + parser.add_argument("-c", '--config', type=str, default=default_config_path, + help="Path to the base configuration YAML file.") + parser.add_argument("-b", "--num_blocks", type=int, default=10, + help="Number of blocks to run. Each block is a complete role rotation.") + parser.add_argument("-r", "--use_random_agents", action="store_true", + help='Use random agents for all players for fast testing.') + parser.add_argument("-d", "--debug", action="store_true", + help='Enable debug mode for the game environment. '\ + 'Note that you can use debug mode to enable intra game sequential execution.') + parser.add_argument("-p", "--parallel", action="store_true", + help='Run games in parallel using multiple processes.') + parser.add_argument("-n", "--num_processes", type=int, default=None, + help="Number of processes for parallel execution.") + parser.add_argument("-a", "--append_timestamp_to_dir", action="store_true", + help="Append a timestamp to the output directory.") + parser.add_argument("-s", "--shuffle_player_ids", action="store_true", + help="Shuffle player ids for each game to account for name bias.") + + args = parser.parse_args() + + output_dir = append_timestamp_to_dir(args.output_dir, append=args.append_timestamp_to_dir) + + os.makedirs(output_dir, exist_ok=True) + + setup_logger(output_dir, 'run_block') + + config = load_config(args.config) + + num_players = len(config.get('game_config', {}).get('agents', [])) + if args.num_processes is None: + num_processes = multiprocessing.cpu_count() * 0.9 + if not args.debug: + num_processes /= num_players + num_processes = max(1, math.floor(num_processes)) + else: + num_processes = args.num_processes + + logger.info("Starting experiment with the following settings:") + logger.info(f"Output Directory: {output_dir}") + logger.info(f"Number of Blocks: {args.num_blocks}") + logger.info(f"Parallel Execution: {args.parallel}") + if args.parallel: + logger.info(f"Number of Processes: {num_processes}") + logger.info(f"Debug Mode: {args.debug}") + logger.info(f"Use Random Agents: {args.use_random_agents}") + logger.info(f"Shuffle Player IDs: {args.shuffle_player_ids}") + + with LogExecutionTime(logger_obj=logger, task_str="block experiment") as timer: + run_experiment( + output_dir=output_dir, + num_blocks=args.num_blocks, + config=config, + use_random_agents=args.use_random_agents, + debug=args.debug, + parallel=args.parallel, + num_processes=num_processes, + shuffle_player_ids=args.shuffle_player_ids + ) + logger.info("Experiment finished successfully.") + + +if __name__ == '__main__': + main() diff --git a/kaggle_environments/envs/werewolf/scripts/run_pairwise_matrix.py b/kaggle_environments/envs/werewolf/scripts/run_pairwise_matrix.py new file mode 100644 index 00000000..9b2aa4a4 --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/run_pairwise_matrix.py @@ -0,0 +1,209 @@ +"""Run pairwise zero-sum setting where one player play the entire team of Werewolf and another player play +the team of Villager. Given a config, we play all possible pairwise combinations N times. +""" + +import argparse +import logging +import math +import multiprocessing +import os +import random +from typing import List +from copy import deepcopy + +import tenacity +import yaml +from tqdm import tqdm + +from kaggle_environments.envs.werewolf.runner import setup_logger, append_timestamp_to_dir, LogExecutionTime +from kaggle_environments.envs.werewolf.game.consts import RoleConst +from kaggle_environments.envs.werewolf.scripts.utils import run_single_game_cli + +# Initialize a placeholder logger +logger = logging.getLogger(__name__) + + +def load_config(config_path): + """Loads the configuration from a YAML file.""" + with open(config_path, 'r') as f: + return yaml.safe_load(f) + + +def get_team_roles(base_roles: List[str]) -> (List[str], List[str]): + """Partitions roles into villager and werewolf teams.""" + villager_roles = [] + werewolf_roles = [] + for role_name in base_roles: + role = RoleConst(role_name) + if role == RoleConst.WEREWOLF: + werewolf_roles.append(role_name) + else: + villager_roles.append(role_name) + return villager_roles, werewolf_roles + + +run_single_game_with_retry = tenacity.retry( + wait=tenacity.wait_exponential(multiplier=1, min=2, max=10), + stop=tenacity.stop_after_attempt(3), + before_sleep=tenacity.before_sleep_log(logger, logging.INFO) +)(run_single_game_cli) + + +def game_runner_wrapper(args): + """Wrapper to unpack arguments for the multiprocessing pool.""" + game_dir, game_config, use_random_agents, debug, _, _ = args + run_single_game_with_retry(game_dir, game_config, use_random_agents, debug) + + +def assign_roles_dup_agents(roles, agent_config, player_ids): + agents = [deepcopy(agent_config) for _ in range(len(roles))] + for role, agent, player_id in zip(roles, agents, player_ids): + agent['role'] = role + agent['id'] = player_id + return agents + + +def prepare_pairwise_agents(villager_roles, werewolf_roles, player_a_config, player_b_config, player_ids): + pid_v, pid_w = player_ids[:len(villager_roles)], player_ids[len(villager_roles):] + agents_v = assign_roles_dup_agents(villager_roles, player_a_config, pid_v) + agents_w = assign_roles_dup_agents(werewolf_roles, player_b_config, pid_w) + agents = agents_v + agents_w + return agents + + +def generate_game_tasks(output_dir, num_tournaments, config, use_random_agents, debug): + """ + Generates game configurations for a pairwise matrix tournament. + """ + base_game_config = config['game_config'] + all_players = base_game_config['agents'] + num_players = len(all_players) + base_roles = [agent['role'] for agent in all_players] + player_ids = [agent['id'] for agent in all_players] + + villager_roles, werewolf_roles = get_team_roles(base_roles) + + if not werewolf_roles: + raise ValueError("Configuration must include at least one werewolf role.") + if not villager_roles: + raise ValueError("Configuration must include at least one villager role.") + + for tourney_idx in range(num_tournaments): + for i in range(num_players): + for j in range(num_players): + game_dir = os.path.join(output_dir, f"tourney_{tourney_idx}", f"game_{i}_vs_{j}") + os.makedirs(game_dir, exist_ok=True) + + player_a_config = all_players[i] + player_b_config = all_players[j] + + game_agents_config = prepare_pairwise_agents( + villager_roles, werewolf_roles, player_a_config, player_b_config, player_ids) + + # since name has to be unique and all names come from config, we by default shuffle all names + # since name might change + random.shuffle(player_ids) + for agent_ind, agent in enumerate(game_agents_config): + agent['id'] = player_ids[agent_ind] + + random.shuffle(game_agents_config) + + game_config = {**base_game_config, 'agents': game_agents_config} + yield game_dir, game_config, use_random_agents, debug, tourney_idx, f"{i}_vs_{j}" + + +def run_tournament( + output_dir, num_tournaments, config, use_random_agents, debug, parallel, num_processes): + """ + Runs a tournament by generating all game tasks and processing them, + potentially in parallel. + """ + total_games = num_tournaments * len(config['game_config']['agents']) ** 2 + + if parallel: + logger.info(f"Running games in parallel with up to {num_processes} processes.") + + game_tasks = generate_game_tasks( + output_dir, num_tournaments, config, use_random_agents, debug + ) + + # the following shuffle is to reduce the load of a particular LLM api + game_tasks = [*game_tasks] + random.shuffle(game_tasks) + + with tqdm(total=total_games, desc="Processing Games") as pbar: + if parallel: + with multiprocessing.Pool(processes=num_processes) as pool: + for _ in pool.imap_unordered(game_runner_wrapper, game_tasks): + pbar.update(1) + else: + for task_args in game_tasks: + game_runner_wrapper(task_args) + pbar.update(1) + + logger.info("All game tasks have been processed.") + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + default_config_path = os.path.join(script_dir, 'configs', 'run', 'run_config.yaml') + + parser = argparse.ArgumentParser( + description="Run a pairwise matrix tournament for the Werewolf game." + ) + parser.add_argument("-o", "--output_dir", type=str, help="Output directory for game replays and logs.", + default="werewolf_pairwise_matrix") + parser.add_argument("-c", '--config', type=str, default=default_config_path, + help="Path to the base configuration YAML file.") + parser.add_argument("-t", "--num_tournaments", type=int, default=1, + help="Number of tournaments to run. Each tournament is a full N*N matrix of games.") + parser.add_argument("-r", "--use_random_agents", action="store_true", + help='Use random agents for all players for fast testing.') + parser.add_argument("-d", "--debug", action="store_true", + help='Enable debug mode for the game environment. Forces sequential execution.') + parser.add_argument("-p", "--parallel", action="store_true", + help='Run games in parallel using multiple processes.') + parser.add_argument("-n", "--num_processes", type=int, default=None, + help="Number of processes for parallel execution.") + parser.add_argument("-a", "--append_timestamp_to_dir", action="store_true", + help="Append a timestamp to the output directory.") + + args = parser.parse_args() + + output_dir = append_timestamp_to_dir(args.output_dir, append=args.append_timestamp_to_dir) + + os.makedirs(output_dir, exist_ok=True) + + setup_logger(output_dir, 'run_pairwise_matrix') + + config = load_config(args.config) + + if args.num_processes is None: + num_processes = max(1, math.floor(multiprocessing.cpu_count() * 0.8)) + else: + num_processes = args.num_processes + + logger.info("Starting tournament with the following settings:") + logger.info(f"Output Directory: {output_dir}") + logger.info(f"Number of Tournaments: {args.num_tournaments}") + logger.info(f"Parallel Execution: {args.parallel}") + if args.parallel: + logger.info(f"Number of Processes: {num_processes}") + logger.info(f"Debug Mode: {args.debug}") + logger.info(f"Use Random Agents: {args.use_random_agents}") + + with LogExecutionTime(logger_obj=logger, task_str="pairwise matrix tournament") as timer: + run_tournament( + output_dir=output_dir, + num_tournaments=args.num_tournaments, + config=config, + use_random_agents=args.use_random_agents, + debug=args.debug, + parallel=args.parallel, + num_processes=num_processes, + ) + logger.info("Tournament finished successfully.") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/kaggle_environments/envs/werewolf/scripts/self_play.py b/kaggle_environments/envs/werewolf/scripts/self_play.py new file mode 100644 index 00000000..bda0df7a --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/self_play.py @@ -0,0 +1,176 @@ +"""Run the settings in a given config with all agents llm agents by substituting all with a single model. +This is useful for example to evaluate the game rule balance. +""" + +import argparse +import copy +import logging +import multiprocessing +from concurrent.futures import ThreadPoolExecutor, as_completed +import os +import random + +import tenacity +import yaml +from tqdm import tqdm + +from kaggle_environments.envs.werewolf.runner import setup_logger, append_timestamp_to_dir, LogExecutionTime +from kaggle_environments.envs.werewolf.scripts.utils import run_single_game_cli + +logger = logging.getLogger(__name__) + + +run_single_game_with_retry = tenacity.retry( + wait=tenacity.wait_random_exponential(multiplier=1, min=2, max=10), + stop=tenacity.stop_after_attempt(3), + before_sleep=tenacity.before_sleep_log(logger, logging.INFO) +)(run_single_game_cli) + + +def game_runner_wrapper(args): + """Wrapper to unpack arguments for the multiprocessing pool.""" + game_dir, game_config, use_random_agents, debug = args + run_single_game_with_retry(game_dir, game_config, use_random_agents, debug) + + +def shuffle_field(agents, field_name): + values = [agent[field_name] for agent in agents] + random.shuffle(values) + for agent, value in zip(agents, values): + agent[field_name] = value + + +def run_self_play_games(model_name, thumbnail,output_dir, num_games, config, use_random_agents, debug, parallel, num_processes, shuffle_roles): + """ + Generates and runs game tasks for the self-play experiment. + """ + if debug: + logger.warning("Debug mode is enabled. Forcing sequential execution.") + + game_tasks = [] + base_game_config = config['game_config'] + + # modify the config to use a single model + agents = base_game_config['agents'] + for agent in agents: + agent['thumbnail'] = thumbnail + agent['agent_id'] = f"llm/{model_name}" + agent['display_name'] = os.path.basename(model_name) + agent['llms'][0]['model_name'] = model_name + + for i in range(num_games): + game_output_dir = os.path.join(output_dir, f"game_{i}") + os.makedirs(game_output_dir, exist_ok=True) + + game_config = copy.deepcopy(base_game_config) + + if shuffle_roles: + logger.info(f"Shuffling roles for game {i}") + role_configs = [ + {'role': agent['role'], 'role_params': agent.get('role_params', {})} + for agent in game_config['agents'] + ] + random.shuffle(role_configs) + for agent, role_config in zip(game_config['agents'], role_configs): + agent['role'] = role_config['role'] + agent['role_params'] = role_config['role_params'] + + # shuffle player ids + logger.info(f"Shuffling player ids for game {i}") + shuffle_field(game_config['agents'], 'id') + + task = (game_output_dir, game_config, use_random_agents, debug) + game_tasks.append(task) + + + with tqdm(total=num_games, desc="Running Self-Play Games") as pbar: + if parallel: + with ThreadPoolExecutor(max_workers=num_processes) as executor: + futures = [executor.submit(game_runner_wrapper, task) for task in game_tasks] + for future in as_completed(futures): + # You could also add error handling here by checking future.exception() + pbar.update(1) + else: + for task in game_tasks: + game_runner_wrapper(task) + pbar.update(1) + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + default_config_path = os.path.join(script_dir, 'configs', 'run', 'roundrobin_discussion_small.yaml') + + parser = argparse.ArgumentParser(description="Run N self-play Werewolf games based on a configuration file.") + parser.add_argument( + "-c", "--config_path", type=str, + default=default_config_path, + help="Path to the YAML configuration file." + ) + parser.add_argument( + "-o", "--output_dir", type=str, + default="werewolf_self_play", + help="Output directory for the log and replay files." + ) + parser.add_argument("-m", "--model_name", type=str, default="gemini/gemini-2.5-flash", + help="The model name by litellm for self play.") + parser.add_argument("-t", "--thumbnail", type=str, + default="https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png", + help="The thumbnail image url.") + parser.add_argument("-n", "--num_games", type=int, default=1, help="Number of self-play games to run.") + parser.add_argument("-d", "--debug", action="store_true", help='Enable debug mode.') + parser.add_argument("-r", "--random_agents", action="store_true", + help='Use random agents for all players for fast testing.') + parser.add_argument("-a", "--append_timestamp_to_dir", action="store_true", + help="Append a timestamp to the output directory.") + parser.add_argument("-s", "--shuffle_roles", action="store_true", + help="If provided, shuffle the roles for each game.") + parser.add_argument("-p", "--parallel", action="store_true", + help='Run games in parallel using multiple processes.') + parser.add_argument("--num_processes", type=int, default=None, + help="Number of processes for parallel execution.") + + args = parser.parse_args() + + run_output_dir = append_timestamp_to_dir(args.output_dir, append=args.append_timestamp_to_dir) + os.makedirs(run_output_dir, exist_ok=True) + setup_logger(output_dir=run_output_dir, base_name="self_play") + + with open(args.config_path, 'r') as f: + config = yaml.safe_load(f) + + num_processes = args.num_processes + if args.parallel and num_processes is None: + # Default to 4x the number of CPUs for I/O bound tasks + num_processes = multiprocessing.cpu_count() * 4 + + logger.info("Starting self-play with the following settings:") + logger.info(f"Model Name: {args.model_name}") + logger.info(f"Thumbnail: {args.thumbnail}") + logger.info(f"Output Directory: {run_output_dir}") + logger.info(f"Number of Games: {args.num_games}") + logger.info(f"Config Path: {args.config_path}") + logger.info(f"Parallel Execution: {args.parallel}") + if args.parallel: + logger.info(f"Number of Processes: {num_processes}") + logger.info(f"Debug Mode: {args.debug}") + logger.info(f"Use Random Agents: {args.random_agents}") + logger.info(f"Shuffle Roles: {args.shuffle_roles}") + + with LogExecutionTime(logger_obj=logger, task_str=f"{args.num_games} self-play games"): + run_self_play_games( + model_name=args.model_name, + thumbnail=args.thumbnail, + output_dir=run_output_dir, + num_games=args.num_games, + config=config, + use_random_agents=args.random_agents, + debug=args.debug, + parallel=args.parallel, + num_processes=num_processes, + shuffle_roles=args.shuffle_roles + ) + + logger.info("Self-play run finished successfully.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/kaggle_environments/envs/werewolf/scripts/utils.py b/kaggle_environments/envs/werewolf/scripts/utils.py new file mode 100644 index 00000000..01703c0f --- /dev/null +++ b/kaggle_environments/envs/werewolf/scripts/utils.py @@ -0,0 +1,48 @@ +import os +import yaml +import sys +import subprocess +import logging + + +logger = logging.getLogger(__name__) + + +def run_single_game_cli(game_dir, game_config, use_random_agents, debug): + """ + Sets up and runs a single game instance by calling run.py. Running a separate process has the distinct advantage + of an atomic game execution unit, so the logging and dumps including html render and json are cleaner. + """ + out_config = {"game_config": game_config} + config_path = os.path.join(game_dir, "config.yaml") + with open(config_path, 'w') as f: + yaml.dump(out_config, f, default_flow_style=False) + + run_py_path = os.path.join(os.path.dirname(__file__), 'run.py') + cmd = [ + sys.executable, + run_py_path, + '--config_path', config_path, + '--output_dir', game_dir, + ] + if use_random_agents: + cmd.append('--random_agents') + if debug: + cmd.append('--debug') + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + logger.info(f"Game in {game_dir} completed successfully.") + if result.stdout: + logger.info(result.stdout) + if result.stderr: + logger.warning(f"Stderr (non-fatal) from game in {game_dir}: {result.stderr}") + except subprocess.CalledProcessError as e: + error_message = ( + f"Error running game in {game_dir}.\n" + f"Return Code: {e.returncode}\n" + f"Stdout: {e.stdout}\n" + f"Stderr: {e.stderr}" + ) + logger.error(error_message) + raise RuntimeError(error_message) from e diff --git a/kaggle_environments/envs/werewolf/test_werewolf.py b/kaggle_environments/envs/werewolf/test_werewolf.py new file mode 100644 index 00000000..c31c72dd --- /dev/null +++ b/kaggle_environments/envs/werewolf/test_werewolf.py @@ -0,0 +1,153 @@ +import pytest + +from kaggle_environments import make + +URLS = { + "gemini": "https://logos-world.net/wp-content/uploads/2025/01/Google-Gemini-Symbol.png", + "openai": "https://images.seeklogo.com/logo-png/46/1/chatgpt-logo-png_seeklogo-465219.png", + "claude": "https://images.seeklogo.com/logo-png/55/1/claude-logo-png_seeklogo-554534.png", + "grok": "https://images.seeklogo.com/logo-png/61/1/grok-logo-png_seeklogo-613403.png" +} + +@pytest.fixture +def agents_config(): + roles = ["Werewolf", "Werewolf", "Doctor", "Seer", "Villager", "Villager", "Villager"] + names = [f"player_{i}" for i in range(len(roles))] + thumbnails = [URLS['gemini'], URLS['gemini'], URLS['openai'], URLS['openai'], URLS['openai'], URLS['claude'], + URLS['grok']] + agents_config = [{"role": role, "id": name, "agent_id": "random", "thumbnail": url} for role, name, url in + zip(roles, names, thumbnails)] + return agents_config + + +@pytest.fixture +def env(agents_config): + env = make( + 'werewolf', + debug=True, + configuration={ + "agents": agents_config + } + ) + return env + + +def test_load_env(env): + agents = ['random'] * 7 + env.run(agents) + + for i, state in enumerate(env.steps): + env.render_step_ind = i + out = env.renderer(state, env) + + +def test_discussion_protocol(agents_config): + env = make( + 'werewolf', + debug=True, + configuration={ + "agents": agents_config, + "discussion_protocol": { + "name": "RoundRobinDiscussion", + "params": { + "max_rounds": 2 + } + } + } + ) + agents = ['random'] * 7 + env.run(agents) + out = env.toJSON() + + +def test_no_reveal_options(agents_config): + env = make( + 'werewolf', + debug=True, + configuration={ + "agents": agents_config, + "night_elimination_reveal_level": "no_reveal", + "day_exile_reveal_level": "no_reveal" + } + ) + agents = ['random'] * 7 + env.run(agents) + out = env.toJSON() + + +def test_disable_doctor_self_save(): + roles = ["Werewolf", "Werewolf", "Doctor", "Seer", "Villager", "Villager", "Villager"] + names = [f"player_{i}" for i in range(len(roles))] + thumbnails = [URLS['gemini'], URLS['gemini'], URLS['openai'], URLS['openai'], URLS['openai'], URLS['claude'], + URLS['grok']] + agents_config = [{"role": role, "id": name, "agent_id": "random", "thumbnail": url} for role, name, url in + zip(roles, names, thumbnails)] + agents_config[2]['role_params'] = {'allow_self_save': False} + env = make( + 'werewolf', + debug=True, + configuration={ + "agents": agents_config, + } + ) + agents = ['random'] * 7 + env.run(agents) + out = env.toJSON() + + +def test_turn_by_turn_bidding_discussion(agents_config): + """Tests the bidding -> chat -> bidding -> chat ... cycle.""" + env = make( + 'werewolf', + debug=True, + configuration={ + "agents": agents_config, + "discussion_protocol": { + "name": "TurnByTurnBiddingDiscussion", + "params": { + "bidding": { + "name": "UrgencyBiddingProtocol", + }, + "max_turns": 16, + "bid_result_public": False + } + } + } + ) + agents = ['random'] * 7 + env.run(agents) + content = env.render(mode='html') + + +@pytest.mark.skip('Slow test, meant for manual testing.') +def test_llm_players(agents_config): + env = make( + 'werewolf', + debug=True, + configuration={ + "actTimeout": 30, + "agents": agents_config + } + ) + agents = ['llm/gemini/gemini-2.5-flash', 'random', 'llm/gemini/gemini-2.5-flash', 'llm/gemini/gemini-2.5-flash', + 'llm/gemini/gemini-2.5-flash', 'random', 'random'] + env.run(agents) + for i, state in enumerate(env.steps): + env.render_step_ind = i + out = env.renderer(state, env) + + +def test_default_env(): + env = make('werewolf', debug=True) + agents = ['random'] * 7 + env.run(agents) + + +def test_html_render(env, tmp_path): + agents = ['random'] * 7 + env.run(agents) + content = env.render(mode='html') + replay_file = tmp_path / "game_replay.html" + with open(replay_file, 'w') as handle: + handle.write(content) + assert replay_file.exists() diff --git a/kaggle_environments/envs/werewolf/werewolf.js b/kaggle_environments/envs/werewolf/werewolf.js new file mode 100644 index 00000000..01935792 --- /dev/null +++ b/kaggle_environments/envs/werewolf/werewolf.js @@ -0,0 +1,3919 @@ +function renderer({ + environment, + step, + parent, + height = 700, // Default height + width = 1100, // Default width +}) { + const systemEntryTypeSet = new Set([ + 'moderator_announcement', + 'elimination', + 'vote_request', + 'heal_request', + 'heal_result', + 'inspect_request', + 'inspect_result', + 'bidding_info', + 'bid_result' + ]); + + if (!window.werewolfGamePlayer) { + window.werewolfGamePlayer = { + initialized: false, + allEvents: [], + displayEvents: [], + eventToKaggleStep: [], + displayStepToAllEventsIndex: [], + originalSteps: environment.steps, + reasoningCounter: 0, + }; + const player = window.werewolfGamePlayer; + + const visibleEventDataTypes = new Set([ + 'ChatDataEntry', + 'DayExileVoteDataEntry', + 'WerewolfNightVoteDataEntry', + 'DoctorHealActionDataEntry', + 'SeerInspectActionDataEntry', + 'DayExileElectedDataEntry', + 'WerewolfNightEliminationDataEntry', + 'SeerInspectResultDataEntry', + 'DoctorSaveDataEntry', + 'GameEndResultsDataEntry', + 'PhaseDividerDataEntry', + 'DiscussionOrderDataEntry' + ]); + + let allEventsIndex = 0; + (environment.info?.MODERATOR_OBSERVATION || []).forEach((stepEvents, kaggleStep) => { + const processedInStep = new Set(); + (stepEvents || []).flat().forEach(dataEntry => { + const event = JSON.parse(dataEntry.json_str); + + const isVisibleDataType = visibleEventDataTypes.has(dataEntry.data_type); + const isVisibleEntryType = systemEntryTypeSet.has(event.event_name) || (event.event_name === 'vote_action' && !event.data); + + if (!isVisibleDataType && !isVisibleEntryType) { + return; + } + + // Additional filter for "has begun" system messages which are not displayed + if (event.event_name === "moderator_announcement" && event.description && event.description.includes('has begun')) { + return; + } + + if (processedInStep.has(dataEntry.json_str)) { + return; + } + processedInStep.add(dataEntry.json_str); + + event.kaggleStep = kaggleStep; + event.dataType = dataEntry.data_type; + player.allEvents.push(event); + player.eventToKaggleStep.push(kaggleStep); + + if (event.dataType !== 'PhaseDividerDataEntry') { + player.displayEvents.push(event); + player.displayStepToAllEventsIndex.push(allEventsIndex); + } + allEventsIndex++; + }); + }); + + const newSteps = player.displayEvents.map((event) => { + return player.originalSteps[event.kaggleStep]; + }); + + setTimeout(() => { + if (window.kaggle) { + window.kaggle.environment.steps = newSteps; + } + window.postMessage({ setSteps: newSteps }, "*"); + }, 100); // A small delay to ensure player is ready + player.initialized = true; + } + + // --- THREE.js Scene Setup (Singleton Pattern) --- + if (!window.werewolfThreeJs) { + window.werewolfThreeJs = { + initialized: false, + demo: null, + }; + } + const threeState = window.werewolfThreeJs; + + function initThreeJs() { + if (threeState.initialized) { + if (threeState.demo && threeState.demo._parent && !parent.contains(threeState.demo._parent)) { + parent.appendChild(threeState.demo._threejs.domElement); + parent.appendChild(threeState.demo._labelRenderer.domElement); + } + return; + } + + const loadAndSetup = async () => { + try { + const THREE = await import('https://cdn.jsdelivr.net/npm/three@0.118/build/three.module.js'); + const { OrbitControls } = await import('https://cdn.jsdelivr.net/npm/three@0.118/examples/jsm/controls/OrbitControls.js'); + const { FBXLoader } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/loaders/FBXLoader.js'); + const { SkeletonUtils } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/utils/SkeletonUtils.js'); + const { CSS2DRenderer, CSS2DObject } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/renderers/CSS2DRenderer.js'); + const { EffectComposer } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/EffectComposer.js'); + const { RenderPass } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/RenderPass.js'); + const { UnrealBloomPass } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/UnrealBloomPass.js'); + const { ShaderPass } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/ShaderPass.js'); + const { FilmPass } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/FilmPass.js'); + + class BasicWorldDemo { + constructor(options) { + this._Initialize(options, THREE, OrbitControls, FBXLoader, SkeletonUtils, CSS2DRenderer, CSS2DObject, EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, FilmPass); + } + + _Initialize(options, THREE, OrbitControls, FBXLoader, SkeletonUtils, CSS2DRenderer, CSS2DObject, EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, FilmPass) { + this._parent = options.parent; + this._width = options.width; + this._height = options.height; + + // WebGL Renderer with enhanced settings + this._threejs = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + powerPreference: "high-performance" + }); + this._threejs.shadowMap.enabled = true; + this._threejs.shadowMap.type = THREE.PCFSoftShadowMap; + this._threejs.shadowMap.autoUpdate = true; + this._threejs.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this._threejs.setSize(this._width, this._height); + this._threejs.outputEncoding = THREE.sRGBEncoding; + this._threejs.toneMapping = THREE.ACESFilmicToneMapping; + this._threejs.toneMappingExposure = 1.2; + this._threejs.domElement.style.position = 'absolute'; + this._threejs.domElement.style.top = '0'; + this._threejs.domElement.style.left = '0'; + this._threejs.domElement.style.zIndex = '0'; + this._parent.appendChild(this._threejs.domElement); + + // CSS2D Renderer + this._labelRenderer = new CSS2DRenderer(); + this._labelRenderer.setSize(this._width, this._height); + this._labelRenderer.domElement.style.position = 'absolute'; + this._labelRenderer.domElement.style.top = '0px'; + this._labelRenderer.domElement.style.left = '0px'; + this._labelRenderer.domElement.style.zIndex = '1'; // On top of 3D, behind UI + this._labelRenderer.domElement.style.pointerEvents = 'none'; + this._parent.appendChild(this._labelRenderer.domElement); + + const fov = 60; + const aspect = this._width / this._height; + const near = 1.0; + const far = 100000.0; + this._camera = new THREE.PerspectiveCamera(fov, aspect, near, far); + this._camera.position.set(0, 0, 50); + + this._scene = new THREE.Scene(); + this._scene.fog = new THREE.FogExp2(0x2a2a4a, 0.01); // Start with day fog color + + this._createSkybox(THREE); + this._createAdvancedLighting(THREE); + this._setupPostProcessing(THREE, EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, FilmPass); + + this._controls = new OrbitControls(this._camera, this._threejs.domElement); + this._controls.target.set(0, 0, 0); + this._controls.enableKeys = false; + this._controls.update(); + + this._votingArcsGroup = new THREE.Group(); + this._votingArcsGroup.name = 'votingArcs'; + this._scene.add(this._votingArcsGroup); + + this._targetRingsGroup = new THREE.Group(); + this._targetRingsGroup.name = 'targetRings'; + this._scene.add(this._targetRingsGroup); + + this._activeVoteArcs = new Map(); + this._activeTargetRings = new Map(); + + this._speakingAnimations = []; + + this._LoadModels(THREE, FBXLoader, SkeletonUtils, CSS2DObject); + this._RAF(); + } + + _createSkybox(THREE) { + // Store THREE reference first + this._THREE = THREE; + + const skyboxSize = 1000; + const skyboxGeo = new THREE.BoxGeometry(skyboxSize, skyboxSize, skyboxSize); + + // Create materials for each face with initial day colors + this._skyboxMaterials = []; + for (let i = 0; i < 6; i++) { + const mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(0x87ceeb), // Start with day color + side: THREE.BackSide + }); + this._skyboxMaterials.push(mat); + } + + const skybox = new THREE.Mesh(skyboxGeo, this._skyboxMaterials); + this._skybox = skybox; + this._scene.add(skybox); + + // Create dynamic sky canvas for the back panel (where moon/sun appears) + const backCanvas = document.createElement('canvas'); + const canvasSize = 2048; + backCanvas.width = canvasSize; + backCanvas.height = canvasSize; + this._skyCanvas = backCanvas; + this._skyContext = backCanvas.getContext('2d'); + + // Store celestial body properties + this._celestialBody = { + x: canvasSize / 2, + y: canvasSize / 3, + size: 250, + phase: 0.0 // Start with day (0 = day, 1 = night) + }; + + // Create texture from canvas + this._skyTexture = new THREE.CanvasTexture(backCanvas); + this._skyboxMaterials[4].map = this._skyTexture; + + // Load moon image + const moonImage = new Image(); + moonImage.crossOrigin = 'Anonymous'; + moonImage.onload = () => { + this._moonImage = moonImage; + this._updateSkybox(0.0); // Start with day + }; + moonImage.onerror = () => { + console.error("Failed to load moon texture for skybox."); + this._updateSkybox(0.0); // Start with day + }; + moonImage.src = 'assets/moon4.png'; + + // Create stars for night sky + this._createStars(THREE); + } + + _createStars(THREE) { + const starsGeometry = new THREE.BufferGeometry(); + const starCount = 2000; + const positions = new Float32Array(starCount * 3); + const colors = new Float32Array(starCount * 3); + const sizes = new Float32Array(starCount); + + for (let i = 0; i < starCount; i++) { + const i3 = i * 3; + + // Random position on a large sphere + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const radius = 400 + Math.random() * 100; + + positions[i3] = radius * Math.sin(phi) * Math.cos(theta); + positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta); + positions[i3 + 2] = radius * Math.cos(phi); + + // Star colors (white to slightly blue/yellow) + const starColor = new THREE.Color(); + const colorChoice = Math.random(); + if (colorChoice < 0.3) { + starColor.setHSL(0.6, 0.1, 0.9); // Bluish + } else if (colorChoice < 0.6) { + starColor.setHSL(0.1, 0.1, 0.95); // Yellowish + } else { + starColor.setHSL(0, 0, 1); // Pure white + } + colors[i3] = starColor.r; + colors[i3 + 1] = starColor.g; + colors[i3 + 2] = starColor.b; + + sizes[i] = Math.random() * 2 + 0.5; + } + + starsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + starsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + starsGeometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const starsMaterial = new THREE.ShaderMaterial({ + uniforms: { + phase: { value: 0.0 } // Start with day (0 = day, 1 = night) + }, + vertexShader: ` + attribute float size; + attribute vec3 color; + varying vec3 vColor; + uniform float phase; + + void main() { + vColor = color; + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = size * (300.0 / -mvPosition.z) * phase; + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + varying vec3 vColor; + uniform float phase; + + void main() { + float dist = distance(gl_PointCoord, vec2(0.5)); + if (dist > 0.5) discard; + + float alpha = (1.0 - dist * 2.0) * phase * 0.8; + gl_FragColor = vec4(vColor, alpha); + } + `, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false + }); + + this._stars = new THREE.Points(starsGeometry, starsMaterial); + this._starsMaterial = starsMaterial; + this._scene.add(this._stars); + } + + _updateSkybox(phase) { + if (!this._skyContext || !this._skyCanvas) { + console.log('Skybox context not ready'); + return; + } + + const ctx = this._skyContext; + const canvas = this._skyCanvas; + const celestial = this._celestialBody; + + // Clear canvas with a transparent background + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Create gradient overlay + const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); + + if (phase > 0.5) { + // Night sky + const nightIntensity = (phase - 0.5) * 2; + gradient.addColorStop(0, `rgba(10, 10, 40, ${nightIntensity})`); + gradient.addColorStop(0.3, `rgba(20, 20, 60, ${nightIntensity})`); + gradient.addColorStop(1, `rgba(5, 5, 20, ${nightIntensity})`); + + // Fill with gradient + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw stars manually + ctx.fillStyle = 'white'; + for (let i = 0; i < 200; i++) { + const x = Math.random() * canvas.width; + const y = Math.random() * canvas.height; + const size = Math.random() * 2; + ctx.globalAlpha = nightIntensity * (0.3 + Math.random() * 0.7); + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + } + + // Draw moon + ctx.globalAlpha = nightIntensity; + if (this._moonImage) { + const moonSize = celestial.size; + const moonX = celestial.x; + const moonY = celestial.y; + ctx.drawImage(this._moonImage, + moonX - moonSize/2, + moonY - moonSize/2, + moonSize, + moonSize + ); + } else { + // Fallback: draw procedural moon + ctx.fillStyle = '#f0f0e0'; + ctx.shadowBlur = 50; + ctx.shadowColor = '#f0f0e0'; + ctx.beginPath(); + ctx.arc(celestial.x, celestial.y, celestial.size/2, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + } + } else { + // Day sky + const dayIntensity = 1 - phase * 2; + gradient.addColorStop(0, `rgba(135, 206, 250, ${dayIntensity})`); + gradient.addColorStop(0.5, `rgba(135, 206, 235, ${dayIntensity})`); + gradient.addColorStop(1, `rgba(255, 255, 200, ${dayIntensity * 0.5})`); + + // Fill with gradient + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw sun + ctx.globalAlpha = dayIntensity; + + // Sun glow + const glowGradient = ctx.createRadialGradient( + celestial.x, celestial.y, 0, + celestial.x, celestial.y, celestial.size * 1.5 + ); + glowGradient.addColorStop(0, 'rgba(255, 255, 200, 0.8)'); + glowGradient.addColorStop(0.3, 'rgba(255, 220, 100, 0.4)'); + glowGradient.addColorStop(1, 'rgba(255, 200, 50, 0)'); + + ctx.fillStyle = glowGradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Sun core + ctx.fillStyle = '#ffff99'; + ctx.shadowBlur = 80; + ctx.shadowColor = '#ffcc00'; + ctx.beginPath(); + ctx.arc(celestial.x, celestial.y, celestial.size/2, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + + // Sun rays + ctx.strokeStyle = `rgba(255, 220, 100, ${dayIntensity * 0.5})`; + ctx.lineWidth = 3; + for (let i = 0; i < 12; i++) { + const angle = (i / 12) * Math.PI * 2; + const innerRadius = celestial.size * 0.6; + const outerRadius = celestial.size * 1.2; + ctx.beginPath(); + ctx.moveTo( + celestial.x + Math.cos(angle) * innerRadius, + celestial.y + Math.sin(angle) * innerRadius + ); + ctx.lineTo( + celestial.x + Math.cos(angle) * outerRadius, + celestial.y + Math.sin(angle) * outerRadius + ); + ctx.stroke(); + } + } + + // Update texture + if (this._skyTexture) { + this._skyTexture.needsUpdate = true; + } + + // Update skybox colors for all faces with more dramatic changes + if (this._skyboxMaterials && this._THREE) { + const THREE = this._THREE; + const nightColor = new THREE.Color(0x000011); // Very dark blue + const dayColor = new THREE.Color(0x87ceeb); // Sky blue + const currentColor = new THREE.Color(); + currentColor.copy(dayColor).lerp(nightColor, phase); + + this._skyboxMaterials.forEach((mat, index) => { + if (index !== 4) { // Skip the back panel with moon/sun + mat.color.copy(currentColor); + mat.needsUpdate = true; + } + }); + + // Force update the canvas texture + if (this._skyTexture) { + this._skyTexture.needsUpdate = true; + } + } + + // Store current phase + if (celestial) { + celestial.phase = phase; + } + } + + _createAdvancedLighting(THREE) { + // Enhanced ambient lighting with color variation + const ambientLight = new THREE.AmbientLight(0x404080, 0.4); + ambientLight.name = 'ambientLight'; + this._scene.add(ambientLight); + + // Main directional light (moon/sun) + const mainLight = new THREE.DirectionalLight(0xffffff, 1.8); + mainLight.position.set(30, 50, 20); + mainLight.castShadow = true; + mainLight.shadow.mapSize.width = 2048; + mainLight.shadow.mapSize.height = 2048; + mainLight.shadow.camera.near = 0.5; + mainLight.shadow.camera.far = 100; + mainLight.shadow.camera.left = -50; + mainLight.shadow.camera.right = 50; + mainLight.shadow.camera.top = 50; + mainLight.shadow.camera.bottom = -50; + mainLight.shadow.bias = -0.001; + mainLight.shadow.normalBias = 0.02; + this._scene.add(mainLight); + + // Rim light for dramatic effect + const rimLight = new THREE.DirectionalLight(0x8080ff, 0.8); + rimLight.position.set(-20, 10, -30); + this._scene.add(rimLight); + + // Atmospheric hemisphere light + const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x1e1e3f, 0.6); + this._scene.add(hemiLight); + + // Ground fill light + const fillLight = new THREE.DirectionalLight(0x404080, 0.3); + fillLight.position.set(0, -1, 0); + this._scene.add(fillLight); + + // Store references for phase updates + this._mainLight = mainLight; + this._rimLight = rimLight; + this._hemiLight = hemiLight; + this._fillLight = fillLight; + + // Create a spotlight for night actions + const spotLight = new THREE.SpotLight(0xffffff, 5.0, 50, Math.PI / 4, 0.5, 2); + spotLight.position.set(0, 25, 0); + spotLight.castShadow = true; + spotLight.visible = false; + this._scene.add(spotLight); + this._spotLight = spotLight; + this._scene.add(spotLight.target); + } + + _createMysticalCircles(THREE, radius) { + // Create multiple concentric circles with mystical patterns + for (let i = 0; i < 3; i++) { + const circleRadius = radius - (i * 2) - 1; + const circleGeometry = new THREE.RingGeometry(circleRadius - 0.1, circleRadius + 0.1, 64); + const circleMaterial = new THREE.MeshStandardMaterial({ + color: new THREE.Color().setHSL(0.6 + i * 0.1, 0.8, 0.3 + i * 0.1), + emissive: new THREE.Color().setHSL(0.6 + i * 0.1, 0.5, 0.1), + emissiveIntensity: 0.2, + transparent: true, + opacity: 0.6 - i * 0.1, + side: THREE.DoubleSide + }); + + const circle = new THREE.Mesh(circleGeometry, circleMaterial); + circle.rotation.x = -Math.PI / 2; + circle.position.y = 0.01 + i * 0.001; + this._scene.add(circle); + } + + // Add runic symbols around the outer circle + // this._createRunicSymbols(THREE, radius); + } + + _createRunicSymbols(THREE, radius) { + const symbolCount = 8; + const symbolGeometry = new THREE.PlaneGeometry(1, 1); + + for (let i = 0; i < symbolCount; i++) { + const angle = (i / symbolCount) * Math.PI * 2; + const x = (radius + 3) * Math.sin(angle); + const z = (radius + 3) * Math.cos(angle); + + // Create a simple runic-like pattern using canvas + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = 'rgba(100, 150, 255, 0.8)'; + ctx.font = '40px serif'; + ctx.textAlign = 'center'; + ctx.fillText(['ᚠ', 'ᚡ', 'ᚢ', 'ᚣ', 'ᚤ', 'ᚥ', 'ᚦ', 'ᚧ'][i], 32, 45); + + const symbolMaterial = new THREE.MeshBasicMaterial({ + map: new THREE.CanvasTexture(canvas), + transparent: true, + alphaTest: 0.1 + }); + + const symbol = new THREE.Mesh(symbolGeometry, symbolMaterial); + symbol.position.set(x, 0.15, z); + symbol.rotation.x = -Math.PI / 2; + this._scene.add(symbol); + } + } + + _createParticleSystem(THREE) { + // Create floating mystical particles + const particleCount = 150; + const particles = new THREE.BufferGeometry(); + const positions = new Float32Array(particleCount * 3); + const colors = new Float32Array(particleCount * 3); + const sizes = new Float32Array(particleCount); + + for (let i = 0; i < particleCount; i++) { + const i3 = i * 3; + + // Random position within a larger area + positions[i3] = (Math.random() - 0.5) * 80; + positions[i3 + 1] = Math.random() * 30 + 5; + positions[i3 + 2] = (Math.random() - 0.5) * 80; + + // Mystical colors (blues, purples, greens) + const hue = Math.random() * 0.3 + 0.5; // 0.5-0.8 range + const color = new THREE.Color().setHSL(hue, 0.8, 0.6); + colors[i3] = color.r; + colors[i3 + 1] = color.g; + colors[i3 + 2] = color.b; + + sizes[i] = Math.random() * 2 + 0.5; + } + + particles.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + particles.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + particles.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const particleMaterial = new THREE.ShaderMaterial({ + uniforms: { + time: { value: 0 } + }, + vertexShader: ` + attribute float size; + attribute vec3 color; + varying vec3 vColor; + uniform float time; + + void main() { + vColor = color; + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = size * (300.0 / -mvPosition.z) * (1.0 + sin(time * 2.0 + position.x * 0.1) * 0.3); + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + varying vec3 vColor; + + void main() { + float dist = distance(gl_PointCoord, vec2(0.5)); + if (dist > 0.5) discard; + + float alpha = 1.0 - (dist * 2.0); + alpha *= alpha; // Softer edges + + gl_FragColor = vec4(vColor, alpha * 0.6); + } + `, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false + }); + + this._particles = new THREE.Points(particles, particleMaterial); + this._scene.add(this._particles); + this._particleMaterial = particleMaterial; + } + + _setupPostProcessing(THREE, EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, FilmPass) { + // Create effect composer + this._composer = new EffectComposer(this._threejs); + + // Render pass + const renderPass = new RenderPass(this._scene, this._camera); + this._composer.addPass(renderPass); + + // Bloom pass for glowing effects - balanced for day + const bloomPass = new UnrealBloomPass( + new THREE.Vector2(this._width, this._height), + 0.15, // strength - moderate for day + 0.333, // radius - good coverage for day + 0.4 // threshold - balanced to allow some bloom + ); + this._composer.addPass(bloomPass); + + // Film grain for atmosphere + const filmPass = new FilmPass( + 0.15, // noise intensity + 0.1, // scanline intensity + 0, // scanline count + false // grayscale + ); + this._composer.addPass(filmPass); + + // Custom atmospheric shader + const atmosphereShader = { + uniforms: { + 'tDiffuse': { value: null }, + 'time': { value: 0.0 }, + 'phase': { value: 0.0 } // 0 = day, 1 = night + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D tDiffuse; + uniform float time; + uniform float phase; + varying vec2 vUv; + + void main() { + vec4 color = texture2D(tDiffuse, vUv); + + // Add subtle color grading based on phase + if (phase > 0.5) { + // Night - add blue tint and increase contrast + color.rgb = mix(color.rgb, color.rgb * vec3(0.8, 0.9, 1.2), 0.3); + color.rgb = pow(color.rgb, vec3(1.1)); + } else { + // Day - add warm tint + color.rgb = mix(color.rgb, color.rgb * vec3(1.1, 1.05, 0.95), 0.2); + } + + // Add subtle vignette + vec2 center = vec2(0.5, 0.5); + float dist = distance(vUv, center); + float vignette = 1.0 - smoothstep(0.3, 0.8, dist); + color.rgb *= mix(0.7, 1.0, vignette); + + gl_FragColor = color; + } + ` + }; + + this._atmospherePass = new ShaderPass(atmosphereShader); + this._composer.addPass(this._atmospherePass); + + // Store references + this._bloomPass = bloomPass; + this._filmPass = filmPass; + } + + _LoadModels(THREE, FBXLoader, SkeletonUtils, CSS2DObject) { + this._playerObjects = new Map(); + this._playerGroup = new THREE.Group(); + this._playerGroup.name = 'playerGroup'; + + // Create enhanced ground circle with better materials + const radius = 15; + const groundGeometry = new THREE.CircleGeometry(radius + 5, 64); + const groundMaterial = new THREE.MeshStandardMaterial({ + color: 0x1a1a2a, + roughness: 0.9, + metalness: 0.1, + normalScale: new THREE.Vector2(0.5, 0.5), + transparent: true, + opacity: 0.95 + }); + + // Add a subtle normal map pattern + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 512; + const ctx = canvas.getContext('2d'); + const imageData = ctx.createImageData(512, 512); + for (let i = 0; i < imageData.data.length; i += 4) { + const noise = Math.random() * 0.1 + 0.5; + imageData.data[i] = Math.floor(noise * 255); // R + imageData.data[i + 1] = Math.floor(noise * 255); // G + imageData.data[i + 2] = 255; // B + imageData.data[i + 3] = 255; // A + } + ctx.putImageData(imageData, 0, 0); + groundMaterial.normalMap = new THREE.CanvasTexture(canvas); + + const ground = new THREE.Mesh(groundGeometry, groundMaterial); + ground.rotation.x = -Math.PI / 2; + ground.position.y = -0.1; + ground.receiveShadow = true; + this._scene.add(ground); + + // Add mystical circle patterns + this._createMysticalCircles(THREE, radius); + + this._scene.add(this._playerGroup); + + // Store references for later use + this._THREE = THREE; + this._CSS2DObject = CSS2DObject; + + // Create particle system for atmosphere + this._createParticleSystem(THREE); + + // Frame the empty group initially with better camera positioning + this._camera.position.set(25, 30, 35); + this._controls.target.set(0, 8, 0); + this._controls.enableDamping = true; + this._controls.dampingFactor = 0.05; + this._controls.minDistance = 20; + this._controls.maxDistance = 80; + this._controls.maxPolarAngle = Math.PI * 0.75; + this._controls.update(); + } + + focusOnPlayer(playerName, leftPanelWidth = 0, rightPanelWidth = 0) { + if (!this._playerGroup || this._playerGroup.children.length === 0 || !this._THREE || !this._playerObjects) { + return; + } + const player = this._playerObjects.get(playerName); + if (!player) return; + + // --- 1. Calculate the required camera distance --- + + // First, determine the real viewport size, excluding the UI panels + const effectiveWidth = this._width - leftPanelWidth - rightPanelWidth; + const effectiveHeight = this._height; + + // Get the bounding box of the entire group of players + const viewBox = new this._THREE.Box3().setFromObject(this._playerGroup); + const viewSize = viewBox.getSize(new this._THREE.Vector3()); + const viewCenter = viewBox.getCenter(new this._THREE.Vector3()); + + // Calculate the camera's field of view in radians + const fov = this._camera.fov * (Math.PI / 180); + const aspect = effectiveWidth / effectiveHeight; + + // Derive the horizontal FoV from the vertical FoV and the new aspect ratio + const horizontalFov = 2 * Math.atan(Math.tan(fov / 2) * aspect); + + // Calculate the distance needed to fit the content vertically and horizontally + const distV = (viewSize.y / 2) / Math.tan(fov / 2); + const distH = (viewSize.x / 2) / Math.tan(horizontalFov / 2); + + // The required distance is the larger of the two, plus some padding + let distance = Math.max(distV, distH) * 1.05; + + // --- 2. Position the camera using the calculated distance --- + + const playerPosition = player.container.position.clone(); + const direction = playerPosition.clone().normalize(); + + // We preserve the angle you liked by scaling the position based on the new distance. + // The camera is positioned on the line extending from the center through the player. + const endPos = playerPosition.clone().add(direction.multiplyScalar(distance * 0.6)); + endPos.y = playerPosition.y + distance * 0.5; // Elevate based on distance + + // The target remains the center of the action + const endTarget = viewCenter; + + // --- 3. Animate the transition --- + + this._cameraAnimation = { + startTime: performance.now(), + duration: 1200, + startPos: this._camera.position.clone(), + endPos: endPos, + startTarget: this._controls.target.clone(), + endTarget: endTarget, + ease: t => 1 - Math.pow(1 - t, 3) + }; + } + + resetCameraView() { + if (!this._playerGroup || this._playerGroup.children.length === 0 || !this._THREE) { + return; // Can't frame an empty group + } + + // Calculate the bounding box that contains all players + const box = new this._THREE.Box3().setFromObject(this._playerGroup); + const size = box.getSize(new this._THREE.Vector3()); + const center = box.getCenter(new this._THREE.Vector3()); + + // Determine the maximum dimension of the box + const maxDim = Math.max(size.x, size.y, size.z); + const fov = this._camera.fov * (Math.PI / 180); + + // Calculate the distance the camera needs to be to fit the box + let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); + + // Add some padding so the players aren't right at the edge of the screen + // cameraZ *= 1.4; + cameraZ *= 1.1; + + // Set a nice isometric-style camera position + const endPos = new this._THREE.Vector3( + center.x, + center.y + cameraZ / 2, // Elevate the camera + center.z + cameraZ // Pull it back + ); + + // The target is the center of the player group + const endTarget = center; + + // Use the same animation system as focusOnPlayer + this._cameraAnimation = { + startTime: performance.now(), + duration: 1200, + startPos: this._camera.position.clone(), + endPos: endPos, + startTarget: this._controls.target.clone(), + endTarget: endTarget, + ease: t => 1 - Math.pow(1 - t, 3) + }; + } + + updatePlayerActive(playerName) { + const player = this._playerObjects.get(playerName); + if (!player) return; + const { orb, orbLight, body, head, shoulders, glow, pedestal, container } = player; + + orb.material.emissiveIntensity = 1.; + orbLight.intensity = 1.; + glow.material.emissiveIntensity = 0.5; + // Slight scale up animation + container.scale.setScalar(1.1); + pedestal.material.emissiveIntensity = 0.3; + } + + updatePlayerStatus(playerName, status, threatLevel = 0, is_active = false) { + const player = this._playerObjects.get(playerName); + if (!player) return; + + const { orb, orbLight, body, head, shoulders, glow, pedestal, container } = player; + + orb.material.color.setHex(0x00ff00); + orb.material.emissive.setHex(0x00ff00); + orb.material.emissiveIntensity = 0.8; + orb.material.opacity = 0.9; + orb.visible = true; + orbLight.color.setHex(0x00ff00); + orbLight.intensity = 0.8; + orbLight.visible = true; + body.material.color.setHex(0x4466ff); + body.material.emissive.setHex(0x111166); + body.material.emissiveIntensity = 0.2; + shoulders.material.color.setHex(0x4466ff); + shoulders.material.emissive.setHex(0x111166); + shoulders.material.emissiveIntensity = 0.2; + head.material.color.setHex(0xfdbcb4); + head.material.emissive.setHex(0x442211); + head.material.emissiveIntensity = 0.1; + glow.material.color.setHex(0x00ff00); + glow.material.emissive.setHex(0x00ff00); + glow.material.emissiveIntensity = 0.3; + glow.visible = true; + pedestal.material.emissive.setHex(0x111122); + pedestal.material.emissiveIntensity = 0.1; + container.scale.setScalar(1.0); + container.position.y = 0; + container.rotation.x = 0; + if (player.nameplate && player.nameplate.element) { + player.nameplate.element.style.transition = 'opacity 0.5s ease-in'; + player.nameplate.element.style.opacity = '1.0'; + } + player.isAlive = true; + + switch(status) { + case 'dead': + orb.visible = false; + orbLight.visible = false; + glow.visible = false; + body.material.color.setHex(0x444444); + body.material.emissive.setHex(0x000000); + shoulders.material.color.setHex(0x444444); + shoulders.material.emissive.setHex(0x000000); + head.material.color.setHex(0x666666); + head.material.emissive.setHex(0x000000); + pedestal.material.emissive.setHex(0x000000); + // Sink into ground + container.position.y = -1.5; + // Tilt slightly + container.rotation.x = 0.2; + // Fade out nameplate + if (player.nameplate && player.nameplate.element) { + player.nameplate.element.style.transition = 'opacity 2s ease-out'; + player.nameplate.element.style.opacity = '0.2'; + } + player.isAlive = false; + break; + case 'werewolf': + body.material.color.setHex(0x880000); + body.material.emissive.setHex(0x440000); + body.material.emissiveIntensity = 0.3; + shoulders.material.color.setHex(0x880000); + shoulders.material.emissive.setHex(0x440000); + shoulders.material.emissiveIntensity = 0.3; + glow.material.color.setHex(0xff0000); + glow.material.emissive.setHex(0xff0000); + glow.material.emissiveIntensity = 0.4; + glow.visible = true; + pedestal.material.emissive.setHex(0x440000); + pedestal.material.emissiveIntensity = 0.2; + break; + case 'doctor': + body.material.color.setHex(0x008800); + body.material.emissive.setHex(0x004400); + body.material.emissiveIntensity = 0.3; + shoulders.material.color.setHex(0x008800); + shoulders.material.emissive.setHex(0x004400); + shoulders.material.emissiveIntensity = 0.3; + glow.material.color.setHex(0x00ff00); + glow.material.emissive.setHex(0x00ff00); + glow.material.emissiveIntensity = 0.4; + glow.visible = true; + pedestal.material.emissive.setHex(0x004400); + pedestal.material.emissiveIntensity = 0.2; + break; + case 'seer': + body.material.color.setHex(0x4B0082); + body.material.emissive.setHex(0x3A005A); + body.material.emissiveIntensity = 0.3; + shoulders.material.color.setHex(0x4B0082); + shoulders.material.emissive.setHex(0x3A005A); + shoulders.material.emissiveIntensity = 0.3; + glow.material.color.setHex(0x9932CC); + glow.material.emissive.setHex(0x9932CC); + glow.material.emissiveIntensity = 0.4; + glow.visible = true; + pedestal.material.emissive.setHex(0x3A005A); + pedestal.material.emissiveIntensity = 0.2; + break; + default: + // This is now covered by the reset block at the top of the function. + break; + } + + if (threatLevel >= 1.0) { // DANGER + orb.material.color.setHex(0xff0000); // Red + orb.material.emissive.setHex(0xff0000); + orb.material.emissiveIntensity = 1.0; + orb.material.opacity = 0.9; + orbLight.color.setHex(0xff0000); + orbLight.intensity = 1.2; + glow.material.color.setHex(0xff0000); + glow.material.emissive.setHex(0xff0000); + glow.material.emissiveIntensity = 0.3; + } else if (threatLevel >= 0.5) { // UNEASY + orb.material.color.setHex(0xffff00); // Yellow + orb.material.emissive.setHex(0xffff00); + orb.material.emissiveIntensity = 1.0; + orb.material.opacity = 0.9; + orbLight.color.setHex(0xffff00); + orbLight.intensity = 1.2; + glow.material.color.setHex(0xffff00); + glow.material.emissive.setHex(0xffff00); + glow.material.emissiveIntensity = 0.3; + } else { // SAFE + // orb.material.color.setHex(0x00ff00); // Green + orb.material.color.setHex(0x00ff00); + orb.material.emissive.setHex(0x00ff00); + orb.material.emissiveIntensity = 1.0; + orb.material.opacity = 0.9; + orbLight.color.setHex(0x00ff00); + orbLight.intensity = 1.2; + glow.material.color.setHex(0x00ff00); + glow.material.emissive.setHex(0x00ff00); + glow.material.emissiveIntensity = 0.3; + } + } + + triggerSpeakingAnimation(playerName) { + const player = this._playerObjects.get(playerName); + if (!player || !player.isAlive) return; + + const wave = this._createSoundWave(this._THREE); + player.container.add(wave); + + // Add the wave to our animation manager array + this._speakingAnimations.push({ + mesh: wave, + startTime: performance.now(), + duration: 1800, // Animation duration in milliseconds + }); + } + + _createSoundWave(THREE) { + const waveGeometry = new THREE.RingGeometry(0.5, 0.7, 32); + const waveMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.8, + side: THREE.DoubleSide, + }); + const wave = new THREE.Mesh(waveGeometry, waveMaterial); + + // Position the wave horizontally at the player's feet + wave.rotation.x = -Math.PI / 2; + wave.position.y = 0.25; // Slightly above the pedestal + return wave; + } + + _createVoteParticleTrail(voterName, targetName, color = 0x00ffff) { + const voter = this._playerObjects.get(voterName); + const target = this._playerObjects.get(targetName); + if (!voter || !target) return; + + const startPos = voter.container.position.clone(); + startPos.y += 1.5; // Start above the voter's head + const endPos = target.container.position.clone(); + endPos.y += 1.5; // End above the target's head + + const midPos = new this._THREE.Vector3().addVectors(startPos, endPos).multiplyScalar(0.5); + const dist = startPos.distanceTo(endPos); + midPos.y += dist * 0.3; // Arc height + + const curve = new this._THREE.CatmullRomCurve3([startPos, midPos, endPos]); + const particleCount = 50; + const particleGeometry = new this._THREE.BufferGeometry(); + const positions = new Float32Array(particleCount * 3); + particleGeometry.setAttribute('position', new this._THREE.BufferAttribute(positions, 3)); + + const particleMaterial = new this._THREE.PointsMaterial({ + color: color, + size: 0.3, + transparent: true, + opacity: 0.8, + blending: this._THREE.AdditiveBlending, + sizeAttenuation: true, + }); + + const particles = new this._THREE.Points(particleGeometry, particleMaterial); + this._votingArcsGroup.add(particles); + + const trail = { + particles, + curve, + target: targetName, + startTime: Date.now(), + update: () => { + const elapsedTime = (Date.now() - trail.startTime) / 1000; + const positions = trail.particles.geometry.attributes.position.array; + for (let i = 0; i < particleCount; i++) { + const t = (elapsedTime * 0.2 + (i / particleCount)) % 1; + const pos = trail.curve.getPointAt(t); + positions[i * 3] = pos.x; + positions[i * 3 + 1] = pos.y; + positions[i * 3 + 2] = pos.z; + } + trail.particles.geometry.attributes.position.needsUpdate = true; + } + }; + this._activeVoteArcs.set(voterName, trail); + + // Also add to a separate list for animation updates + if (!this._animatingTrails) this._animatingTrails = []; + this._animatingTrails.push(trail); + } + + _updateTargetRing(targetName, voteCount) { + const target = this._playerObjects.get(targetName); + if (!target) return; + + let ringData = this._activeTargetRings.get(targetName); + + if (voteCount > 0 && !ringData) { + const geometry = new this._THREE.RingGeometry(2, 2.2, 32); + const material = new this._THREE.MeshBasicMaterial({ + color: 0x00ffff, + transparent: true, + opacity: 0, // Start invisible, fade in + side: this._THREE.DoubleSide, + }); + const ring = new this._THREE.Mesh(geometry, material); + ring.position.copy(target.container.position); + ring.position.y = 0.1; + ring.rotation.x = -Math.PI / 2; + + this._targetRingsGroup.add(ring); + ringData = { ring, material, targetOpacity: 0 }; + this._activeTargetRings.set(targetName, ringData); + } + + if (ringData) { + if (voteCount > 0) { + ringData.targetOpacity = 0.3 + Math.min(voteCount * 0.2, 0.7); + } else { + ringData.targetOpacity = 0; + } + } + } + + updateVoteVisuals(votes, clearAll = false) { + if (!this._playerObjects || this._playerObjects.size === 0) return; + + if (clearAll) { + votes.clear(); + } + + // Remove arcs from players who are no longer voting or if clearing all + this._activeVoteArcs.forEach((trail, voterName) => { + if (!votes.has(voterName)) { + this._votingArcsGroup.remove(trail.particles); + this._activeVoteArcs.delete(voterName); + if (this._animatingTrails) { + this._animatingTrails = this._animatingTrails.filter(t => t !== trail); + } + } + }); + + + // Update existing arcs or create new ones + votes.forEach((voteData, voterName) => { + const { target: targetName, type } = voteData; + const existingTrail = this._activeVoteArcs.get(voterName); + + let color = 0x00ffff; // Default to cyan + if (type === 'night_vote') color = 0xff0000; // Red + else if (type === 'doctor_heal_action') color = 0x00ff00; // Green + else if (type === 'seer_inspection') color = 0x800080; // Purple + + if (existingTrail) { + if (existingTrail.target !== targetName) { + this._votingArcsGroup.remove(existingTrail.particles); + if (this._animatingTrails) { + this._animatingTrails = this._animatingTrails.filter(t => t !== existingTrail); + } + this._createVoteParticleTrail(voterName, targetName, color); + } + } else { + this._createVoteParticleTrail(voterName, targetName, color); + } + }); + + // Update target rings + const targetVoteCounts = new Map(); + votes.forEach((voteData) => { + const { target: targetName } = voteData; + targetVoteCounts.set(targetName, (targetVoteCounts.get(targetName) || 0) + 1); + }); + + this._playerObjects.forEach((player, playerName) => { + this._updateTargetRing(playerName, targetVoteCounts.get(playerName) || 0); + }); + } + + updatePhase(phase) { + if (!this._scene) return; + + // Handle various phase formats (DAY, NIGHT, or lowercase) + const normalizedPhase = (phase || 'DAY').toUpperCase(); + + // Calculate target phase value (0 = day, 1 = night) + const targetPhase = normalizedPhase === 'NIGHT' ? 1.0 : 0.0; + + // Initialize transition system if not exists + if (!this._phaseTransition) { + this._phaseTransition = { + current: targetPhase, + target: targetPhase, + speed: 0.05 // Increased transition speed for testing + }; + // Immediately set to target on first call + this._updateSceneForPhase(targetPhase); + } else if (this._phaseTransition.target !== targetPhase) { + // Only update if phase actually changed + this._phaseTransition.target = targetPhase; + } + } + + _updateSceneForPhase(phaseValue) { + const THREE = this._THREE; + + // Update renderer tone mapping for day/night mood + if (this._threejs) { + this._threejs.toneMappingExposure = 1.2 - phaseValue * 0.5; // Darker at night + } + + // Smoothly interpolate lighting + if (this._mainLight) { + const nightColor = new THREE.Color(0x6666cc); // More blue at night + const dayColor = new THREE.Color(0xffffcc); + this._mainLight.color.copy(dayColor).lerp(nightColor, phaseValue); + this._mainLight.intensity = 1.8 - phaseValue * 1.0; // Much dimmer at night + + // Animate light position for sun/moon movement + const angle = phaseValue * Math.PI * 0.3; + this._mainLight.position.set( + 30 * Math.cos(angle), + 50 - phaseValue * 20, + 20 * Math.sin(angle) + ); + } + + if (this._rimLight) { + const nightColor = new THREE.Color(0x6666ff); + const dayColor = new THREE.Color(0xffcc99); + this._rimLight.color.copy(dayColor).lerp(nightColor, phaseValue); + this._rimLight.intensity = 0.6 + phaseValue * 0.4; + } + + if (this._hemiLight) { + const nightSkyColor = new THREE.Color(0x4a4a6a); + const daySkyColor = new THREE.Color(0x87ceeb); + const nightGroundColor = new THREE.Color(0x1e1e3f); + const dayGroundColor = new THREE.Color(0x8b7355); + + this._hemiLight.color.copy(daySkyColor).lerp(nightSkyColor, phaseValue); + this._hemiLight.groundColor.copy(dayGroundColor).lerp(nightGroundColor, phaseValue); + this._hemiLight.intensity = 0.8 - phaseValue * 0.4; + } + + // Update ambient light + const ambientLight = this._scene.getObjectByName('ambientLight'); + if (ambientLight) { + const nightColor = new THREE.Color(0x404080); + const dayColor = new THREE.Color(0x606090); + ambientLight.color.copy(dayColor).lerp(nightColor, phaseValue); + ambientLight.intensity = 0.4 + phaseValue * 0.1; + } + + // Smoothly transition fog - more dramatic change + if (this._scene.fog) { + const nightFogColor = new THREE.Color(0x050515); // Very dark blue at night + const dayFogColor = new THREE.Color(0x2a2a4a); // Lighter blue during day + this._scene.fog.color.copy(dayFogColor).lerp(nightFogColor, phaseValue); + this._scene.fog.density = 0.01 + phaseValue * 0.015; // Denser fog at night + } + + // Update skybox + this._updateSkybox(phaseValue); + + // Update stars visibility + if (this._starsMaterial) { + this._starsMaterial.uniforms.phase.value = phaseValue; + } + + // Update atmosphere shader + if (this._atmospherePass) { + this._atmospherePass.uniforms.phase.value = phaseValue; + } + + // Update particle colors based on phase + if (this._particles && this._particles.geometry.attributes.color) { + const colors = this._particles.geometry.attributes.color.array; + for (let i = 0; i < colors.length; i += 3) { + // Shift particle hue based on phase + const baseHue = 0.5 + Math.random() * 0.3; // Base blue-purple range + const phaseShift = phaseValue * 0.1; // Shift towards purple at night + const hue = baseHue + phaseShift; + const saturation = 0.8 - phaseValue * 0.2; // Less saturated at night + const lightness = 0.6 - phaseValue * 0.2; // Darker at night + + const color = new THREE.Color().setHSL(hue, saturation, lightness); + colors[i] = color.r; + colors[i + 1] = color.g; + colors[i + 2] = color.b; + } + this._particles.geometry.attributes.color.needsUpdate = true; + } + + // Update bloom intensity based on phase - moderate during day, more at night + if (this._bloomPass) { + this._bloomPass.strength = 0.35 + phaseValue * 0.35; // Moderate bloom during day (0.35), stronger at night (0.7) + this._bloomPass.radius = 0.6 + phaseValue * 0.3; // Good radius during day (0.6), wider at night (0.9) + this._bloomPass.threshold = 0.4 - phaseValue * 0.15; // Balanced threshold + } + } + + _createNameplate(name, displayName, imageUrl, CSS2DObject) { + const container = document.createElement('div'); + container.style.backgroundColor = 'rgba(255, 255, 255, 0)'; + container.style.padding = '6px 10px'; // Slightly smaller padding + container.style.borderRadius = '8px'; + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.justifyContent = 'center'; + container.style.gap = '8px'; // Reduced gap + container.style.textAlign = 'center'; + + const img = document.createElement('img'); + img.src = imageUrl; + img.style.width = '40px'; // Reduced from 60px + img.style.height = '40px'; // Reduced from 60px + img.style.borderRadius = '50%'; + img.style.objectFit = 'cover'; + img.style.backgroundColor = 'white'; + img.style.border = '2px solid rgba(255, 255, 255, 0.3)'; + + const textContainer = document.createElement('div'); + textContainer.style.display = 'flex'; + textContainer.style.flexDirection = 'column'; + textContainer.style.alignItems = 'center'; + + const nameText = document.createElement('div'); + nameText.textContent = name; + nameText.style.color = 'white'; + nameText.style.fontFamily = 'Arial, sans-serif'; + nameText.style.fontSize = '14px'; + nameText.style.fontWeight = '500'; + textContainer.appendChild(nameText); + + if (displayName && displayName !== "" && displayName !== name) { + const displayNameText = document.createElement('div'); + displayNameText.textContent = displayName; + displayNameText.style.color = 'grey'; + displayNameText.style.fontSize = '12px'; + displayNameText.style.fontFamily = 'Arial, sans-serif'; + displayNameText.style.marginTop = '4px'; + textContainer.appendChild(displayNameText); + } + + container.appendChild(img); + container.appendChild(textContainer); + + const label = new CSS2DObject(container); + return label; + } + + _FrameGroup(group, THREE) { + const box = new THREE.Box3().setFromObject(group); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + + const maxDim = Math.max(size.x, size.y, size.z); + const fov = this._camera.fov * (Math.PI / 180); + + let cameraZ = Math.abs(maxDim / Math.tan(fov / 2)); + cameraZ *= 0.5; + + this._camera.position.set(center.x, center.y, center.z - cameraZ); + + const shiftY = size.y / 2.5; + this._camera.position.y += shiftY; + + const newTarget = center.clone(); + newTarget.y += shiftY; + this._controls.target.copy(newTarget); + this._controls.update(); + } + + _NormalizeModel(model, THREE) { + const box = new THREE.Box3().setFromObject(model); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + model.position.copy(center).negate(); + const wrapper = new THREE.Group(); + wrapper.add(model); + const scale = 1.0 / size.y; + wrapper.scale.set(scale, scale, scale); + return wrapper; + } + + _RAF() { + requestAnimationFrame((time) => { + // Animate phase transition with visual feedback + if (this._phaseTransition) { + const diff = this._phaseTransition.target - this._phaseTransition.current; + if (Math.abs(diff) > 0.001) { + this._phaseTransition.current += diff * this._phaseTransition.speed; + this._updateSceneForPhase(this._phaseTransition.current); + + // Log transition progress occasionally + // if (Math.floor(time / 1000) % 5 === 0 && Math.abs(diff) > 0.01) { + // console.log(`Phase transitioning: ${this._phaseTransition.current.toFixed(2)} → ${this._phaseTransition.target}`); + // } + } + } + + // Update time-based uniforms + if (this._particleMaterial) { + this._particleMaterial.uniforms.time.value = time * 0.001; + } + if (this._atmospherePass) { + this._atmospherePass.uniforms.time.value = time * 0.001; + } + + // Animate particle system with phase-aware movement + if (this._particles) { + const phaseValue = this._phaseTransition ? this._phaseTransition.current : 0; + // Slower rotation at night + this._particles.rotation.y = time * 0.0001 * (1 - phaseValue * 0.5); + + const positions = this._particles.geometry.attributes.position.array; + for (let i = 0; i < positions.length; i += 3) { + // More gentle movement at night + const movementScale = 1 - phaseValue * 0.5; + positions[i + 1] += Math.sin(time * 0.001 + positions[i] * 0.01) * 0.02 * movementScale; + // Wrap around if particles fall too low + if (positions[i + 1] < 0) { + positions[i + 1] = 35; + } + } + this._particles.geometry.attributes.position.needsUpdate = true; + } + + // Animate stars twinkling + if (this._stars && this._phaseTransition && this._phaseTransition.current > 0.5) { + const sizes = this._stars.geometry.attributes.size.array; + for (let i = 0; i < sizes.length; i++) { + sizes[i] = (Math.random() * 2 + 0.5) * (0.8 + Math.sin(time * 0.001 + i) * 0.2); + } + this._stars.geometry.attributes.size.needsUpdate = true; + } + + // Use performance.now() for more precise animation timing + const now = performance.now(); + + if (this._cameraAnimation) { + const anim = this._cameraAnimation; + const elapsed = now - anim.startTime; + let progress = Math.min(elapsed / anim.duration, 1.0); + + // Apply easing function + const easedProgress = anim.ease(progress); + + // Interpolate camera position and controls target + this._camera.position.lerpVectors(anim.startPos, anim.endPos, easedProgress); + this._controls.target.lerpVectors(anim.startTarget, anim.endTarget, easedProgress); + this._controls.update(); + + // If animation is complete, clear it + if (progress >= 1.0) { + this._cameraAnimation = null; + } + } + + // Animate speaking sound waves + this._speakingAnimations = this._speakingAnimations.filter(anim => { + const elapsedTime = now - anim.startTime; + if (elapsedTime >= anim.duration) { + // Animation is over, remove the mesh from the scene + if (anim.mesh.parent) { + anim.mesh.parent.remove(anim.mesh); + } + // Clean up Three.js objects to free memory + anim.mesh.geometry.dispose(); + anim.mesh.material.dispose(); + return false; // Remove from the animations array + } + + // Calculate animation progress (from 0.0 to 1.0) + const progress = elapsedTime / anim.duration; + + // Make the wave expand and fade out + anim.mesh.scale.setScalar(1 + progress * 5); + anim.mesh.material.opacity = 0.8 * (1 - progress); + + return true; // Keep the animation in the array + }); + + // Animate player objects with enhanced effects + if (this._playerObjects) { + this._playerObjects.forEach((player, name) => { + if (player.isAlive) { + // Enhanced floating animation for alive players + const floatOffset = Math.sin(time * 0.001 + player.baseAngle) * 0.2; + const bobOffset = Math.cos(time * 0.0015 + player.baseAngle * 2) * 0.05; + player.container.position.y = floatOffset + bobOffset; + + // More dynamic orb rotation + player.orb.rotation.y = time * 0.003; + player.orb.rotation.x = Math.sin(time * 0.002) * 0.15; + player.orb.rotation.z = Math.cos(time * 0.0025) * 0.1; + + // Enhanced glow animation + if (player.glow && player.glow.visible) { + player.glow.rotation.y = -time * 0.002; + const glowScale = 1 + Math.sin(time * 0.004 + player.baseAngle) * 0.15; + player.glow.scale.setScalar(glowScale); + + // Pulsing emissive intensity + const pulseIntensity = 0.3 + Math.sin(time * 0.005 + player.baseAngle) * 0.1; + player.glow.material.emissiveIntensity = pulseIntensity; + } + + // Enhanced pulse effect for active players + if (player.container.scale.x > 1.0) { + const pulseScale = 1.05 + Math.sin(time * 0.008) * 0.08; + player.container.scale.setScalar(pulseScale); + } + + // Enhanced breathing effect + if (player.body) { + const breathScale = 1 + Math.sin(time * 0.002 + player.baseAngle) * 0.03; + player.body.scale.y = breathScale; + if (player.shoulders) { + player.shoulders.scale.y = 0.6 * breathScale; + } + } + + // Subtle head movement + if (player.head) { + player.head.rotation.y = Math.sin(time * 0.001 + player.baseAngle) * 0.1; + } + } else { + // Dead players have reduced animation + if (player.orb) { + player.orb.rotation.y = time * 0.0008; + } + } + }); + } + + // Animate voting trails + if (this._animatingTrails) { + this._animatingTrails.forEach(trail => trail.update()); + } + + // Animate target rings + if (this._activeTargetRings) { + this._activeTargetRings.forEach((ringData, targetName) => { + const diff = ringData.targetOpacity - ringData.material.opacity; + if (Math.abs(diff) > 0.01) { + ringData.material.opacity += diff * 0.1; + } else if (ringData.targetOpacity === 0 && ringData.material.opacity > 0) { + this._targetRingsGroup.remove(ringData.ring); + this._activeTargetRings.delete(targetName); + } + }); + } + + // Use post-processing composer if available, otherwise fallback to direct render + if (this._composer) { + this._composer.render(); + } else { + this._threejs.render(this._scene, this._camera); + } + this._labelRenderer.render(this._scene, this._camera); + this._RAF(); + }); + } + } + + setupScene(BasicWorldDemo); + } catch (error) { + console.error("Failed to load Three.js modules:", error); + parent.textContent = "Error loading 3D assets. Please refresh."; + } + }; + + loadAndSetup(); + } + + function setupScene(BasicWorldDemo) { + if (threeState.initialized) return; + threeState.demo = new BasicWorldDemo({ parent, width, height }); + threeState.initialized = true; + } + + function updateSceneFromGameState(gameState, playerMap, actingPlayerName) { + if (!threeState.demo || !threeState.demo._playerObjects) return; + + const logUpToCurrentStep = gameState.eventLog; + const lastEvent = logUpToCurrentStep.length > 0 ? logUpToCurrentStep[logUpToCurrentStep.length - 1] : null; + + // Determine correct phase from the last event log entry + let phase = gameState.game_state_phase; // Default + if (lastEvent && lastEvent.phase) { + phase = lastEvent.phase; + } + + // Update player statuses + gameState.players.forEach(player => { + const playerObj = threeState.demo._playerObjects.get(player.name); + if (!playerObj) return; + + const threatLevel = gameState.playerThreatLevels.get(player.name) || 0; + + let primaryStatus = 'default'; // Default for alive players in daytime. + if (!player.is_alive) { + primaryStatus = 'dead'; + } else if (player.role === 'Werewolf' && phase.toUpperCase() === 'NIGHT') { + primaryStatus = 'werewolf'; + } else if (player.role === 'Doctor' && phase.toUpperCase() === 'NIGHT') { + primaryStatus = 'doctor'; + } else if (player.role === 'Seer' && phase.toUpperCase() === 'NIGHT') { + primaryStatus = 'seer'; + } + + threeState.demo.updatePlayerStatus(player.name, primaryStatus, threatLevel); + }); + + // Update phase lighting + threeState.demo.updatePhase(phase); + + // --- Vote Visualization Logic --- + const currentVotes = new Map(); + + // Find the start of the current voting/action session + const lastNightStart = logUpToCurrentStep.findLastIndex(e => e.type === 'phase_divider' && e.divider === 'NIGHT START'); + const lastDayVoteStart = logUpToCurrentStep.findLastIndex(e => e.type === 'phase_divider' && e.divider === 'DAY VOTE START'); + const sessionStartIndex = Math.max(lastNightStart, lastDayVoteStart); + + let isVotingSession = false; + if (sessionStartIndex > -1) { + const lastOutcomeEventIndex = logUpToCurrentStep.findLastIndex(e => e.type === 'exile' || e.type === 'elimination' || e.type === 'save'); + // A session is active if it started after the last outcome, OR if the outcome is the current event. + if (sessionStartIndex > lastOutcomeEventIndex || (lastOutcomeEventIndex > -1 && lastOutcomeEventIndex === logUpToCurrentStep.length - 1)) { + isVotingSession = true; + } + } + + if (isVotingSession) { + const alivePlayerNames = new Set(gameState.players.filter(p => p.is_alive).map(p => p.name)); + const relevantEvents = logUpToCurrentStep.slice(sessionStartIndex); + for (const event of relevantEvents) { + if (event.type === 'vote' || event.type === 'night_vote' || event.type === 'doctor_heal_action' || event.type === 'seer_inspection') { + if (alivePlayerNames.has(event.actor_id)) { + currentVotes.set(event.actor_id, { target: event.target, type: event.type }); + } + } else if (event.type === 'timeout') { + currentVotes.delete(event.actor_id); + } + } + } + + const clearVotingVisuals = !isVotingSession; + threeState.demo.updateVoteVisuals(currentVotes, clearVotingVisuals); + + + // Spotlight logic for night actions + if (threeState.demo._spotLight) { + const lastEvent = gameState.eventLog[gameState.eventLog.length - 1]; + const nightActor = (gameState.game_state_phase === 'NIGHT' && lastEvent && lastEvent.actor_id && ['WerewolfNightVoteDataEntry', 'DoctorHealActionDataEntry', 'SeerInspectActionDataEntry'].includes(lastEvent.dataType)) ? lastEvent.actor_id : null; + + if (nightActor) { + const actorPlayer = threeState.demo._playerObjects.get(nightActor); + if (actorPlayer) { + const targetPosition = actorPlayer.container.position.clone(); + threeState.demo._spotLight.target.position.copy(targetPosition); + threeState.demo._spotLight.position.set(targetPosition.x, targetPosition.y + 20, targetPosition.z + 5); + threeState.demo._spotLight.visible = true; + } else { + threeState.demo._spotLight.visible = false; + } + } else { + threeState.demo._spotLight.visible = false; + } + } + + // Handle animation for the current event actor + if (lastEvent) { + if (lastEvent.event_name === 'moderator_announcement') { + // Moderator is speaking, expand all alive players + gameState.players.forEach(player => { + if (player.is_alive) { + threeState.demo.updatePlayerActive(player.name); + } + }); + } else if (lastEvent.actor_id && playerMap.has(lastEvent.actor_id)) { + // A player is the actor + const actorName = lastEvent.actor_id; + threeState.demo.updatePlayerActive(actorName); + + // If the action was speaking, trigger the sound wave animation + if (lastEvent.type === 'chat' && threeState.demo.triggerSpeakingAnimation) { + threeState.demo.triggerSpeakingAnimation(actorName); + } + } + } + } + + // --- CSS for the UI --- + const css = ` + /* Game Status Scoreboard */ + .game-scoreboard { + position: fixed; + top: 70px; + left: 50%; + transform: translateX(-50%); + z-index: 999; + background: linear-gradient(135deg, rgba(33, 40, 54, 0.95), rgba(44, 52, 68, 0.95)); + backdrop-filter: blur(15px); + border: 1px solid rgba(116, 185, 255, 0.3); + border-radius: 12px; + padding: 12px 20px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + display: flex; + gap: 20px; + align-items: center; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + pointer-events: none; + } + + .scoreboard-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 10px; + border-right: 1px solid rgba(116, 185, 255, 0.2); + } + + .scoreboard-item:last-child { + border-right: none; + } + + .scoreboard-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; + font-weight: 500; + } + + .scoreboard-value { + font-size: 1.1rem; + color: var(--text-primary); + font-weight: 600; + } + + .scoreboard-value.alive { + color: #00b894; + } + + .scoreboard-value.dead { + color: #e17055; + } + + .scoreboard-value.werewolf { + color: #e17055; + } + + .scoreboard-value.villager { + color: #74b9ff; + } + + .scoreboard-action { + background: linear-gradient(135deg, rgba(116, 185, 255, 0.2), rgba(116, 185, 255, 0.1)); + border: 1px solid rgba(116, 185, 255, 0.3); + border-radius: 8px; + padding: 6px 12px; + font-size: 0.9rem; + color: #74b9ff; + font-weight: 500; + animation: pulse 2s infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } + } + + /* Phase Indicator */ + .phase-indicator { + position: fixed; + top: 60px; + left: 50px; + transform: translateX(-50%); + z-index: 1000; + padding: 12px 24px; + border-radius: 30px; + font-size: 1.2rem; + font-weight: 600; + letter-spacing: 0.05em; + transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + backdrop-filter: blur(10px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + scale: 0.6; + } + + .phase-indicator.day { + background: linear-gradient(135deg, rgba(255, 220, 100, 0.9), rgba(255, 180, 50, 0.9)); + color: #2d3436; + border: 2px solid rgba(255, 255, 255, 0.5); + } + + .phase-indicator.night { + background: linear-gradient(135deg, rgba(30, 30, 60, 0.9), rgba(60, 60, 120, 0.9)); + color: #f8f9fa; + border: 2px solid rgba(100, 100, 200, 0.5); + } + + .phase-indicator .phase-icon { + display: inline-block; + margin-right: 8px; + font-size: 1.4rem; + vertical-align: middle; + } + + :root { + --night-bg: linear-gradient(135deg, #1a1a2e, #16213e); + --day-bg: linear-gradient(135deg, #74b9ff, #0984e3); + --night-text: #f8f9fa; + --day-text: #2d3436; + --dead-filter: grayscale(100%) brightness(40%) contrast(0.8); + --active-border: #fdcb6e; + --active-glow: rgba(253, 203, 110, 0.4); + --werewolf-color: #e17055; + --villager-color: #00b894; + --doctor-color: #6c5ce7; + --seer-color: #fd79a8; + --panel-bg: rgba(33, 40, 54, 0.95); + --panel-border: rgba(116, 185, 255, 0.2); + --hover-bg: rgba(116, 185, 255, 0.1); + --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + --text-primary: #f8f9fa; + --text-secondary: #b2bec3; + --text-muted: #74b9ff; + } + + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + + .werewolf-parent { + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + background: radial-gradient(ellipse at center, #0f1419 0%, #000000 100%); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + .main-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + pointer-events: none; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: var(--text-primary); + font-weight: 400; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Enhanced Panel Styling */ + .left-panel, .right-panel { + position: fixed; + top: 54px; + max-height: calc(100vh - 124px); + background: var(--panel-bg); + backdrop-filter: blur(20px) saturate(1.5); + border-radius: 16px; + border: 1px solid var(--panel-border); + padding: 20px; + display: flex; + flex-direction: column; + box-sizing: border-box; + pointer-events: auto; + box-shadow: var(--card-shadow), 0 0 40px rgba(116, 185, 255, 0.05); + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .left-panel { + left: 20px; + width: 320px; + } + + .right-panel { + right: 20px; + width: 420px; + } + + .left-panel:hover, .right-panel:hover { + border-color: rgba(116, 185, 255, 0.3); + box-shadow: var(--card-shadow), 0 0 60px rgba(116, 185, 255, 0.08); + } + + /* Enhanced Headers */ + .right-panel h1, #player-list-area h1 { + margin: 0 0 20px 0; + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary); + position: relative; + padding-bottom: 15px; + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + } + + .right-panel h1 > span, #player-list-area h1 > span { + background: linear-gradient(135deg, #74b9ff, #0984e3); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .right-panel h1::after, #player-list-area h1::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 60px; + height: 3px; + background: linear-gradient(90deg, transparent, #74b9ff, transparent); + border-radius: 2px; + } + + #global-reasoning-toggle { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + transition: all 0.2s ease; + } + #global-reasoning-toggle:hover { + background-color: var(--hover-bg); + color: var(--text-primary); + } + #global-reasoning-toggle svg { + stroke: currentColor; + width: 20px; + height: 20px; + } + + .reset-view-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + transition: all 0.2s ease; + margin-left: 8px; /* Add some space */ + } + .reset-view-btn:hover { + background-color: var(--hover-bg); + color: var(--text-primary); + } + .reset-view-btn svg { + stroke: currentColor; + width: 20px; + height: 20px; + } + + /* Enhanced Player List */ + #player-list-area { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + + #player-list-container { + overflow-y: auto; + flex-grow: 1; + padding-right: 8px; + margin-right: -8px; + } + + #player-list-container::-webkit-scrollbar { + width: 6px; + } + + #player-list-container::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; + } + + #player-list-container::-webkit-scrollbar-thumb { + background: rgba(116, 185, 255, 0.3); + border-radius: 3px; + } + + #player-list-container::-webkit-scrollbar-thumb:hover { + background: rgba(116, 185, 255, 0.5); + } + + #player-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; + } + + /* Enhanced Player Cards */ + .player-card { + position: relative; + display: flex; + align-items: center; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)); + padding: 16px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + overflow: hidden; + } + + .player-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: transparent; + transition: all 0.3s ease; + } + + .player-card:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.06)); + border-color: rgba(116, 185, 255, 0.3); + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + } + + .player-card.active { + background: linear-gradient(135deg, rgba(253, 203, 110, 0.15), rgba(253, 203, 110, 0.05)); + border-color: var(--active-border); + box-shadow: 0 0 20px var(--active-glow), 0 4px 20px rgba(0, 0, 0, 0.1); + } + + .player-card.active::before { + background: linear-gradient(180deg, var(--active-border), rgba(253, 203, 110, 0.5)); + } + + .player-card.dead { + opacity: 0.5; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)); + filter: brightness(0.7); + } + + /* Enhanced Avatar */ + .avatar-container { + position: relative; + width: 40px; + height: 40px; + margin-right: 16px; + flex-shrink: 0; + } + + .player-card .avatar { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + background-color: #ffffff; + border: 2px solid rgba(116, 185, 255, 0.2); + transition: all 0.3s ease; + } + + .player-card:hover .avatar { + border-color: rgba(116, 185, 255, 0.4); + box-shadow: 0 0 15px rgba(116, 185, 255, 0.2); + } + + .player-card.active .avatar { + border-color: var(--active-border); + box-shadow: 0 0 15px var(--active-glow); + } + + .player-card.dead .avatar { + filter: var(--dead-filter); + border-color: rgba(255, 255, 255, 0.1); + } + + /* Enhanced Player Info */ + .player-info { + flex-grow: 1; + overflow: hidden; + min-width: 0; + } + + .player-name { + font-weight: 600; + font-size: 1.1rem; + margin-bottom: 4px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; + } + + .player-role { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + } + + .player-role.werewolf { color: var(--werewolf-color); } + .player-role.villager { color: var(--villager-color); } + .player-role.doctor { color: var(--doctor-color); } + .player-role.seer { color: var(--seer-color); } + + .display-name { + font-size: 0.8em; + color: #888; + margin-left: 5px; + } + + /* Enhanced Threat Indicator */ + .threat-indicator { + position: absolute; + top: 12px; + right: 12px; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: transparent; + transition: all 0.3s ease; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); + } + + /* Enhanced Chat/Event Log */ + #chat-log { + list-style: none; + padding: 0; + margin: 0; + flex-grow: 1; + overflow-y: auto; + padding-right: 8px; + margin-right: -8px; + } + + #chat-log::-webkit-scrollbar { + width: 6px; + } + + #chat-log::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; + } + + #chat-log::-webkit-scrollbar-thumb { + background: rgba(116, 185, 255, 0.3); + border-radius: 3px; + } + + #chat-log::-webkit-scrollbar-thumb:hover { + background: rgba(116, 185, 255, 0.5); + } + + /* Enhanced Chat Entries */ + .chat-entry { + display: flex; + margin-bottom: 20px; + align-items: flex-start; + animation: fadeInUp 0.3s ease-out; + } + + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .chat-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + margin-right: 12px; + object-fit: cover; + flex-shrink: 0; + border: 2px solid rgba(116, 185, 255, 0.2); + transition: all 0.3s ease; + background-color: #ffffff; + } + + .chat-entry:hover .chat-avatar { + border-color: rgba(116, 185, 255, 0.4); + } + + .message-content { + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 0; + } + + /* Enhanced Message Bubbles */ + .balloon { + padding: 14px 16px; + border-radius: 16px 16px 16px 4px; + max-width: 85%; + word-wrap: break-word; + background: linear-gradient(135deg, rgba(116, 185, 255, 0.1), rgba(116, 185, 255, 0.05)); + border: 1px solid rgba(116, 185, 255, 0.2); + transition: all 0.3s ease; + position: relative; + line-height: 1.4; + font-size: 0.95rem; + } + + .balloon:hover { + background: linear-gradient(135deg, rgba(116, 185, 255, 0.15), rgba(116, 185, 255, 0.08)); + border-color: rgba(116, 185, 255, 0.3); + } + + .chat-entry.event-day .balloon { + background: linear-gradient(135deg, rgba(255, 193, 7, 0.1), rgba(255, 193, 7, 0.05)); + border-color: rgba(255, 193, 7, 0.2); + color: var(--text-primary); + } + + .chat-entry.event-night .balloon { + background: linear-gradient(135deg, rgba(108, 92, 231, 0.1), rgba(108, 92, 231, 0.05)); + border-color: rgba(108, 92, 231, 0.2); + } + + /* Enhanced System Messages */ + .msg-entry { + border-left: 4px solid #f39c12; + padding: 16px; + margin: 16px 0; + border-radius: 8px; + background: linear-gradient(135deg, rgba(243, 156, 18, 0.1), rgba(243, 156, 18, 0.05)); + border: 1px solid rgba(243, 156, 18, 0.2); + transition: all 0.3s ease; + animation: fadeInUp 0.3s ease-out; + } + + .msg-entry:hover { + background: linear-gradient(135deg, rgba(243, 156, 18, 0.15), rgba(243, 156, 18, 0.08)); + border-color: rgba(243, 156, 18, 0.3); + } + + .msg-entry.event-day { + background: linear-gradient(135deg, rgba(255, 193, 7, 0.1), rgba(255, 193, 7, 0.05)); + border-color: rgba(255, 193, 7, 0.2); + } + + .msg-entry.event-night { + background: linear-gradient(135deg, rgba(108, 92, 231, 0.1), rgba(108, 92, 231, 0.05)); + border-color: rgba(108, 92, 231, 0.2); + } + + .msg-entry.game-event { + border-left-color: #e74c3c; + background: linear-gradient(135deg, rgba(231, 76, 60, 0.1), rgba(231, 76, 60, 0.05)); + border-color: rgba(231, 76, 60, 0.2); + } + + .msg-entry.game-win { + border-left-color: #2ecc71; + background: linear-gradient(135deg, rgba(46, 204, 113, 0.1), rgba(46, 204, 113, 0.05)); + border-color: rgba(46, 204, 113, 0.2); + line-height: 1.6; + } + + /* Enhanced Reasoning Text */ + .reasoning-text { + font-size: 0.85rem; + color: var(--text-muted); + font-style: italic; + margin-top: 8px; + padding-left: 12px; + border-left: 2px solid rgba(116, 185, 255, 0.3); + line-height: 1.4; + font-family: 'JetBrains Mono', monospace; + display: none; + } + .reasoning-text.visible { + display: block; + } + .reasoning-toggle { + cursor: pointer; + font-size: 1rem; + margin-left: 5; + opacity: 0.6; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + } + .reasoning-toggle:hover { + opacity: 1; + } + .msg-entry .reasoning-toggle { + float: right; + } + + /* Enhanced Citations */ + #chat-log cite { + font-style: normal; + font-weight: 600; + display: flex; + align-items: center; + font-size: 0.9rem; + color: var(--text-primary); + margin-bottom: 6px; + gap: 8px; + } + + .cite-text-wrapper { + display: flex; + flex-direction: column; + } + + /* Enhanced Moderator Announcements */ + .moderator-announcement { + margin: 16px 0; + animation: fadeInUp 0.3s ease-out; + } + + .moderator-announcement-content { + padding: 16px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(46, 204, 113, 0.1), rgba(46, 204, 113, 0.05)); + border: 1px solid rgba(46, 204, 113, 0.2); + border-left: 4px solid #2ecc71; + color: var(--text-primary); + line-height: 1.5; + transition: all 0.3s ease; + } + + .moderator-announcement-content:hover { + background: linear-gradient(135deg, rgba(46, 204, 113, 0.15), rgba(46, 204, 113, 0.08)); + border-color: rgba(46, 204, 113, 0.3); + } + + /* Enhanced Timestamps */ + .timestamp { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; + font-family: 'JetBrains Mono', monospace; + background: rgba(116, 185, 255, 0.1); + padding: 2px 6px; + border-radius: 4px; + margin-left: auto; + } + + /* Enhanced Player Capsules */ + .player-capsule { + display: inline-flex; + align-items: center; + background: linear-gradient(135deg, rgba(116, 185, 255, 0.15), rgba(116, 185, 255, 0.08)); + border: 1px solid rgba(116, 185, 255, 0.2); + border-radius: 16px; + padding: 2px 10px 2px 2px; + font-size: 0.875rem; + font-weight: 500; + margin: 0 2px; + vertical-align: middle; + transition: all 0.3s ease; + } + + .player-capsule:hover { + background: linear-gradient(135deg, rgba(116, 185, 255, 0.2), rgba(116, 185, 255, 0.1)); + border-color: rgba(116, 185, 255, 0.3); + } + + .capsule-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + margin-right: 6px; + object-fit: cover; + border: 1px solid rgba(255, 255, 255, 0.2); + background-color: #ffffff; + } + + .capsule-display-name { + font-size: 0.9em; + color: #888; + margin-left: 5px; + } + + /* Enhanced TTS Button */ + .tts-button { + cursor: pointer; + font-size: 1.1rem; + margin-left: 12px; + padding: 4px; + border-radius: 50%; + transition: all 0.3s ease; + opacity: 0.6; + } + + .tts-button:hover { + opacity: 1; + background: rgba(116, 185, 255, 0.1); + transform: scale(1.1); + } + + /* Enhanced Audio Controls */ + .audio-controls { + padding: 16px 0; + border-top: 1px solid rgba(116, 185, 255, 0.2); + margin-top: 16px; + background: rgba(255, 255, 255, 0.02); + border-radius: 8px; + padding: 16px; + } + + .audio-controls label { + display: block; + margin-bottom: 8px; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + } + + .audio-controls input[type="range"] { + width: 100%; + height: 6px; + border-radius: 3px; + background: rgba(116, 185, 255, 0.2); + outline: none; + -webkit-appearance: none; + } + + .audio-controls input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #74b9ff; + cursor: pointer; + border: 2px solid #ffffff; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + } + + #pause-audio { + background-color: rgba(116, 185, 255, 0.1); + border: 1px solid rgba(116, 185, 255, 0.3); + border-radius: 50%; + width: 36px; + height: 36px; + cursor: pointer; + padding: 0; + background-size: 16px; + background-repeat: no-repeat; + background-position: center; + transition: all 0.3s ease; + filter: none; + } + + #pause-audio:hover { + background-color: rgba(116, 185, 255, 0.2); + border-color: rgba(116, 185, 255, 0.5); + transform: scale(1.1); + } + + #pause-audio.paused { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiM3NGI5ZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWdvbiBwb2ludHM9IjYgMyAyMCAxMiA2IDIxIi8+PC9zdmc+'); + } + + #pause-audio.playing { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiM3NGI5ZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB4PSI2IiB5PSI0IiB3aWR0aD0iNCIgaGVpZ2h0PSIxNiIgcng9IjEiLz48cmVjdCB4PSIxNCIgeT0iNCIgd2lkdGg9IjQiIGhlaWdodD0iMTYiIHJ4PSIxIi8+PC9zdmc+'); + } + + /* Message text formatting */ + .msg-text { + line-height: 1.5; + font-size: 0.95rem; + } + + .msg-text br { + display: block; + margin-bottom: 0.5em; + content: ""; + } + + /* Smooth scrolling */ + * { + scrollbar-width: thin; + scrollbar-color: rgba(116, 185, 255, 0.3) transparent; + } + `; + + // --- TTS Management --- + const audioMap = window.AUDIO_MAP || {}; + + if (!window.kaggleWerewolf) { + window.kaggleWerewolf = { + audioQueue: [], + isAudioPlaying: false, + isAudioEnabled: false, + isPaused: false, + lastPlayedStep: -1, + audioPlayer: new Audio(), + playbackRate: 1.4, + }; + } + const audioState = window.kaggleWerewolf; + + function togglePause() { + audioState.isPaused = !audioState.isPaused; + const pauseButton = parent.querySelector('#pause-audio'); + if (pauseButton) { + pauseButton.classList.toggle('paused', audioState.isPaused); + pauseButton.classList.toggle('playing', !audioState.isPaused); + } + if (!audioState.isPaused && !audioState.isAudioPlaying) { + playNextInQueue(); + } else if (audioState.isPaused && audioState.isAudioPlaying) { + audioState.audioPlayer.pause(); + } else if (!audioState.isPaused && audioState.isAudioPlaying) { + audioState.audioPlayer.play(); + } + } + + function setPlaybackRate(rate) { + audioState.playbackRate = rate; + if (audioState.isAudioPlaying) { + audioState.audioPlayer.playbackRate = rate; + } + } + + function playNextInQueue() { + if (audioState.isPaused || audioState.isAudioPlaying || audioState.audioQueue.length === 0 || !audioState.isAudioEnabled) { + return; + } + audioState.isAudioPlaying = true; + const event = audioState.audioQueue.shift(); + const audioKey = event.speaker === 'moderator' ? `moderator:${event.message}` : `${event.speaker}:${event.message}`; + const audioPath = audioMap[audioKey]; + + if (audioPath) { + audioState.audioPlayer.src = audioPath; + audioState.audioPlayer.playbackRate = audioState.playbackRate; + audioState.audioPlayer.onended = () => { + audioState.isAudioPlaying = false; + if (!audioState.isPaused) { + playNextInQueue(); + } + }; + audioState.audioPlayer.onerror = () => { + console.error("Audio playback failed for key:", audioKey); + audioState.isAudioPlaying = false; + playNextInQueue(); + }; + audioState.audioPlayer.play().catch(e => { + console.error("Audio playback failed:", e); + audioState.isAudioPlaying = false; + playNextInQueue(); + }); + } else { + console.warn(`No audio found for key: "${audioKey}"`); + audioState.isAudioPlaying = false; + playNextInQueue(); + } + } + + if (!parent.dataset.audioListenerAttached) { + parent.dataset.audioListenerAttached = 'true'; + parent.addEventListener('click', () => { + if (!audioState.isAudioEnabled) { + audioState.isAudioEnabled = true; + const audio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA'); + audio.play().catch(e => console.warn("Audio context activation failed:", e)); + playNextInQueue(); + } + }, { once: true }); + } + + function speak(message, speaker) { + if (audioState.isAudioEnabled) { + audioState.audioQueue.push({ message, speaker }); + if (!audioState.isAudioPlaying) { + playNextInQueue(); + } + } + } + + // --- Helper Functions --- + function formatTimestamp(isoString) { + if (!isoString) return ''; + try { + return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); + } catch (e) { + return ''; + } + } + + /** + * Creates a memoized function to replace player IDs with HTML capsules. + * This function pre-computes and caches sorted player data for efficiency. + * @param {Map} playerMap - A map from player ID to player object. + * @returns {function(string): string} A function that takes text and returns it with player IDs replaced. + */ + function createPlayerIdReplacer(playerMap) { + // Cache for already processed text strings (memoization) + const textCache = new Map(); + + // --- Pre-computation Cache --- + const sortedPlayerReplacements = [...playerMap.keys()] + .sort((a, b) => b.length - a.length) // Sort by length to match longest names first + .map(playerId => { + const player = playerMap.get(playerId); + if (!player) return null; + + return { + capsule: createPlayerCapsule(player), + // IMPROVEMENT: This new regex correctly handles both internal periods in names (e.g., 'gemini-1.5-pro') + // and sentence-ending periods (e.g., '... says Kai.'). + // Breakdown: + // 1. (^|[^\w.-]) - The prefix boundary must not be a name character. This is unchanged. + // 2. (PLAYER_ID) - The player's name. + // 3. (\.?) - Optionally captures a single trailing period. + // 4. (?![-\w]) - A negative lookahead asserts that the name is not followed by another name character (a-z, 0-9, _, -). + // This is the key part that allows a trailing period to be treated as a boundary. + regex: new RegExp(`(^|[^\\w.-])(${playerId.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')})(\\.?)(?![\\w-])`, 'g') + }; + }).filter(Boolean); + + return function (text) { + if (!text) return ''; + if (textCache.has(text)) { + return textCache.get(text); + } + + let newText = text; + for (const replacement of sortedPlayerReplacements) { + // The replacement string now uses $3 to append the optionally captured period after the capsule. + newText = newText.replace(replacement.regex, `$1${replacement.capsule}$3`); + } + + textCache.set(text, newText); + return newText; + }; + } + + function createPlayerCapsule(player) { + if (!player) return ''; + let display_name_elem = (player.display_name && (player.name !== player.display_name)) ? `${player.display_name}` : ""; + return ` + ${player.name} + ${player.name}${display_name_elem} + `; + } + + function replacePlayerIdsWithCapsules(text, playerIds, playerMap) { + if (!text) return ''; + if (!playerIds || playerIds.length === 0) { + return text; + } + let newText = text; + const sortedPlayerIds = [...playerIds].sort((a, b) => b.length - a.length); + + sortedPlayerIds.forEach(playerId => { + const player = playerMap.get(playerId); + if (player) { + const capsule = createPlayerCapsule(player); + const escapedPlayerId = playerId.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + + // Using the same improved regex as in the factory function. + const regex = new RegExp(`(^|[^\\w.-])(${escapedPlayerId})(\\.?)(?![\\w-])`, 'g'); + + // The replacement correctly places the captured prefix ($1) and optional period ($3) around the capsule. + newText = newText.replace(regex, `$1${capsule}$3`); + } + }); + return newText; + } + + function replacePlayerIdsWithBold(text, playerIds) { + if (!text) return ''; + if (!playerIds || playerIds.length === 0) { + return text; + } + let newText = text; + const sortedPlayerIds = [...playerIds].sort((a, b) => b.length - a.length); + + sortedPlayerIds.forEach(playerId => { + const regex = new RegExp(`\b${playerId.replace(/[-\/\\^$*+?.()|[\\]{}/g, '\\$&')}\b`, 'g'); + newText = newText.replace(regex, `${playerId}`); + }); + return newText; + } + + + function getThreatColor(threatLevel) { + const value = Math.max(0, Math.min(1, threatLevel)); + const hue = 120 * (1 - value); + return `hsl(${hue}, 100%, 50%)`; + } + + function updatePlayerList(container, gameState, actingPlayerName) { + // Get or create header + let header = container.querySelector('h1'); + if (!header) { + header = document.createElement('h1'); + // Create a span for the title to sit next to the button + const titleSpan = document.createElement('span'); + titleSpan.textContent = 'Players'; + header.appendChild(titleSpan); + + // Create the reset button + const resetButton = document.createElement('button'); + resetButton.id = 'reset-view-btn'; + resetButton.className = 'reset-view-btn'; + resetButton.title = 'Reset Camera View'; + resetButton.innerHTML = ``; + + header.appendChild(resetButton); + container.appendChild(header); + + // Add the click listener only once, when the button is created + resetButton.onclick = () => { + if (threeState && threeState.demo) { + threeState.demo.resetCameraView(); + } + }; + } + + // Get or create list container + let listContainer = container.querySelector('#player-list-container'); + if (!listContainer) { + listContainer = document.createElement('div'); + listContainer.id = 'player-list-container'; + container.appendChild(listContainer); + } + + // Get or create player list + let playerUl = listContainer.querySelector('#player-list'); + if (!playerUl) { + playerUl = document.createElement('ul'); + playerUl.id = 'player-list'; + listContainer.appendChild(playerUl); + } + + // Update player cards + gameState.players.forEach((player, index) => { + let li = playerUl.children[index]; + if (!li) { + li = document.createElement('li'); + playerUl.appendChild(li); + } + + // Add the onclick handler of player's first person perspective + // This will call the focus function on the Three.js demo instance + li.onclick = () => { + if (threeState && threeState.demo) { + // Get the current widths of the UI panels + const leftPanel = parent.querySelector('.left-panel'); + const rightPanel = parent.querySelector('.right-panel'); + const leftPanelWidth = leftPanel ? leftPanel.offsetWidth : 0; + const rightPanelWidth = rightPanel ? rightPanel.offsetWidth : 0; + + // Pass the panel widths to the focus function + threeState.demo.focusOnPlayer(player.name, leftPanelWidth, rightPanelWidth); + } + }; + + // Update player card classes + li.className = 'player-card'; + if (!player.is_alive) li.classList.add('dead'); + if (player.name === actingPlayerName) li.classList.add('active'); + + let roleDisplay = player.role; + if (player.role === 'Werewolf') { + roleDisplay = `🐺 ${player.role}`; + } else if (player.role === 'Doctor') { + roleDisplay = `🩺 ${player.role}`; + } else if (player.role === 'Seer') { + roleDisplay = `🔮 ${player.role}`; + } else if (player.role === 'Villager') { + roleDisplay = `🧑 ${player.role}`; + } + + const roleText = player.role !== 'Unknown' ? `Role: ${roleDisplay}` : 'Role: Unknown'; + + // Update content + let player_name_element = `
${player.name}
` + if (player.display_name && player.display_name !== player.name) { + player_name_element = `
+ ${player.name}${player.display_name} +
` + } + + li.innerHTML = ` +
+ ${player.name} +
+
+ ${player_name_element} +
${roleText}
+
+
+ `; + + // Update threat indicator + const indicator = li.querySelector('.threat-indicator'); + if (indicator && player.is_alive) { + const threatLevel = gameState.playerThreatLevels.get(player.name) || 0; + indicator.style.backgroundColor = getThreatColor(threatLevel); + } else if (indicator) { + indicator.style.backgroundColor = 'transparent'; + } + }); + + // Remove excess player cards + while (playerUl.children.length > gameState.players.length) { + playerUl.removeChild(playerUl.lastChild); + } + + // Get or create audio controls + let audioControls = container.querySelector('.audio-controls'); + if (!audioControls) { + audioControls = document.createElement('div'); + audioControls.className = 'audio-controls'; + const pauseButtonClass = audioState.isPaused ? 'paused' : 'playing'; + audioControls.innerHTML = ` + +
+ + +
+ `; + container.appendChild(audioControls); + + // Add event listeners only once + const speedSlider = audioControls.querySelector('#playback-speed'); + const speedLabel = audioControls.querySelector('#speed-label'); + const pauseButton = audioControls.querySelector('#pause-audio'); + + speedSlider.addEventListener('input', (e) => { + const newRate = parseFloat(e.target.value); + setPlaybackRate(newRate); + speedLabel.textContent = newRate.toFixed(1); + }); + + pauseButton.addEventListener('click', () => { + togglePause(); + }); + } + } + + function updateEventLog(container, gameState, playerMap) { + container.innerHTML = ` +

+ Event Log + +

+ `; + const logUl = document.createElement('ul'); + logUl.id = 'chat-log'; + + const logEntries = gameState.eventLog; + + if (logEntries.length === 0) { + const li = document.createElement('li'); + li.className = 'msg-entry'; + li.innerHTML = `System
The game is about to begin...
`; + logUl.appendChild(li); + } else { + logEntries.forEach(entry => { + const li = document.createElement('li'); + let reasoningHtml = ''; + let reasoningToggleHtml = ''; + if (entry.reasoning) { + const reasoningId = `reasoning-${window.werewolfGamePlayer.reasoningCounter++}`; + reasoningHtml = `
"${entry.reasoning}"
`; + reasoningToggleHtml = ` + + `; + } + + let phase = (entry.phase || 'Day').toUpperCase(); + const entryType = entry.type; + const systemText = (entry.text || '').toLowerCase(); + + const phaseClass = `event-${phase.toLowerCase()}`; + + let phaseEmoji = phase; + if (phase === 'DAY') { + phaseEmoji = '☀️'; + } else if (phase === 'NIGHT') { + phaseEmoji = '🌙'; + } + + const dayPhaseString = entry.day !== Infinity ? `[${phaseEmoji} ${entry.day}]` : ''; + const timestampHtml = `${dayPhaseString} ${formatTimestamp(entry.timestamp)}`; + + switch (entry.type) { + case 'chat': + const speaker = playerMap.get(entry.speaker); + if (!speaker) return; + const messageText = window.werewolfGamePlayer.playerIdReplacer(entry.message); + li.className = `chat-entry event-day`; + li.innerHTML = ` + ${speaker.name} +
+ + ${speaker.name} + ${speaker.display_name && speaker.name !== speaker.display_name ? `${speaker.display_name}` : ''} + ${reasoningToggleHtml} + ${timestampHtml} + +
+
+ ${messageText} +
+ ${reasoningHtml} +
+
+ `; + const balloonText = li.querySelector('.balloon-text'); + if (balloonText) { + const ttsButton = document.createElement('span'); + ttsButton.className = 'tts-button'; + ttsButton.innerHTML = '🔊'; + ttsButton.onclick = () => speak(entry.message, entry.speaker); + balloonText.appendChild(ttsButton); + } + break; + case 'seer_inspection': + const seerInspector = playerMap.get(entry.actor_id); + if (!seerInspector) return; + const seerTargetCap = createPlayerCapsule(playerMap.get(entry.target)); + const seerCap = createPlayerCapsule(playerMap.get(entry.actor_id)); + li.className = `chat-entry event-night`; + li.innerHTML = ` + ${seerInspector.name} +
+ Seer Secret Inspect ${reasoningToggleHtml} ${timestampHtml} +
+
${seerCap} chose to inspect ${seerTargetCap}'s role.
+ ${reasoningHtml} +
+
+ `; + break; + case 'seer_inspection_result': + const seerResultViewer = playerMap.get(entry.seer); + if (!seerResultViewer) return; + const seerCap_ = createPlayerCapsule(playerMap.get(entry.seer)); + const seerResultTargetCap = createPlayerCapsule(playerMap.get(entry.target)); + const resultString = entry.role + ? `role is a ${entry.role}` + : `team is ${entry.team}`; + + li.className = `chat-entry ${phaseClass}`; + li.innerHTML = ` + ${seerResultViewer.name} +
+ Seer Inspect Result ${timestampHtml} +
+
${seerCap_} saw ${seerResultTargetCap}'s ${resultString}.
+
+
+ `; + break; + case 'doctor_heal_action': + const doctor = playerMap.get(entry.actor_id); + if (!doctor) return; + const docTargetCap = createPlayerCapsule(playerMap.get(entry.target)); + const docCap = createPlayerCapsule(playerMap.get(entry.actor_id)); + li.className = `chat-entry event-night`; + li.innerHTML = ` + ${doctor.name} +
+ Doctor Secret Heal ${reasoningToggleHtml} ${timestampHtml} +
+
${docCap} chose to heal ${docTargetCap}.
+ ${reasoningHtml} +
+
+ `; + break; + case 'system': + if (entry.text && entry.text.includes('has begun')) return; + + let systemText = entry.text; + + // This enhanced regex captures the list content (group 1) and any optional + // trailing punctuation like a period or comma (group 2). + const listRegex = /\[(.*?)\](\s*[.,?!])?/g; + + systemText = systemText.replace(listRegex, (match, listContent, punctuation) => { + // Clean the list content as before + const cleanedContent = listContent.replace(/'/g, "").replace(/, /g, " ").trim(); + + // If punctuation was captured, return the content with a space before the punctuation + if (punctuation) { + return cleanedContent + " " + punctuation.trim(); + } + + // Otherwise, just return the cleaned content + return cleanedContent; + }); + + // NOW, run the efficient replacer on the cleaned-up string. + const finalSystemText = window.werewolfGamePlayer.playerIdReplacer(systemText); + + li.className = `moderator-announcement`; + li.innerHTML = ` + Moderator + ${timestampHtml} +
+
${finalSystemText.replace(/\n/g, '
')}
+
+ `; + break; + case 'exile': + const exiledPlayerCap = createPlayerCapsule(playerMap.get(entry.name)); + li.className = `msg-entry game-event event-day`; + let role_text = (entry.role) ? ` (${entry.role})` : ""; + li.innerHTML = `Exile ${timestampHtml}
${exiledPlayerCap}${role_text} was exiled by vote.
`; + break; + case 'elimination': + const elimPlayerCap = createPlayerCapsule(playerMap.get(entry.name)); + li.className = `msg-entry game-event event-night`; + let elim_role_text = (entry.role) ? ` Their role was a ${entry.role}.` : ""; + li.innerHTML = `Elimination ${timestampHtml}
${elimPlayerCap} was eliminated.${elim_role_text}
`; + break; + case 'save': + const savedPlayerCap = createPlayerCapsule(playerMap.get(entry.saved_player)); + li.className = `msg-entry event-night`; + li.innerHTML = `Doctor Save ${timestampHtml}
Player ${savedPlayerCap} was attacked but saved by a Doctor!
`; + break; + case 'vote': + const voter = playerMap.get(entry.actor_id); + if (!voter) return; + const voterCap = createPlayerCapsule(playerMap.get(entry.actor_id)); + const voteTargetCap = createPlayerCapsule(playerMap.get(entry.target)); + li.className = `chat-entry event-day`; + li.innerHTML = ` + ${voter.name} +
+ + ${voter.name} + ${voter.display_name && voter.name !== voter.display_name ? `${voter.display_name}` : ''} + ${reasoningToggleHtml} + ${timestampHtml} + +
+
${voterCap} votes to eliminate ${voteTargetCap}.
+ ${reasoningHtml} +
+
+ `; + break; + case 'timeout': + const to_voter = playerMap.get(entry.actor_id); + if (!to_voter) return; + const to_voterCap = createPlayerCapsule(playerMap.get(entry.actor_id)); + li.className = `chat-entry event-day`; + li.innerHTML = ` + ${to_voter.name} +
+ ${to_voter.name} ${reasoningToggleHtml} ${timestampHtml} +
+
${to_voterCap} timed out and abstained from voting.
+ ${reasoningHtml} +
+
+ `; + break; + case 'night_vote': + const nightVoter = playerMap.get(entry.actor_id); + if (!nightVoter) return; + const nightVoterCap = createPlayerCapsule(playerMap.get(entry.actor_id)); + const nightVoteTargetCap = createPlayerCapsule(playerMap.get(entry.target)); + li.className = `chat-entry event-night`; + li.innerHTML = ` + ${nightVoter.name} +
+ Werewolf Secret Vote ${reasoningToggleHtml} ${timestampHtml} +
+
${nightVoterCap} votes to eliminate ${nightVoteTargetCap}.
+ ${reasoningHtml} +
+
+ `; + break; + case 'game_over': + const winnersText = entry.winners.map(p => createPlayerCapsule(playerMap.get(p))).join(' '); + const losersText = entry.losers.map(p => createPlayerCapsule(playerMap.get(p))).join(' '); + li.className = `msg-entry game-win ${phaseClass}`; + li.innerHTML = ` + Game Over ${timestampHtml} +
+
The ${entry.winner} team has won!

+
Winning Team: ${winnersText}
+
Losing Team: ${losersText}
+
+ `; + break; + } + if (li.innerHTML) logUl.appendChild(li); + }); + } + + container.appendChild(logUl); + logUl.scrollTop = logUl.scrollHeight; + + const globalToggle = container.querySelector('#global-reasoning-toggle'); + if (globalToggle) { + globalToggle.addEventListener('click', (event) => { + event.stopPropagation(); + const reasoningTexts = logUl.querySelectorAll('.reasoning-text'); + if (reasoningTexts.length === 0) return; + + // Determine if we should show or hide all. If any are visible, we hide all. Otherwise, show all. + const shouldShow = ![...reasoningTexts].some(el => el.classList.contains('visible')); + + reasoningTexts.forEach(el => { + el.classList.toggle('visible', shouldShow); + }); + }); + } + } + + function renderPlayerList(container, gameState, actingPlayerName) { + container.innerHTML = '

Players

'; + const listContainer = document.createElement('div'); + listContainer.id = 'player-list-container'; + const playerUl = document.createElement('ul'); + playerUl.id = 'player-list'; + + gameState.players.forEach(player => { + const li = document.createElement('li'); + li.className = 'player-card'; + if (!player.is_alive) li.classList.add('dead'); + if (player.name === actingPlayerName) li.classList.add('active'); + + let roleDisplay = player.role; + if (player.role === 'Werewolf') { + roleDisplay = `🐺 ${player.role}`; + } else if (player.role === 'Doctor') { + roleDisplay = `🩺 ${player.role}`; + } else if (player.role === 'Seer') { + roleDisplay = `🔮 ${player.role}`; + } else if (player.role === 'Villager') { + roleDisplay = `🧑 ${player.role}`; + } + + const roleText = player.role !== 'Unknown' ? `Role: ${roleDisplay}` : 'Role: Unknown'; + + li.innerHTML = ` +
+ ${player.name} +
+
+
${player.name}
+
${roleText}
+
+
+ `; + playerUl.appendChild(li); + }); + + listContainer.appendChild(playerUl); + container.appendChild(listContainer); + + gameState.players.forEach((player, index) => { + const li = playerUl.children[index]; + const indicator = li.querySelector('.threat-indicator'); + if (!indicator) return; + + if (player.is_alive) { + const threatLevel = gameState.playerThreatLevels.get(player.name) || 0; + indicator.style.backgroundColor = getThreatColor(threatLevel); + } else { + indicator.style.backgroundColor = 'transparent'; + } + }); + + const audioControls = document.createElement('div'); + audioControls.className = 'audio-controls'; + const pauseButtonClass = audioState.isPaused ? 'paused' : 'playing'; + audioControls.innerHTML = ` + +
+ + +
+ `; + container.appendChild(audioControls); + + const speedSlider = audioControls.querySelector('#playback-speed'); + const speedLabel = audioControls.querySelector('#speed-label'); + const pauseButton = audioControls.querySelector('#pause-audio'); + + speedSlider.addEventListener('input', (e) => { + const newRate = parseFloat(e.target.value); + setPlaybackRate(newRate); + speedLabel.textContent = newRate.toFixed(1); + }); + + pauseButton.addEventListener('click', () => { + togglePause(); + }); + } + + // --- Main Rendering Logic (Incremental) --- + // Only create UI elements if they don't exist + let mainContainer = parent.querySelector('.main-container'); + let style = parent.querySelector('style'); + + if (!style) { + style = document.createElement('style'); + style.textContent = css; + parent.appendChild(style); + } + + initThreeJs(); + + if (!environment || !environment.steps || environment.steps.length === 0 || step >= environment.steps.length) { + if (!mainContainer) { + const tempContainer = document.createElement("div"); + tempContainer.textContent = "Waiting for game data or invalid step..."; + parent.appendChild(tempContainer); + } + return; + } + + // Initialize player mapping for 3D scene + let playerNamesFor3D = []; + let playerThumbnailsFor3D = {}; + + // --- State Reconstruction --- + const player = window.werewolfGamePlayer; + const { allEvents, displayStepToAllEventsIndex, originalSteps, eventToKaggleStep } = player; + + if (step >= displayStepToAllEventsIndex.length) { + console.error("Step is out of bounds for displayStepToAllEventsIndex", step, displayStepToAllEventsIndex.length); + return; + } + const allEventsIndex = displayStepToAllEventsIndex[step]; + const eventStep = allEventsIndex; // for clarity + const kaggleStep = eventToKaggleStep[eventStep] || 0; + + let gameState = { + players: [], + day: 0, + phase: 'GAME_SETUP', + game_state_phase: 'DAY', + gameWinner: null, + eventLog: [], + playerThreatLevels: new Map() + }; + + const firstObs = originalSteps[0]?.[0]?.observation?.raw_observation; + let allPlayerNamesList; + let playerThumbnails = {}; + + if (firstObs && firstObs.all_player_ids) { + allPlayerNamesList = firstObs.all_player_ids; + 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}; + } + + if (!allPlayerNamesList || allPlayerNamesList.length === 0) { + const tempContainer = document.createElement("div"); + tempContainer.textContent = "Waiting for game data: No players found in observation or configuration."; + 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 + })); + const playerMap = new Map(gameState.players.map(p => [p.name, p])); + + // Initialize and cache the replacer function if it doesn't exist + if (!player.playerIdReplacer) { + player.playerIdReplacer = createPlayerIdReplacer(playerMap); + } + + gameState.players.forEach(p => gameState.playerThreatLevels.set(p.name, 0)); + + const roleAndTeamMap = new Map(); + const moderatorInitialLog = environment.info?.MODERATOR_OBSERVATION?.[0] || []; + moderatorInitialLog.flat().forEach(dataEntry => { + if (dataEntry.data_type === 'GameStartRoleDataEntry') { + const historyEvent = JSON.parse(dataEntry.json_str); + const data = historyEvent.data; + if (data) roleAndTeamMap.set(data.player_id, { role: data.role, team: data.team }); + } + }); + roleAndTeamMap.forEach((info, playerId) => { + const player = playerMap.get(playerId); + if (player) { player.role = info.role; player.team = info.team; } + }); + + function threatStringToLevel(threatString) { + switch(threatString) { + case 'SAFE': return 0; + case 'UNEASY': return 0.5; + case 'DANGER': return 1.0; + default: return 0; + } + } + + // Reconstruct state up to current kaggleStep + for (let s = 0; s <= kaggleStep; s++) { + const stepStateList = originalSteps[s]; + if (!stepStateList) continue; + + const currentObsForStep = stepStateList[0]?.observation?.raw_observation; + if (currentObsForStep) { + gameState.day = currentObsForStep.day; + gameState.phase = currentObsForStep.phase; + gameState.game_state_phase = currentObsForStep.game_state_phase; + } + } + + // Populate event log up to current eventStep + for (let i = 0; i <= eventStep; i++) { + const historyEvent = allEvents[i]; + const data = historyEvent.data; + const timestamp = historyEvent.created_at; + + if (data && data.actor_id && data.perceived_threat_level) { + const threatScore = threatStringToLevel(data.perceived_threat_level); + gameState.playerThreatLevels.set(data.actor_id, threatScore); + } + + if (!data) { + if (historyEvent.event_name === 'vote_action') { + const match = historyEvent.description.match(/P(player_\d+)/); + if (match) { + const actor_id = match[1]; + gameState.eventLog.push({ type: 'timeout', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: actor_id, reasoning: "Timed out", timestamp: historyEvent.created_at }); + } + } + continue; + } + + switch (historyEvent.dataType) { + case 'ChatDataEntry': + gameState.eventLog.push({ type: 'chat', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, speaker: data.actor_id, message: data.message, reasoning: data.reasoning, timestamp, mentioned_player_ids: data.mentioned_player_ids || [] }); + break; + case 'DayExileVoteDataEntry': + gameState.eventLog.push({ type: 'vote', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, target: data.target_id, reasoning: data.reasoning, timestamp }); + break; + case 'WerewolfNightVoteDataEntry': + gameState.eventLog.push({ type: 'night_vote', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, target: data.target_id, reasoning: data.reasoning, timestamp }); + break; + case 'DoctorHealActionDataEntry': + gameState.eventLog.push({ type: 'doctor_heal_action', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, target: data.target_id, reasoning: data.reasoning, timestamp }); + break; + case 'SeerInspectActionDataEntry': + gameState.eventLog.push({ type: 'seer_inspection', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, target: data.target_id, reasoning: data.reasoning, timestamp }); + break; + case 'DayExileElectedDataEntry': + gameState.eventLog.push({ type: 'exile', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, name: data.elected_player_id, role: data.elected_player_role_name, timestamp }); + break; + case 'WerewolfNightEliminationDataEntry': + gameState.eventLog.push({ type: 'elimination', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, name: data.eliminated_player_id, role: data.eliminated_player_role_name, timestamp }); + break; + case 'SeerInspectResultDataEntry': + gameState.eventLog.push({ type: 'seer_inspection_result', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, seer: data.actor_id, target: data.target_id, role: data.role, team: data.team, timestamp }); + break; + case 'DoctorSaveDataEntry': + gameState.eventLog.push({ type: 'save', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, saved_player: data.saved_player_id, timestamp }); + break; + case 'PhaseDividerDataEntry': + gameState.eventLog.push({ type: 'phase_divider', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, divider: data.divider_type, timestamp }); + break; + case 'GameEndResultsDataEntry': + gameState.gameWinner = data.winner_team; + const winners = gameState.players.filter(p => p.team === data.winner_team).map(p => p.name); + const losers = gameState.players.filter(p => p.team !== data.winner_team).map(p => p.name); + gameState.eventLog.push({ type: 'game_over', step: historyEvent.kaggleStep, day: Infinity, phase: 'GAME_OVER', winner: data.winner_team, winners, losers, timestamp }); + break; + default: + if (systemEntryTypeSet.has(historyEvent.event_name)) { + gameState.eventLog.push({ type: 'system', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, text: historyEvent.description, timestamp, data: data}); + } + break; + } + } + + if (eventStep < audioState.lastPlayedStep) { + audioState.audioQueue = []; + audioState.isAudioPlaying = false; + if (audioState.audioPlayer) { + audioState.audioPlayer.pause(); + } + const chatLog = parent.querySelector('#chat-log'); + if (chatLog) { + chatLog.innerHTML = ''; + } + } + + const eventsToPlay = gameState.eventLog.slice(audioState.lastPlayedStep > -1 ? audioState.lastPlayedStep + 1 : 0); + + if (eventsToPlay.length > 0) { + eventsToPlay.forEach(entry => { + let audioEvent = null; + if (entry.type === 'chat') { + audioEvent = { message: entry.message, speaker: entry.speaker }; + } else if (entry.type === 'system') { + const text = entry.text.toLowerCase(); + if (text.includes('night') && text.includes('begins')) { + audioEvent = { message: 'night_begins', speaker: 'moderator' }; + } else if (text.includes('day') && text.includes('begins')) { + audioEvent = { message: 'day_begins', speaker: 'moderator' }; + } else if (text.includes('discussion')) { + audioEvent = { message: 'discussion_begins', speaker: 'moderator' }; + } else if (text.includes('voting phase begins')) { + audioEvent = { message: 'voting_begins', speaker: 'moderator' }; + } + } else if (entry.type === 'exile') { + const message = `Player ${entry.name} was exiled by vote. Their role was a ${entry.role}.`; + audioEvent = { message: message, speaker: 'moderator' }; + } else if (entry.type === 'elimination') { + const message = `Player ${entry.name} was eliminated. Their role was a ${entry.role}.`; + audioEvent = { message: message, speaker: 'moderator' }; + } else if (entry.type === 'save') { + const message = `Player ${entry.saved_player} was attacked but saved by a Doctor!`; + audioEvent = { message: message, speaker: 'moderator' }; + } else if (entry.type === 'game_over') { + const message = `The game is over. The ${entry.winner} team has won!`; + audioEvent = { message: message, speaker: 'moderator' }; + } + + if (audioEvent) { + audioState.audioQueue.push(audioEvent); + } + }); + + if (audioState.isAudioEnabled && !audioState.isAudioPlaying) { + playNextInQueue(); + } + } + audioState.lastPlayedStep = eventStep; + + gameState.players.forEach(p => { p.is_alive = true; p.status = 'Alive'; }); + gameState.eventLog.forEach(entry => { + if (entry.type === 'exile' || entry.type === 'elimination') { + const player = playerMap.get(entry.name); + if (player) { + player.is_alive = false; + player.status = entry.type === 'exile' ? 'Exiled' : 'Eliminated'; + } + } + }); + + const lastStepStateList = environment.steps[step]; + const actingPlayerIndex = lastStepStateList.findIndex(s => s.status === 'ACTIVE'); + const actingPlayerName = actingPlayerIndex !== -1 ? allPlayerNamesList[actingPlayerIndex] : "N.A"; + + Object.assign(parent.style, { width: `${width}px`, height: `${height}px` }); + parent.className = 'werewolf-parent'; + + // Create or get existing main container + if (!mainContainer) { + mainContainer = document.createElement('div'); + mainContainer.className = 'main-container'; + parent.appendChild(mainContainer); + } + + // Create or update phase indicator + let phaseIndicator = parent.querySelector('.phase-indicator'); + if (!phaseIndicator) { + phaseIndicator = document.createElement('div'); + phaseIndicator.className = 'phase-indicator'; + parent.appendChild(phaseIndicator); + } + + // Update phase indicator based on current game state + const currentPhase = allEvents[eventStep].phase.toUpperCase() || 'DAY'; + const isNight = currentPhase === 'NIGHT'; + phaseIndicator.className = `phase-indicator ${isNight ? 'night' : 'day'}`; + if (allEvents[eventStep]?.event_name == 'game_end') { + phaseIndicator.innerHTML = ` + ${isNight ? '🌙' : '☀'} + `; + } else { + phaseIndicator.innerHTML = ` + ${isNight ? '🌙' : '☀'} + ${allEvents[eventStep].day} + `; + } + + // Create or update game scoreboard + let scoreboard = parent.querySelector('.game-scoreboard'); + if (!scoreboard) { + scoreboard = document.createElement('div'); + scoreboard.className = 'game-scoreboard'; + parent.appendChild(scoreboard); + } + + // Calculate game statistics + const alivePlayers = gameState.players.filter(p => p.is_alive).length; + const deadPlayers = gameState.players.filter(p => !p.is_alive).length; + const werewolves = gameState.players.filter(p => p.is_alive && p.role === 'Werewolf').length; + const villagers = gameState.players.filter(p => p.is_alive && p.role !== 'Werewolf' && p.role !== 'Unknown').length; + + // Determine current action based on phase and recent events + let currentAction = 'Waiting...'; + const lastEvent = gameState.eventLog[gameState.eventLog.length - 1]; + + if (gameState.gameWinner) { + currentAction = `${gameState.gameWinner} Win!`; + } else if (gameState.phase === 'VOTING') { + currentAction = 'Voting Phase'; + } else if (gameState.phase === 'DISCUSSION') { + currentAction = 'Discussion'; + } else if (isNight) { + // Check for recent night actions + if (lastEvent) { + if (lastEvent.type === 'night_vote') { + currentAction = 'Werewolves Voting'; + } else if (lastEvent.type === 'doctor_heal_action') { + currentAction = 'Doctor Saving'; + } else if (lastEvent.type === 'seer_inspection') { + currentAction = 'Seer Inspecting'; + } else { + currentAction = 'Night Actions'; + } + } else { + currentAction = 'Night Phase'; + } + } else { + // Day phase + if (lastEvent && lastEvent.type === 'chat') { + currentAction = 'Discussion'; + } else if (lastEvent && lastEvent.type === 'vote') { + currentAction = 'Exile Voting'; + } else { + currentAction = 'Day Phase'; + } + } + + // Update scoreboard content + scoreboard.innerHTML = ` +
+
Day
+
${gameState.day || 0}
+
+
+
Alive
+
${alivePlayers}
+
+
+
Out
+
${deadPlayers}
+
+ ${werewolves > 0 || villagers > 0 ? ` +
+
Werewolves
+
${werewolves}
+
+
+
Villagers
+
${villagers}
+
+ ` : ''} +
+
${currentAction}
+
+ `; + + // Create or get existing panels + let leftPanel = mainContainer.querySelector('.left-panel'); + if (!leftPanel) { + leftPanel = document.createElement('div'); + leftPanel.className = 'left-panel'; + mainContainer.appendChild(leftPanel); + } + + let playerListArea = leftPanel.querySelector('#player-list-area'); + if (!playerListArea) { + playerListArea = document.createElement('div'); + playerListArea.id = 'player-list-area'; + leftPanel.appendChild(playerListArea); + } + + let rightPanel = mainContainer.querySelector('.right-panel'); + if (!rightPanel) { + rightPanel = document.createElement('div'); + rightPanel.className = 'right-panel'; + mainContainer.appendChild(rightPanel); + } + + // Update existing content instead of clearing and rebuilding + updatePlayerList(playerListArea, gameState, actingPlayerName); + updateEventLog(rightPanel, gameState, playerMap); + + // Update 3D scene based on game state + updateSceneFromGameState(gameState, playerMap, actingPlayerName); + + // Initialize 3D players if needed + if (threeState.demo && threeState.demo._playerObjects && threeState.demo._playerObjects.size === 0 && playerNamesFor3D.length > 0) { + initializePlayers3D(gameState, playerNamesFor3D, playerThumbnailsFor3D, threeState); + } +} + +function initializePlayers3D(gameState, playerNames, playerThumbnails, threeState) { + if (!threeState || !threeState.demo || !threeState.demo._playerObjects) return; + + // Clear existing player objects + if (threeState.demo._playerGroup) { + // Remove all children from the group + while(threeState.demo._playerGroup.children.length > 0) { + threeState.demo._playerGroup.remove(threeState.demo._playerGroup.children[0]); + } + } + threeState.demo._playerObjects.clear(); + + const numPlayers = playerNames.length; + const radius = 18; // Increased radius to use more space + const playerHeight = 4; + + const THREE = threeState.demo._THREE; + const CSS2DObject = threeState.demo._CSS2DObject; + + // Create a circular platform + const platformGeometry = new THREE.RingGeometry(radius - 2, radius + 3, 64); + const platformMaterial = new THREE.MeshStandardMaterial({ + color: 0x2a2a3a, + roughness: 0.9, + metalness: 0.1, + transparent: true, + opacity: 0.5, + side: THREE.DoubleSide + }); + const platform = new THREE.Mesh(platformGeometry, platformMaterial); + platform.rotation.x = -Math.PI / 2; + platform.position.y = -0.05; + platform.receiveShadow = true; + threeState.demo._playerGroup.add(platform); + + // Create center decoration + const centerGeometry = new THREE.CylinderGeometry(3, 3, 0.2, 32); + const centerMaterial = new THREE.MeshStandardMaterial({ + color: 0x444466, + roughness: 0.7, + metalness: 0.3, + emissive: 0x222244, + emissiveIntensity: 0.2 + }); + const centerPlatform = new THREE.Mesh(centerGeometry, centerMaterial); + centerPlatform.position.y = 0.1; + centerPlatform.receiveShadow = true; + threeState.demo._playerGroup.add(centerPlatform); + + // Add decorative lines from center to each player position + const linesMaterial = new THREE.LineBasicMaterial({ + color: 0x334455, + transparent: true, + opacity: 0.3 + }); + + playerNames.forEach((name, i) => { + const displayName = gameState.players[i].display_name || ''; + const playerContainer = new THREE.Group(); + // Use full circle (360 degrees) + const angle = (i / numPlayers) * Math.PI * 2; + + const x = radius * Math.sin(angle); + const z = radius * Math.cos(angle); + playerContainer.position.set(x, 0, z); + + // Create line from center to player + const lineGeometry = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(0, 0.05, 0), + new THREE.Vector3(x, 0.05, z) + ]); + const line = new THREE.Line(lineGeometry, linesMaterial); + threeState.demo._playerGroup.add(line); + + // Create pedestal for each player + const pedestalGeometry = new THREE.CylinderGeometry(1.5, 1.8, 0.4, 16); + const pedestalMaterial = new THREE.MeshStandardMaterial({ + color: 0x333344, + roughness: 0.8, + metalness: 0.2, + emissive: 0x111122, + emissiveIntensity: 0.1 + }); + const pedestal = new THREE.Mesh(pedestalGeometry, pedestalMaterial); + pedestal.position.y = 0.2; + pedestal.castShadow = true; + pedestal.receiveShadow = true; + playerContainer.add(pedestal); + + // Create player body (more detailed) + const bodyGeometry = new THREE.CylinderGeometry(0.8, 1, playerHeight * 0.6, 16); + const bodyMaterial = new THREE.MeshStandardMaterial({ + color: 0x4466ff, + roughness: 0.5, + metalness: 0.3, + emissive: 0x111166, + emissiveIntensity: 0.2 + }); + const body = new THREE.Mesh(bodyGeometry, bodyMaterial); + body.position.y = playerHeight * 0.4; + body.castShadow = true; + body.receiveShadow = true; + playerContainer.add(body); + + // Create shoulders + const shoulderGeometry = new THREE.SphereGeometry(1, 16, 8); + const shoulderMaterial = new THREE.MeshStandardMaterial({ + color: 0x4466ff, + roughness: 0.5, + metalness: 0.3, + emissive: 0x111166, + emissiveIntensity: 0.2 + }); + const shoulders = new THREE.Mesh(shoulderGeometry, shoulderMaterial); + shoulders.position.y = playerHeight * 0.65; + shoulders.scale.set(1.2, 0.6, 0.8); + shoulders.castShadow = true; + playerContainer.add(shoulders); + + // Create player head (sphere) + const headGeometry = new THREE.SphereGeometry(0.7, 16, 16); + const headMaterial = new THREE.MeshStandardMaterial({ + color: 0xfdbcb4, + roughness: 0.7, + metalness: 0.1, + emissive: 0x442211, + emissiveIntensity: 0.1 + }); + const head = new THREE.Mesh(headGeometry, headMaterial); + head.position.y = playerHeight * 0.85; + head.castShadow = true; + head.receiveShadow = true; + playerContainer.add(head); + + // Create eyes + const eyeGeometry = new THREE.SphereGeometry(0.08, 8, 6); + const eyeMaterial = new THREE.MeshStandardMaterial({ + color: 0x000000, + roughness: 0.3, + metalness: 0.8 + }); + const leftEye = new THREE.Mesh(eyeGeometry, eyeMaterial); + leftEye.position.set(-0.2, playerHeight * 0.87, 0.6); + playerContainer.add(leftEye); + + const rightEye = new THREE.Mesh(eyeGeometry, eyeMaterial); + rightEye.position.set(0.2, playerHeight * 0.87, 0.6); + playerContainer.add(rightEye); + + // Create glowing orb for status (more dramatic) + const orbGeometry = new THREE.IcosahedronGeometry(0.3, 2); + const orbMaterial = new THREE.MeshStandardMaterial({ + color: 0x00ff00, + emissive: 0x00ff00, + emissiveIntensity: 0.8, + transparent: true, + opacity: 0.9 + }); + const orb = new THREE.Mesh(orbGeometry, orbMaterial); + orb.position.y = playerHeight * 1.2; + orb.name = 'statusOrb'; + playerContainer.add(orb); + + // Add outer glow sphere + const glowGeometry = new THREE.SphereGeometry(0.5, 12, 8); + const glowMaterial = new THREE.MeshStandardMaterial({ + color: 0x00ff00, + emissive: 0x00ff00, + emissiveIntensity: 0.3, + transparent: true, + opacity: 0.3 + }); + const glow = new THREE.Mesh(glowGeometry, glowMaterial); + glow.position.y = playerHeight * 1.2; + playerContainer.add(glow); + + // Add point light for glow effect + const orbLight = new THREE.PointLight(0x00ff00, 0.8, 8); + orbLight.position.y = playerHeight * 1.2; + orbLight.name = 'orbLight'; + orbLight.castShadow = true; + playerContainer.add(orbLight); + + // Make player face center without flipping + // Calculate the angle to face the center + playerContainer.rotation.y = -angle + Math.PI / 2; + + // Create nameplate with actual player thumbnail + const thumbnailUrl = playerThumbnails[name] || `https://via.placeholder.com/60/2c3e50/ecf0f1?text=${name.charAt(0)}`; + const nameplate = threeState.demo._createNameplate(name, displayName, thumbnailUrl, CSS2DObject); + nameplate.position.set(0, playerHeight * 2.0, 0); + playerContainer.add(nameplate); + + // Store references + threeState.demo._playerObjects.set(name, { + container: playerContainer, + body: body, + head: head, + shoulders: shoulders, + orb: orb, + glow: glow, + orbLight: orbLight, + nameplate: nameplate, + pedestal: pedestal, + originalPosition: playerContainer.position.clone(), + baseAngle: angle, + isAlive: true + }); + + threeState.demo._playerGroup.add(playerContainer); + }); + + // Adjust camera to see the full circle + if (threeState.demo._camera) { + threeState.demo._camera.position.set(25, 30, 25); + threeState.demo._controls.target.set(0, 5, 0); + threeState.demo._controls.update(); + } +} diff --git a/kaggle_environments/envs/werewolf/werewolf.json b/kaggle_environments/envs/werewolf/werewolf.json new file mode 100644 index 00000000..01f96d16 --- /dev/null +++ b/kaggle_environments/envs/werewolf/werewolf.json @@ -0,0 +1,286 @@ +{ + "name": "werewolf", + "version": "1.0.0", + "title": "Werewolf", + "description": "A social deduction game.", + "agents": [7, 8, 9, 10, 11, 12, 13, 14, 15], + "configuration": { + "episodeSteps": { "type": "integer", "default": 1000 }, + "actTimeout": { "type": "number", "default": 3 }, + "runTimeout": { "type": "number", "default": 1800 }, + "seed": { "type": "integer", "description": "Seed to use for episodes" }, + "night_elimination_reveal_level": { + "type": "string", + "enum": ["no_reveal", "team", "role"], + "default": "role", + "description": "During night elimination, the moderator should reveal this level of information about the eliminated player." + }, + "day_exile_reveal_level": { + "type": "string", + "enum": ["no_reveal", "team", "role"], + "default": "role", + "description": "During day exile, the moderator should reveal this level of information about the exiled player." + }, + "agents": { + "type": "array", + "description": "Configuration for each agent playing the game.", + "default": [ + { "id": "Player 1", "role": "Werewolf", "agent_id": "random"}, + { "id": "Player 2", "role": "Werewolf", "agent_id": "random"}, + { "id": "Player 3", "role": "Seer", "agent_id": "random"}, + { "id": "Player 4", "role": "Doctor", "agent_id": "random"}, + { "id": "Player 5", "role": "Villager", "agent_id": "random"}, + { "id": "Player 6", "role": "Villager", "agent_id": "random"}, + { "id": "Player 7", "role": "Villager", "agent_id": "random"} + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The unique name of the player. Visible to everyone." + }, + "display_name": { + "type": "string", + "description": "The display name of the agent. Visible only to the UI but not to players.", + "default": "" + }, + "agent_id": { + "type": "string", + "description": "The id of the agent. Might not be unique since different players might be using the same underlying agent. Not visible to everyone." + }, + "role": { + "type": "string", + "enum": ["Werewolf", "Doctor", "Seer", "Villager"], + "description": "The role assigned to the player." + }, + "role_params": { + "type": "object", + "description": "The parameter dict for the selected Role subclass.", + "default": {} + }, + "thumbnail": { + "type": "string", + "format": "uri", + "description": "A URL for the player's thumbnail image.", + "default": "" + }, + "agent_harness_name": { + "type": "string", + "description": "The name of the agent harness to use.", + "default": "" + }, + "chat_mode": { + "type": "string", + "description": "Select between chat mode or text mode to prompt the LLM to produce different style of response.", + "default": "text", + "enum": ["text", "audio"] + }, + "enable_bid_reasoning": { + "type": "boolean", + "description": "Determine whether to prompt LLM to generate reasoning for bidding or not.", + "default": false + }, + "llms": { + "type": "array", + "description": "A list of Language Learning Models to be used by the agent.", + "items": { + "type": "object", + "properties": { + "model_name": { + "type": "string", + "description": "The name of the LLM." + }, + "parameters": { + "type": "object", + "description": "Parameters specific to this LLM.", + "default": {} + } + }, + "required": ["model_name"] + }, + "default": [] + } + }, + "required": ["id", "role"] + } + }, + "discussion_protocol": { + "description": "Configuration for the daytime discussion protocol.", + "type": "object", + "properties": { + "name": { + "description": "The name of the discussion protocol to use.", + "type": "string", + "enum": ["RoundRobinDiscussion", "ParallelDiscussion", "TurnByTurnBiddingDiscussion", "RoundByRoundBiddingDiscussion"], + "default": "RoundRobinDiscussion" + }, + "params": { + "description": "Parameters for the selected discussion protocol.", + "type": "object", + "default": {}, + "properties": {} + } + }, + "default": { "name": "RoundRobinDiscussion", "params": { "max_rounds": 1 } }, + "oneOf": [ + { + "if": { "properties": { "name": { "const": "RoundRobinDiscussion" } } }, + "then": { + "properties": { + "params": { + "type": "object", + "properties": { + "max_rounds": { "description": "Number of full rounds of discussion.", "type": "integer", "minimum": 1, "default": 1 }, + "assign_random_first_speaker": { + "description": "Wether to assign a random player as the first speaker", + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } + }, + "else": false + }, + { + "if": { "properties": { "name": { "const": "ParallelDiscussion" } } }, + "then": { + "properties": { + "params": { + "type": "object", + "properties": { + "ticks": { "description": "Number of ticks where all players can speak simultaneously.", "type": "integer", "minimum": 1, "default": 3 } + }, + "additionalProperties": false + } + } + }, + "else": false + }, + { + "if": { "properties": { "name": { "const": "TurnByTurnBiddingDiscussion" } } }, + "then": { + "properties": { + "params": { + "type": "object", + "properties": { + "bidding": { "type": "object" }, + "max_turns": { "type": "integer", "minimum": 1, "default": 5 }, + "bid_result_public": { "type": "boolean", "default": true } + }, + "additionalProperties": false + } + } + }, + "else": false + }, + { + "if": { "properties": { "name": { "const": "RoundByRoundBiddingDiscussion" } } }, + "then": { + "properties": { + "params": { + "type": "object", + "properties": { + "bidding": { "type": "object" }, + "max_rounds": { "type": "integer", "minimum": 1, "default": 2 }, + "bid_result_public": { "type": "boolean", "default": true } + }, + "additionalProperties": false + } + } + }, + "else": false + } + ] + }, + "day_voting_protocol": { + "description": "Configuration for the daytime voting protocol.", + "type": "object", + "properties": { + "name": { + "description": "The name of the voting protocol to use.", + "type": "string", + "enum": ["SimultaneousMajority", "SequentialVoting"], + "default": "SequentialVoting" + }, + "params": { + "description": "Parameters for the selected voting protocol.", + "type": "object", + "default": {}, + "properties": {} + } + }, + "default": { "name": "SequentialVoting", "params": {} }, + "oneOf": [ + { + "if": { "properties": { "name": { "const": "SimultaneousMajority" } } }, + "then": { + "properties": { + "params": { "type": "object", "properties": {}, "additionalProperties": true } + } + }, + "else": false + }, + { + "if": { "properties": { "name": { "const": "SequentialVoting" } } }, + "then": { + "properties": { + "params": { "type": "object", "properties": {}, "additionalProperties": true } + } + }, + "else": false + } + ] + }, + "werewolf_night_vote_protocol": { + "description": "Configuration for the werewolf night voting protocol.", + "type": "object", + "properties": { + "name": { + "description": "The name of the voting protocol to use.", + "type": "string", + "enum": ["SimultaneousMajority", "SequentialVoting"], + "default": "SequentialVoting" + }, + "params": { + "description": "Parameters for the selected voting protocol.", + "type": "object", + "default": {}, + "properties": {} + } + }, + "default": { "name": "SequentialVoting", "params": {} }, + "oneOf": [ + { + "if": { "properties": { "name": { "const": "SimultaneousMajority" } } }, + "then": { + "properties": { + "params": { "type": "object", "properties": {}, "additionalProperties": true } + } + }, + "else": false + }, + { + "if": { "properties": { "name": { "const": "SequentialVoting" } } }, + "then": { + "properties": { + "params": { "type": "object", "properties": {}, "additionalProperties": true } + } + }, + "else": false + } + ] + } + }, + "observation": { + "remainingOverageTime": 2 + }, + "action": { "type": "object", "additionalProperties": true }, + "reward": { + "type": "number", + "minimum": -1, + "maximum": 1 + } +} diff --git a/kaggle_environments/envs/werewolf/werewolf.py b/kaggle_environments/envs/werewolf/werewolf.py new file mode 100644 index 00000000..cbff3df2 --- /dev/null +++ b/kaggle_environments/envs/werewolf/werewolf.py @@ -0,0 +1,486 @@ +import json +import logging +import random +from os import path, getenv +from typing import Dict, Optional, List, Callable + +from pydantic import BaseModel, Field + +from kaggle_environments.envs.werewolf.game.consts import EnvInfoKeys, DetailedPhase +from .game.base import PlayerID +from .game.actions import ( + Action, VoteAction, HealAction, InspectAction, + BidAction, ChatAction, NoOpAction, create_action +) +from .game.consts import RoleConst +from .game.engine import Moderator +from .game.protocols.factory import create_protocol +from .game.records import WerewolfObservationModel, set_raw_observation, get_raw_observation +from .game.roles import create_players_from_agents_config +from .game.states import GameState, EventName, get_last_action_request +from .harness.base import LLMWerewolfAgent, LLMCostTracker + +logger = logging.getLogger(__name__) + +# --- Protocol Factory --- +DEFAULT_DISCUSSION_PROTOCOL_NAME = "RoundRobinDiscussion" +DEFAULT_VOTING_PROTOCOL_NAME = "SimultaneousMajority" +DEFAULT_BIDDING_PROTOCOL_NAME = "UrgencyBiddingProtocol" + + +class AgentCost(BaseModel): + total_cost: float = 0.0 + prompt_tokens: int = 0 + completion_tokens: int = 0 + + +class AgentCostSummary(BaseModel): + agent_config: Dict + costs: AgentCost = Field(default_factory=AgentCost) + data: Optional[LLMCostTracker] = None + + +class CostSummary(BaseModel): + cost_per_agent: List[AgentCostSummary] = Field(default_factory=list) + total_cost: float = 0.0 + total_prompt_tokens: int = 0 + total_completion_tokens: int = 0 + total_tokens: int = 0 + + +def random_agent(obs): + raw_obs = get_raw_observation(obs) + + entries = raw_obs.new_player_event_views + current_phase = DetailedPhase(raw_obs.detailed_phase) + my_role = raw_obs.role + all_player_names = raw_obs.all_player_ids + my_id = raw_obs.player_id + alive_players = raw_obs.alive_players + day = raw_obs.day + phase = raw_obs.game_state_phase + common_args = {"day": day, "phase": phase, "actor_id": my_id} + + action = NoOpAction(**common_args, reasoning="There's nothing to be done.") # Default action + threat_level = random.choice(['SAFE', 'UNEASY', 'DANGER']) + + if current_phase == DetailedPhase.NIGHT_AWAIT_ACTIONS: + if my_role == RoleConst.WEREWOLF: + history_entry = get_last_action_request(entries, EventName.VOTE_REQUEST) + if history_entry: + valid_targets = history_entry.data.get('valid_targets') + if valid_targets: + target_id = random.choice(valid_targets) + action = VoteAction(**common_args, target_id=target_id, reasoning="I randomly chose one.", + perceived_threat_level=threat_level) + + elif my_role == RoleConst.DOCTOR: + history_entry = get_last_action_request(entries, EventName.HEAL_REQUEST) + if history_entry: + valid_targets = history_entry.data['valid_candidates'] + if valid_targets: + target_id = random.choice(valid_targets) + action = HealAction(**common_args, target_id=target_id, reasoning="I randomly chose one to heal.", + perceived_threat_level=threat_level) + + elif my_role == RoleConst.SEER: + history_entry = get_last_action_request(entries, EventName.INSPECT_REQUEST) + if history_entry: + valid_targets = history_entry.data['valid_candidates'] + if valid_targets: + target_id = random.choice(valid_targets) + action = InspectAction(**common_args, target_id=target_id, + reasoning="I randomly chose one to inspect.", + perceived_threat_level=threat_level) + + elif current_phase in [DetailedPhase.DAY_BIDDING_AWAIT, DetailedPhase.DAY_CHAT_AWAIT]: + if current_phase == DetailedPhase.DAY_BIDDING_AWAIT: + if my_id in alive_players: + action = BidAction( + **common_args, + amount=random.randint(1, 4), + reasoning="I am bidding randomly.", + perceived_threat_level=threat_level + ) + else: # It's a chat turn (DAY_CHAT_AWAIT) + if my_id in alive_players: + action = ChatAction( + **common_args, + message=random.choice([ + "Hello everyone!", + f"I suspect {random.choice(all_player_names)}.", + "Any information to share?", + "I am a simple Villager just trying to survive.", + "Let's think carefully before voting." + ]), + reasoning="I randomly chose one message.", + perceived_threat_level=threat_level + ) + + elif current_phase == DetailedPhase.DAY_VOTING_AWAIT: + if my_id in alive_players: + # A real agent would parse the prompt for valid targets + valid_targets = [p_id for p_id in alive_players if p_id != my_id] + if valid_targets: + action = VoteAction( + **common_args, + target_id=random.choice(valid_targets), + reasoning="I randomly chose one.", + perceived_threat_level=threat_level + ) + + return action.serialize() + + +class AgentFactoryWrapper: + """ + A wrapper that creates and manages separate agent instances for each player. + This is necessary for stateful agents to be used in the agent registry, + preventing them from sharing state (like memory or history) across different players. + """ + + def __init__(self, agent_class, **kwargs): + self._agent_class = agent_class + self._shared_kwargs = kwargs + self._kwargs = {} # store configs of individual agents + self._instances = {} + self._agent_configs = None + + @property + def agent_class(self): + return self._agent_class + + def get_instance(self, player_id: PlayerID): + return self._instances.get(player_id) + + def __call__(self, obs, config): + """ + The main callable method for the agent. It routes the call to the correct + player-specific agent instance. + """ + raw_obs = get_raw_observation(obs) + player_id = raw_obs.player_id # get the current active player id + + if not player_id: + # This could happen on initial steps or for an inactive agent. + # Returning a NO_OP action is a safe fallback. + return NoOpAction( + day=raw_obs.day, + phase=raw_obs.game_state_phase, + actor_id="unknown_fallback", + reasoning="AgentFactoryWrapper: No player_id found in observation." + ).serialize() + + if not self._agent_configs: + self._agent_configs = {agent_config.id: agent_config for agent_config in config.agents} + + if player_id not in self._instances: + # Create a new agent instance for this player + self._kwargs[player_id] = {"agent_config": self._agent_configs.get(player_id)} + self._instances[player_id] = self._agent_class(**self._shared_kwargs, **self._kwargs[player_id]) + return self._instances[player_id](obs) + + def reset(self): + self._instances.clear() + + +# --- Agent Registry --- +LLM_SYSTEM_PROMPT = "You are a master strategist playing the game of Werewolf. Your goal is to win. You win as a team and not as individuals." + + +# *Package variable required by Kaggle Environments framework* +# These are base agents that the calling framework can choose from +# Provides a random_agent for testing and a convenient default 'llm' agent. + +agents = { + "random": random_agent, + "llm": AgentFactoryWrapper( + LLMWerewolfAgent, + model_name=getenv("WEREWOLF_LLM_MODEL", "gemini/gemini-2.5-pro"), + system_prompt=LLM_SYSTEM_PROMPT + ) +} + + +def register_agents(agent_dict: Dict[str, Callable]): + agents.update(agent_dict) + + +def log_error(status_code, state, env): + invalid_action = any(player_state["status"] == status_code for player_state in state) + if invalid_action: + 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}.") + return invalid_action + + +def interpreter(state, env): + """ + * Required interface function for kaggle environments package * + + This is the primary interface for the kaggle environment (kEnv) to step game forward. + Briefly flow of logic is: + Initialization - kEnv creates werewolf object and chooses players. Schema definition for + this is in werewolf.json + 1) kEnv calls interpreter() with current game state recorded in env.game_state + 2) interpreter() reads game state and any new player actions and updates + the games state based on those actions and flow of the game to env.game_state. + 3) interpreter() writes events to history data and also writes events about + state change in the game to env.game_state and returns back to kEnv + 4) kEnv parses out the relevant game events via agent logic in harness/base.py, + constructs final prompt, and performs external API calls for models and records back + to env.game_state + Go back to 1 and continue + + For example - consider discussion and voting by villagers. werewolf.interpreter() + updates phase and writes history entry that solicits players for discussion. + kEnv calls agents to get their discussion and writes them to the history/game state. + kEnv then calls interpreter() that then updates game phase and writes history entry soliciting + votes for exile. kEnv then calls agents and associated models to get their votes and writes + responses to game state. env then calls interpreter() and moderator collects votes, determine + who was exiled, performs that action and advances game phase and game state. + And so on... + + Note - The UI is also updated after each call to interpreter() as that is the tick unit + for the game. + + Note - env framework assumes that there is an action to be done by player, but + for werewolf there are places where moderator is the one taking the action (e.g. + counting votes and performing exile) so some game 'ticks' are larger than others. + + state: list of dictionaries, one for each agent. + Each dict has: {observation, action, reward, status, info} + env: the kaggle_environments.Environment object itself including the env.game_state + """ + agent_error = False + for status_code in ["TIMEOUT", "ERROR", "INVALID"]: + if log_error(status_code, state, env): + agent_error = True + + # --- Initialize Moderator and GameState if it's the start of an episode --- + if not hasattr(env, 'moderator') or env.done: # env.done is true after reset by Kaggle core + initialize_moderator(state, env) + + moderator: Moderator = env.moderator + game_state: GameState = env.game_state + + # 1. Collect and parse actions from Kaggle agents + parsed_player_actions = parse_player_actions(state, moderator, game_state) + + # 2. Advance the Moderator + moderator.advance(parsed_player_actions) + + # 3. Update Kaggle state (observations, rewards, statuses) + is_game_done = moderator.is_game_over() or agent_error + current_info = {} + if is_game_done: + record_game_end(state, env, game_state, current_info, agent_error) + + # 4. Moderator interprets player actions, updates game phase, and advance game player actions + active_player_ids_after_advance = set(moderator.get_active_player_ids()) + + # 4.1. Accumulate God mode observations from env for rendering + global_messages = env.game_state.consume_messages() + global_data = [rec.serialize() for rec in global_messages if rec.data] + env.info[EnvInfoKeys.MODERATOR_OBS].append(global_data) + + # 4.2. Update observations for individual agents + update_agent_messages( + state, env, moderator, game_state, is_game_done, current_info, active_player_ids_after_advance, agent_error) + return state + + +def collect_cost_summary(env) -> CostSummary: + cost_summary = CostSummary() + + for agent_config in env.configuration.agents: + player_id = agent_config['id'] + agent_id = agent_config['agent_id'] + + agent_cost_summary = AgentCostSummary(agent_config=agent_config) + + if ( + isinstance(agents.get(agent_id), AgentFactoryWrapper) + and issubclass(agents[agent_id].agent_class, LLMWerewolfAgent) + ): + agent_instance = agents[agent_id].get_instance(player_id) + if agent_instance: + cost_tracker = agent_instance.cost_tracker + agent_cost = AgentCost( + total_cost=cost_tracker.query_token_cost.total_costs_usd, + prompt_tokens=cost_tracker.prompt_token_cost.total_tokens, + completion_tokens=cost_tracker.completion_token_cost.total_tokens + ) + agent_cost_summary.costs = agent_cost + agent_cost_summary.data = cost_tracker + + cost_summary.total_cost += agent_cost.total_cost + cost_summary.total_prompt_tokens += agent_cost.prompt_tokens + cost_summary.total_completion_tokens += agent_cost.completion_tokens + + cost_summary.cost_per_agent.append(agent_cost_summary) + + cost_summary.total_tokens = cost_summary.total_prompt_tokens + cost_summary.total_completion_tokens + return cost_summary + + +def record_game_end(state, env, game_state, current_info, agent_error): + # log game end to env.info using GameEndResultsDataEntry + game_end_entry = next(iter(game_state.get_event_by_name(EventName.GAME_END)), None) + if game_end_entry and game_end_entry.data: + current_info.update(game_end_entry.data.model_dump()) + # Record if terminated with agent error. If so, the game record is invalid. + current_info['terminated_with_agent_error'] = agent_error + + # Record cost from endpoints if any. + current_info['cost_summary'] = collect_cost_summary(env).model_dump() + + env.info[EnvInfoKeys.GAME_END] = current_info + # Determine winner based on game_state.history's GAME_END entry + if game_end_entry: + scores = game_end_entry.data.scores + for i, player_id in enumerate(env.player_id_str_list): + state[i].reward = scores[player_id] + + +def update_agent_messages( + state, env, moderator, game_state, is_game_done, current_info, active_player_ids_after_advance, agent_error): + for player_index, player_state in enumerate(state): + player_id_str = env.player_ids_map[player_index] + + # skip if player not active and game is not done + if player_id_str not in active_player_ids_after_advance and not is_game_done: + player_state.status = 'INACTIVE' + continue + + # set the status of active player to ACTIVE + player_state.status = 'ACTIVE' + player_obj = game_state.get_player_by_id(player_id_str) + + # Observation processing + new_history_entries = player_obj.consume_messages() + + obs = WerewolfObservationModel( + player_id=player_obj.id, + role=player_obj.role.name, + team=player_obj.role.team.value, + is_alive=player_obj.alive, + day=game_state.day_count, + detailed_phase=moderator.detailed_phase.value, + all_player_ids=game_state.all_player_ids, + player_thumbnails=env.player_thumbnails, + alive_players=[p.id for p in game_state.alive_players()], + revealed_players=game_state.revealed_players(), + new_visible_announcements=[entry.description for entry in new_history_entries], + new_player_event_views=new_history_entries, + game_state_phase=game_state.phase.value + ) + + set_raw_observation(player_state, raw_obs=obs) + + # Status + if is_game_done or agent_error: + player_state.status = "DONE" + elif player_id_str in active_player_ids_after_advance: + player_state.status = "ACTIVE" + else: + player_state.status = "INACTIVE" + + # Info + player_state.info = current_info + + +def parse_player_actions(state, moderator, game_state): + parsed_player_actions: Dict[str, Action] = {} + active_player_ids_from_moderator = moderator.get_active_player_ids() + + for sub_state, player in zip(state, game_state.players): + player_id_str = player.id + if player_id_str in active_player_ids_from_moderator and sub_state.status == "ACTIVE": + serialized_action = sub_state.action + if serialized_action: + parsed_player_actions[player_id_str] = create_action(serialized_action) + return parsed_player_actions + + +def initialize_moderator(state, env): + num_players = len(state) + + agents_from_config = env.configuration.agents + + # below checks for configuration consistency with agent count. If inconsistent, it will cause down stream subtle error. + if len(agents_from_config) < num_players: + raise ValueError( + f"Configuration has {len(agents_from_config)} agents, but {num_players} kaggle agents are present.") + + players = create_players_from_agents_config(agents_from_config) + + env.game_state = GameState( + players=players, + history={}, + night_elimination_reveal_level=env.configuration.night_elimination_reveal_level, + day_exile_reveal_level=env.configuration.day_exile_reveal_level + ) + + env.player_ids_map = {i: p.id for i, p in enumerate(players)} + env.player_id_str_list = [p.id for p in players] + + env.player_thumbnails = {p.id: p.agent.thumbnail for p in players} + # Initialize protocols from configuration or defaults + discussion_protocol = create_protocol( + env.configuration.get("discussion_protocol", {}), + default_name=DEFAULT_DISCUSSION_PROTOCOL_NAME + ) + day_voting_protocol = create_protocol( + env.configuration.get("day_voting_protocol", {}), + default_name=DEFAULT_VOTING_PROTOCOL_NAME + ) + night_voting_protocol = create_protocol( + env.configuration.get("werewolf_night_vote_protocol", {}), + default_name=DEFAULT_VOTING_PROTOCOL_NAME + ) + + logger.info( + f"Interpreter: Using Discussion: {type(discussion_protocol).__name__}, " + f"Day Voting: {type(day_voting_protocol).__name__}, " + f"Night WW Voting: {type(night_voting_protocol).__name__}" + ) + + env.moderator = Moderator( + state=env.game_state, + discussion=discussion_protocol, + day_voting=day_voting_protocol, + night_voting=night_voting_protocol, + night_elimination_reveal_level=env.configuration.night_elimination_reveal_level, + day_exile_reveal_level=env.configuration.day_exile_reveal_level + ) + + env.player_full_visible_history_cache = {p_id: [] for p_id in env.player_id_str_list} + env.info = {EnvInfoKeys.MODERATOR_OBS: []} + env.agents = agents + + +def renderer(state, env): + if not hasattr(env, 'moderator') or not hasattr(env, 'game_state'): + return "Game not initialized by interpreter yet." + + game_state: GameState = env.game_state + + lines = [] + for entry in game_state.consume_messages(): + lines.append(entry.description) + return "\n\n".join(lines) + + +def html_renderer(): + js_path = path.abspath(path.join(path.dirname(__file__), "werewolf.js")) + with open(js_path, encoding="utf-8") as buff: + return buff.read() + + +jsonpath = path.abspath(path.join(path.dirname(__file__), "werewolf.json")) +with open(jsonpath) as handle: + specification = json.load(handle) diff --git a/requirements.txt b/requirements.txt index 38e46cc5..37b06b7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,11 @@ flask gym -ipython -jsonschema -numpy -requests \ No newline at end of file +kaggle-environments +kaggle +google-generativeai +python-dotenv +PyYAML +pyjson5 +pydantic +litellm +tenacity \ No newline at end of file diff --git a/setup.py b/setup.py index 4928e410..f25a85eb 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ def get_version(rel_path): "shimmy >= 1.2.1", "Chessnut >= 0.4.1", "open_spiel >= 1.6.0", + "pydantic >= 2.11.4", ], packages=find_packages(), include_package_data=True,