Spaces:
Running
Running
Commit ·
641f13e
1
Parent(s): b93075b
first commiiiiiiiiit
Browse files- .gitignore +42 -0
- README.md +73 -13
- app.py +139 -0
- baseline.json +169 -0
- codenames/__init__.py +8 -0
- codenames/challenge.py +170 -0
- codenames/challenge_runner.py +87 -0
- codenames/cli.py +90 -0
- codenames/data/codenames_dict.txt +420 -0
- codenames/dictionary.py +21 -0
- codenames/embedder.py +86 -0
- codenames/master.py +119 -0
- codenames/models.py +15 -0
- my_guesser.py +17 -0
- requirements.txt +4 -0
.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
*.egg
|
| 8 |
+
*.egg-info/
|
| 9 |
+
dist/
|
| 10 |
+
build/
|
| 11 |
+
eggs/
|
| 12 |
+
parts/
|
| 13 |
+
var/
|
| 14 |
+
sdist/
|
| 15 |
+
develop-eggs/
|
| 16 |
+
.installed.cfg
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
|
| 20 |
+
# Virtual environments
|
| 21 |
+
.env
|
| 22 |
+
.venv
|
| 23 |
+
env/
|
| 24 |
+
venv/
|
| 25 |
+
ENV/
|
| 26 |
+
|
| 27 |
+
# Jupyter
|
| 28 |
+
.ipynb_checkpoints/
|
| 29 |
+
*.ipynb
|
| 30 |
+
|
| 31 |
+
# macOS
|
| 32 |
+
.DS_Store
|
| 33 |
+
|
| 34 |
+
# IDEs
|
| 35 |
+
.vscode/
|
| 36 |
+
.idea/
|
| 37 |
+
*.swp
|
| 38 |
+
*.swo
|
| 39 |
+
|
| 40 |
+
# Logs
|
| 41 |
+
*.log
|
| 42 |
+
log.json
|
README.md
CHANGED
|
@@ -1,13 +1,73 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Codenames LLM Challenge
|
| 2 |
+
|
| 3 |
+
A Python framework for students to implement guesser bots for Codenames. The LLM acts as spymaster using embeddings.
|
| 4 |
+
|
| 5 |
+
## Game Rules
|
| 6 |
+
|
| 7 |
+
**Challenge Mode (Single Team):**
|
| 8 |
+
- Goal: Guess all RED words in minimum rounds
|
| 9 |
+
- Board: 25 words total (9 RED, 8 BLUE, 8 ASSASSIN)
|
| 10 |
+
- Each round: LLM spymaster gives a clue + number
|
| 11 |
+
- Guesser makes up to (number + 1) guesses
|
| 12 |
+
- Round ends if: BLUE word revealed, max guesses reached, or guesser stops
|
| 13 |
+
- Game ends: WIN if all RED found, LOSE if ASSASSIN revealed
|
| 14 |
+
|
| 15 |
+
## Setup
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
uv venv
|
| 19 |
+
source .venv/bin/activate
|
| 20 |
+
uv pip install -r requirements.txt
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
**Dictionary:** Fixed list of 420 Codenames words. Clues and board words must be from this dictionary (case-insensitive).
|
| 24 |
+
|
| 25 |
+
**Pre-build Embedding Cache (Recommended):**
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
python -m codenames.cli init-cache
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Downloads the embedding model and computes vectors for all 420 words (~30 seconds). Cached for reuse.
|
| 32 |
+
|
| 33 |
+
## Test Your Guesser
|
| 34 |
+
|
| 35 |
+
Create a Python file with a `guesser` function:
|
| 36 |
+
|
| 37 |
+
```python
|
| 38 |
+
# my_guesser.py
|
| 39 |
+
def guesser(clue: str, board_state: list[str]) -> str | None:
|
| 40 |
+
"""
|
| 41 |
+
Args:
|
| 42 |
+
clue: The spymaster's one-word clue (from dictionary)
|
| 43 |
+
board_state: List of unrevealed words on the board
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
A word to guess from board_state, or None to stop the round
|
| 47 |
+
"""
|
| 48 |
+
# Your embedding-based or heuristic logic here
|
| 49 |
+
return board_state[0] # Simple example: always guess first word
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Run against LLM spymaster:
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
python -m codenames.cli challenge my_guesser.py --seed 42 --output log.json
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
**Options:**
|
| 59 |
+
- `--seed`: Random seed for reproducible boards
|
| 60 |
+
- `--model`: Embedding model (default: `sentence-transformers/all-MiniLM-L6-v2`)
|
| 61 |
+
- `--max-rounds`: Maximum rounds before timeout (default: 10)
|
| 62 |
+
- `--output`: Save JSON log with board state, clues, guesses, and result
|
| 63 |
+
|
| 64 |
+
## Log Format
|
| 65 |
+
|
| 66 |
+
The JSON output contains:
|
| 67 |
+
- `seed`: Random seed used
|
| 68 |
+
- `board_words`: All 25 words on the board
|
| 69 |
+
- `board_roles`: Role for each word (RED/BLUE/ASSASSIN)
|
| 70 |
+
- `rounds`: Array of rounds with clue, number, and guesses
|
| 71 |
+
- `final_state`: Win/loss status and rounds taken
|
| 72 |
+
|
| 73 |
+
Use this data to analyze performance or train ML models.
|
app.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
|
| 3 |
+
INTRO_MD = """
|
| 4 |
+
# 🕵️ Codenames LLM Challenge
|
| 5 |
+
|
| 6 |
+
Welcome! This is a coding challenge where you build a **guesser bot** for the board game
|
| 7 |
+
[Codenames](https://en.wikipedia.org/wiki/Codenames_(board_game)).
|
| 8 |
+
|
| 9 |
+
An LLM-powered **spymaster** will give you one-word clues. Your job is to write the
|
| 10 |
+
**guesser** that picks the right words on the board.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## How the game works
|
| 15 |
+
|
| 16 |
+
The board has **25 words** split into hidden roles:
|
| 17 |
+
|
| 18 |
+
| Role | Count | Meaning |
|
| 19 |
+
|------|-------|---------|
|
| 20 |
+
| 🟥 RED | 9 | Your team's words — guess these! |
|
| 21 |
+
| 🟦 BLUE | 8 | Opponent words — avoid these |
|
| 22 |
+
| 💀 ASSASSIN | 8 | Instant loss if revealed |
|
| 23 |
+
|
| 24 |
+
Each round:
|
| 25 |
+
1. The **spymaster** gives a one-word clue + a number (e.g. `"ocean 3"`)
|
| 26 |
+
2. Your **guesser** can make up to `number + 1` guesses
|
| 27 |
+
3. A round ends when you reveal a BLUE word, reach the max guesses, or return `None`
|
| 28 |
+
4. The game ends when **all RED words are found** (win) or the **assassin is revealed** (loss)
|
| 29 |
+
|
| 30 |
+
Your score is based on the number of rounds needed — fewer is better.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## Two steps to participate
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
STEP1_MD = """
|
| 38 |
+
## Step 1 — Clone the repository
|
| 39 |
+
|
| 40 |
+
Open a terminal and run:
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
git clone https://huggingface.co/spaces/LLM-course/codenames
|
| 44 |
+
cd codenames
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
Then set up the Python environment:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
uv venv
|
| 51 |
+
source .venv/bin/activate # on Windows: .venv\\Scripts\\activate
|
| 52 |
+
uv pip install -r requirements.txt
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
*(Optional but recommended)* Pre-build the embedding cache so runs are faster:
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
python -m codenames.cli init-cache
|
| 59 |
+
```
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
STEP2_MD = """
|
| 63 |
+
## Step 2 — Implement `my_guesser.py`
|
| 64 |
+
|
| 65 |
+
Open `my_guesser.py` and fill in the `guesser` function:
|
| 66 |
+
|
| 67 |
+
```python
|
| 68 |
+
# my_guesser.py
|
| 69 |
+
from codenames.embedder import EmbeddingIndex
|
| 70 |
+
from codenames.dictionary import load_dictionary
|
| 71 |
+
|
| 72 |
+
_dictionary = load_dictionary()
|
| 73 |
+
_index = EmbeddingIndex(_dictionary)
|
| 74 |
+
|
| 75 |
+
def guesser(clue: str, board_state: list[str]) -> str | None:
|
| 76 |
+
\"\"\"
|
| 77 |
+
Args:
|
| 78 |
+
clue: The spymaster's one-word clue (always from the 420-word dictionary)
|
| 79 |
+
board_state: List of unrevealed words currently on the board
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
A word from board_state to guess, or None to stop guessing this round.
|
| 83 |
+
\"\"\"
|
| 84 |
+
# TODO: replace this with your smart logic!
|
| 85 |
+
return board_state[0]
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Tips
|
| 89 |
+
- Use **embedding similarity** between the clue and board words — `_index.vector(word)`
|
| 90 |
+
returns a numpy vector for any dictionary word.
|
| 91 |
+
- Return `None` when you're not confident: it's safer than hitting a BLUE or ASSASSIN word.
|
| 92 |
+
- The clue is always a word from the shared 420-word dictionary (case-insensitive).
|
| 93 |
+
|
| 94 |
+
### Test your guesser
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
python -m codenames.cli challenge my_guesser.py --seed 42 --output log.json
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
Options:
|
| 101 |
+
- `--seed` — fix the board for reproducibility
|
| 102 |
+
- `--max-rounds` — limit rounds (default 10)
|
| 103 |
+
- `--output` — save a detailed JSON log with clues, guesses and the final result
|
| 104 |
+
|
| 105 |
+
The JSON log is useful for inspecting mistakes and improving your strategy.
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
RULES_MD = """
|
| 109 |
+
## Full rules summary
|
| 110 |
+
|
| 111 |
+
- Board: 25 words, 9 RED / 8 BLUE / 8 ASSASSIN (challenge / single-team mode)
|
| 112 |
+
- Clue + number are given each round by the LLM spymaster
|
| 113 |
+
- Guesser may guess up to `number + 1` times per round
|
| 114 |
+
- Revealing a BLUE word ends the round immediately
|
| 115 |
+
- Revealing the ASSASSIN ends the game immediately (loss)
|
| 116 |
+
- **Goal:** reveal all 9 RED words in as few rounds as possible
|
| 117 |
+
|
| 118 |
+
## Dictionary
|
| 119 |
+
|
| 120 |
+
All clue words and board words are drawn from a **fixed list of 420 Codenames words**
|
| 121 |
+
(see `codenames/data/codenames_dict.txt`). Your guesser receives words from this list
|
| 122 |
+
and must return a word from the current `board_state`.
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
with gr.Blocks(title="Codenames LLM Challenge") as demo:
|
| 126 |
+
gr.Markdown(INTRO_MD)
|
| 127 |
+
|
| 128 |
+
with gr.Tabs():
|
| 129 |
+
with gr.TabItem("🛠️ Step 1 — Clone"):
|
| 130 |
+
gr.Markdown(STEP1_MD)
|
| 131 |
+
|
| 132 |
+
with gr.TabItem("💡 Step 2 — Implement"):
|
| 133 |
+
gr.Markdown(STEP2_MD)
|
| 134 |
+
|
| 135 |
+
with gr.TabItem("📋 Full Rules"):
|
| 136 |
+
gr.Markdown(RULES_MD)
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
demo.launch()
|
baseline.json
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seed": 42,
|
| 3 |
+
"board_words": [
|
| 4 |
+
"Snowman",
|
| 5 |
+
"Carrot",
|
| 6 |
+
"Atlantis",
|
| 7 |
+
"Tube",
|
| 8 |
+
"France",
|
| 9 |
+
"Fan",
|
| 10 |
+
"Dwarf",
|
| 11 |
+
"Chocolate",
|
| 12 |
+
"Trip",
|
| 13 |
+
"Canada",
|
| 14 |
+
"Stock",
|
| 15 |
+
"Pool",
|
| 16 |
+
"Bridge",
|
| 17 |
+
"Row",
|
| 18 |
+
"Mercury",
|
| 19 |
+
"Ball",
|
| 20 |
+
"Back",
|
| 21 |
+
"Buffalo",
|
| 22 |
+
"Drill",
|
| 23 |
+
"England",
|
| 24 |
+
"Penguin",
|
| 25 |
+
"Scientist",
|
| 26 |
+
"Australia",
|
| 27 |
+
"Pyramid",
|
| 28 |
+
"Degree"
|
| 29 |
+
],
|
| 30 |
+
"board_roles": [
|
| 31 |
+
"ASSASSIN",
|
| 32 |
+
"ASSASSIN",
|
| 33 |
+
"ASSASSIN",
|
| 34 |
+
"BLUE",
|
| 35 |
+
"ASSASSIN",
|
| 36 |
+
"BLUE",
|
| 37 |
+
"BLUE",
|
| 38 |
+
"RED",
|
| 39 |
+
"BLUE",
|
| 40 |
+
"RED",
|
| 41 |
+
"RED",
|
| 42 |
+
"RED",
|
| 43 |
+
"BLUE",
|
| 44 |
+
"RED",
|
| 45 |
+
"BLUE",
|
| 46 |
+
"RED",
|
| 47 |
+
"RED",
|
| 48 |
+
"RED",
|
| 49 |
+
"BLUE",
|
| 50 |
+
"RED",
|
| 51 |
+
"BLUE",
|
| 52 |
+
"ASSASSIN",
|
| 53 |
+
"ASSASSIN",
|
| 54 |
+
"ASSASSIN",
|
| 55 |
+
"ASSASSIN"
|
| 56 |
+
],
|
| 57 |
+
"rounds": [
|
| 58 |
+
{
|
| 59 |
+
"round": 1,
|
| 60 |
+
"clue": "bat",
|
| 61 |
+
"number": 3,
|
| 62 |
+
"guesses": [
|
| 63 |
+
{
|
| 64 |
+
"word": "Ball",
|
| 65 |
+
"role": "RED"
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"word": "Pool",
|
| 69 |
+
"role": "RED"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"word": "Drill",
|
| 73 |
+
"role": "BLUE"
|
| 74 |
+
}
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"round": 2,
|
| 79 |
+
"clue": "bark",
|
| 80 |
+
"number": 3,
|
| 81 |
+
"guesses": [
|
| 82 |
+
{
|
| 83 |
+
"word": "Buffalo",
|
| 84 |
+
"role": "RED"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"word": "Chocolate",
|
| 88 |
+
"role": "RED"
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"word": "Back",
|
| 92 |
+
"role": "RED"
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"word": "Bridge",
|
| 96 |
+
"role": "BLUE"
|
| 97 |
+
}
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"round": 3,
|
| 102 |
+
"clue": "mail",
|
| 103 |
+
"number": 1,
|
| 104 |
+
"guesses": [
|
| 105 |
+
{
|
| 106 |
+
"word": "Stock",
|
| 107 |
+
"role": "RED"
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"word": "Canada",
|
| 111 |
+
"role": "RED"
|
| 112 |
+
}
|
| 113 |
+
]
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"round": 4,
|
| 117 |
+
"clue": "jam",
|
| 118 |
+
"number": 1,
|
| 119 |
+
"guesses": [
|
| 120 |
+
{
|
| 121 |
+
"word": "Row",
|
| 122 |
+
"role": "RED"
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"word": "Trip",
|
| 126 |
+
"role": "BLUE"
|
| 127 |
+
}
|
| 128 |
+
]
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"round": 5,
|
| 132 |
+
"clue": "well",
|
| 133 |
+
"number": 1,
|
| 134 |
+
"guesses": [
|
| 135 |
+
{
|
| 136 |
+
"word": "Fan",
|
| 137 |
+
"role": "BLUE"
|
| 138 |
+
}
|
| 139 |
+
]
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"round": 6,
|
| 143 |
+
"clue": "boot",
|
| 144 |
+
"number": 1,
|
| 145 |
+
"guesses": [
|
| 146 |
+
{
|
| 147 |
+
"word": "Tube",
|
| 148 |
+
"role": "BLUE"
|
| 149 |
+
}
|
| 150 |
+
]
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"round": 7,
|
| 154 |
+
"clue": "boot",
|
| 155 |
+
"number": 1,
|
| 156 |
+
"guesses": [
|
| 157 |
+
{
|
| 158 |
+
"word": "Scientist",
|
| 159 |
+
"role": "ASSASSIN"
|
| 160 |
+
}
|
| 161 |
+
]
|
| 162 |
+
}
|
| 163 |
+
],
|
| 164 |
+
"final_state": {
|
| 165 |
+
"won": false,
|
| 166 |
+
"lost": true,
|
| 167 |
+
"rounds_taken": 7
|
| 168 |
+
}
|
| 169 |
+
}
|
codenames/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__all__ = [
|
| 2 |
+
"models",
|
| 3 |
+
"dictionary",
|
| 4 |
+
"embedder",
|
| 5 |
+
"master",
|
| 6 |
+
"challenge",
|
| 7 |
+
"challenge_runner",
|
| 8 |
+
]
|
codenames/challenge.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from .models import Role, Card
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class ChallengeBoard:
|
| 12 |
+
"""Single-team challenge: RED team vs (BLUE + ASSASSIN). No neutrals."""
|
| 13 |
+
|
| 14 |
+
cards: List[Card] = field(default_factory=list)
|
| 15 |
+
index_by_word: dict = field(default_factory=dict)
|
| 16 |
+
|
| 17 |
+
@staticmethod
|
| 18 |
+
def create(words: List[str], rng: random.Random) -> "ChallengeBoard":
|
| 19 |
+
"""Create a board with RED, BLUE, ASSASSIN roles (no NEUTRAL)."""
|
| 20 |
+
if len(words) != 25:
|
| 21 |
+
raise ValueError("Challenge board must have exactly 25 words")
|
| 22 |
+
|
| 23 |
+
# 8 RED, 8 BLUE, 1 ASSASSIN (9 remaining would need adjustment)
|
| 24 |
+
# Standard: 9 RED, 8 BLUE, 8 ASSASSIN? Let's use 9 RED, 8 BLUE, 8 ASSASSIN
|
| 25 |
+
roles = [Role.RED] * 9 + [Role.BLUE] * 8 + [Role.ASSASSIN] * 8
|
| 26 |
+
rng.shuffle(roles)
|
| 27 |
+
|
| 28 |
+
cards = [Card(word=w, role=roles[i]) for i, w in enumerate(words)]
|
| 29 |
+
board = ChallengeBoard(cards=cards)
|
| 30 |
+
board.index_by_word = {c.word.lower(): i for i, c in enumerate(cards)}
|
| 31 |
+
return board
|
| 32 |
+
|
| 33 |
+
def get_card(self, word: str) -> Optional[Card]:
|
| 34 |
+
idx = self.index_by_word.get(word.lower())
|
| 35 |
+
return self.cards[idx] if idx is not None else None
|
| 36 |
+
|
| 37 |
+
def reveal(self, word: str) -> Optional[Role]:
|
| 38 |
+
card = self.get_card(word)
|
| 39 |
+
if card is None or card.revealed:
|
| 40 |
+
return None
|
| 41 |
+
card.revealed = True
|
| 42 |
+
return card.role
|
| 43 |
+
|
| 44 |
+
def red_remaining(self) -> int:
|
| 45 |
+
return sum(1 for c in self.cards if c.role == Role.RED and not c.revealed)
|
| 46 |
+
|
| 47 |
+
def all_red_revealed(self) -> bool:
|
| 48 |
+
return all(c.revealed for c in self.cards if c.role == Role.RED)
|
| 49 |
+
|
| 50 |
+
def as_grid(self) -> List[List[Card]]:
|
| 51 |
+
return [self.cards[i : i + 5] for i in range(0, 25, 5)]
|
| 52 |
+
|
| 53 |
+
def board_view(self, show_key: bool = False) -> List[List[str]]:
|
| 54 |
+
grid = []
|
| 55 |
+
for row in self.as_grid():
|
| 56 |
+
display_row = []
|
| 57 |
+
for c in row:
|
| 58 |
+
label = c.word
|
| 59 |
+
if c.revealed:
|
| 60 |
+
label = f"[{label}]"
|
| 61 |
+
elif show_key:
|
| 62 |
+
label = f"{label}:{c.role.value}"
|
| 63 |
+
display_row.append(label)
|
| 64 |
+
grid.append(display_row)
|
| 65 |
+
return grid
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class ChallengeGame:
|
| 70 |
+
"""Single-team challenge: guess all RED words while avoiding BLUE and ASSASSIN."""
|
| 71 |
+
|
| 72 |
+
dictionary: List[str]
|
| 73 |
+
seed: Optional[int] = None
|
| 74 |
+
rng: random.Random = field(init=False)
|
| 75 |
+
board: ChallengeBoard = field(init=False)
|
| 76 |
+
dictionary_set: set = field(init=False)
|
| 77 |
+
|
| 78 |
+
# Turn state
|
| 79 |
+
clue_word: Optional[str] = None
|
| 80 |
+
clue_number: Optional[int] = None
|
| 81 |
+
guesses_made: int = 0
|
| 82 |
+
max_guesses_this_turn: int = 0
|
| 83 |
+
round_number: int = 1
|
| 84 |
+
|
| 85 |
+
# Outcome
|
| 86 |
+
won: bool = False
|
| 87 |
+
lost: bool = False
|
| 88 |
+
|
| 89 |
+
def __post_init__(self):
|
| 90 |
+
self.rng = random.Random(self.seed)
|
| 91 |
+
self.dictionary_set = {w.lower() for w in self.dictionary}
|
| 92 |
+
if len(self.dictionary) < 25:
|
| 93 |
+
raise ValueError("Dictionary must contain at least 25 words")
|
| 94 |
+
words = self.rng.sample(self.dictionary, 25)
|
| 95 |
+
self.board = ChallengeBoard.create(words, self.rng)
|
| 96 |
+
|
| 97 |
+
def give_clue(self, clue_word: str, number: int):
|
| 98 |
+
if self.won or self.lost:
|
| 99 |
+
raise RuntimeError("Game already over")
|
| 100 |
+
if number < 0:
|
| 101 |
+
raise ValueError("Number must be non-negative")
|
| 102 |
+
if clue_word.lower() not in self.dictionary_set:
|
| 103 |
+
raise ValueError("Clue must be a word from the dictionary")
|
| 104 |
+
if clue_word.lower() in self.board.index_by_word:
|
| 105 |
+
raise ValueError("Clue cannot be a word that is on the board")
|
| 106 |
+
|
| 107 |
+
self.clue_word = clue_word
|
| 108 |
+
self.clue_number = number
|
| 109 |
+
self.max_guesses_this_turn = number + 1
|
| 110 |
+
self.guesses_made = 0
|
| 111 |
+
|
| 112 |
+
def guess(self, word: str) -> tuple[Optional[Role], str]:
|
| 113 |
+
if self.won or self.lost:
|
| 114 |
+
return None, "Game over"
|
| 115 |
+
if self.clue_word is None:
|
| 116 |
+
return None, "No clue given yet"
|
| 117 |
+
|
| 118 |
+
card = self.board.get_card(word)
|
| 119 |
+
if card is None:
|
| 120 |
+
return None, f"Word '{word}' is not on the board. Try again."
|
| 121 |
+
if card.revealed:
|
| 122 |
+
return None, f"Word '{word}' is already revealed. Try again."
|
| 123 |
+
|
| 124 |
+
role = self.board.reveal(word)
|
| 125 |
+
self.guesses_made += 1
|
| 126 |
+
|
| 127 |
+
# Check outcomes
|
| 128 |
+
if role == Role.ASSASSIN:
|
| 129 |
+
self.lost = True
|
| 130 |
+
return role, "Revealed ASSASSIN. You lose."
|
| 131 |
+
|
| 132 |
+
if role == Role.BLUE:
|
| 133 |
+
self.end_round()
|
| 134 |
+
return role, "Revealed BLUE. Round ends."
|
| 135 |
+
|
| 136 |
+
# Revealed RED
|
| 137 |
+
if self.board.all_red_revealed():
|
| 138 |
+
self.won = True
|
| 139 |
+
return role, f"Revealed RED. All RED words found! You win in {self.round_number} round(s)."
|
| 140 |
+
|
| 141 |
+
# Can continue if guesses remain
|
| 142 |
+
if self.guesses_made >= self.max_guesses_this_turn:
|
| 143 |
+
self.end_round()
|
| 144 |
+
return role, f"Revealed RED. Max guesses reached. Round ends."
|
| 145 |
+
|
| 146 |
+
return role, f"Revealed RED. You may guess again or 'stop'."
|
| 147 |
+
|
| 148 |
+
def end_round(self):
|
| 149 |
+
self.clue_word = None
|
| 150 |
+
self.clue_number = None
|
| 151 |
+
self.guesses_made = 0
|
| 152 |
+
self.max_guesses_this_turn = 0
|
| 153 |
+
self.round_number += 1
|
| 154 |
+
|
| 155 |
+
def status_text(self) -> str:
|
| 156 |
+
if self.won:
|
| 157 |
+
return f"🏆 YOU WIN in {self.round_number} round(s)! 🏆"
|
| 158 |
+
if self.lost:
|
| 159 |
+
return "💀 YOU LOSE - Assassin revealed. 💀"
|
| 160 |
+
|
| 161 |
+
lines = [
|
| 162 |
+
f"🎯 SPYMASTER (Round {self.round_number})",
|
| 163 |
+
f"RED remaining: {self.board.red_remaining()}",
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
if self.clue_word is not None:
|
| 167 |
+
lines.append(f"Clue: {self.clue_word.upper()} ({self.clue_number})")
|
| 168 |
+
lines.append(f"{self.max_guesses_this_turn - self.guesses_made} guesses left")
|
| 169 |
+
|
| 170 |
+
return "\n".join(lines)
|
codenames/challenge_runner.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Callable, Optional
|
| 4 |
+
|
| 5 |
+
from .challenge import ChallengeGame
|
| 6 |
+
from .master import MasterPlayer
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def play_challenge(
|
| 10 |
+
guesser_fn: Callable,
|
| 11 |
+
dictionary: list[str],
|
| 12 |
+
seed: Optional[int] = None,
|
| 13 |
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
|
| 14 |
+
max_rounds: int = 10,
|
| 15 |
+
verbose: bool = True,
|
| 16 |
+
) -> dict:
|
| 17 |
+
"""
|
| 18 |
+
Play a challenge game with LLM spymaster and student guesser code.
|
| 19 |
+
|
| 20 |
+
The guesser_fn should have signature:
|
| 21 |
+
guesser_fn(clue: str, board_state: list[str]) -> Optional[str]
|
| 22 |
+
where board_state is a list of unrevealed word strings.
|
| 23 |
+
"""
|
| 24 |
+
game = ChallengeGame(dictionary=dictionary, seed=seed)
|
| 25 |
+
master = MasterPlayer(dictionary, model_name=model_name)
|
| 26 |
+
|
| 27 |
+
log = {
|
| 28 |
+
"seed": seed,
|
| 29 |
+
"board_words": [c.word for c in game.board.cards],
|
| 30 |
+
"board_roles": [c.role.value for c in game.board.cards],
|
| 31 |
+
"rounds": [],
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
while not game.won and not game.lost and game.round_number <= max_rounds:
|
| 35 |
+
if verbose:
|
| 36 |
+
print(f"\n{game.status_text()}")
|
| 37 |
+
|
| 38 |
+
# Spymaster suggests clue
|
| 39 |
+
suggestion = master.suggest_clue(game)
|
| 40 |
+
if suggestion is None:
|
| 41 |
+
if verbose:
|
| 42 |
+
print("No clue available.")
|
| 43 |
+
break
|
| 44 |
+
|
| 45 |
+
game.give_clue(suggestion.clue, suggestion.number)
|
| 46 |
+
if verbose:
|
| 47 |
+
print(f"Clue: {suggestion.clue} ({suggestion.number})")
|
| 48 |
+
|
| 49 |
+
round_log = {
|
| 50 |
+
"round": game.round_number,
|
| 51 |
+
"clue": suggestion.clue,
|
| 52 |
+
"number": suggestion.number,
|
| 53 |
+
"guesses": [],
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# Guesser phase with student code
|
| 57 |
+
while not game.won and not game.lost and game.clue_word is not None:
|
| 58 |
+
unrevealed = [c.word for c in game.board.cards if not c.revealed]
|
| 59 |
+
try:
|
| 60 |
+
guess_word = guesser_fn(game.clue_word, unrevealed)
|
| 61 |
+
except Exception as e:
|
| 62 |
+
if verbose:
|
| 63 |
+
print(f"Guesser error: {e}")
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
if guess_word is None:
|
| 67 |
+
break
|
| 68 |
+
|
| 69 |
+
role, msg = game.guess(guess_word)
|
| 70 |
+
if verbose:
|
| 71 |
+
print(f" Guess: {guess_word} -> {msg}")
|
| 72 |
+
|
| 73 |
+
if role is not None:
|
| 74 |
+
round_log["guesses"].append({"word": guess_word, "role": role.value})
|
| 75 |
+
|
| 76 |
+
log["rounds"].append(round_log)
|
| 77 |
+
|
| 78 |
+
log["final_state"] = {
|
| 79 |
+
"won": game.won,
|
| 80 |
+
"lost": game.lost,
|
| 81 |
+
"rounds_taken": game.round_number - 1 if game.won else game.round_number,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if verbose:
|
| 85 |
+
print(f"\nFinal: {game.status_text()}")
|
| 86 |
+
|
| 87 |
+
return log
|
codenames/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from .dictionary import load_dictionary
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def cmd_init_cache(args):
|
| 13 |
+
"""Pre-build embedding cache for the dictionary."""
|
| 14 |
+
from .embedder import load_or_build_embeddings
|
| 15 |
+
words = load_dictionary()
|
| 16 |
+
print(f"Pre-building cache for {len(words)} words with model: {args.model}")
|
| 17 |
+
cache = load_or_build_embeddings(words, model_name=args.model, rebuild=args.rebuild)
|
| 18 |
+
print(f"✓ Cache ready: {cache.vectors.shape}")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def cmd_challenge(args):
|
| 25 |
+
"""Play challenge mode with LLM spymaster and student guesser code."""
|
| 26 |
+
words = load_dictionary()
|
| 27 |
+
|
| 28 |
+
# Import student code
|
| 29 |
+
import importlib.util
|
| 30 |
+
spec = importlib.util.spec_from_file_location("student_guesser", args.guesser_code)
|
| 31 |
+
if spec is None or spec.loader is None:
|
| 32 |
+
print(f"Could not load {args.guesser_code}")
|
| 33 |
+
return
|
| 34 |
+
module = importlib.util.module_from_spec(spec)
|
| 35 |
+
spec.loader.exec_module(module)
|
| 36 |
+
|
| 37 |
+
if not hasattr(module, "guesser"):
|
| 38 |
+
print("Student code must define a 'guesser' function with signature: guesser(clue: str, board_state: list[str]) -> Optional[str]")
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
from .challenge_runner import play_challenge
|
| 42 |
+
log = play_challenge(
|
| 43 |
+
module.guesser,
|
| 44 |
+
words,
|
| 45 |
+
seed=args.seed,
|
| 46 |
+
model_name=args.model,
|
| 47 |
+
max_rounds=args.max_rounds,
|
| 48 |
+
verbose=True,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if args.output:
|
| 52 |
+
import json
|
| 53 |
+
with open(args.output, "w") as f:
|
| 54 |
+
json.dump(log, f, indent=2)
|
| 55 |
+
print(f"\nLog saved to {args.output}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def main(argv: Optional[list] = None):
|
| 59 |
+
parser = argparse.ArgumentParser(description="Codenames Challenge CLI")
|
| 60 |
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
| 61 |
+
|
| 62 |
+
p_cache = sub.add_parser("init-cache", help="Pre-build embedding cache for dictionary")
|
| 63 |
+
p_cache.add_argument(
|
| 64 |
+
"--model",
|
| 65 |
+
type=str,
|
| 66 |
+
default="sentence-transformers/all-MiniLM-L6-v2",
|
| 67 |
+
help="SentenceTransformers model name",
|
| 68 |
+
)
|
| 69 |
+
p_cache.add_argument("--rebuild", action="store_true", help="Force rebuild cache even if it exists")
|
| 70 |
+
p_cache.set_defaults(func=cmd_init_cache)
|
| 71 |
+
|
| 72 |
+
p_challenge = sub.add_parser("challenge", help="Challenge mode: LLM spymaster + student guesser")
|
| 73 |
+
p_challenge.add_argument("guesser_code", help="Path to Python file with guesser() function")
|
| 74 |
+
p_challenge.add_argument("--seed", type=int, default=None, help="Random seed")
|
| 75 |
+
p_challenge.add_argument(
|
| 76 |
+
"--model",
|
| 77 |
+
type=str,
|
| 78 |
+
default="sentence-transformers/all-MiniLM-L6-v2",
|
| 79 |
+
help="SentenceTransformers model name",
|
| 80 |
+
)
|
| 81 |
+
p_challenge.add_argument("--max-rounds", type=int, default=10, help="Maximum rounds")
|
| 82 |
+
p_challenge.add_argument("--output", type=str, default=None, help="Save log to JSON file")
|
| 83 |
+
p_challenge.set_defaults(func=cmd_challenge)
|
| 84 |
+
|
| 85 |
+
args = parser.parse_args(argv)
|
| 86 |
+
args.func(args)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
main()
|
codenames/data/codenames_dict.txt
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Africa
|
| 2 |
+
Agent
|
| 3 |
+
Air
|
| 4 |
+
Alien
|
| 5 |
+
Alps
|
| 6 |
+
Amazon
|
| 7 |
+
Ambulance
|
| 8 |
+
America
|
| 9 |
+
Angel
|
| 10 |
+
Antarctica
|
| 11 |
+
Apple
|
| 12 |
+
Arm
|
| 13 |
+
Atlantis
|
| 14 |
+
Australia
|
| 15 |
+
Aztec
|
| 16 |
+
Back
|
| 17 |
+
Ball
|
| 18 |
+
Band
|
| 19 |
+
Bank
|
| 20 |
+
Bar
|
| 21 |
+
Bark
|
| 22 |
+
Bat
|
| 23 |
+
Battery
|
| 24 |
+
Beach
|
| 25 |
+
Bear
|
| 26 |
+
Beat
|
| 27 |
+
Bed
|
| 28 |
+
Beijing
|
| 29 |
+
Bell
|
| 30 |
+
Belt
|
| 31 |
+
Berlin
|
| 32 |
+
Bermuda
|
| 33 |
+
Berry
|
| 34 |
+
Bill
|
| 35 |
+
Block
|
| 36 |
+
Board
|
| 37 |
+
Bolt
|
| 38 |
+
Bomb
|
| 39 |
+
Bond
|
| 40 |
+
Boom
|
| 41 |
+
Boot
|
| 42 |
+
Bottle
|
| 43 |
+
Bow
|
| 44 |
+
Box
|
| 45 |
+
Bridge
|
| 46 |
+
Brush
|
| 47 |
+
Buck
|
| 48 |
+
Buffalo
|
| 49 |
+
Bug
|
| 50 |
+
Bugle
|
| 51 |
+
Button
|
| 52 |
+
Calf
|
| 53 |
+
Canada
|
| 54 |
+
Cap
|
| 55 |
+
Capital
|
| 56 |
+
Car
|
| 57 |
+
Card
|
| 58 |
+
Carrot
|
| 59 |
+
Casino
|
| 60 |
+
Cast
|
| 61 |
+
Cat
|
| 62 |
+
Cell
|
| 63 |
+
Centaur
|
| 64 |
+
Center
|
| 65 |
+
Chair
|
| 66 |
+
Change
|
| 67 |
+
Charge
|
| 68 |
+
Check
|
| 69 |
+
Chest
|
| 70 |
+
Chick
|
| 71 |
+
China
|
| 72 |
+
Chocolate
|
| 73 |
+
Church
|
| 74 |
+
Circle
|
| 75 |
+
Cliff
|
| 76 |
+
Cloak
|
| 77 |
+
Club
|
| 78 |
+
Code
|
| 79 |
+
Cold
|
| 80 |
+
Comic
|
| 81 |
+
Compound
|
| 82 |
+
Concert
|
| 83 |
+
Conductor
|
| 84 |
+
Contract
|
| 85 |
+
Cook
|
| 86 |
+
Copper
|
| 87 |
+
Cotton
|
| 88 |
+
Court
|
| 89 |
+
Cover
|
| 90 |
+
Crane
|
| 91 |
+
Crash
|
| 92 |
+
Cricket
|
| 93 |
+
Cross
|
| 94 |
+
Crown
|
| 95 |
+
Cycle
|
| 96 |
+
Czech
|
| 97 |
+
Dance
|
| 98 |
+
Date
|
| 99 |
+
Day
|
| 100 |
+
Death
|
| 101 |
+
Deck
|
| 102 |
+
Degree
|
| 103 |
+
Diamond
|
| 104 |
+
Dice
|
| 105 |
+
Dinosaur
|
| 106 |
+
Disease
|
| 107 |
+
Doctor
|
| 108 |
+
Dog
|
| 109 |
+
Draft
|
| 110 |
+
Dragon
|
| 111 |
+
Dress
|
| 112 |
+
Drill
|
| 113 |
+
Drop
|
| 114 |
+
Duck
|
| 115 |
+
Dwarf
|
| 116 |
+
Eagle
|
| 117 |
+
Egypt
|
| 118 |
+
Embassy
|
| 119 |
+
Engine
|
| 120 |
+
England
|
| 121 |
+
Europe
|
| 122 |
+
Eye
|
| 123 |
+
Face
|
| 124 |
+
Fair
|
| 125 |
+
Fall
|
| 126 |
+
Fan
|
| 127 |
+
Fence
|
| 128 |
+
Field
|
| 129 |
+
Fighter
|
| 130 |
+
Figure
|
| 131 |
+
File
|
| 132 |
+
Film
|
| 133 |
+
Fire
|
| 134 |
+
Fish
|
| 135 |
+
Flute
|
| 136 |
+
Fly
|
| 137 |
+
Foot
|
| 138 |
+
Force
|
| 139 |
+
Forest
|
| 140 |
+
Fork
|
| 141 |
+
France
|
| 142 |
+
Game
|
| 143 |
+
Gas
|
| 144 |
+
Genius
|
| 145 |
+
Germany
|
| 146 |
+
Ghost
|
| 147 |
+
Giant
|
| 148 |
+
Glass
|
| 149 |
+
Glove
|
| 150 |
+
Gold
|
| 151 |
+
Grace
|
| 152 |
+
Grass
|
| 153 |
+
Greece
|
| 154 |
+
Green
|
| 155 |
+
Ground
|
| 156 |
+
Ham
|
| 157 |
+
Hand
|
| 158 |
+
Hawk
|
| 159 |
+
Head
|
| 160 |
+
Heart
|
| 161 |
+
Helicopter
|
| 162 |
+
Himalayas
|
| 163 |
+
Hole
|
| 164 |
+
Hollywood
|
| 165 |
+
Honey
|
| 166 |
+
Hood
|
| 167 |
+
Hook
|
| 168 |
+
Horn
|
| 169 |
+
Horse
|
| 170 |
+
Horseshoe
|
| 171 |
+
Hospital
|
| 172 |
+
Hotel
|
| 173 |
+
Ice
|
| 174 |
+
Ice Cream
|
| 175 |
+
India
|
| 176 |
+
Iron
|
| 177 |
+
Ivory
|
| 178 |
+
Jack
|
| 179 |
+
Jam
|
| 180 |
+
Jet
|
| 181 |
+
Jupiter
|
| 182 |
+
Kangaroo
|
| 183 |
+
Ketchup
|
| 184 |
+
Key
|
| 185 |
+
Kid
|
| 186 |
+
King
|
| 187 |
+
Kiwi
|
| 188 |
+
Knife
|
| 189 |
+
Knight
|
| 190 |
+
Lab
|
| 191 |
+
Lap
|
| 192 |
+
Laser
|
| 193 |
+
Lawyer
|
| 194 |
+
Lead
|
| 195 |
+
Leather
|
| 196 |
+
Lemon
|
| 197 |
+
Leprechaun
|
| 198 |
+
Life
|
| 199 |
+
Light
|
| 200 |
+
Limousine
|
| 201 |
+
Line
|
| 202 |
+
Link
|
| 203 |
+
Lion
|
| 204 |
+
Litter
|
| 205 |
+
Loch Ness
|
| 206 |
+
Lock
|
| 207 |
+
Log
|
| 208 |
+
London
|
| 209 |
+
Luck
|
| 210 |
+
Mail
|
| 211 |
+
Mammoth
|
| 212 |
+
Maple
|
| 213 |
+
Marble
|
| 214 |
+
March
|
| 215 |
+
Mass
|
| 216 |
+
Match
|
| 217 |
+
Mercury
|
| 218 |
+
Mexico
|
| 219 |
+
Microscope
|
| 220 |
+
Millionaire
|
| 221 |
+
Mine
|
| 222 |
+
Mint
|
| 223 |
+
Missile
|
| 224 |
+
Model
|
| 225 |
+
Mole
|
| 226 |
+
Moon
|
| 227 |
+
Moscow
|
| 228 |
+
Mount
|
| 229 |
+
Mouse
|
| 230 |
+
Mouth
|
| 231 |
+
Mug
|
| 232 |
+
Nail
|
| 233 |
+
Needle
|
| 234 |
+
Net
|
| 235 |
+
New York
|
| 236 |
+
Night
|
| 237 |
+
Ninja
|
| 238 |
+
Note
|
| 239 |
+
Novel
|
| 240 |
+
Nurse
|
| 241 |
+
Nut
|
| 242 |
+
Octopus
|
| 243 |
+
Oil
|
| 244 |
+
Olive
|
| 245 |
+
Olympus
|
| 246 |
+
Opera
|
| 247 |
+
Orange
|
| 248 |
+
Organ
|
| 249 |
+
Pain
|
| 250 |
+
Palm
|
| 251 |
+
Pan
|
| 252 |
+
Pants
|
| 253 |
+
Paper
|
| 254 |
+
Parachute
|
| 255 |
+
Park
|
| 256 |
+
Part
|
| 257 |
+
Pass
|
| 258 |
+
Paste
|
| 259 |
+
Penguin
|
| 260 |
+
Phoenix
|
| 261 |
+
Piano
|
| 262 |
+
Pie
|
| 263 |
+
Pilot
|
| 264 |
+
Pin
|
| 265 |
+
Pipe
|
| 266 |
+
Pirate
|
| 267 |
+
Pistol
|
| 268 |
+
Pit
|
| 269 |
+
Pitch
|
| 270 |
+
Plane
|
| 271 |
+
Plastic
|
| 272 |
+
Plate
|
| 273 |
+
Platypus
|
| 274 |
+
Play
|
| 275 |
+
Plot
|
| 276 |
+
Point
|
| 277 |
+
Poison
|
| 278 |
+
Pole
|
| 279 |
+
Police
|
| 280 |
+
Pool
|
| 281 |
+
Port
|
| 282 |
+
Post
|
| 283 |
+
Pound
|
| 284 |
+
Press
|
| 285 |
+
Princess
|
| 286 |
+
Pumpkin
|
| 287 |
+
Pupil
|
| 288 |
+
Pyramid
|
| 289 |
+
Queen
|
| 290 |
+
Rabbit
|
| 291 |
+
Racket
|
| 292 |
+
Ray
|
| 293 |
+
Revolution
|
| 294 |
+
Ring
|
| 295 |
+
Robin
|
| 296 |
+
Robot
|
| 297 |
+
Rock
|
| 298 |
+
Rome
|
| 299 |
+
Root
|
| 300 |
+
Rose
|
| 301 |
+
Roulette
|
| 302 |
+
Round
|
| 303 |
+
Row
|
| 304 |
+
Ruler
|
| 305 |
+
Satellite
|
| 306 |
+
Saturn
|
| 307 |
+
Scale
|
| 308 |
+
School
|
| 309 |
+
Scientist
|
| 310 |
+
Scorpion
|
| 311 |
+
Screen
|
| 312 |
+
Scuba Diver
|
| 313 |
+
Seal
|
| 314 |
+
Server
|
| 315 |
+
Shadow
|
| 316 |
+
Shakespeare
|
| 317 |
+
Shark
|
| 318 |
+
Ship
|
| 319 |
+
Shoe
|
| 320 |
+
Shop
|
| 321 |
+
Shot
|
| 322 |
+
Sink
|
| 323 |
+
Skyscraper
|
| 324 |
+
Slip
|
| 325 |
+
Slug
|
| 326 |
+
Smuggler
|
| 327 |
+
Snow
|
| 328 |
+
Snowman
|
| 329 |
+
Sock
|
| 330 |
+
Soldier
|
| 331 |
+
Soul
|
| 332 |
+
Sound
|
| 333 |
+
Space
|
| 334 |
+
Spell
|
| 335 |
+
Spider
|
| 336 |
+
Spike
|
| 337 |
+
Spine
|
| 338 |
+
Spot
|
| 339 |
+
Spring
|
| 340 |
+
Spy
|
| 341 |
+
Square
|
| 342 |
+
Stadium
|
| 343 |
+
Staff
|
| 344 |
+
Star
|
| 345 |
+
State
|
| 346 |
+
Stick
|
| 347 |
+
Stock
|
| 348 |
+
Straw
|
| 349 |
+
Stream
|
| 350 |
+
Strike
|
| 351 |
+
String
|
| 352 |
+
Sub
|
| 353 |
+
Suit
|
| 354 |
+
Superhero
|
| 355 |
+
Swing
|
| 356 |
+
Switch
|
| 357 |
+
Table
|
| 358 |
+
Tablet
|
| 359 |
+
Tag
|
| 360 |
+
Tail
|
| 361 |
+
Tap
|
| 362 |
+
Teacher
|
| 363 |
+
Telescope
|
| 364 |
+
Temple
|
| 365 |
+
Theater
|
| 366 |
+
Thief
|
| 367 |
+
Thumb
|
| 368 |
+
Tick
|
| 369 |
+
Tie
|
| 370 |
+
Time
|
| 371 |
+
Tokyo
|
| 372 |
+
Tooth
|
| 373 |
+
Torch
|
| 374 |
+
Tower
|
| 375 |
+
Track
|
| 376 |
+
Train
|
| 377 |
+
Triangle
|
| 378 |
+
Trip
|
| 379 |
+
Trunk
|
| 380 |
+
Tube
|
| 381 |
+
Turkey
|
| 382 |
+
Undertaker
|
| 383 |
+
Unicorn
|
| 384 |
+
Vacuum
|
| 385 |
+
Van
|
| 386 |
+
Vet
|
| 387 |
+
Wake
|
| 388 |
+
Wall
|
| 389 |
+
War
|
| 390 |
+
Washer
|
| 391 |
+
Washington
|
| 392 |
+
Watch
|
| 393 |
+
Water
|
| 394 |
+
Wave
|
| 395 |
+
Web
|
| 396 |
+
Well
|
| 397 |
+
Whale
|
| 398 |
+
Whip
|
| 399 |
+
Wind
|
| 400 |
+
Witch
|
| 401 |
+
Worm
|
| 402 |
+
Yard
|
| 403 |
+
Beard
|
| 404 |
+
Pleasure
|
| 405 |
+
Orgasm
|
| 406 |
+
Volcano
|
| 407 |
+
Eruption
|
| 408 |
+
Burger
|
| 409 |
+
Hot Dog
|
| 410 |
+
Pizza
|
| 411 |
+
Sausage
|
| 412 |
+
Hands
|
| 413 |
+
Cherry
|
| 414 |
+
Tomato
|
| 415 |
+
Pickle
|
| 416 |
+
Sexy
|
| 417 |
+
Cave
|
| 418 |
+
Volley
|
| 419 |
+
Mold
|
| 420 |
+
Mould
|
codenames/dictionary.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
WORDS_DIR = Path(__file__).parent / "data"
|
| 7 |
+
WORDS_FILE = WORDS_DIR / "codenames_dict.txt"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def load_dictionary() -> List[str]:
|
| 11 |
+
if not WORDS_FILE.exists():
|
| 12 |
+
raise FileNotFoundError(f"Dictionary file not found at {WORDS_FILE}")
|
| 13 |
+
words: List[str] = []
|
| 14 |
+
with open(WORDS_FILE, "r", encoding="utf-8") as f:
|
| 15 |
+
for line in f:
|
| 16 |
+
w = line.strip()
|
| 17 |
+
if w:
|
| 18 |
+
words.append(w)
|
| 19 |
+
if len(words) < 100:
|
| 20 |
+
raise ValueError("Dictionary seems too small.")
|
| 21 |
+
return words
|
codenames/embedder.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, Iterable, List, Optional, Sequence
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from sentence_transformers import SentenceTransformer
|
| 9 |
+
|
| 10 |
+
from .dictionary import WORDS_DIR
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class EmbeddingCache:
|
| 15 |
+
words: List[str]
|
| 16 |
+
vectors: np.ndarray # shape (N, D), normalized
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _cache_path(model_name: str) -> Path:
|
| 20 |
+
safe = model_name.replace("/", "_")
|
| 21 |
+
return WORDS_DIR / f"embeddings_{safe}.npz"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_or_build_embeddings(
|
| 25 |
+
words: Sequence[str],
|
| 26 |
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
|
| 27 |
+
rebuild: bool = False,
|
| 28 |
+
batch_size: int = 64,
|
| 29 |
+
) -> EmbeddingCache:
|
| 30 |
+
WORDS_DIR.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
cache_file = _cache_path(model_name)
|
| 32 |
+
lower_words = [w.lower() for w in words]
|
| 33 |
+
|
| 34 |
+
if cache_file.exists() and not rebuild:
|
| 35 |
+
data = np.load(cache_file, allow_pickle=False)
|
| 36 |
+
cached_words = data["words"].tolist()
|
| 37 |
+
vectors = data["vectors"].astype(np.float32)
|
| 38 |
+
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
|
| 39 |
+
norms = np.where(norms == 0, 1.0, norms)
|
| 40 |
+
vectors = vectors / norms
|
| 41 |
+
if cached_words == lower_words:
|
| 42 |
+
print(f"✓ Loaded cached embeddings for {len(lower_words)} words (model: {model_name})")
|
| 43 |
+
return EmbeddingCache(words=lower_words, vectors=vectors)
|
| 44 |
+
else:
|
| 45 |
+
print(f"⚠ Cache mismatch: expected {len(lower_words)} words, found {len(cached_words)}. Rebuilding...")
|
| 46 |
+
|
| 47 |
+
print(f"Building embeddings for {len(lower_words)} words with {model_name}...")
|
| 48 |
+
model = SentenceTransformer(model_name)
|
| 49 |
+
vectors = model.encode(
|
| 50 |
+
lower_words,
|
| 51 |
+
batch_size=batch_size,
|
| 52 |
+
normalize_embeddings=True,
|
| 53 |
+
convert_to_numpy=True,
|
| 54 |
+
).astype(np.float32)
|
| 55 |
+
np.savez(cache_file, words=np.array(lower_words), vectors=vectors)
|
| 56 |
+
print(f"✓ Cache saved to {cache_file.name}")
|
| 57 |
+
return EmbeddingCache(words=lower_words, vectors=vectors)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class EmbeddingIndex:
|
| 61 |
+
def __init__(
|
| 62 |
+
self,
|
| 63 |
+
words: Sequence[str],
|
| 64 |
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
|
| 65 |
+
rebuild: bool = False,
|
| 66 |
+
) -> None:
|
| 67 |
+
self.cache = load_or_build_embeddings(words, model_name=model_name, rebuild=rebuild)
|
| 68 |
+
self.lookup: Dict[str, int] = {w: i for i, w in enumerate(self.cache.words)}
|
| 69 |
+
|
| 70 |
+
def vector(self, word: str) -> Optional[np.ndarray]:
|
| 71 |
+
idx = self.lookup.get(word.lower())
|
| 72 |
+
if idx is None:
|
| 73 |
+
return None
|
| 74 |
+
return self.cache.vectors[idx]
|
| 75 |
+
|
| 76 |
+
def vectors_for(self, words: Iterable[str]) -> List[np.ndarray]:
|
| 77 |
+
out: List[np.ndarray] = []
|
| 78 |
+
for w in words:
|
| 79 |
+
v = self.vector(w)
|
| 80 |
+
if v is not None:
|
| 81 |
+
out.append(v)
|
| 82 |
+
return out
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def all_words(self) -> List[str]:
|
| 86 |
+
return list(self.cache.words)
|
codenames/master.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from .embedder import EmbeddingIndex
|
| 9 |
+
from .challenge import ChallengeGame
|
| 10 |
+
from .models import Role
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class ClueSuggestion:
|
| 15 |
+
clue: str
|
| 16 |
+
number: int
|
| 17 |
+
score: float
|
| 18 |
+
supports: List[Tuple[str, float]]
|
| 19 |
+
blockers: List[Tuple[str, float]]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MasterPlayer:
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
dictionary: Sequence[str],
|
| 26 |
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
|
| 27 |
+
good_top_k: int = 3,
|
| 28 |
+
good_threshold: float = 0.32,
|
| 29 |
+
bad_weight: float = 1.2,
|
| 30 |
+
assassin_weight: float = 3.0,
|
| 31 |
+
) -> None:
|
| 32 |
+
self.index = EmbeddingIndex(dictionary, model_name=model_name)
|
| 33 |
+
self.dictionary = [w.lower() for w in dictionary]
|
| 34 |
+
self.good_top_k = good_top_k
|
| 35 |
+
self.good_threshold = good_threshold
|
| 36 |
+
self.bad_weight = bad_weight
|
| 37 |
+
self.assassin_weight = assassin_weight
|
| 38 |
+
|
| 39 |
+
def suggest_clue(self, game: ChallengeGame) -> Optional[ClueSuggestion]:
|
| 40 |
+
good_words = [
|
| 41 |
+
c.word.lower()
|
| 42 |
+
for c in game.board.cards
|
| 43 |
+
if not c.revealed and c.role == Role.RED
|
| 44 |
+
]
|
| 45 |
+
bad_words = [
|
| 46 |
+
c.word.lower()
|
| 47 |
+
for c in game.board.cards
|
| 48 |
+
if not c.revealed and (c.role == Role.BLUE or c.role == Role.ASSASSIN)
|
| 49 |
+
]
|
| 50 |
+
assassin_words = [c.word.lower() for c in game.board.cards if c.role == Role.ASSASSIN and not c.revealed]
|
| 51 |
+
|
| 52 |
+
board_words = {c.word.lower() for c in game.board.cards}
|
| 53 |
+
|
| 54 |
+
good_vecs = self._vectors_for(good_words)
|
| 55 |
+
bad_vecs = self._vectors_for(bad_words)
|
| 56 |
+
assassin_vecs = self._vectors_for(assassin_words)
|
| 57 |
+
|
| 58 |
+
if not good_vecs:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
good_matrix = np.stack(good_vecs)
|
| 62 |
+
bad_matrix = np.stack(bad_vecs) if bad_vecs else None
|
| 63 |
+
assassin_matrix = np.stack(assassin_vecs) if assassin_vecs else None
|
| 64 |
+
|
| 65 |
+
best: Optional[ClueSuggestion] = None
|
| 66 |
+
for clue in self.dictionary:
|
| 67 |
+
if clue in board_words:
|
| 68 |
+
continue
|
| 69 |
+
vec = self.index.vector(clue)
|
| 70 |
+
if vec is None:
|
| 71 |
+
continue
|
| 72 |
+
norm = np.linalg.norm(vec)
|
| 73 |
+
if norm == 0 or not np.isfinite(norm):
|
| 74 |
+
continue
|
| 75 |
+
vec = vec / norm
|
| 76 |
+
|
| 77 |
+
with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
|
| 78 |
+
good_sims = good_matrix @ vec
|
| 79 |
+
sorted_good_idx = np.argsort(-good_sims)
|
| 80 |
+
top_idx = sorted_good_idx[: self.good_top_k]
|
| 81 |
+
top_pairs = [(good_words[i], float(good_sims[i])) for i in top_idx]
|
| 82 |
+
good_score = float(np.sum([good_sims[i] for i in top_idx]))
|
| 83 |
+
|
| 84 |
+
bad_max = 0.0
|
| 85 |
+
bad_pairs: List[Tuple[str, float]] = []
|
| 86 |
+
if bad_matrix is not None:
|
| 87 |
+
bad_sims = bad_matrix @ vec
|
| 88 |
+
max_idx = int(np.argmax(bad_sims)) if bad_sims.size else 0
|
| 89 |
+
bad_max = float(bad_sims[max_idx]) if bad_sims.size else 0.0
|
| 90 |
+
bad_pairs = [(bad_words[max_idx], float(bad_max))]
|
| 91 |
+
|
| 92 |
+
assassin_max = 0.0
|
| 93 |
+
if assassin_matrix is not None:
|
| 94 |
+
ass_sims = assassin_matrix @ vec
|
| 95 |
+
assassin_max = float(np.max(ass_sims)) if ass_sims.size else 0.0
|
| 96 |
+
|
| 97 |
+
score = good_score - self.bad_weight * bad_max - self.assassin_weight * assassin_max
|
| 98 |
+
|
| 99 |
+
guessable = sum(1 for s in good_sims if s >= self.good_threshold)
|
| 100 |
+
number = max(1, min(self.good_top_k, guessable))
|
| 101 |
+
|
| 102 |
+
suggestion = ClueSuggestion(
|
| 103 |
+
clue=clue,
|
| 104 |
+
number=number,
|
| 105 |
+
score=score,
|
| 106 |
+
supports=top_pairs,
|
| 107 |
+
blockers=bad_pairs,
|
| 108 |
+
)
|
| 109 |
+
if best is None or suggestion.score > best.score:
|
| 110 |
+
best = suggestion
|
| 111 |
+
return best
|
| 112 |
+
|
| 113 |
+
def _vectors_for(self, words: Iterable[str]) -> List[np.ndarray]:
|
| 114 |
+
out: List[np.ndarray] = []
|
| 115 |
+
for w in words:
|
| 116 |
+
v = self.index.vector(w)
|
| 117 |
+
if v is not None:
|
| 118 |
+
out.append(v)
|
| 119 |
+
return out
|
codenames/models.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Role(Enum):
|
| 6 |
+
RED = "RED"
|
| 7 |
+
BLUE = "BLUE"
|
| 8 |
+
ASSASSIN = "ASSASSIN"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class Card:
|
| 13 |
+
word: str
|
| 14 |
+
role: Role
|
| 15 |
+
revealed: bool = False
|
my_guesser.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from codenames.embedder import EmbeddingIndex
|
| 6 |
+
from codenames.dictionary import load_dictionary
|
| 7 |
+
|
| 8 |
+
_dictionary = load_dictionary()
|
| 9 |
+
_index = EmbeddingIndex(_dictionary)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def guesser(clue: str, board_state: list[str]) -> Optional[str]:
|
| 13 |
+
"""
|
| 14 |
+
Given a clue word and list of unrevealed board words,
|
| 15 |
+
return a word.
|
| 16 |
+
"""
|
| 17 |
+
return board_state[0]
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
wordfreq>=3.0
|
| 2 |
+
numpy>=1.26
|
| 3 |
+
sentence-transformers>=3.0
|
| 4 |
+
colorama>=0.4.6
|