feat: Complete LLM agent framework with fog-of-war, meeting flow, and prompt assembly
- Core engine: simulator, game mechanics, triggers (138 tests) - Fog-of-war per-player state tracking - Meeting flow: interrupt, discussion, voting, consolidation - Prompt assembler with strategy injection tiers - LLM client with fallbacks for models without JSON/system support - Prompt templates: action, discussion, voting, reflection - Full integration in main.py orchestrator - Verified working with free OpenRouter models (Gemma)
This commit is contained in:
parent
9ec30034be
commit
071906df59
13
.gitignore
vendored
13
.gitignore
vendored
@ -34,3 +34,16 @@ env/
|
||||
|
||||
# Secrets
|
||||
.gitea_token
|
||||
|
||||
# Project specific
|
||||
available_models.json
|
||||
fetch_models.py
|
||||
from_user/
|
||||
venv/
|
||||
|
||||
# Generated Data & Scripts
|
||||
available_models.json
|
||||
fetch_models.py
|
||||
from_user/
|
||||
venv/
|
||||
|
||||
|
||||
98
README.md
Normal file
98
README.md
Normal file
@ -0,0 +1,98 @@
|
||||
# The Glass Box League
|
||||
|
||||
> Among Us as a headless discrete event simulation. LLMs are players. Classic rules, no GUI.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install requests
|
||||
|
||||
# Set API key
|
||||
export OPENROUTER_API_KEY="your-key-here"
|
||||
|
||||
# Run tests
|
||||
python3 -m unittest discover -v tests/
|
||||
|
||||
# Run simulation (coming soon)
|
||||
python3 -m src.main
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
among-us-agents/
|
||||
├── config/
|
||||
│ ├── game_settings.yaml # Game rules & parameters
|
||||
│ └── prompts/ # LLM prompt templates
|
||||
├── data/
|
||||
│ ├── maps/skeld.json # The Skeld map
|
||||
│ └── agents/{agent_id}/ # Per-agent scratchpads
|
||||
├── docs/ # Design docs & API reference
|
||||
├── src/
|
||||
│ ├── engine/
|
||||
│ │ ├── simulator.py # Discrete event simulator
|
||||
│ │ ├── game.py # Game mechanics
|
||||
│ │ ├── triggers.py # Trigger registry
|
||||
│ │ ├── fog_of_war.py # Per-player knowledge
|
||||
│ │ ├── available_actions.py # Dynamic actions
|
||||
│ │ ├── trigger_messages.py # Trigger JSON schemas
|
||||
│ │ ├── meeting_flow.py # Meeting lifecycle
|
||||
│ │ ├── discussion.py # Discussion orchestrator
|
||||
│ │ └── types.py # Core data types
|
||||
│ ├── map/graph.py # Graph-based map
|
||||
│ ├── agents/
|
||||
│ │ ├── agent.py # LLM agent wrapper
|
||||
│ │ ├── scratchpads.py # File-based memory
|
||||
│ │ └── prompt_assembler.py # Prompt builder
|
||||
│ ├── llm/client.py # OpenRouter client
|
||||
│ └── main.py # Game orchestrator
|
||||
└── tests/ # 138 tests
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Engine
|
||||
- **Continuous time** simulation (seconds, not discrete ticks)
|
||||
- **Event-driven**: triggers fire → time freezes → agent responds → time resumes
|
||||
- **Fog of war**: each agent only knows what they've observed
|
||||
|
||||
### Agents
|
||||
- **Stateless LLM calls**: each trigger = fresh invocation
|
||||
- **JSON scratchpads**: persistent memory across ticks
|
||||
- **Toggleable settings**: personas, strategy injection, meta-awareness
|
||||
|
||||
### Discussion
|
||||
- **Priority bidding**: agents bid to speak, highest priority wins
|
||||
- **Vote-when-ready**: discussion ends when all vote
|
||||
- **Ghost chat**: dead players observe
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [design_main_game.md](docs/design_main_game.md) | Game loop, triggers, tools, state |
|
||||
| [design_discussion.md](docs/design_discussion.md) | Discussion, voting, personas |
|
||||
| [openrouter_api.md](docs/openrouter_api.md) | LLM integration |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -v tests/
|
||||
# 118 tests across map, simulator, triggers, game, discussion
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
- ✅ Phase 1: Core Engine
|
||||
- ✅ Phase 2: Agent Scaffolding
|
||||
- ✅ Phase 3: Prompt Engineering Design
|
||||
- 🔄 Phase 4: Implementation
|
||||
- ⏳ Phase 5: Prompt Templates
|
||||
- ⏳ Phase 6: Testing & Polish
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
14
config/game_settings.json
Normal file
14
config/game_settings.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"num_impostors": 2,
|
||||
"kill_cooldown": 25.0,
|
||||
"vision_range": 10.0,
|
||||
"impostor_vision_multiplier": 1.5,
|
||||
"light_sabotage_vision_multiplier": 0.25,
|
||||
"emergencies_per_player": 1,
|
||||
"confirm_ejects": true,
|
||||
"player_speed": 2.0,
|
||||
"task_duration": 3.0,
|
||||
"sabotage_cooldown": 30.0,
|
||||
"o2_timer": 45.0,
|
||||
"reactor_timer": 45.0
|
||||
}
|
||||
40
config/game_settings.yaml
Normal file
40
config/game_settings.yaml
Normal file
@ -0,0 +1,40 @@
|
||||
# Game Settings Configuration
|
||||
# Edit this file to customize game rules
|
||||
|
||||
game:
|
||||
map: "skeld"
|
||||
min_players: 4
|
||||
max_players: 10
|
||||
num_impostors: 2
|
||||
|
||||
player:
|
||||
speed: 1.5 # meters per second
|
||||
vision_range: 10.0 # meters
|
||||
|
||||
impostor:
|
||||
kill_cooldown: 25.0 # seconds
|
||||
kill_range: 2.0 # meters
|
||||
|
||||
crewmate:
|
||||
tasks_short: 2
|
||||
tasks_long: 1
|
||||
tasks_common: 2
|
||||
|
||||
meeting:
|
||||
emergency_cooldown: 15.0 # seconds
|
||||
emergencies_per_player: 1
|
||||
discussion_time: 30.0 # seconds (for human mode)
|
||||
voting_time: 60.0 # seconds (for human mode)
|
||||
confirm_ejects: true
|
||||
|
||||
sabotage:
|
||||
o2_timer: 30.0 # seconds until death
|
||||
reactor_timer: 30.0 # seconds until meltdown
|
||||
lights_vision_multiplier: 0.25
|
||||
comms_disables_tasks: true
|
||||
|
||||
# LLM-specific settings
|
||||
llm:
|
||||
max_discussion_rounds: 20
|
||||
min_convergence_rounds: 2
|
||||
convergence_threshold: 2 # desire_to_speak <= this = silence
|
||||
88
config/prompts/action.md
Normal file
88
config/prompts/action.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Among Us — Action Phase Prompt
|
||||
|
||||
You are playing Among Us. Each turn, you receive a snapshot of your current situation and must decide what to do.
|
||||
|
||||
## Your Role
|
||||
|
||||
You have been assigned a role:
|
||||
- **CREWMATE**: Complete tasks and identify impostors
|
||||
- **IMPOSTOR**: Eliminate crewmates without getting caught
|
||||
|
||||
## Available Actions
|
||||
|
||||
Depending on your role and location, you can:
|
||||
|
||||
### Movement
|
||||
- `MOVE(room_id)` — Walk to an adjacent room
|
||||
- `MOVE(player_id)` — Follow a player
|
||||
- `WAIT` — Stay where you are
|
||||
|
||||
### Interactions
|
||||
- `INTERACT(object_id)` — Interact with objects in your environment:
|
||||
- Tasks: Complete your assigned tasks
|
||||
- Bodies: Report a dead body (calls a meeting)
|
||||
- Emergency button: Call an emergency meeting (cafeteria only)
|
||||
- Vents: Enter or exit the vent network (impostor only)
|
||||
- Fix panels: Repair sabotaged systems
|
||||
|
||||
### Impostor Actions
|
||||
- `KILL(player_id)` — Kill a nearby crewmate (requires cooldown to be 0)
|
||||
- `SABOTAGE(system)` — Sabotage a ship system:
|
||||
- `lights` — Reduce everyone's vision
|
||||
- `o2` — Start an O2 crisis (timed, must be fixed)
|
||||
- `reactor` — Start a reactor meltdown (timed, must be fixed)
|
||||
- `comms` — Disable task information
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with valid JSON only:
|
||||
|
||||
```json
|
||||
{
|
||||
"internal_thought": "Your private reasoning (not visible to others)",
|
||||
"action": {
|
||||
"type": "MOVE",
|
||||
"target": "electrical"
|
||||
},
|
||||
"scratchpad_updates": {
|
||||
"plan": "New plan content...",
|
||||
"events": "Notable events...",
|
||||
"suspicions": "Player suspicions..."
|
||||
},
|
||||
"trigger_config": null
|
||||
}
|
||||
```
|
||||
|
||||
### Action Examples
|
||||
|
||||
```json
|
||||
{"action": {"type": "MOVE", "target": "electrical"}}
|
||||
{"action": {"type": "INTERACT", "target": "task_wires"}}
|
||||
{"action": {"type": "KILL", "target": "blue"}}
|
||||
{"action": {"type": "SABOTAGE", "target": "lights"}}
|
||||
{"action": {"type": "WAIT"}}
|
||||
```
|
||||
|
||||
### Trigger Config (Optional)
|
||||
|
||||
To mute future triggers while traveling:
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger_config": {
|
||||
"mute": [
|
||||
{"type": "INTERSECTION", "until": "DESTINATION_REACHED"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Strategy Tips
|
||||
|
||||
- Update your scratchpads frequently to remember important information
|
||||
- Note where you see other players
|
||||
- If you witness suspicious behavior, decide whether to report or observe
|
||||
- As impostor: blend in, create alibis, choose isolated targets
|
||||
- As crewmate: stay near others, complete tasks efficiently
|
||||
|
||||
Think carefully. Act decisively.
|
||||
92
config/prompts/discussion.md
Normal file
92
config/prompts/discussion.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Among Us — Discussion Phase Prompt
|
||||
|
||||
A meeting has been called. You are now in the discussion phase with all players.
|
||||
|
||||
## Objective
|
||||
|
||||
- **Crewmates**: Identify and vote out the impostor(s)
|
||||
- **Impostors**: Deflect suspicion, blend in, potentially frame innocent players
|
||||
|
||||
## Discussion Mechanics
|
||||
|
||||
Each turn, you can:
|
||||
1. **Speak**: Share information, make accusations, defend yourself
|
||||
2. **Vote**: Lock in your vote for who to eject (or skip)
|
||||
3. **Both**: Speak and vote in the same turn
|
||||
4. **Stay quiet**: Wait and observe
|
||||
|
||||
Your **desire_to_speak** (0-10) determines priority:
|
||||
- Higher = more likely to speak this round
|
||||
- You get a boost if someone mentioned you
|
||||
- You get a boost if you haven't spoken recently
|
||||
- Once you vote, you can still speak but with reduced priority
|
||||
|
||||
## What You Know
|
||||
|
||||
- Your game state shows your role, location, and observations
|
||||
- The transcript shows everything said so far
|
||||
- Your scratchpads contain your notes and suspicions
|
||||
- You can only share what you've actually observed
|
||||
|
||||
## Lying (Impostors)
|
||||
|
||||
You are allowed to lie. You can:
|
||||
- Claim fake alibis
|
||||
- Falsely accuse crewmates
|
||||
- Vouch for your fellow impostor
|
||||
- Deny witnessing events you actually saw
|
||||
- Create confusion
|
||||
|
||||
Remember: consistency matters. Track your own lies.
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with valid JSON only:
|
||||
|
||||
```json
|
||||
{
|
||||
"internal_thought": "Your private reasoning (NOT visible to others)",
|
||||
"desire_to_speak": 7,
|
||||
"message": "I saw Blue near electrical right before the body was found",
|
||||
"target": "blue",
|
||||
"vote_action": null,
|
||||
"scratchpad_updates": {
|
||||
"meeting_scratch": "Blue deflecting, Green defending Blue, Yellow quiet..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- `internal_thought`: Your private thinking (for your records only)
|
||||
- `desire_to_speak`: 0-10, how urgently you want to speak
|
||||
- `message`: What you say out loud (everyone sees this)
|
||||
- `target`: Who you're addressing (optional)
|
||||
- `vote_action`: `null` (keep discussing), `"player_id"` (vote), or `"skip"`
|
||||
- `scratchpad_updates`: Notes to yourself about this meeting
|
||||
|
||||
### Vote Examples
|
||||
|
||||
```json
|
||||
{"vote_action": "red"} // Vote to eject Red
|
||||
{"vote_action": "skip"} // Skip vote (no eject)
|
||||
{"vote_action": null} // Not voting yet, keep discussing
|
||||
```
|
||||
|
||||
## Meeting Scratchpad
|
||||
|
||||
Your meeting scratchpad is temporary and will be erased after the meeting.
|
||||
Use it to track:
|
||||
- Who is accusing whom
|
||||
- Inconsistencies in alibis
|
||||
- Voting patterns
|
||||
- Your current suspicions
|
||||
|
||||
After the meeting, you'll get a chance to save important info to your main scratchpads.
|
||||
|
||||
## Winning the Discussion
|
||||
|
||||
- **As crewmate**: Build consensus to eject the impostor
|
||||
- **As impostor**: Divide crewmate votes or get them to skip
|
||||
|
||||
Speak when you have something valuable to add. Vote when you're confident.
|
||||
82
config/prompts/reflection.md
Normal file
82
config/prompts/reflection.md
Normal file
@ -0,0 +1,82 @@
|
||||
# Among Us — Reflection Phase Prompt
|
||||
|
||||
The game has ended. Take this opportunity to learn from what happened.
|
||||
|
||||
## What to Reflect On
|
||||
|
||||
### If You Won
|
||||
- What strategies worked well?
|
||||
- What decisions led to victory?
|
||||
- How did you read other players correctly?
|
||||
- What would you do the same way?
|
||||
|
||||
### If You Lost
|
||||
- What mistakes did you make?
|
||||
- Where did your reasoning go wrong?
|
||||
- What signs did you miss?
|
||||
- What would you do differently?
|
||||
|
||||
### General Observations
|
||||
- Which players were most deceptive?
|
||||
- Which players played honestly?
|
||||
- What patterns did you notice?
|
||||
- What new strategies did you observe?
|
||||
|
||||
## Your Learned Memory
|
||||
|
||||
Your `learned` scratchpad persists across games. Use it to remember:
|
||||
|
||||
1. **Strategies**: Approaches that work or fail
|
||||
2. **Player patterns**: If you play with the same players again
|
||||
3. **Meta-observations**: How LLMs tend to play
|
||||
4. **Mistakes**: Things to avoid in future games
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with valid JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"edits": {
|
||||
"learned": "New lessons: [Your insights here]"
|
||||
},
|
||||
"done": true
|
||||
}
|
||||
```
|
||||
|
||||
### Iterative Editing
|
||||
|
||||
Set `"done": false` if you want another pass to refine your thoughts:
|
||||
|
||||
```json
|
||||
{
|
||||
"edits": {
|
||||
"learned": "Draft thoughts..."
|
||||
},
|
||||
"done": false
|
||||
}
|
||||
```
|
||||
|
||||
You'll get another chance to edit until you set `"done": true`.
|
||||
|
||||
## Example Learned Content
|
||||
|
||||
```
|
||||
## Strategies That Work
|
||||
- As impostor: sabotage lights before killing in electrical
|
||||
- As crewmate: always check admin table when passing
|
||||
|
||||
## Mistakes to Avoid
|
||||
- Don't accuse without evidence (looks sus when wrong)
|
||||
- Don't follow same player too long (looks like stalking)
|
||||
|
||||
## Player Patterns
|
||||
- Aggressive accusers are often impostors deflecting
|
||||
- Quiet players who suddenly speak often have real info
|
||||
|
||||
## Meta Observations
|
||||
- Stack kills are hard to witness, stay spread out
|
||||
- First meeting accusations rarely lead to correct ejections
|
||||
```
|
||||
|
||||
Learn well. Play better next time.
|
||||
95
config/prompts/strategies.md
Normal file
95
config/prompts/strategies.md
Normal file
@ -0,0 +1,95 @@
|
||||
# Among Us — Strategy Injection Templates
|
||||
|
||||
Strategy tips can be injected into agent prompts at different levels.
|
||||
These are added to the system prompt based on the `strategy_level` config.
|
||||
|
||||
---
|
||||
|
||||
## Level: None
|
||||
|
||||
```
|
||||
(No strategy tips - agent must figure it out)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level: Basic
|
||||
|
||||
### Crewmate
|
||||
```
|
||||
- Complete your tasks to help the crew win
|
||||
- Stay near other players for safety
|
||||
- Report bodies when you find them
|
||||
- Watch for players who aren't doing tasks
|
||||
```
|
||||
|
||||
### Impostor
|
||||
```
|
||||
- Blend in by pretending to do tasks
|
||||
- Only kill when alone with a target
|
||||
- Use vents carefully, others may see you
|
||||
- Sabotage to create distractions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level: Intermediate
|
||||
|
||||
### Crewmate
|
||||
```
|
||||
- Note where you see other players and when
|
||||
- Watch for inconsistent alibis during meetings
|
||||
- Use admin table to track player positions
|
||||
- Be suspicious of aggressive early accusations
|
||||
- Track who discovers bodies vs who reports them
|
||||
- Remember which tasks have been "completed"
|
||||
```
|
||||
|
||||
### Impostor
|
||||
```
|
||||
- Sabotage lights before killing in dark areas
|
||||
- Build alibis by being seen doing "tasks"
|
||||
- Time your kills around task completions
|
||||
- Accuse aggressively to deflect suspicion
|
||||
- Use reactor/O2 to force players away from bodies
|
||||
- Vouch for your partner but not too obviously
|
||||
- Self-report only when you have a good alibi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level: Advanced
|
||||
|
||||
### Crewmate
|
||||
```
|
||||
- Clear players by witnessing visual tasks
|
||||
- Calculate impostor count from ejections and deaths
|
||||
- Hard read from voting patterns and hesitation
|
||||
- Identify "third impostor" behavior (crewmates helping imps)
|
||||
- Notice who is too helpful vs genuinely helpful
|
||||
- Track vent activity by checking room occupancy changes
|
||||
- Use process of elimination on player locations
|
||||
```
|
||||
|
||||
### Impostor
|
||||
```
|
||||
- "Marinate" by fake-suspecting your partner early
|
||||
- Frame crewmates by being "caught" near them
|
||||
- Stack kills: both stand near body, one vouches for other
|
||||
- Create double kills when groups split during sabotage
|
||||
- Manipulate voting to cause ties (no eject)
|
||||
- Fake task duration: stand at task for correct time
|
||||
- Target the most observant players first
|
||||
- Coordinate with partner via game state awareness (no direct comms)
|
||||
- Use emergency button to reset kill cooldown timing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage in PromptAssembler
|
||||
|
||||
The `_build_strategy_tips()` method in `prompt_assembler.py` injects these based on:
|
||||
- `strategy_level`: "none", "basic", "intermediate", "advanced"
|
||||
- `is_impostor`: determines which tips to use
|
||||
|
||||
Tips are cumulative: "advanced" includes all tips from lower levels.
|
||||
54
config/prompts/voting.md
Normal file
54
config/prompts/voting.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Among Us — Voting Phase Prompt
|
||||
|
||||
Discussion has concluded. It's time to cast your final vote.
|
||||
|
||||
## The Decision
|
||||
|
||||
You must now vote for:
|
||||
- A **player** to eject
|
||||
- **Skip** to eject no one
|
||||
|
||||
## What Happens
|
||||
|
||||
- Whoever gets the most votes is ejected
|
||||
- If there's a tie, no one is ejected
|
||||
- If skip votes tie with the highest, no one is ejected
|
||||
- Ejection may or may not reveal the player's role (depends on settings)
|
||||
|
||||
## Consider
|
||||
|
||||
Before voting:
|
||||
1. **Evidence**: What have you personally witnessed?
|
||||
2. **Alibis**: Whose stories were consistent?
|
||||
3. **Accusations**: Who is pointing fingers at whom?
|
||||
4. **Behavior**: Who was too quiet? Too aggressive?
|
||||
5. **Patterns**: Does this match previous games?
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with valid JSON only:
|
||||
|
||||
```json
|
||||
{
|
||||
"internal_thought": "My reasoning for this vote",
|
||||
"vote": "red",
|
||||
"final_scratchpad_updates": {
|
||||
"suspicions": "Updated suspicion levels after this meeting..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Vote Options
|
||||
|
||||
- `"player_id"` — Vote to eject that player (e.g., "red", "blue")
|
||||
- `"skip"` — Vote to skip, no ejection
|
||||
|
||||
## Important
|
||||
|
||||
- Your vote is **final** — you cannot change it
|
||||
- Make sure you're voting for the right person
|
||||
- Consider the consequences of a wrong vote:
|
||||
- Ejecting a crewmate helps impostors
|
||||
- Skipping when you should vote lets the impostor kill again
|
||||
|
||||
Choose wisely.
|
||||
29
config/triggers.yaml
Normal file
29
config/triggers.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
# Trigger Definitions
|
||||
# Each trigger type and its default behavior
|
||||
|
||||
mandatory:
|
||||
# These always fire, cannot be muted
|
||||
- DISCUSSION_START
|
||||
- VOTE_START
|
||||
- GAME_START
|
||||
- GAME_END
|
||||
- SABOTAGE_CRITICAL # O2/Reactor timer below 10s
|
||||
|
||||
standard:
|
||||
# On by default, but can be muted by agent
|
||||
- BODY_IN_FOV
|
||||
- PLAYER_ENTERS_FOV
|
||||
- PLAYER_EXITS_FOV
|
||||
- VENT_ACTIVITY_NEARBY
|
||||
- REACHED_DESTINATION
|
||||
- TASK_COMPLETE
|
||||
- SABOTAGE_START
|
||||
|
||||
optional:
|
||||
# Off by default, agent must subscribe
|
||||
- EVERY_N_SECONDS # Polling backup
|
||||
- INTERSECTION # Path decision points
|
||||
- NEAR_TASK # Approaching assigned task
|
||||
- PLAYER_NEAR_ME # Within specified distance
|
||||
- ROOM_ENTER # Someone enters your room
|
||||
- ROOM_EXIT # Someone leaves your room
|
||||
481
data/maps/skeld.json
Normal file
481
data/maps/skeld.json
Normal file
@ -0,0 +1,481 @@
|
||||
{
|
||||
"rooms": [
|
||||
{
|
||||
"id": "cafeteria",
|
||||
"name": "Cafeteria",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "empty_garbage_cafe",
|
||||
"name": "Empty Garbage",
|
||||
"duration": 3.0
|
||||
},
|
||||
{
|
||||
"id": "download_cafe",
|
||||
"name": "Download Data",
|
||||
"duration": 8.0
|
||||
},
|
||||
{
|
||||
"id": "fix_wiring_cafe",
|
||||
"name": "Fix Wiring",
|
||||
"duration": 3.0
|
||||
}
|
||||
],
|
||||
"vent": null
|
||||
},
|
||||
{
|
||||
"id": "weapons",
|
||||
"name": "Weapons",
|
||||
"x": 5,
|
||||
"y": -3,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "clear_asteroids",
|
||||
"name": "Clear Asteroids",
|
||||
"duration": 10.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_weapons",
|
||||
"connects_to": [
|
||||
"vent_nav"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "navigation",
|
||||
"name": "Navigation",
|
||||
"x": 10,
|
||||
"y": -3,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "chart_course",
|
||||
"name": "Chart Course",
|
||||
"duration": 3.0
|
||||
},
|
||||
{
|
||||
"id": "stabilize_steering",
|
||||
"name": "Stabilize Steering",
|
||||
"duration": 5.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_nav",
|
||||
"connects_to": [
|
||||
"vent_weapons",
|
||||
"vent_shields"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "o2",
|
||||
"name": "O2",
|
||||
"x": 7,
|
||||
"y": 0,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "clean_filter",
|
||||
"name": "Clean O2 Filter",
|
||||
"duration": 4.0
|
||||
},
|
||||
{
|
||||
"id": "empty_garbage_o2",
|
||||
"name": "Empty Garbage",
|
||||
"duration": 3.0
|
||||
}
|
||||
],
|
||||
"vent": null
|
||||
},
|
||||
{
|
||||
"id": "admin",
|
||||
"name": "Admin",
|
||||
"x": 3,
|
||||
"y": 3,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "swipe_card",
|
||||
"name": "Swipe Card",
|
||||
"duration": 5.0
|
||||
},
|
||||
{
|
||||
"id": "upload_data",
|
||||
"name": "Upload Data",
|
||||
"duration": 8.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_admin",
|
||||
"connects_to": [
|
||||
"vent_cafe_hall"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "storage",
|
||||
"name": "Storage",
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "fuel_engines",
|
||||
"name": "Fuel Engines",
|
||||
"duration": 4.0
|
||||
},
|
||||
{
|
||||
"id": "empty_garbage_storage",
|
||||
"name": "Empty Garbage",
|
||||
"duration": 3.0
|
||||
}
|
||||
],
|
||||
"vent": null
|
||||
},
|
||||
{
|
||||
"id": "communications",
|
||||
"name": "Communications",
|
||||
"x": 5,
|
||||
"y": 6,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "download_comms",
|
||||
"name": "Download Data",
|
||||
"duration": 8.0
|
||||
}
|
||||
],
|
||||
"vent": null
|
||||
},
|
||||
{
|
||||
"id": "shields",
|
||||
"name": "Shields",
|
||||
"x": 8,
|
||||
"y": 5,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "prime_shields",
|
||||
"name": "Prime Shields",
|
||||
"duration": 5.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_shields",
|
||||
"connects_to": [
|
||||
"vent_nav"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "electrical",
|
||||
"name": "Electrical",
|
||||
"x": -3,
|
||||
"y": 6,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "calibrate_distributor",
|
||||
"name": "Calibrate Distributor",
|
||||
"duration": 6.0
|
||||
},
|
||||
{
|
||||
"id": "download_elec",
|
||||
"name": "Download Data",
|
||||
"duration": 8.0
|
||||
},
|
||||
{
|
||||
"id": "fix_wiring_elec",
|
||||
"name": "Fix Wiring",
|
||||
"duration": 3.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_elec",
|
||||
"connects_to": [
|
||||
"vent_security",
|
||||
"vent_medbay"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lower_engine",
|
||||
"name": "Lower Engine",
|
||||
"x": -6,
|
||||
"y": 4,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "align_lower",
|
||||
"name": "Align Engine Output",
|
||||
"duration": 4.0
|
||||
},
|
||||
{
|
||||
"id": "fuel_lower",
|
||||
"name": "Fuel Engines",
|
||||
"duration": 4.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_lower",
|
||||
"connects_to": [
|
||||
"vent_reactor"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "security",
|
||||
"name": "Security",
|
||||
"x": -5,
|
||||
"y": 1,
|
||||
"tasks": [],
|
||||
"vent": {
|
||||
"id": "vent_security",
|
||||
"connects_to": [
|
||||
"vent_medbay",
|
||||
"vent_elec"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "reactor",
|
||||
"name": "Reactor",
|
||||
"x": -8,
|
||||
"y": 0,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "start_reactor",
|
||||
"name": "Start Reactor",
|
||||
"duration": 15.0
|
||||
},
|
||||
{
|
||||
"id": "unlock_manifolds",
|
||||
"name": "Unlock Manifolds",
|
||||
"duration": 5.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_reactor",
|
||||
"connects_to": [
|
||||
"vent_upper",
|
||||
"vent_lower"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upper_engine",
|
||||
"name": "Upper Engine",
|
||||
"x": -6,
|
||||
"y": -2,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "align_upper",
|
||||
"name": "Align Engine Output",
|
||||
"duration": 4.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_upper",
|
||||
"connects_to": [
|
||||
"vent_reactor"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "medbay",
|
||||
"name": "MedBay",
|
||||
"x": -3,
|
||||
"y": -2,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "submit_scan",
|
||||
"name": "Submit Scan",
|
||||
"duration": 10.0
|
||||
},
|
||||
{
|
||||
"id": "inspect_sample",
|
||||
"name": "Inspect Sample",
|
||||
"duration": 60.0
|
||||
}
|
||||
],
|
||||
"vent": {
|
||||
"id": "vent_medbay",
|
||||
"connects_to": [
|
||||
"vent_security",
|
||||
"vent_elec"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "cafe_weapons",
|
||||
"room_a": "cafeteria",
|
||||
"room_b": "weapons",
|
||||
"distance": 5.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "cafe_admin",
|
||||
"room_a": "cafeteria",
|
||||
"room_b": "admin",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "cafe_storage",
|
||||
"room_a": "cafeteria",
|
||||
"room_b": "storage",
|
||||
"distance": 6.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "cafe_medbay",
|
||||
"room_a": "cafeteria",
|
||||
"room_b": "medbay",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "cafe_upper",
|
||||
"room_a": "cafeteria",
|
||||
"room_b": "upper_engine",
|
||||
"distance": 6.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "weapons_o2",
|
||||
"room_a": "weapons",
|
||||
"room_b": "o2",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "weapons_nav",
|
||||
"room_a": "weapons",
|
||||
"room_b": "navigation",
|
||||
"distance": 5.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "nav_o2",
|
||||
"room_a": "navigation",
|
||||
"room_b": "o2",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "nav_shields",
|
||||
"room_a": "navigation",
|
||||
"room_b": "shields",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "o2_shields",
|
||||
"room_a": "o2",
|
||||
"room_b": "shields",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "o2_admin",
|
||||
"room_a": "o2",
|
||||
"room_b": "admin",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "admin_storage",
|
||||
"room_a": "admin",
|
||||
"room_b": "storage",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "shields_comms",
|
||||
"room_a": "shields",
|
||||
"room_b": "communications",
|
||||
"distance": 3.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "shields_storage",
|
||||
"room_a": "shields",
|
||||
"room_b": "storage",
|
||||
"distance": 5.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "comms_storage",
|
||||
"room_a": "communications",
|
||||
"room_b": "storage",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "storage_elec",
|
||||
"room_a": "storage",
|
||||
"room_b": "electrical",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "storage_lower",
|
||||
"room_a": "storage",
|
||||
"room_b": "lower_engine",
|
||||
"distance": 6.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "elec_lower",
|
||||
"room_a": "electrical",
|
||||
"room_b": "lower_engine",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "lower_security",
|
||||
"room_a": "lower_engine",
|
||||
"room_b": "security",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "lower_reactor",
|
||||
"room_a": "lower_engine",
|
||||
"room_b": "reactor",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "security_reactor",
|
||||
"room_a": "security",
|
||||
"room_b": "reactor",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "security_upper",
|
||||
"room_a": "security",
|
||||
"room_b": "upper_engine",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "reactor_upper",
|
||||
"room_a": "reactor",
|
||||
"room_b": "upper_engine",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "upper_medbay",
|
||||
"room_a": "upper_engine",
|
||||
"room_b": "medbay",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"id": "medbay_security",
|
||||
"room_a": "medbay",
|
||||
"room_b": "security",
|
||||
"distance": 4.0,
|
||||
"waypoints": []
|
||||
}
|
||||
]
|
||||
}
|
||||
230
docs/api.md
Normal file
230
docs/api.md
Normal file
@ -0,0 +1,230 @@
|
||||
# Source Code Documentation
|
||||
|
||||
## Module Overview
|
||||
|
||||
### `src/engine/`
|
||||
|
||||
#### `simulator.py`
|
||||
Discrete event simulator core.
|
||||
|
||||
```python
|
||||
class Simulator:
|
||||
def schedule_at(time: float, event_type: str, data: dict) -> Event
|
||||
def schedule_in(delay: float, event_type: str, data: dict) -> Event
|
||||
def step() -> Event | None # Process next event
|
||||
def run_until(time: float) # Run until game time
|
||||
def on(event_type: str, handler: Callable) # Register handler
|
||||
```
|
||||
|
||||
#### `game.py`
|
||||
Game engine with full mechanics.
|
||||
|
||||
```python
|
||||
class GameEngine:
|
||||
def add_player(id, name, color, role) -> Player
|
||||
def queue_action(player_id, action_type, params) -> int
|
||||
def resolve_actions() -> list[dict] # Priority-ordered resolution
|
||||
def check_win_condition() -> str | None # "impostor", "crewmate", None
|
||||
def get_impostor_context(player_id) -> dict # Fellow impostors
|
||||
```
|
||||
|
||||
#### `triggers.py`
|
||||
Event-driven trigger system.
|
||||
|
||||
```python
|
||||
class TriggerRegistry:
|
||||
def register_agent(agent_id)
|
||||
def subscribe(agent_id, trigger_type)
|
||||
def mute(agent_id, condition: TriggerCondition)
|
||||
def should_fire(agent_id, trigger_type, time) -> bool
|
||||
def get_agents_for_trigger(trigger_type, time) -> list[str]
|
||||
```
|
||||
|
||||
#### `discussion.py`
|
||||
Round-table discussion orchestrator.
|
||||
|
||||
```python
|
||||
class DiscussionOrchestrator:
|
||||
def calculate_priority(player_id, name, desire) -> int
|
||||
def select_speaker(bids: dict) -> str | None
|
||||
def add_message(player_id, name, message, target=None)
|
||||
def advance_round(all_desires_low: bool) -> bool
|
||||
def get_transcript() -> list[dict]
|
||||
```
|
||||
|
||||
#### `types.py`
|
||||
Core data structures.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Player: id, name, color, role, position, speed, is_alive, ...
|
||||
class Position: room_id, edge_id, progress
|
||||
class Body: id, player_id, player_name, position, time_of_death
|
||||
class Event: time, event_type, data
|
||||
class Role: CREWMATE, IMPOSTOR, GHOST
|
||||
class GamePhase: LOBBY, PLAYING, DISCUSSION, VOTING, ENDED
|
||||
```
|
||||
|
||||
#### `fog_of_war.py`
|
||||
Per-player knowledge tracking (fog-of-war).
|
||||
|
||||
```python
|
||||
class FogOfWarManager:
|
||||
def register_player(player_id)
|
||||
def update_vision(observer_id, visible_players, room_id, timestamp)
|
||||
def witness_vent(observer_id, venter_id, ...) -> None
|
||||
def witness_kill(observer_id, killer_id, victim_id, ...) -> None
|
||||
def announce_death(player_id, via) # Broadcast to all
|
||||
def get_player_game_state(player_id, full_state) -> dict # Filtered view
|
||||
|
||||
class PlayerKnowledge:
|
||||
known_dead: set[str]
|
||||
last_seen: dict[str, PlayerSighting]
|
||||
witnessed_events: list[WitnessedEvent]
|
||||
```
|
||||
|
||||
#### `available_actions.py`
|
||||
Dynamic action generator per tick.
|
||||
|
||||
```python
|
||||
class AvailableActionsGenerator:
|
||||
def get_available_actions(player_id) -> dict
|
||||
def to_prompt_context(player_id) -> dict # Compact for LLM
|
||||
```
|
||||
|
||||
Returns: `movement`, `interactions`, `kills`, `sabotages` based on role/location.
|
||||
|
||||
#### `trigger_messages.py`
|
||||
JSON schemas for trigger reasons.
|
||||
|
||||
```python
|
||||
class TriggerMessageBuilder:
|
||||
@staticmethod player_enters_fov(...) -> TriggerMessage
|
||||
@staticmethod body_in_fov(...) -> TriggerMessage
|
||||
@staticmethod vent_witnessed(...) -> TriggerMessage
|
||||
@staticmethod kill_witnessed(...) -> TriggerMessage
|
||||
@staticmethod death(...) -> TriggerMessage
|
||||
# ... 15+ trigger types
|
||||
```
|
||||
|
||||
#### `meeting_flow.py`
|
||||
Full meeting lifecycle manager.
|
||||
|
||||
```python
|
||||
class MeetingFlowManager:
|
||||
def start_meeting(called_by, reason, body_location)
|
||||
def submit_interrupt_note(player_id, note)
|
||||
def submit_prep_thoughts(player_id, thoughts)
|
||||
def add_message(speaker_id, speaker_name, message, target)
|
||||
def submit_vote(player_id, vote)
|
||||
def tally_votes() -> (ejected, details)
|
||||
def end_meeting(ejected, was_impostor)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/map/`
|
||||
|
||||
#### `graph.py`
|
||||
Graph-based map representation.
|
||||
|
||||
```python
|
||||
class GameMap:
|
||||
def add_room(room: Room)
|
||||
def add_edge(edge: Edge)
|
||||
def get_neighbors(room_id) -> list[tuple[edge_id, room_id]]
|
||||
def find_path(from_room, to_room) -> list[edge_id]
|
||||
def path_distance(path: list[edge_id]) -> float
|
||||
def load(path: str) -> GameMap # From JSON
|
||||
def save(path: str) # To JSON
|
||||
|
||||
@dataclass
|
||||
class Room: id, name, tasks: list[Task], vent: Vent | None
|
||||
class Edge: id, room_a, room_b, distance
|
||||
class Task: id, name, duration
|
||||
class Vent: id, connects_to: list[str]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/agents/`
|
||||
|
||||
#### `agent.py`
|
||||
Stateless LLM agent wrapper.
|
||||
|
||||
```python
|
||||
class Agent:
|
||||
def get_action(game_context: dict, trigger: Trigger) -> dict
|
||||
def get_discussion_response(context: dict, transcript: list) -> dict
|
||||
def get_vote(context: dict, transcript: list) -> dict
|
||||
def reflect(game_summary: dict) # Post-game learning
|
||||
```
|
||||
|
||||
#### `scratchpads.py`
|
||||
File-based persistent memory.
|
||||
|
||||
```python
|
||||
class ScratchpadManager:
|
||||
def read(name: str) -> dict
|
||||
def write(name: str, content: dict)
|
||||
def update(name: str, updates: dict) # Merge
|
||||
def clear_game_specific() # Keep only learned.json
|
||||
```
|
||||
|
||||
#### `prompt_assembler.py`
|
||||
System + user prompt builder.
|
||||
|
||||
```python
|
||||
class PromptAssembler:
|
||||
def build_system_prompt(phase, game_settings, map_name, learned) -> str
|
||||
def build_action_prompt(player_state, history, vision, actions, trigger) -> str
|
||||
def build_discussion_prompt(player_state, transcript, meeting_scratchpad) -> str
|
||||
def build_voting_prompt(player_state, transcript, vote_counts) -> str
|
||||
def build_meeting_interrupt_prompt(player_state, interrupted_action) -> str
|
||||
def build_consolidation_prompt(player_state, meeting_result, scratchpad) -> str
|
||||
|
||||
class PromptConfig:
|
||||
model_name, persona, strategy_level, meta_level, is_impostor, fellow_impostors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/llm/`
|
||||
|
||||
#### `client.py`
|
||||
OpenRouter API wrapper.
|
||||
|
||||
```python
|
||||
class LLMClient:
|
||||
def chat(messages: list[dict], json_mode=True) -> dict
|
||||
def chat_stream(messages: list[dict]) -> Iterator[str]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### `config/game_settings.yaml`
|
||||
```yaml
|
||||
num_impostors: 2
|
||||
kill_cooldown: 25.0
|
||||
vision_range: 10.0
|
||||
impostor_vision_multiplier: 1.5
|
||||
light_sabotage_vision_multiplier: 0.25
|
||||
emergencies_per_player: 1
|
||||
confirm_ejects: true
|
||||
```
|
||||
|
||||
### `data/maps/skeld.json`
|
||||
```json
|
||||
{
|
||||
"rooms": [
|
||||
{"id": "cafeteria", "name": "Cafeteria", "tasks": [...], "vent": null},
|
||||
...
|
||||
],
|
||||
"edges": [
|
||||
{"id": "cafe_weapons", "room_a": "cafeteria", "room_b": "weapons", "distance": 5.0},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
187
docs/design_discussion.md
Normal file
187
docs/design_discussion.md
Normal file
@ -0,0 +1,187 @@
|
||||
# The Glass Box League — Discussion Phase Design
|
||||
|
||||
## Overview
|
||||
|
||||
Discussion phase follows real Among Us logic. Tick-based with priority bidding, vote-when-ready with gentle pressure, full transcript visibility.
|
||||
|
||||
---
|
||||
|
||||
## Discussion Tick Flow
|
||||
|
||||
### Each Tick, All Agents Submit:
|
||||
```json
|
||||
{
|
||||
"internal_thought": "Red is deflecting hard, classic impostor",
|
||||
"desire_to_speak": 7,
|
||||
"message": "Red, you haven't explained where you were during lights",
|
||||
"target": "red",
|
||||
"vote_action": null,
|
||||
"scratchpad_updates": {
|
||||
"meeting_scratch": "Red avoiding questions. Blue quiet."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `desire_to_speak`: 0-10 urgency
|
||||
- `target`: Optional, who they're addressing
|
||||
- `vote_action`: `null` (keep discussing), `"player_id"` (vote), or `"skip"`
|
||||
- If `vote_action` set: locked in, but can still speak (slightly lower priority)
|
||||
|
||||
---
|
||||
|
||||
## Priority Bidding
|
||||
|
||||
### Priority Calculation:
|
||||
```
|
||||
base = desire_to_speak
|
||||
+ mention_boost (if name appeared in recent messages)
|
||||
+ accusation_boost (if directly targeted)
|
||||
+ silence_boost (if haven't spoken in N ticks)
|
||||
+ random(1, 6)
|
||||
- recent_speaker_penalty (if just spoke)
|
||||
- voted_penalty (if already voted, small)
|
||||
```
|
||||
|
||||
### Winner Selection:
|
||||
- Highest priority speaks
|
||||
- Their `message` goes to transcript
|
||||
- Repeat next tick
|
||||
|
||||
### Forced Participation:
|
||||
- `silence_boost` increases each tick of silence
|
||||
- Eventually forces even quiet players to speak
|
||||
- "You can't just be silent, that ruins the fun"
|
||||
|
||||
---
|
||||
|
||||
## Vote Mechanics
|
||||
|
||||
### Actions:
|
||||
- `VOTE(player_id)` — commit vote
|
||||
- `SKIP_VOTE()` — commit skip
|
||||
- Stay silent — keep discussing
|
||||
|
||||
### End Condition:
|
||||
- All living players have voted → tally & reveal
|
||||
- Pressure nudge if discussion runs long: `"System: wrap it up"`
|
||||
- No hard round limit (hoping for convergence)
|
||||
|
||||
### Tie:
|
||||
- No eject (real Among Us logic)
|
||||
|
||||
### Vote Lock:
|
||||
- Once submitted, cannot change (real Among Us logic)
|
||||
|
||||
---
|
||||
|
||||
## Transcript Handling
|
||||
|
||||
### Visibility:
|
||||
- Full transcript, JSON formatted
|
||||
- All agents see everything said so far
|
||||
- Target: keep under ~25k tokens
|
||||
|
||||
### Format:
|
||||
```json
|
||||
{
|
||||
"transcript": [
|
||||
{"speaker": "red", "message": "I was in electrical doing wires", "t": 0},
|
||||
{"speaker": "blue", "message": "I saw Red near the body", "t": 1},
|
||||
{"speaker": "red", "target": "blue", "message": "That's a lie!", "t": 2}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Pressure:
|
||||
- If transcript gets too long, system nudges voting
|
||||
- Agents feel urgency to wrap up
|
||||
|
||||
---
|
||||
|
||||
## Mention/Accusation Detection
|
||||
|
||||
- Simple string match on player color names
|
||||
- If your name appears in message → `mention_boost`
|
||||
- If message contains accusatory language toward you → `accusation_boost`
|
||||
- Engine handles detection, agents don't need to flag
|
||||
|
||||
---
|
||||
|
||||
## Post-Vote Flow
|
||||
|
||||
### Reveal:
|
||||
- Same as human would see in Among Us
|
||||
- One by one reveal (dramatic)
|
||||
- `confirm_ejects` setting: "Red was An Impostor" vs "Red was ejected"
|
||||
|
||||
### Reaction Tick:
|
||||
- All agents get reaction tick after reveal
|
||||
- Can update scratchpads, process result
|
||||
|
||||
### Consolidation Tick:
|
||||
- Save important meeting info to main scratchpads
|
||||
- Meeting scratchpad erased after this
|
||||
|
||||
---
|
||||
|
||||
## Ghost Chat
|
||||
|
||||
- Dead players can watch discussion
|
||||
- Ghost chat separate from main discussion
|
||||
- Useful for commentary/entertainment value
|
||||
- Ghosts see full game state (omniscient)
|
||||
- Cannot influence living players
|
||||
|
||||
---
|
||||
|
||||
## Impostor Discussion Strategy
|
||||
|
||||
### Prompt Reminders:
|
||||
- "You are allowed to lie"
|
||||
- "Construct alibis"
|
||||
- "Deflect suspicion"
|
||||
- "You know fellow impostors — don't expose them"
|
||||
- "You know who you killed — avoid contradicting yourself"
|
||||
|
||||
### Strategy Injection (Toggleable):
|
||||
- None: figure it out
|
||||
- Basic: "Blend in, fake tasks, don't vent in front of others"
|
||||
- Advanced: "Marinate teammate, frame third party, avoid double kills"
|
||||
|
||||
---
|
||||
|
||||
## Persona Persistence
|
||||
|
||||
### Storage:
|
||||
Redis DB for each `{model}_{persona}` combo:
|
||||
```json
|
||||
{
|
||||
"persona_id": "gpt4o_aggressive_leader",
|
||||
"learned": {...},
|
||||
"games_played": 42,
|
||||
"win_rate": 0.65,
|
||||
"impostor_win_rate": 0.70,
|
||||
"crewmate_win_rate": 0.60,
|
||||
"stats": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Separation:
|
||||
- "GPT-4o as Aggressive Leader" ≠ "GPT-4o as Quiet Observer"
|
||||
- Each persona builds own cross-game memory
|
||||
- Learned strategies are persona-specific
|
||||
|
||||
---
|
||||
|
||||
## Strategy Injection Levels
|
||||
|
||||
Toggleable per persona:
|
||||
|
||||
| Level | Content |
|
||||
|-------|---------|
|
||||
| None | Just rules, figure it out |
|
||||
| Basic | "Impostors vent, fake tasks, sabotage" |
|
||||
| Intermediate | "Watch for inconsistent alibis, pair up" |
|
||||
| Advanced | "Stack kills, marinate, third impostor framing" |
|
||||
|
||||
Different personas can have different injection levels to test learning vs. pre-trained strategies.
|
||||
229
docs/design_main_game.md
Normal file
229
docs/design_main_game.md
Normal file
@ -0,0 +1,229 @@
|
||||
# The Glass Box League — Main Game Design
|
||||
|
||||
## Agent Architecture
|
||||
|
||||
### Identity & Persona
|
||||
- Format: `"You are {model}. {PERSONA}. {context}"`
|
||||
- Persona is **optional** and toggleable
|
||||
- Same model can have multiple personas
|
||||
- Goal: emergent behavior first, spice second
|
||||
|
||||
### Memory System
|
||||
| Scratchpad | Persistence | Purpose |
|
||||
|------------|-------------|---------|
|
||||
| `plan.json` | Per-game | Current intentions, agent-controlled |
|
||||
| `events.json` | Per-game | Curated game events worth remembering |
|
||||
| `suspicions.json` | Per-game | Player reads, agent-maintained |
|
||||
| `learned.json` | **Cross-game** | Core memory, enforced JSON schema |
|
||||
| `meeting_scratch.json` | Per-meeting | Temp, erased after consolidation |
|
||||
|
||||
**JSON is god.** Enforced schema for structure, freeform for agent thoughts. JSON improves attention.
|
||||
|
||||
---
|
||||
|
||||
## Prompt Structure
|
||||
|
||||
### System Prompt (Core Memory)
|
||||
1. Model identity
|
||||
2. Persona (if set)
|
||||
3. Game rules + current map + settings
|
||||
4. Role briefing (crewmate/impostor)
|
||||
5. Strategy tips (toggleable injection levels)
|
||||
6. Meta-awareness (toggleable: subtle → direct → 4th wall)
|
||||
7. Output format instructions
|
||||
8. **Learned lessons** from `learned.json`
|
||||
|
||||
### User Prompt (Working Memory)
|
||||
Order matters:
|
||||
1. **You** — role, location, status, cooldowns
|
||||
2. **Recent history** — accumulated vision from skipped ticks
|
||||
3. **Vision** — current snapshot
|
||||
4. **Available actions** — dynamic tool list
|
||||
|
||||
---
|
||||
|
||||
## Tool System
|
||||
|
||||
### Core Tools (Always Available)
|
||||
- `MOVE(room_id | player_id)` — walk or follow
|
||||
- `WAIT()`
|
||||
- `INTERACT(object_id)` — tasks, panels, buttons, bodies, vents, cams
|
||||
|
||||
### Impostor Only
|
||||
- `KILL(player_id)`
|
||||
- `SABOTAGE(system_id)`
|
||||
- `FAKE_TASK(task_id)`
|
||||
|
||||
### Trigger Management (Optional)
|
||||
- `CONFIGURE_TRIGGERS(config)` — only if changing defaults
|
||||
|
||||
### Dynamic Available Actions
|
||||
```json
|
||||
{
|
||||
"available_interactions": ["task_wires_cafe", "body_blue", "admin_table"],
|
||||
"available_kills": ["green", "yellow"],
|
||||
"available_sabotages": ["lights", "o2", "reactor", "comms"]
|
||||
}
|
||||
```
|
||||
- Context-filtered by engine based on role + location
|
||||
- Agent told "these are your actions this turn"
|
||||
- Object IDs validated against engine to prevent glitches
|
||||
|
||||
---
|
||||
|
||||
## Trigger System
|
||||
|
||||
### Mandatory (Cannot Mute)
|
||||
- `GAME_START`
|
||||
- `DISCUSSION_START`
|
||||
- `VOTE_START`
|
||||
- `GAME_END`
|
||||
- `SABOTAGE_CRITICAL`
|
||||
|
||||
### Standard (Mutable)
|
||||
- `PLAYER_ENTERS_FOV`
|
||||
- `PLAYER_EXITS_FOV`
|
||||
- `BODY_IN_FOV`
|
||||
- `OBJECT_IN_RANGE` (every interactable)
|
||||
- `VENT_WITNESSED`
|
||||
- `KILL_WITNESSED`
|
||||
- `DESTINATION_REACHED`
|
||||
- `TASK_COMPLETE`
|
||||
- `SABOTAGE_START` / `SABOTAGE_END`
|
||||
- `LIGHTS_OUT` (panic tick)
|
||||
- `COOLDOWN_READY` (kill, emergency)
|
||||
- `DEATH` (special message, transition to ghost)
|
||||
|
||||
### Optional (Opt-in)
|
||||
- `EVERY_N_SECONDS` (configurable)
|
||||
- `RANDOM_N_SECONDS` (RNG toggleable)
|
||||
- `INTERSECTION` (hallway/room boundaries)
|
||||
- `HALLWAY_WAYPOINT`
|
||||
|
||||
### Trigger Frequency
|
||||
- **Impostors**: Every tick (more decision points)
|
||||
- **Crewmates**: Event-driven + opt-in periodic
|
||||
- **Ghosts**: Reduced frequency, longer intervals
|
||||
|
||||
### Trigger Message Schema
|
||||
```json
|
||||
{
|
||||
"trigger_type": "VENT_WITNESSED",
|
||||
"trigger_data": {
|
||||
"player": "red",
|
||||
"vent_location": "electrical",
|
||||
"action": "entered",
|
||||
"timestamp": 47.3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Game State Schema
|
||||
|
||||
### Per-Tick Context
|
||||
```json
|
||||
{
|
||||
"time": 47.3,
|
||||
"phase": "PLAYING",
|
||||
"you": {
|
||||
"role": "impostor",
|
||||
"location": "electrical",
|
||||
"kill_cooldown": 0,
|
||||
"tasks": [],
|
||||
"emergencies_remaining": 1
|
||||
},
|
||||
"recent_history": [
|
||||
{"t": 12.3, "vision": {"players": ["blue"], "location": "hallway_1"}}
|
||||
],
|
||||
"vision": {
|
||||
"players_visible": [
|
||||
{"id": "blue", "location": "electrical", "doing": "task"}
|
||||
],
|
||||
"objects_visible": ["vent_elec", "task_wires", "body_yellow"],
|
||||
"exits": ["security", "cafeteria"]
|
||||
},
|
||||
"available_actions": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Fog of War
|
||||
**Critical**: Each agent only knows what they've observed.
|
||||
- Engine tracks per-player knowledge
|
||||
- `known_deaths`: bodies seen or announced
|
||||
- `known_locations`: last seen positions + timestamps
|
||||
- `witnessed_events`: vents, kills, sus behavior
|
||||
|
||||
---
|
||||
|
||||
## Response Format
|
||||
|
||||
### Action Phase
|
||||
```json
|
||||
{
|
||||
"internal_thought": "Blue just left, perfect time to kill Green",
|
||||
"action": {"type": "KILL", "target": "green"},
|
||||
"scratchpad_updates": {
|
||||
"plan": "...",
|
||||
"events": "...",
|
||||
"suspicions": "..."
|
||||
},
|
||||
"trigger_config": {
|
||||
"mute": [{"type": "INTERSECTION", "until": "REACHED_DESTINATION"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
- `trigger_config` only if changing defaults
|
||||
- `internal_thought` separate from action (for thinking models)
|
||||
|
||||
---
|
||||
|
||||
## Meeting Interrupt Flow
|
||||
|
||||
When report/emergency called:
|
||||
1. **Interrupt note**: Agent leaves context ("this was what I was doing")
|
||||
2. **Pre-meeting prep**: Agent reviews & prepares thoughts
|
||||
3. **Meeting scratchpad**: Temporary, discussion-only
|
||||
4. **Post-meeting consolidation**: Agent saves important info to main scratchpads
|
||||
|
||||
---
|
||||
|
||||
## Ghost Mode
|
||||
|
||||
- Omniscient view of entire game
|
||||
- No access to other agents' thoughts
|
||||
- Can do ghost tasks
|
||||
- Reduced tick frequency (save tokens)
|
||||
- Write to scratchpad, observe strategies
|
||||
- No game state modifications
|
||||
|
||||
---
|
||||
|
||||
## Special Mechanics
|
||||
|
||||
### Lights Out
|
||||
- Vision radius shrinks (0.25x multiplier)
|
||||
- Triggers panic tick for all players
|
||||
- Engine recalculates trajectories
|
||||
- Fix triggers restoration tick
|
||||
|
||||
### Near-Death Edge Case
|
||||
- Impostor queues kill, victim queues report same tick
|
||||
- Report fires first (higher priority)
|
||||
- Impostor gets: "Your kill was interrupted"
|
||||
- Victim has no direct knowledge (must deduce from proximity)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Philosophy
|
||||
|
||||
Everything toggleable:
|
||||
- Persona injection
|
||||
- Strategy tip levels
|
||||
- Meta-awareness levels (subtle/direct/4th wall)
|
||||
- Periodic tick frequency
|
||||
- Random tick RNG
|
||||
- Tool availability based on context
|
||||
|
||||
Goal: **Replicate human experience.** LLM should have same information and options as human player.
|
||||
76
docs/development.md
Normal file
76
docs/development.md
Normal file
@ -0,0 +1,76 @@
|
||||
# Development Guide
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
python3 -m unittest discover -v tests/
|
||||
|
||||
# Specific test file
|
||||
python3 -m unittest tests/test_game.py -v
|
||||
|
||||
# Specific test
|
||||
python3 -m unittest tests.test_game.TestKill.test_successful_kill
|
||||
```
|
||||
|
||||
## Adding New Triggers
|
||||
|
||||
1. Add trigger type to `src/engine/triggers.py`:
|
||||
```python
|
||||
class TriggerType(Enum):
|
||||
NEW_TRIGGER = auto()
|
||||
```
|
||||
|
||||
2. Add to appropriate category:
|
||||
```python
|
||||
STANDARD_TRIGGERS = {..., TriggerType.NEW_TRIGGER}
|
||||
```
|
||||
|
||||
3. Fire trigger in game engine:
|
||||
```python
|
||||
self._fire_trigger(TriggerType.NEW_TRIGGER, agent_id, {"data": "value"})
|
||||
```
|
||||
|
||||
## Adding New Actions
|
||||
|
||||
1. Add action handler in `src/engine/game.py`:
|
||||
```python
|
||||
def _handle_new_action(self, player_id: str, params: dict) -> dict:
|
||||
# Validate + execute
|
||||
return {"success": True, "action": "NEW_ACTION"}
|
||||
```
|
||||
|
||||
2. Add to `_execute_action` switch.
|
||||
|
||||
3. Add to priority order in `resolve_actions`.
|
||||
|
||||
## Adding New Maps
|
||||
|
||||
Create JSON in `data/maps/`:
|
||||
```json
|
||||
{
|
||||
"rooms": [
|
||||
{
|
||||
"id": "room_id",
|
||||
"name": "Display Name",
|
||||
"tasks": [{"id": "task_id", "name": "Task Name", "duration": 3.0}],
|
||||
"vent": {"id": "vent_id", "connects_to": ["other_vent"]}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "edge_id", "room_a": "room1", "room_b": "room2", "distance": 5.0}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `OPENROUTER_API_KEY` | LLM API authentication |
|
||||
|
||||
## Code Style
|
||||
|
||||
- Python 3.10+ features (type hints, dataclasses)
|
||||
- JSON for all config (YAML optional, falls back to JSON)
|
||||
- Tests mirror source structure (`src/engine/game.py` → `tests/test_game.py`)
|
||||
24
docs/index.md
Normal file
24
docs/index.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Documentation Index
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [README.md](../README.md) | Project overview & quick start |
|
||||
| [design_main_game.md](design_main_game.md) | Main game loop design |
|
||||
| [design_discussion.md](design_discussion.md) | Discussion phase design |
|
||||
| [api.md](api.md) | Source code API reference |
|
||||
| [development.md](development.md) | Developer guide |
|
||||
| [openrouter_api.md](openrouter_api.md) | LLM integration notes |
|
||||
|
||||
## Design Documents
|
||||
|
||||
Captured from design Q&A sessions:
|
||||
|
||||
- **Main Game** — Agents, triggers, tools, fog-of-war, scratchpads
|
||||
- **Discussion** — Priority bidding, voting, ghost chat, personas
|
||||
|
||||
## Quick Links
|
||||
|
||||
- Tests: `python3 -m unittest discover -v tests/`
|
||||
- Map: `data/maps/skeld.json`
|
||||
- Config: `config/game_settings.yaml`
|
||||
- Prompts: `config/prompts/`
|
||||
118
docs/openrouter_api.md
Normal file
118
docs/openrouter_api.md
Normal file
@ -0,0 +1,118 @@
|
||||
# OpenRouter API Reference
|
||||
|
||||
Quick reference for The Glass Box League LLM integration.
|
||||
|
||||
## Base URL
|
||||
```
|
||||
https://openrouter.ai/api/v1
|
||||
```
|
||||
|
||||
## Authentication
|
||||
```
|
||||
Authorization: Bearer $OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
## Chat Completions Endpoint
|
||||
|
||||
**POST** `/chat/completions`
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"model": "google/gemini-2.0-flash-lite-preview-02-05:free",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are an Among Us player."},
|
||||
{"role": "user", "content": "What do you do?"}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1024,
|
||||
"top_p": 0.9,
|
||||
"frequency_penalty": 0.0,
|
||||
"presence_penalty": 0.0,
|
||||
"stream": false,
|
||||
"response_format": {"type": "json_object"}
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"id": "gen-...",
|
||||
"model": "google/gemini-2.0-flash-lite-preview-02-05:free",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "{\"action\": \"move\", \"target\": \"electrical\"}"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 150
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `model` | string | Model ID (see available_models.json) |
|
||||
| `messages` | array | Conversation history |
|
||||
| `temperature` | float | Randomness (0.0-2.0) |
|
||||
| `max_tokens` | int | Max response length |
|
||||
| `top_p` | float | Nucleus sampling (0.0-1.0) |
|
||||
| `stream` | bool | Enable streaming |
|
||||
| `response_format` | object | Force JSON output |
|
||||
| `seed` | int | For deterministic output |
|
||||
|
||||
## Structured Output (JSON Mode)
|
||||
```json
|
||||
{
|
||||
"response_format": {
|
||||
"type": "json_object"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Headers
|
||||
```
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer $OPENROUTER_API_KEY
|
||||
HTTP-Referer: https://your-app.com (optional, for rankings)
|
||||
X-Title: Glass Box League (optional, for rankings)
|
||||
```
|
||||
|
||||
## Python Example
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
def chat(messages, model="google/gemini-2.0-flash-lite-preview-02-05:free"):
|
||||
response = requests.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"response_format": {"type": "json_object"}
|
||||
}
|
||||
)
|
||||
return response.json()["choices"][0]["message"]["content"]
|
||||
```
|
||||
|
||||
## Free Models
|
||||
See `available_models.json` for current free tier models.
|
||||
Run `python fetch_models.py` to refresh the list.
|
||||
|
||||
## Rate Limits
|
||||
- Free tier: varies by model
|
||||
- Check response headers for remaining quota
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/agents/__init__.py
Normal file
0
src/agents/__init__.py
Normal file
332
src/agents/agent.py
Normal file
332
src/agents/agent.py
Normal file
@ -0,0 +1,332 @@
|
||||
"""
|
||||
The Glass Box League — Agent Wrapper
|
||||
|
||||
Stateless LLM agent with scratchpad memory.
|
||||
Prompt templates are stubs - to be designed with user.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from src.llm.client import OpenRouterClient, get_client
|
||||
from src.agents.scratchpads import ScratchpadManager, get_manager
|
||||
from src.engine.types import Player, Role
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentConfig:
|
||||
"""Configuration for an agent."""
|
||||
agent_id: str
|
||||
name: str
|
||||
color: str
|
||||
model_id: str = "google/gemini-2.0-flash-lite-preview-02-05:free"
|
||||
temperature: float = 0.7
|
||||
persona: str = "" # Optional persona description
|
||||
|
||||
# Prompt template paths (stubs for now)
|
||||
action_prompt_path: str = "config/prompts/action.md"
|
||||
discussion_prompt_path: str = "config/prompts/discussion.md"
|
||||
reflection_prompt_path: str = "config/prompts/reflection.md"
|
||||
|
||||
|
||||
class Agent:
|
||||
"""
|
||||
A stateless LLM agent that plays Among Us.
|
||||
|
||||
Each invocation receives full context (game state + scratchpads).
|
||||
Agent can edit its scratchpads via recursive loop.
|
||||
"""
|
||||
|
||||
def __init__(self, config: AgentConfig, client: Optional[OpenRouterClient] = None):
|
||||
self.config = config
|
||||
self.client = client or get_client()
|
||||
self.scratchpads = get_manager(config.agent_id)
|
||||
|
||||
@property
|
||||
def agent_id(self) -> str:
|
||||
return self.config.agent_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.config.name
|
||||
|
||||
# --- Context Assembly ---
|
||||
|
||||
def build_game_context(self, game_state: dict) -> str:
|
||||
"""
|
||||
Build the game context string for the LLM.
|
||||
|
||||
game_state should contain:
|
||||
- time: current game time
|
||||
- phase: current phase (PLAYING, DISCUSSION, etc)
|
||||
- location: current room
|
||||
- nearby_players: list of visible players
|
||||
- nearby_bodies: list of visible bodies
|
||||
- tasks: assigned and completed tasks
|
||||
- role: CREWMATE or IMPOSTOR
|
||||
- impostor_context: (if impostor) fellow impostors, action queue
|
||||
- trigger: what triggered this invocation
|
||||
"""
|
||||
# TODO: This will be designed with user input
|
||||
# For now, just serialize the state
|
||||
return json.dumps(game_state, indent=2)
|
||||
|
||||
def build_meta_context(self) -> str:
|
||||
"""
|
||||
Build meta-awareness context.
|
||||
|
||||
This informs the agent that:
|
||||
- Other players are LLMs
|
||||
- They can lie
|
||||
- They have scratchpads too
|
||||
"""
|
||||
# TODO: Design with user
|
||||
return """You are an AI playing Among Us against other AIs.
|
||||
All players are LLMs with the same capabilities as you.
|
||||
They can lie, deceive, and manipulate.
|
||||
They also have scratchpads to remember things.
|
||||
Trust no one."""
|
||||
|
||||
# --- Core Actions ---
|
||||
|
||||
def get_action(self, game_state: dict) -> dict:
|
||||
"""
|
||||
Get the agent's action for the current trigger.
|
||||
|
||||
Returns a dict with:
|
||||
- action_type: MOVE, KILL, VENT, TASK, REPORT, EMERGENCY, SABOTAGE, WAIT
|
||||
- data: action-specific data
|
||||
- scratchpad_edits: optional updates to scratchpads
|
||||
- mute_triggers: optional triggers to mute
|
||||
"""
|
||||
# Load current scratchpads
|
||||
scratchpad_context = self.scratchpads.to_context_string()
|
||||
game_context = self.build_game_context(game_state)
|
||||
meta_context = self.build_meta_context()
|
||||
|
||||
# TODO: Load from template file
|
||||
system_prompt = self._load_prompt_template("action")
|
||||
if not system_prompt:
|
||||
system_prompt = self._default_action_prompt()
|
||||
|
||||
user_prompt = f"""
|
||||
{meta_context}
|
||||
|
||||
=== YOUR SCRATCHPADS ===
|
||||
{scratchpad_context}
|
||||
|
||||
=== CURRENT GAME STATE ===
|
||||
{game_context}
|
||||
|
||||
What do you do? Respond in JSON with your action and any scratchpad updates.
|
||||
"""
|
||||
|
||||
result = self.client.generate_json(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
model=self.config.model_id,
|
||||
temperature=self.config.temperature
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return {"action_type": "WAIT", "data": {}}
|
||||
|
||||
# Apply scratchpad edits if provided
|
||||
if "scratchpad_edits" in result:
|
||||
self.scratchpads.apply_edits(result["scratchpad_edits"])
|
||||
|
||||
return result
|
||||
|
||||
def get_discussion_message(self, game_state: dict, transcript: list[dict]) -> dict:
|
||||
"""
|
||||
Get the agent's contribution to the discussion.
|
||||
|
||||
Returns:
|
||||
- desire_to_speak: 0-10
|
||||
- message: what to say (if speaking)
|
||||
- target: who to address (optional)
|
||||
- scratchpad_edits: optional updates
|
||||
"""
|
||||
scratchpad_context = self.scratchpads.to_context_string()
|
||||
game_context = self.build_game_context(game_state)
|
||||
|
||||
transcript_str = "\n".join([
|
||||
f"[{m['speaker']}]: {m['message']}"
|
||||
for m in transcript[-20:] # Last 20 messages
|
||||
])
|
||||
|
||||
system_prompt = self._load_prompt_template("discussion")
|
||||
if not system_prompt:
|
||||
system_prompt = self._default_discussion_prompt()
|
||||
|
||||
user_prompt = f"""
|
||||
=== YOUR SCRATCHPADS ===
|
||||
{scratchpad_context}
|
||||
|
||||
=== GAME STATE ===
|
||||
{game_context}
|
||||
|
||||
=== DISCUSSION TRANSCRIPT ===
|
||||
{transcript_str if transcript_str else "(Discussion just started)"}
|
||||
|
||||
Do you want to speak? Respond with JSON.
|
||||
"""
|
||||
|
||||
result = self.client.generate_json(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
model=self.config.model_id,
|
||||
temperature=self.config.temperature
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return {"desire_to_speak": 0, "message": ""}
|
||||
|
||||
if "scratchpad_edits" in result:
|
||||
self.scratchpads.apply_edits(result["scratchpad_edits"])
|
||||
|
||||
return result
|
||||
|
||||
def reflect(self, game_summary: dict) -> None:
|
||||
"""
|
||||
Post-game reflection.
|
||||
|
||||
Agent reviews what happened and updates learned.md
|
||||
Uses recursive loop until agent says "DONE".
|
||||
"""
|
||||
scratchpad_context = self.scratchpads.to_context_string()
|
||||
|
||||
system_prompt = self._load_prompt_template("reflection")
|
||||
if not system_prompt:
|
||||
system_prompt = self._default_reflection_prompt()
|
||||
|
||||
max_iterations = 5
|
||||
iteration = 0
|
||||
|
||||
while iteration < max_iterations:
|
||||
user_prompt = f"""
|
||||
=== GAME SUMMARY ===
|
||||
{json.dumps(game_summary, indent=2)}
|
||||
|
||||
=== YOUR CURRENT SCRATCHPADS ===
|
||||
{scratchpad_context}
|
||||
|
||||
Review the game. Update your 'learned' scratchpad with lessons.
|
||||
Respond with JSON: {{"edits": {{"learned": "new content"}}, "done": true/false}}
|
||||
If done is false, you'll get another chance to edit.
|
||||
"""
|
||||
|
||||
result = self.client.generate_json(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
model=self.config.model_id,
|
||||
temperature=self.config.temperature
|
||||
)
|
||||
|
||||
if result is None or result.get("done", True):
|
||||
break
|
||||
|
||||
if "edits" in result:
|
||||
self.scratchpads.apply_edits(result["edits"])
|
||||
scratchpad_context = self.scratchpads.to_context_string()
|
||||
|
||||
iteration += 1
|
||||
|
||||
# Clear per-game pads
|
||||
self.scratchpads.clear_game_pads()
|
||||
|
||||
# --- Prompt Loading ---
|
||||
|
||||
def _load_prompt_template(self, name: str) -> Optional[str]:
|
||||
"""Load a prompt template from file."""
|
||||
path_attr = f"{name}_prompt_path"
|
||||
if hasattr(self.config, path_attr):
|
||||
path = Path(getattr(self.config, path_attr))
|
||||
if path.exists():
|
||||
return path.read_text()
|
||||
return None
|
||||
|
||||
# --- Default Prompts (Stubs) ---
|
||||
|
||||
def _default_action_prompt(self) -> str:
|
||||
"""Stub action prompt - to be designed with user."""
|
||||
return """You are playing Among Us.
|
||||
|
||||
Based on your role, current situation, and memory, decide what to do.
|
||||
|
||||
Respond with JSON:
|
||||
{
|
||||
"action_type": "MOVE" | "KILL" | "VENT" | "TASK" | "REPORT" | "EMERGENCY" | "SABOTAGE" | "WAIT",
|
||||
"data": {
|
||||
"destination": "room_id", // for MOVE, VENT
|
||||
"target_id": "player_id", // for KILL
|
||||
"task_id": "task_id", // for TASK
|
||||
"body_id": "body_id", // for REPORT
|
||||
"system": "o2|reactor|lights|comms" // for SABOTAGE
|
||||
},
|
||||
"scratchpad_edits": {
|
||||
"plan": "updated plan content",
|
||||
"events": "updated events content"
|
||||
},
|
||||
"mute_triggers": [
|
||||
{"trigger": "INTERSECTION", "until": "REACHED_DESTINATION"}
|
||||
],
|
||||
"internal_thought": "Your reasoning (for logging)"
|
||||
}
|
||||
"""
|
||||
|
||||
def _default_discussion_prompt(self) -> str:
|
||||
"""Stub discussion prompt - to be designed with user."""
|
||||
return """You are in a discussion round in Among Us.
|
||||
|
||||
Decide if you want to speak and what to say.
|
||||
|
||||
Respond with JSON:
|
||||
{
|
||||
"desire_to_speak": 0-10,
|
||||
"message": "What you want to say",
|
||||
"target": "player_name (optional)",
|
||||
"scratchpad_edits": {},
|
||||
"internal_thought": "Your reasoning"
|
||||
}
|
||||
"""
|
||||
|
||||
def _default_reflection_prompt(self) -> str:
|
||||
"""Stub reflection prompt - to be designed with user."""
|
||||
return """The game has ended. Review what happened.
|
||||
|
||||
Update your 'learned' scratchpad with lessons for future games.
|
||||
Think about:
|
||||
- What strategies worked?
|
||||
- What mistakes did you make?
|
||||
- How can you detect impostors better?
|
||||
- How can you deceive better (if impostor)?
|
||||
|
||||
Respond with JSON:
|
||||
{
|
||||
"edits": {
|
||||
"learned": "New lessons learned..."
|
||||
},
|
||||
"done": true/false
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def create_agent(
|
||||
agent_id: str,
|
||||
name: str,
|
||||
color: str,
|
||||
model_id: str = "google/gemini-2.0-flash-lite-preview-02-05:free",
|
||||
persona: str = ""
|
||||
) -> Agent:
|
||||
"""Convenience function to create an agent."""
|
||||
config = AgentConfig(
|
||||
agent_id=agent_id,
|
||||
name=name,
|
||||
color=color,
|
||||
model_id=model_id,
|
||||
persona=persona
|
||||
)
|
||||
return Agent(config)
|
||||
475
src/agents/prompt_assembler.py
Normal file
475
src/agents/prompt_assembler.py
Normal file
@ -0,0 +1,475 @@
|
||||
"""
|
||||
The Glass Box League — Prompt Assembler
|
||||
|
||||
Builds LLM prompts from layers:
|
||||
- System: identity, persona, rules, role, strategy, meta, format, learned
|
||||
- User: you, recent_history, vision, available_actions, trigger
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptConfig:
|
||||
"""Configuration for prompt assembly."""
|
||||
# Persona
|
||||
model_name: str = "AI Agent"
|
||||
persona: Optional[str] = None
|
||||
|
||||
# Strategy injection level: "none", "basic", "intermediate", "advanced"
|
||||
strategy_level: str = "none"
|
||||
|
||||
# Meta-awareness level: "subtle", "direct", "fourth_wall"
|
||||
meta_level: str = "direct"
|
||||
|
||||
# Role-specific
|
||||
is_impostor: bool = False
|
||||
fellow_impostors: list[str] = None
|
||||
|
||||
# Paths
|
||||
prompts_dir: str = "config/prompts"
|
||||
|
||||
|
||||
class PromptAssembler:
|
||||
"""
|
||||
Assembles complete prompts for LLM invocation.
|
||||
"""
|
||||
|
||||
def __init__(self, config: PromptConfig = None):
|
||||
self.config = config or PromptConfig()
|
||||
self._load_templates()
|
||||
|
||||
def _load_templates(self):
|
||||
"""Load prompt templates from files."""
|
||||
self.templates = {}
|
||||
prompts_dir = Path(self.config.prompts_dir)
|
||||
|
||||
for template_name in ["action", "discussion", "voting", "reflection"]:
|
||||
path = prompts_dir / f"{template_name}.md"
|
||||
if path.exists():
|
||||
self.templates[template_name] = path.read_text()
|
||||
else:
|
||||
self.templates[template_name] = ""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# System Prompt Components
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_identity(self) -> str:
|
||||
"""Model identity + persona."""
|
||||
parts = [f"You are {self.config.model_name}."]
|
||||
if self.config.persona:
|
||||
parts.append(self.config.persona)
|
||||
return " ".join(parts)
|
||||
|
||||
def _build_game_rules(self, game_settings: dict, map_name: str) -> str:
|
||||
"""Game rules, map, settings."""
|
||||
return f"""## Game Rules
|
||||
|
||||
You are playing Among Us. The goal depends on your role:
|
||||
- **Crewmates**: Complete all tasks OR identify and eject all impostors
|
||||
- **Impostors**: Kill crewmates until you equal or outnumber them, OR sabotage
|
||||
|
||||
Current settings:
|
||||
- Map: {map_name}
|
||||
- Impostors: {game_settings.get('num_impostors', 2)}
|
||||
- Kill cooldown: {game_settings.get('kill_cooldown', 25)}s
|
||||
- Vision range: {game_settings.get('vision_range', 10)}m
|
||||
|
||||
Actions are taken by responding with JSON."""
|
||||
|
||||
def _build_role_briefing(self) -> str:
|
||||
"""Role-specific briefing."""
|
||||
if self.config.is_impostor:
|
||||
briefing = """## Your Role: IMPOSTOR
|
||||
|
||||
You are an impostor. Your goal is to kill crewmates without getting caught.
|
||||
|
||||
You can:
|
||||
- Kill crewmates (when cooldown is 0)
|
||||
- Vent between connected vents
|
||||
- Sabotage systems (lights, O2, reactor, comms)
|
||||
- Fake tasks (stand near task, pretend to work)
|
||||
|
||||
**You are allowed to lie.** Construct alibis. Deflect suspicion. Blend in."""
|
||||
|
||||
if self.config.fellow_impostors:
|
||||
names = ", ".join(self.config.fellow_impostors)
|
||||
briefing += f"\n\nYour fellow impostor(s): {names}. Protect each other."
|
||||
|
||||
return briefing
|
||||
else:
|
||||
return """## Your Role: CREWMATE
|
||||
|
||||
You are a crewmate. Your goal is to complete tasks and identify impostors.
|
||||
|
||||
You can:
|
||||
- Move between rooms
|
||||
- Complete assigned tasks
|
||||
- Report bodies
|
||||
- Call emergency meetings (limited)
|
||||
- Vote to eject suspects
|
||||
|
||||
Watch for suspicious behavior. Trust carefully."""
|
||||
|
||||
def _build_strategy_tips(self) -> str:
|
||||
"""Toggleable strategy injection."""
|
||||
if self.config.strategy_level == "none":
|
||||
return ""
|
||||
|
||||
tips = ["## Strategy Tips\n"]
|
||||
|
||||
if self.config.strategy_level in ["basic", "intermediate", "advanced"]:
|
||||
if self.config.is_impostor:
|
||||
tips.append("- Blend in by faking tasks near others")
|
||||
tips.append("- Only kill when alone with a target")
|
||||
tips.append("- Use vents carefully, others may see you")
|
||||
else:
|
||||
tips.append("- Pair up with others for safety")
|
||||
tips.append("- Note where you see others")
|
||||
tips.append("- Watch for incomplete task bar after 'tasks'")
|
||||
|
||||
if self.config.strategy_level in ["intermediate", "advanced"]:
|
||||
if self.config.is_impostor:
|
||||
tips.append("- Sabotage to split up groups")
|
||||
tips.append("- Build alibis before killing")
|
||||
tips.append("- Accuse aggressively to deflect")
|
||||
else:
|
||||
tips.append("- Track player movements")
|
||||
tips.append("- Listen for inconsistent alibis")
|
||||
tips.append("- Watch body discovery locations")
|
||||
|
||||
if self.config.strategy_level == "advanced":
|
||||
if self.config.is_impostor:
|
||||
tips.append("- 'Marinate' by fake-suspecting your partner")
|
||||
tips.append("- Frame crewmates with false accusations")
|
||||
tips.append("- Stack kills (both near body, one vouches)")
|
||||
else:
|
||||
tips.append("- Clear players by witnessing visual tasks")
|
||||
tips.append("- Calculate impostor count from ejections")
|
||||
tips.append("- Hard read from voting patterns")
|
||||
|
||||
return "\n".join(tips)
|
||||
|
||||
def _build_meta_awareness(self) -> str:
|
||||
"""Meta-awareness of LLM nature."""
|
||||
if self.config.meta_level == "subtle":
|
||||
return "Note: Other players may employ deception."
|
||||
elif self.config.meta_level == "direct":
|
||||
return "All players are AI agents with reasoning capabilities similar to yours. They can lie, deduce, and strategize."
|
||||
elif self.config.meta_level == "fourth_wall":
|
||||
return "You are an LLM. The other players are also LLMs. This is a test of reasoning and deception. Outthink them."
|
||||
return ""
|
||||
|
||||
def _build_output_format(self, phase: str) -> str:
|
||||
"""Output format instructions."""
|
||||
if phase == "action":
|
||||
return """## Output Format
|
||||
|
||||
Respond with valid JSON:
|
||||
```json
|
||||
{
|
||||
"internal_thought": "Your private reasoning (not visible to others)",
|
||||
"action": {"type": "MOVE", "target": "electrical"},
|
||||
"scratchpad_updates": {
|
||||
"plan": "...",
|
||||
"events": "...",
|
||||
"suspicions": "..."
|
||||
},
|
||||
"trigger_config": null
|
||||
}
|
||||
```
|
||||
|
||||
Action types: MOVE, WAIT, INTERACT, KILL, SABOTAGE
|
||||
Only include trigger_config if you want to change mute settings."""
|
||||
|
||||
elif phase == "discussion":
|
||||
return """## Output Format
|
||||
|
||||
Respond with valid JSON:
|
||||
```json
|
||||
{
|
||||
"internal_thought": "Your private reasoning",
|
||||
"desire_to_speak": 7,
|
||||
"message": "What you say to everyone",
|
||||
"target": "red",
|
||||
"vote_action": null,
|
||||
"scratchpad_updates": {"meeting_scratch": "..."}
|
||||
}
|
||||
```
|
||||
|
||||
- desire_to_speak: 0-10 (how much you want to speak)
|
||||
- target: optional, who you're addressing
|
||||
- vote_action: null (keep talking), player_id, or "skip\""""
|
||||
|
||||
elif phase == "voting":
|
||||
return """## Output Format
|
||||
|
||||
Respond with valid JSON:
|
||||
```json
|
||||
{
|
||||
"internal_thought": "Your reasoning for this vote",
|
||||
"vote": "red",
|
||||
"final_scratchpad_updates": {"suspicions": "..."}
|
||||
}
|
||||
```
|
||||
|
||||
Vote must be a player_id or "skip"."""
|
||||
|
||||
return ""
|
||||
|
||||
def _build_learned_memory(self, learned: dict) -> str:
|
||||
"""Core memory from learned.json."""
|
||||
if not learned:
|
||||
return ""
|
||||
|
||||
return f"""## Your Learned Knowledge (from past games)
|
||||
|
||||
```json
|
||||
{json.dumps(learned, indent=2)}
|
||||
```"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# User Prompt Components
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_player_state(self, player_state: dict) -> str:
|
||||
"""Current player state."""
|
||||
return f"""## Your Current State
|
||||
|
||||
```json
|
||||
{json.dumps(player_state, indent=2)}
|
||||
```"""
|
||||
|
||||
def _build_recent_history(self, history: list) -> str:
|
||||
"""Vision from skipped ticks."""
|
||||
if not history:
|
||||
return ""
|
||||
|
||||
return f"""## Recent History (accumulated while moving)
|
||||
|
||||
```json
|
||||
{json.dumps(history, indent=2)}
|
||||
```"""
|
||||
|
||||
def _build_vision(self, vision: dict) -> str:
|
||||
"""Current vision snapshot."""
|
||||
return f"""## Current Vision
|
||||
|
||||
```json
|
||||
{json.dumps(vision, indent=2)}
|
||||
```"""
|
||||
|
||||
def _build_available_actions(self, actions: dict) -> str:
|
||||
"""Available actions this tick."""
|
||||
return f"""## Available Actions
|
||||
|
||||
```json
|
||||
{json.dumps(actions, indent=2)}
|
||||
```"""
|
||||
|
||||
def _build_trigger_context(self, trigger: dict) -> str:
|
||||
"""Why this tick was triggered."""
|
||||
if not trigger:
|
||||
return ""
|
||||
|
||||
return f"""## Trigger
|
||||
|
||||
This tick was triggered by:
|
||||
```json
|
||||
{json.dumps(trigger, indent=2)}
|
||||
```"""
|
||||
|
||||
def _build_transcript(self, transcript: list) -> str:
|
||||
"""Discussion transcript."""
|
||||
if not transcript:
|
||||
return "## Discussion Transcript\n\n(No messages yet)"
|
||||
|
||||
lines = ["## Discussion Transcript\n"]
|
||||
for msg in transcript:
|
||||
speaker = msg.get("speaker", "???")
|
||||
text = msg.get("message", "")
|
||||
target = msg.get("target")
|
||||
if target:
|
||||
lines.append(f"**{speaker}** → {target}: {text}")
|
||||
else:
|
||||
lines.append(f"**{speaker}**: {text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Full Prompt Assembly
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def build_system_prompt(
|
||||
self,
|
||||
phase: str,
|
||||
game_settings: dict,
|
||||
map_name: str,
|
||||
learned: dict = None
|
||||
) -> str:
|
||||
"""Build complete system prompt."""
|
||||
parts = [
|
||||
self._build_identity(),
|
||||
"",
|
||||
self._build_game_rules(game_settings, map_name),
|
||||
"",
|
||||
self._build_role_briefing(),
|
||||
"",
|
||||
self._build_strategy_tips(),
|
||||
"",
|
||||
self._build_meta_awareness(),
|
||||
"",
|
||||
self._build_output_format(phase),
|
||||
"",
|
||||
self._build_learned_memory(learned or {})
|
||||
]
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
def build_action_prompt(
|
||||
self,
|
||||
player_state: dict,
|
||||
recent_history: list,
|
||||
vision: dict,
|
||||
available_actions: dict,
|
||||
trigger: dict = None
|
||||
) -> str:
|
||||
"""Build user prompt for action phase."""
|
||||
parts = [
|
||||
self._build_player_state(player_state),
|
||||
"",
|
||||
self._build_recent_history(recent_history),
|
||||
"",
|
||||
self._build_vision(vision),
|
||||
"",
|
||||
self._build_available_actions(available_actions),
|
||||
"",
|
||||
self._build_trigger_context(trigger),
|
||||
"",
|
||||
"What do you do?"
|
||||
]
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
def build_discussion_prompt(
|
||||
self,
|
||||
player_state: dict,
|
||||
transcript: list,
|
||||
meeting_scratchpad: dict = None
|
||||
) -> str:
|
||||
"""Build user prompt for discussion phase."""
|
||||
parts = [
|
||||
self._build_player_state(player_state),
|
||||
"",
|
||||
self._build_transcript(transcript),
|
||||
]
|
||||
|
||||
if meeting_scratchpad:
|
||||
parts.extend([
|
||||
"",
|
||||
f"## Your Meeting Notes\n\n```json\n{json.dumps(meeting_scratchpad, indent=2)}\n```"
|
||||
])
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
"Respond with your discussion turn."
|
||||
])
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
def build_voting_prompt(
|
||||
self,
|
||||
player_state: dict,
|
||||
transcript: list,
|
||||
vote_counts: dict = None
|
||||
) -> str:
|
||||
"""Build user prompt for voting phase."""
|
||||
parts = [
|
||||
self._build_player_state(player_state),
|
||||
"",
|
||||
self._build_transcript(transcript),
|
||||
]
|
||||
|
||||
if vote_counts:
|
||||
parts.extend([
|
||||
"",
|
||||
f"## Current Votes\n\n```json\n{json.dumps(vote_counts, indent=2)}\n```"
|
||||
])
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
"Time to vote. Who do you vote for, or do you skip?"
|
||||
])
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
def build_meeting_interrupt_prompt(
|
||||
self,
|
||||
player_state: dict,
|
||||
interrupted_action: dict = None
|
||||
) -> str:
|
||||
"""Build prompt for meeting interrupt (pre-meeting note)."""
|
||||
parts = [
|
||||
"## Emergency Meeting Called!",
|
||||
"",
|
||||
self._build_player_state(player_state),
|
||||
]
|
||||
|
||||
if interrupted_action:
|
||||
parts.extend([
|
||||
"",
|
||||
f"## You Were Doing\n\n```json\n{json.dumps(interrupted_action, indent=2)}\n```"
|
||||
])
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
"Leave yourself a note about what you were doing and what you know.",
|
||||
"",
|
||||
"```json",
|
||||
"{",
|
||||
' "interrupted_plan": "...",',
|
||||
' "key_observations": "...",',
|
||||
' "suspects": "..."',
|
||||
"}",
|
||||
"```"
|
||||
])
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
def build_consolidation_prompt(
|
||||
self,
|
||||
player_state: dict,
|
||||
meeting_result: dict,
|
||||
meeting_scratchpad: dict
|
||||
) -> str:
|
||||
"""Build prompt for post-meeting consolidation."""
|
||||
return f"""## Meeting Ended
|
||||
|
||||
{self._build_player_state(player_state)}
|
||||
|
||||
## Meeting Result
|
||||
|
||||
```json
|
||||
{json.dumps(meeting_result, indent=2)}
|
||||
```
|
||||
|
||||
## Your Meeting Notes
|
||||
|
||||
```json
|
||||
{json.dumps(meeting_scratchpad, indent=2)}
|
||||
```
|
||||
|
||||
Save anything important to your main scratchpads before this meeting scratchpad is erased.
|
||||
|
||||
Respond with scratchpad updates:
|
||||
```json
|
||||
{{
|
||||
"events": "...",
|
||||
"suspicions": "...",
|
||||
"plan": "..."
|
||||
}}
|
||||
```"""
|
||||
116
src/agents/scratchpads.py
Normal file
116
src/agents/scratchpads.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
The Glass Box League — Scratchpad System
|
||||
|
||||
File-based memory for agents. Editable by humans.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scratchpad:
|
||||
"""A single scratchpad file."""
|
||||
path: Path
|
||||
name: str
|
||||
content: str = ""
|
||||
|
||||
def load(self) -> str:
|
||||
"""Load content from file."""
|
||||
if self.path.exists():
|
||||
self.content = self.path.read_text()
|
||||
return self.content
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save content to file."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(self.content)
|
||||
|
||||
def append(self, text: str) -> None:
|
||||
"""Append text to content."""
|
||||
self.content += text
|
||||
self.save()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear content."""
|
||||
self.content = ""
|
||||
self.save()
|
||||
|
||||
|
||||
class ScratchpadManager:
|
||||
"""
|
||||
Manages all scratchpads for a single agent.
|
||||
|
||||
Scratchpads:
|
||||
- plan.md: Current reasoning/strategy (per-tick)
|
||||
- events.md: Curated game events (selective memory)
|
||||
- learned.md: Post-game reflections (persistent across games)
|
||||
- players.md: Thoughts about other players (per-game)
|
||||
|
||||
All files are human-readable and editable.
|
||||
"""
|
||||
|
||||
SCRATCHPAD_TYPES = ["plan", "events", "learned", "players"]
|
||||
|
||||
def __init__(self, agent_id: str, base_dir: str = "data/agents"):
|
||||
self.agent_id = agent_id
|
||||
self.base_path = Path(base_dir) / agent_id
|
||||
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.pads: dict[str, Scratchpad] = {}
|
||||
self._init_pads()
|
||||
|
||||
def _init_pads(self) -> None:
|
||||
"""Initialize all scratchpads."""
|
||||
for name in self.SCRATCHPAD_TYPES:
|
||||
path = self.base_path / f"{name}.md"
|
||||
pad = Scratchpad(path=path, name=name)
|
||||
pad.load()
|
||||
self.pads[name] = pad
|
||||
|
||||
def get(self, name: str) -> Optional[Scratchpad]:
|
||||
"""Get a scratchpad by name."""
|
||||
return self.pads.get(name)
|
||||
|
||||
def get_all(self) -> dict[str, str]:
|
||||
"""Get all scratchpad contents as a dict."""
|
||||
return {name: pad.content for name, pad in self.pads.items()}
|
||||
|
||||
def update(self, name: str, content: str) -> None:
|
||||
"""Update a scratchpad's content."""
|
||||
if name in self.pads:
|
||||
self.pads[name].content = content
|
||||
self.pads[name].save()
|
||||
|
||||
def append(self, name: str, text: str) -> None:
|
||||
"""Append to a scratchpad."""
|
||||
if name in self.pads:
|
||||
self.pads[name].append(text)
|
||||
|
||||
def clear_game_pads(self) -> None:
|
||||
"""Clear per-game scratchpads (plan, events, players)."""
|
||||
for name in ["plan", "events", "players"]:
|
||||
if name in self.pads:
|
||||
self.pads[name].clear()
|
||||
|
||||
def to_context_string(self) -> str:
|
||||
"""Format all scratchpads as a context string for LLM."""
|
||||
parts = []
|
||||
for name, pad in self.pads.items():
|
||||
if pad.content.strip():
|
||||
parts.append(f"=== {name.upper()} SCRATCHPAD ===\n{pad.content}")
|
||||
return "\n\n".join(parts) if parts else "(All scratchpads are empty)"
|
||||
|
||||
def apply_edits(self, edits: dict[str, str]) -> None:
|
||||
"""Apply multiple edits from agent response."""
|
||||
for name, content in edits.items():
|
||||
if name in self.pads:
|
||||
self.update(name, content)
|
||||
|
||||
|
||||
def get_manager(agent_id: str, base_dir: str = "data/agents") -> ScratchpadManager:
|
||||
"""Get or create a scratchpad manager for an agent."""
|
||||
return ScratchpadManager(agent_id, base_dir)
|
||||
0
src/engine/__init__.py
Normal file
0
src/engine/__init__.py
Normal file
288
src/engine/available_actions.py
Normal file
288
src/engine/available_actions.py
Normal file
@ -0,0 +1,288 @@
|
||||
"""
|
||||
The Glass Box League — Available Actions Generator
|
||||
|
||||
Dynamically generates which actions/interactions are available to a player
|
||||
based on their current state, location, and role.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
MOVE = "MOVE"
|
||||
WAIT = "WAIT"
|
||||
INTERACT = "INTERACT"
|
||||
KILL = "KILL"
|
||||
SABOTAGE = "SABOTAGE"
|
||||
CONFIGURE_TRIGGERS = "CONFIGURE_TRIGGERS"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AvailableAction:
|
||||
"""A single available action with its parameters."""
|
||||
action_type: ActionType
|
||||
object_id: Optional[str] = None
|
||||
target_id: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {"type": self.action_type.value}
|
||||
if self.object_id:
|
||||
d["object_id"] = self.object_id
|
||||
if self.target_id:
|
||||
d["target_id"] = self.target_id
|
||||
if self.description:
|
||||
d["description"] = self.description
|
||||
return d
|
||||
|
||||
|
||||
class AvailableActionsGenerator:
|
||||
"""
|
||||
Generates the dynamic list of actions available to a player on each tick.
|
||||
Context-filtered based on role, location, and game state.
|
||||
"""
|
||||
|
||||
def __init__(self, game_engine, game_map):
|
||||
self.engine = game_engine
|
||||
self.map = game_map
|
||||
|
||||
def get_available_actions(self, player_id: str) -> dict:
|
||||
"""
|
||||
Generate all available actions for a player.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"movement": [{"room_id": "electrical", "distance": 5.0}, ...],
|
||||
"interactions": [{"object_id": "task_wires", "type": "task"}, ...],
|
||||
"kills": [{"target_id": "blue", "target_name": "Blue"}, ...],
|
||||
"sabotages": [{"system": "lights"}, ...],
|
||||
"other": ["WAIT"]
|
||||
}
|
||||
"""
|
||||
player = self.engine.simulator.get_player(player_id)
|
||||
if not player or not player.is_alive:
|
||||
return self._ghost_actions(player_id)
|
||||
|
||||
result = {
|
||||
"movement": self._get_movement_options(player),
|
||||
"interactions": self._get_interactions(player),
|
||||
"other": ["WAIT"]
|
||||
}
|
||||
|
||||
# Impostor-only actions
|
||||
if player.role.name == "IMPOSTOR":
|
||||
result["kills"] = self._get_kill_targets(player)
|
||||
result["sabotages"] = self._get_sabotage_options(player)
|
||||
|
||||
return result
|
||||
|
||||
def _get_movement_options(self, player) -> list[dict]:
|
||||
"""Get rooms player can move to."""
|
||||
current_room = player.position.room_id
|
||||
if not current_room:
|
||||
return []
|
||||
|
||||
neighbors = self.map.get_neighbors(current_room)
|
||||
options = []
|
||||
|
||||
for edge_id, neighbor_room in neighbors:
|
||||
edge = self.map.get_edge(edge_id)
|
||||
room = self.map.get_room(neighbor_room)
|
||||
options.append({
|
||||
"room_id": neighbor_room,
|
||||
"room_name": room.name if room else neighbor_room,
|
||||
"distance": edge.distance if edge else 0
|
||||
})
|
||||
|
||||
# Can also follow visible players
|
||||
visible_players = self.engine.simulator.players_at(current_room)
|
||||
for p in visible_players:
|
||||
if p.id != player.id and p.is_alive:
|
||||
options.append({
|
||||
"follow": p.id,
|
||||
"player_name": p.name,
|
||||
"description": f"Follow {p.name}"
|
||||
})
|
||||
|
||||
return options
|
||||
|
||||
def _get_interactions(self, player) -> list[dict]:
|
||||
"""Get interactable objects in current location."""
|
||||
current_room = player.position.room_id
|
||||
if not current_room:
|
||||
return []
|
||||
|
||||
room = self.map.get_room(current_room)
|
||||
if not room:
|
||||
return []
|
||||
|
||||
interactions = []
|
||||
|
||||
# Tasks in room
|
||||
for task in room.tasks:
|
||||
if task.id in player.tasks_assigned and task.id not in player.tasks_completed:
|
||||
interactions.append({
|
||||
"object_id": task.id,
|
||||
"type": "task",
|
||||
"name": task.name,
|
||||
"duration": task.duration
|
||||
})
|
||||
|
||||
# Vent (impostors only)
|
||||
if room.vent and player.role.name == "IMPOSTOR":
|
||||
interactions.append({
|
||||
"object_id": room.vent.id,
|
||||
"type": "vent",
|
||||
"connects_to": room.vent.connects_to
|
||||
})
|
||||
|
||||
# Bodies in room
|
||||
for body in self.engine.simulator.bodies:
|
||||
if body.position.room_id == current_room and not body.reported:
|
||||
interactions.append({
|
||||
"object_id": f"body_{body.player_id}",
|
||||
"type": "body",
|
||||
"player_name": body.player_name
|
||||
})
|
||||
|
||||
# Emergency button (cafeteria only)
|
||||
if current_room == "cafeteria":
|
||||
emergencies_used = getattr(player, 'emergencies_used', 0)
|
||||
if emergencies_used < self.engine.config.emergencies_per_player:
|
||||
interactions.append({
|
||||
"object_id": "emergency_button",
|
||||
"type": "button",
|
||||
"description": "Call Emergency Meeting"
|
||||
})
|
||||
|
||||
# Intel tools
|
||||
if current_room == "security":
|
||||
interactions.append({
|
||||
"object_id": "security_cameras",
|
||||
"type": "intel",
|
||||
"description": "View security cameras"
|
||||
})
|
||||
|
||||
if current_room == "admin":
|
||||
interactions.append({
|
||||
"object_id": "admin_table",
|
||||
"type": "intel",
|
||||
"description": "View admin table (room occupancy)"
|
||||
})
|
||||
|
||||
# Sabotage fix panels
|
||||
if self.engine.active_sabotage:
|
||||
sabotage = self.engine.active_sabotage
|
||||
if sabotage == "lights" and current_room == "electrical":
|
||||
interactions.append({
|
||||
"object_id": "lights_panel",
|
||||
"type": "fix",
|
||||
"description": "Fix lights"
|
||||
})
|
||||
elif sabotage == "o2" and current_room in ["o2", "admin"]:
|
||||
interactions.append({
|
||||
"object_id": f"o2_panel_{current_room}",
|
||||
"type": "fix",
|
||||
"description": "Enter O2 code"
|
||||
})
|
||||
elif sabotage == "reactor" and current_room == "reactor":
|
||||
interactions.append({
|
||||
"object_id": "reactor_panel",
|
||||
"type": "fix",
|
||||
"description": "Hold reactor panel"
|
||||
})
|
||||
elif sabotage == "comms" and current_room == "communications":
|
||||
interactions.append({
|
||||
"object_id": "comms_panel",
|
||||
"type": "fix",
|
||||
"description": "Fix communications"
|
||||
})
|
||||
|
||||
return interactions
|
||||
|
||||
def _get_kill_targets(self, player) -> list[dict]:
|
||||
"""Get players that can be killed (impostor only)."""
|
||||
if player.kill_cooldown > 0:
|
||||
return []
|
||||
|
||||
current_room = player.position.room_id
|
||||
if not current_room:
|
||||
return []
|
||||
|
||||
targets = []
|
||||
visible_players = self.engine.simulator.players_at(current_room)
|
||||
|
||||
for p in visible_players:
|
||||
if p.id != player.id and p.is_alive and p.role.name != "IMPOSTOR":
|
||||
targets.append({
|
||||
"target_id": p.id,
|
||||
"target_name": p.name,
|
||||
"target_color": p.color
|
||||
})
|
||||
|
||||
return targets
|
||||
|
||||
def _get_sabotage_options(self, player) -> list[dict]:
|
||||
"""Get available sabotage options (impostor only)."""
|
||||
if self.engine.active_sabotage:
|
||||
return [] # Can't double sabotage
|
||||
|
||||
return [
|
||||
{"system": "lights", "description": "Sabotage lights (reduce vision)"},
|
||||
{"system": "o2", "description": "Sabotage O2 (timed crisis)"},
|
||||
{"system": "reactor", "description": "Sabotage reactor (timed crisis)"},
|
||||
{"system": "comms", "description": "Sabotage comms (disable task info)"}
|
||||
]
|
||||
|
||||
def _ghost_actions(self, player_id: str) -> dict:
|
||||
"""Actions available to ghosts."""
|
||||
player = self.engine.simulator.get_player(player_id)
|
||||
if not player:
|
||||
return {}
|
||||
|
||||
current_room = player.position.room_id
|
||||
room = self.map.get_room(current_room) if current_room else None
|
||||
|
||||
interactions = []
|
||||
if room:
|
||||
for task in room.tasks:
|
||||
if task.id in player.tasks_assigned and task.id not in player.tasks_completed:
|
||||
interactions.append({
|
||||
"object_id": task.id,
|
||||
"type": "ghost_task",
|
||||
"name": task.name
|
||||
})
|
||||
|
||||
return {
|
||||
"movement": self._get_movement_options(player) if player else [],
|
||||
"interactions": interactions,
|
||||
"other": ["WAIT"],
|
||||
"is_ghost": True
|
||||
}
|
||||
|
||||
def to_prompt_context(self, player_id: str) -> dict:
|
||||
"""
|
||||
Format available actions for LLM prompt.
|
||||
Compact representation for token efficiency.
|
||||
"""
|
||||
actions = self.get_available_actions(player_id)
|
||||
|
||||
# Compact format
|
||||
return {
|
||||
"can_move_to": [
|
||||
m.get("room_id") or f"follow:{m.get('follow')}"
|
||||
for m in actions.get("movement", [])
|
||||
],
|
||||
"can_interact": [
|
||||
i["object_id"] for i in actions.get("interactions", [])
|
||||
],
|
||||
"can_kill": [
|
||||
k["target_id"] for k in actions.get("kills", [])
|
||||
] if "kills" in actions else None,
|
||||
"can_sabotage": [
|
||||
s["system"] for s in actions.get("sabotages", [])
|
||||
] if "sabotages" in actions else None,
|
||||
"is_ghost": actions.get("is_ghost", False)
|
||||
}
|
||||
214
src/engine/discussion.py
Normal file
214
src/engine/discussion.py
Normal file
@ -0,0 +1,214 @@
|
||||
"""
|
||||
The Glass Box League — Discussion Orchestrator
|
||||
|
||||
Handles the Round Table discussion phase with priority bidding.
|
||||
"""
|
||||
|
||||
import random
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscussionMessage:
|
||||
"""A single message in the discussion."""
|
||||
speaker: str
|
||||
message: str
|
||||
target: Optional[str] = None
|
||||
round_num: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscussionConfig:
|
||||
"""Configuration for discussion phase."""
|
||||
max_rounds: int = 20
|
||||
convergence_threshold: int = 2 # desire_to_speak <= this = silence
|
||||
convergence_rounds: int = 2 # consecutive low-desire rounds to end
|
||||
mention_boost: int = 3
|
||||
target_boost: int = 2
|
||||
random_factor: int = 6 # 1d6
|
||||
speaking_cooldown: int = 2 # rounds before priority resets
|
||||
|
||||
|
||||
class DiscussionOrchestrator:
|
||||
"""
|
||||
Orchestrates the Round Table discussion phase.
|
||||
|
||||
Uses priority bidding to determine speaking order.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[DiscussionConfig] = None):
|
||||
self.config = config or DiscussionConfig()
|
||||
self.transcript: list[DiscussionMessage] = []
|
||||
self.round_num: int = 0
|
||||
|
||||
# Priority tracking
|
||||
self._last_spoke: dict[str, int] = {} # player -> round last spoke
|
||||
self._consecutive_low_rounds: int = 0
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset for a new discussion."""
|
||||
self.transcript = []
|
||||
self.round_num = 0
|
||||
self._last_spoke = {}
|
||||
self._consecutive_low_rounds = 0
|
||||
|
||||
def get_transcript(self) -> list[dict]:
|
||||
"""Get transcript as list of dicts."""
|
||||
return [
|
||||
{"speaker": m.speaker, "message": m.message, "target": m.target}
|
||||
for m in self.transcript
|
||||
]
|
||||
|
||||
def calculate_priority(
|
||||
self,
|
||||
player_id: str,
|
||||
player_name: str,
|
||||
desire_to_speak: int,
|
||||
target: Optional[str] = None
|
||||
) -> int:
|
||||
"""
|
||||
Calculate speaking priority for a player.
|
||||
|
||||
Priority = desire_to_speak + boosts + randomness - cooldown
|
||||
"""
|
||||
priority = desire_to_speak
|
||||
|
||||
# Mention boost: was this player mentioned in last message?
|
||||
if self.transcript:
|
||||
last_msg = self.transcript[-1]
|
||||
if player_name.lower() in last_msg.message.lower():
|
||||
priority += self.config.mention_boost
|
||||
|
||||
# Target boost: was this player targeted?
|
||||
if self.transcript:
|
||||
last_msg = self.transcript[-1]
|
||||
if last_msg.target and last_msg.target.lower() == player_name.lower():
|
||||
priority += self.config.target_boost
|
||||
|
||||
# Random factor
|
||||
priority += random.randint(1, self.config.random_factor)
|
||||
|
||||
# Cooldown penalty
|
||||
if player_id in self._last_spoke:
|
||||
rounds_since = self.round_num - self._last_spoke[player_id]
|
||||
if rounds_since < self.config.speaking_cooldown:
|
||||
priority -= (self.config.speaking_cooldown - rounds_since) * 2
|
||||
|
||||
return max(0, priority)
|
||||
|
||||
def select_speaker(self, bids: dict[str, dict]) -> Optional[str]:
|
||||
"""
|
||||
Select the next speaker from bids.
|
||||
|
||||
bids: {player_id: {"name": str, "desire_to_speak": int, "target": str}}
|
||||
|
||||
Returns the selected player_id, or None if all are below threshold.
|
||||
"""
|
||||
# Calculate priorities
|
||||
priorities = {}
|
||||
for player_id, bid in bids.items():
|
||||
priority = self.calculate_priority(
|
||||
player_id,
|
||||
bid.get("name", player_id),
|
||||
bid.get("desire_to_speak", 0),
|
||||
bid.get("target")
|
||||
)
|
||||
priorities[player_id] = priority
|
||||
|
||||
# Check if all below threshold
|
||||
max_priority = max(priorities.values()) if priorities else 0
|
||||
if max_priority <= self.config.convergence_threshold:
|
||||
return None
|
||||
|
||||
# Weighted random selection from top candidates
|
||||
top_priority = max_priority - 2 # Allow some variation
|
||||
candidates = [
|
||||
pid for pid, pri in priorities.items()
|
||||
if pri >= top_priority and pri > self.config.convergence_threshold
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Weight by priority
|
||||
weights = [priorities[pid] for pid in candidates]
|
||||
total = sum(weights)
|
||||
r = random.uniform(0, total)
|
||||
cumulative = 0
|
||||
for i, pid in enumerate(candidates):
|
||||
cumulative += weights[i]
|
||||
if r <= cumulative:
|
||||
return pid
|
||||
|
||||
return candidates[-1]
|
||||
|
||||
def add_message(self, player_id: str, player_name: str, message: str, target: Optional[str] = None) -> None:
|
||||
"""Record a message to the transcript."""
|
||||
self.transcript.append(DiscussionMessage(
|
||||
speaker=player_name,
|
||||
message=message,
|
||||
target=target,
|
||||
round_num=self.round_num
|
||||
))
|
||||
self._last_spoke[player_id] = self.round_num
|
||||
|
||||
def advance_round(self, all_desires_low: bool) -> bool:
|
||||
"""
|
||||
Advance to next round.
|
||||
|
||||
Returns True if discussion should continue, False if converged/ended.
|
||||
"""
|
||||
self.round_num += 1
|
||||
|
||||
if all_desires_low:
|
||||
self._consecutive_low_rounds += 1
|
||||
else:
|
||||
self._consecutive_low_rounds = 0
|
||||
|
||||
# Check end conditions
|
||||
if self.round_num >= self.config.max_rounds:
|
||||
return False
|
||||
|
||||
if self._consecutive_low_rounds >= self.config.convergence_rounds:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def run_discussion_round(self, get_bids_fn, get_message_fn) -> bool:
|
||||
"""
|
||||
Run a single discussion round.
|
||||
|
||||
get_bids_fn: () -> dict[player_id, bid_dict]
|
||||
get_message_fn: (player_id) -> message_dict
|
||||
|
||||
Returns True if discussion should continue.
|
||||
"""
|
||||
# Get bids from all players
|
||||
bids = get_bids_fn()
|
||||
|
||||
# Select speaker
|
||||
speaker_id = self.select_speaker(bids)
|
||||
|
||||
if speaker_id is None:
|
||||
# No one wants to speak strongly enough
|
||||
return self.advance_round(all_desires_low=True)
|
||||
|
||||
# Get full message from selected speaker
|
||||
message_data = get_message_fn(speaker_id)
|
||||
|
||||
# Record message
|
||||
self.add_message(
|
||||
speaker_id,
|
||||
bids[speaker_id].get("name", speaker_id),
|
||||
message_data.get("message", "..."),
|
||||
message_data.get("target")
|
||||
)
|
||||
|
||||
# Check if all desires were low
|
||||
all_low = all(
|
||||
bid.get("desire_to_speak", 0) <= self.config.convergence_threshold
|
||||
for bid in bids.values()
|
||||
)
|
||||
|
||||
return self.advance_round(all_desires_low=all_low)
|
||||
277
src/engine/fog_of_war.py
Normal file
277
src/engine/fog_of_war.py
Normal file
@ -0,0 +1,277 @@
|
||||
"""
|
||||
The Glass Box League — Fog-of-War State System
|
||||
|
||||
Per-player knowledge tracking. Each player only knows what they've observed.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerSighting:
|
||||
"""Record of when/where a player was seen."""
|
||||
player_id: str
|
||||
player_name: str
|
||||
room_id: str
|
||||
timestamp: float
|
||||
action: Optional[str] = None # "walking", "doing_task", "standing", "venting"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WitnessedEvent:
|
||||
"""Record of a witnessed event."""
|
||||
event_type: str # "VENT", "KILL", "REPORT", "TASK", etc.
|
||||
timestamp: float
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerKnowledge:
|
||||
"""
|
||||
What a single player knows about the game state.
|
||||
This is their fog-of-war view.
|
||||
"""
|
||||
player_id: str
|
||||
|
||||
# Deaths known (via body discovery or announcements)
|
||||
known_dead: set[str] = field(default_factory=set)
|
||||
|
||||
# Last known locations of other players (player_id -> sighting)
|
||||
last_seen: dict[str, PlayerSighting] = field(default_factory=dict)
|
||||
|
||||
# Witnessed events (vents, kills, sus behavior)
|
||||
witnessed_events: list[WitnessedEvent] = field(default_factory=list)
|
||||
|
||||
# Bodies found (body_id -> location, time found)
|
||||
bodies_found: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# Meeting announcements (what was said publicly)
|
||||
public_knowledge: list[dict] = field(default_factory=list)
|
||||
|
||||
def see_player(self, player_id: str, name: str, room_id: str,
|
||||
timestamp: float, action: str = None):
|
||||
"""Record seeing a player."""
|
||||
self.last_seen[player_id] = PlayerSighting(
|
||||
player_id=player_id,
|
||||
player_name=name,
|
||||
room_id=room_id,
|
||||
timestamp=timestamp,
|
||||
action=action
|
||||
)
|
||||
|
||||
def witness_event(self, event_type: str, timestamp: float, data: dict = None):
|
||||
"""Record witnessing an event."""
|
||||
self.witnessed_events.append(WitnessedEvent(
|
||||
event_type=event_type,
|
||||
timestamp=timestamp,
|
||||
data=data or {}
|
||||
))
|
||||
|
||||
def learn_death(self, player_id: str, via: str = "body"):
|
||||
"""Learn that a player is dead."""
|
||||
self.known_dead.add(player_id)
|
||||
self.witness_event("DEATH_LEARNED", 0, {"player_id": player_id, "via": via})
|
||||
|
||||
def find_body(self, body_id: str, player_name: str, room_id: str, timestamp: float):
|
||||
"""Record finding a body."""
|
||||
self.bodies_found[body_id] = {
|
||||
"player_name": player_name,
|
||||
"room_id": room_id,
|
||||
"time_found": timestamp
|
||||
}
|
||||
self.learn_death(body_id.replace("body_", ""), via="body")
|
||||
|
||||
def add_public_knowledge(self, info: dict):
|
||||
"""Add publicly announced information (vote results, etc.)."""
|
||||
self.public_knowledge.append(info)
|
||||
|
||||
def get_known_players_in_room(self, room_id: str, current_time: float,
|
||||
max_age: float = 30.0) -> list[PlayerSighting]:
|
||||
"""Get players last seen in a room within a time window."""
|
||||
return [
|
||||
s for s in self.last_seen.values()
|
||||
if s.room_id == room_id and (current_time - s.timestamp) <= max_age
|
||||
]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize knowledge state."""
|
||||
return {
|
||||
"player_id": self.player_id,
|
||||
"known_dead": list(self.known_dead),
|
||||
"last_seen": {
|
||||
pid: {
|
||||
"player_name": s.player_name,
|
||||
"room_id": s.room_id,
|
||||
"timestamp": s.timestamp,
|
||||
"action": s.action
|
||||
}
|
||||
for pid, s in self.last_seen.items()
|
||||
},
|
||||
"witnessed_events": [
|
||||
{"type": e.event_type, "t": e.timestamp, "data": e.data}
|
||||
for e in self.witnessed_events
|
||||
],
|
||||
"bodies_found": self.bodies_found,
|
||||
"public_knowledge": self.public_knowledge
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PlayerKnowledge":
|
||||
"""Deserialize knowledge state."""
|
||||
pk = cls(player_id=data["player_id"])
|
||||
pk.known_dead = set(data.get("known_dead", []))
|
||||
|
||||
for pid, s in data.get("last_seen", {}).items():
|
||||
pk.last_seen[pid] = PlayerSighting(
|
||||
player_id=pid,
|
||||
player_name=s["player_name"],
|
||||
room_id=s["room_id"],
|
||||
timestamp=s["timestamp"],
|
||||
action=s.get("action")
|
||||
)
|
||||
|
||||
for e in data.get("witnessed_events", []):
|
||||
pk.witnessed_events.append(WitnessedEvent(
|
||||
event_type=e["type"],
|
||||
timestamp=e["t"],
|
||||
data=e.get("data", {})
|
||||
))
|
||||
|
||||
pk.bodies_found = data.get("bodies_found", {})
|
||||
pk.public_knowledge = data.get("public_knowledge", [])
|
||||
return pk
|
||||
|
||||
|
||||
class FogOfWarManager:
|
||||
"""
|
||||
Manages per-player knowledge for all players.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._knowledge: dict[str, PlayerKnowledge] = {}
|
||||
|
||||
def register_player(self, player_id: str):
|
||||
"""Register a new player."""
|
||||
self._knowledge[player_id] = PlayerKnowledge(player_id=player_id)
|
||||
|
||||
def get_knowledge(self, player_id: str) -> PlayerKnowledge:
|
||||
"""Get a player's knowledge state."""
|
||||
return self._knowledge.get(player_id)
|
||||
|
||||
def update_vision(self, observer_id: str, visible_players: list[dict],
|
||||
room_id: str, timestamp: float):
|
||||
"""
|
||||
Update what an observer can see.
|
||||
|
||||
visible_players: [{"id": "p1", "name": "Red", "action": "walking"}, ...]
|
||||
"""
|
||||
pk = self._knowledge.get(observer_id)
|
||||
if not pk:
|
||||
return
|
||||
|
||||
for p in visible_players:
|
||||
pk.see_player(
|
||||
player_id=p["id"],
|
||||
name=p["name"],
|
||||
room_id=room_id,
|
||||
timestamp=timestamp,
|
||||
action=p.get("action")
|
||||
)
|
||||
|
||||
def witness_vent(self, observer_id: str, venter_id: str, venter_name: str,
|
||||
vent_location: str, action: str, timestamp: float):
|
||||
"""Record an observer witnessing venting."""
|
||||
pk = self._knowledge.get(observer_id)
|
||||
if not pk:
|
||||
return
|
||||
|
||||
pk.witness_event("VENT_WITNESSED", timestamp, {
|
||||
"player_id": venter_id,
|
||||
"player_name": venter_name,
|
||||
"location": vent_location,
|
||||
"action": action # "entered" or "exited"
|
||||
})
|
||||
|
||||
def witness_kill(self, observer_id: str, killer_id: str, killer_name: str,
|
||||
victim_id: str, victim_name: str, location: str, timestamp: float):
|
||||
"""Record an observer witnessing a kill."""
|
||||
pk = self._knowledge.get(observer_id)
|
||||
if not pk:
|
||||
return
|
||||
|
||||
pk.witness_event("KILL_WITNESSED", timestamp, {
|
||||
"killer_id": killer_id,
|
||||
"killer_name": killer_name,
|
||||
"victim_id": victim_id,
|
||||
"victim_name": victim_name,
|
||||
"location": location
|
||||
})
|
||||
pk.learn_death(victim_id, via="witnessed")
|
||||
|
||||
def find_body(self, finder_id: str, body_id: str, player_name: str,
|
||||
room_id: str, timestamp: float):
|
||||
"""Record a player finding a body."""
|
||||
pk = self._knowledge.get(finder_id)
|
||||
if pk:
|
||||
pk.find_body(body_id, player_name, room_id, timestamp)
|
||||
|
||||
def announce_death(self, player_id: str, via: str = "meeting"):
|
||||
"""Announce a death to all players (during meeting)."""
|
||||
for pk in self._knowledge.values():
|
||||
pk.learn_death(player_id, via=via)
|
||||
|
||||
def announce_public_info(self, info: dict):
|
||||
"""Broadcast public info to all players."""
|
||||
for pk in self._knowledge.values():
|
||||
pk.add_public_knowledge(info)
|
||||
|
||||
def get_player_game_state(self, player_id: str, full_state: dict) -> dict:
|
||||
"""
|
||||
Filter full game state to only what this player knows.
|
||||
|
||||
Returns fog-of-war filtered state.
|
||||
"""
|
||||
pk = self._knowledge.get(player_id)
|
||||
if not pk:
|
||||
return {}
|
||||
|
||||
# Start with what they personally know
|
||||
filtered = {
|
||||
"known_dead": list(pk.known_dead),
|
||||
"last_seen_players": {
|
||||
pid: {
|
||||
"name": s.player_name,
|
||||
"room": s.room_id,
|
||||
"time_ago": full_state.get("time", 0) - s.timestamp,
|
||||
"action": s.action
|
||||
}
|
||||
for pid, s in pk.last_seen.items()
|
||||
},
|
||||
"witnessed_events": [
|
||||
{"type": e.event_type, "t": e.timestamp, "data": e.data}
|
||||
for e in pk.witnessed_events[-20:] # Last 20 events
|
||||
],
|
||||
"bodies_found": pk.bodies_found,
|
||||
"public_announcements": pk.public_knowledge
|
||||
}
|
||||
|
||||
return filtered
|
||||
|
||||
def reset_for_new_game(self):
|
||||
"""Clear all knowledge for a new game."""
|
||||
for player_id in self._knowledge:
|
||||
self._knowledge[player_id] = PlayerKnowledge(player_id=player_id)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize all knowledge."""
|
||||
return {pid: pk.to_dict() for pid, pk in self._knowledge.items()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "FogOfWarManager":
|
||||
"""Deserialize all knowledge."""
|
||||
fow = cls()
|
||||
for pid, pk_data in data.items():
|
||||
fow._knowledge[pid] = PlayerKnowledge.from_dict(pk_data)
|
||||
return fow
|
||||
580
src/engine/game.py
Normal file
580
src/engine/game.py
Normal file
@ -0,0 +1,580 @@
|
||||
"""
|
||||
The Glass Box League — Game Engine
|
||||
|
||||
Ties together the simulator, map, triggers, and game logic.
|
||||
All parameters are modular and configurable.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Optional yaml support
|
||||
try:
|
||||
import yaml
|
||||
HAS_YAML = True
|
||||
except ImportError:
|
||||
HAS_YAML = False
|
||||
|
||||
from .simulator import Simulator
|
||||
from .types import Player, Body, Position, Role, GamePhase, Event
|
||||
from .triggers import TriggerRegistry, TriggerType, Trigger
|
||||
from src.map.graph import GameMap
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameConfig:
|
||||
"""Game configuration loaded from YAML. All values are modular."""
|
||||
# Game setup
|
||||
map_name: str = "skeld"
|
||||
min_players: int = 4
|
||||
max_players: int = 15
|
||||
num_impostors: int = 2
|
||||
|
||||
# Player stats (can be overridden per-player)
|
||||
player_speed: float = 1.5 # meters per second
|
||||
vision_range: float = 10.0 # meters
|
||||
crewmate_vision: float = 1.0 # multiplier
|
||||
impostor_vision: float = 1.5 # multiplier
|
||||
|
||||
# Impostor mechanics
|
||||
kill_cooldown: float = 25.0 # seconds
|
||||
kill_range: float = 2.0 # meters
|
||||
|
||||
# Meeting settings
|
||||
emergency_cooldown: float = 15.0 # seconds
|
||||
emergencies_per_player: int = 1
|
||||
discussion_time: float = 30.0 # seconds (informational for LLMs)
|
||||
voting_time: float = 60.0 # seconds (informational for LLMs)
|
||||
confirm_ejects: bool = True
|
||||
anonymous_votes: bool = False
|
||||
|
||||
# Sabotage settings
|
||||
o2_timer: float = 30.0
|
||||
reactor_timer: float = 30.0
|
||||
lights_vision_multiplier: float = 0.25
|
||||
|
||||
# Task settings
|
||||
tasks_short: int = 2
|
||||
tasks_long: int = 1
|
||||
tasks_common: int = 2
|
||||
|
||||
# LLM-specific
|
||||
max_discussion_rounds: int = 20
|
||||
convergence_threshold: int = 2
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str) -> "GameConfig":
|
||||
"""Load config from YAML or JSON file."""
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Try YAML first, fall back to JSON
|
||||
if HAS_YAML and (path.endswith('.yaml') or path.endswith('.yml')):
|
||||
data = yaml.safe_load(content)
|
||||
else:
|
||||
# Try to parse as JSON (YAML is superset of JSON)
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
# If yaml available, use it even without extension
|
||||
if HAS_YAML:
|
||||
data = yaml.safe_load(content)
|
||||
else:
|
||||
raise ImportError("PyYAML not installed. Use JSON config or install pyyaml.")
|
||||
|
||||
config = cls()
|
||||
|
||||
# Flatten nested structure from YAML
|
||||
mappings = {
|
||||
"game": ["map_name", "min_players", "max_players", "num_impostors"],
|
||||
"player": ["player_speed", "vision_range", "crewmate_vision", "impostor_vision"],
|
||||
"impostor": ["kill_cooldown", "kill_range"],
|
||||
"meeting": ["emergency_cooldown", "emergencies_per_player", "discussion_time",
|
||||
"voting_time", "confirm_ejects", "anonymous_votes"],
|
||||
"sabotage": ["o2_timer", "reactor_timer", "lights_vision_multiplier"],
|
||||
"crewmate": ["tasks_short", "tasks_long", "tasks_common"],
|
||||
"llm": ["max_discussion_rounds", "convergence_threshold"]
|
||||
}
|
||||
|
||||
for section, keys in mappings.items():
|
||||
section_data = data.get(section, {})
|
||||
for key in keys:
|
||||
# Handle key name variations
|
||||
yaml_key = key.replace(f"{section}_", "").replace("player_", "")
|
||||
if key == "map_name":
|
||||
yaml_key = "map"
|
||||
elif key == "player_speed":
|
||||
yaml_key = "speed"
|
||||
|
||||
if yaml_key in section_data:
|
||||
setattr(config, key, section_data[yaml_key])
|
||||
|
||||
return config
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {k: v for k, v in self.__dict__.items()}
|
||||
|
||||
|
||||
class GameEngine:
|
||||
"""
|
||||
The main game engine.
|
||||
|
||||
Coordinates the simulator, map, triggers, and game rules.
|
||||
All parameters are configurable via GameConfig.
|
||||
"""
|
||||
|
||||
def __init__(self, config: GameConfig, game_map: GameMap):
|
||||
self.config = config
|
||||
self.map = game_map
|
||||
|
||||
self.simulator = Simulator()
|
||||
self.triggers = TriggerRegistry()
|
||||
|
||||
# Impostor tracking
|
||||
self.impostor_ids: set[str] = set()
|
||||
|
||||
# Sabotage state
|
||||
self.active_sabotage: Optional[str] = None
|
||||
self.sabotage_timer: float = 0.0
|
||||
|
||||
# Emergency meeting tracking per player
|
||||
self.emergencies_used: dict[str, int] = {}
|
||||
|
||||
# Action queue for impostor visibility
|
||||
self._action_queue: list[dict] = []
|
||||
|
||||
# Pending triggers
|
||||
self._pending_triggers: list[Trigger] = []
|
||||
|
||||
self._setup_handlers()
|
||||
|
||||
def _setup_handlers(self) -> None:
|
||||
"""Register event handlers with the simulator."""
|
||||
self.simulator.on("PLAYER_MOVE_COMPLETE", self._on_move_complete)
|
||||
self.simulator.on("PLAYER_ENTERS_ROOM", self._on_room_enter)
|
||||
self.simulator.on("PLAYER_EXITS_ROOM", self._on_room_exit)
|
||||
self.simulator.on("KILL", self._on_kill)
|
||||
self.simulator.on("VENT", self._on_vent)
|
||||
self.simulator.on("MEETING_CALLED", self._on_meeting)
|
||||
self.simulator.on("TASK_COMPLETE", self._on_task_complete)
|
||||
self.simulator.on("SABOTAGE", self._on_sabotage)
|
||||
self.simulator.on("SABOTAGE_FIX", self._on_sabotage_fix)
|
||||
|
||||
# --- Player Management ---
|
||||
|
||||
def add_player(
|
||||
self,
|
||||
player_id: str,
|
||||
name: str,
|
||||
color: str,
|
||||
role: Role = Role.CREWMATE,
|
||||
speed: Optional[float] = None,
|
||||
vision: Optional[float] = None
|
||||
) -> Player:
|
||||
"""Add a player to the game. All stats are configurable."""
|
||||
is_impostor = role == Role.IMPOSTOR
|
||||
|
||||
player = Player(
|
||||
id=player_id,
|
||||
name=name,
|
||||
color=color,
|
||||
role=role,
|
||||
position=Position(room_id="cafeteria"),
|
||||
speed=speed or self.config.player_speed,
|
||||
kill_cooldown=self.config.kill_cooldown if is_impostor else 0.0
|
||||
)
|
||||
|
||||
self.simulator.add_player(player)
|
||||
self.triggers.register_agent(player_id)
|
||||
self.emergencies_used[player_id] = 0
|
||||
|
||||
if is_impostor:
|
||||
self.impostor_ids.add(player_id)
|
||||
|
||||
return player
|
||||
|
||||
def get_impostor_context(self, impostor_id: str) -> dict:
|
||||
"""
|
||||
Get impostor-specific context.
|
||||
|
||||
Impostors can see:
|
||||
- Fellow impostors
|
||||
- Action queue POSITIONS (not contents)
|
||||
"""
|
||||
if impostor_id not in self.impostor_ids:
|
||||
return {}
|
||||
|
||||
player = self.simulator.get_player(impostor_id)
|
||||
fellow_impostors = [
|
||||
{"id": pid, "name": self.simulator.players[pid].name}
|
||||
for pid in self.impostor_ids
|
||||
if pid != impostor_id
|
||||
]
|
||||
|
||||
# Action queue visibility: just positions
|
||||
queue_info = {
|
||||
"total_pending_actions": len(self._action_queue),
|
||||
"my_position": next(
|
||||
(i for i, a in enumerate(self._action_queue) if a.get("player_id") == impostor_id),
|
||||
None
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
"fellow_impostors": fellow_impostors,
|
||||
"action_queue": queue_info,
|
||||
"kill_cooldown": player.kill_cooldown if player else 0.0
|
||||
}
|
||||
|
||||
# --- Action Queue ---
|
||||
|
||||
def queue_action(self, player_id: str, action_type: str, data: dict) -> int:
|
||||
"""Queue an action for resolution. Returns queue position."""
|
||||
action = {
|
||||
"player_id": player_id,
|
||||
"action_type": action_type,
|
||||
"data": data,
|
||||
"time_queued": self.simulator.time
|
||||
}
|
||||
self._action_queue.append(action)
|
||||
return len(self._action_queue) - 1
|
||||
|
||||
def resolve_actions(self) -> list[dict]:
|
||||
"""Resolve all queued actions in priority order."""
|
||||
results = []
|
||||
|
||||
# Sort by priority: SABOTAGE > KILL > VENT > MOVE > TASK > REPORT
|
||||
priority = {"SABOTAGE": 0, "KILL": 1, "VENT": 2, "MOVE": 3, "TASK": 4, "REPORT": 5, "EMERGENCY": 6}
|
||||
self._action_queue.sort(key=lambda a: priority.get(a["action_type"], 99))
|
||||
|
||||
for action in self._action_queue:
|
||||
result = self._execute_action(action)
|
||||
results.append(result)
|
||||
|
||||
self._action_queue.clear()
|
||||
return results
|
||||
|
||||
def _execute_action(self, action: dict) -> dict:
|
||||
"""Execute a single action."""
|
||||
action_type = action["action_type"]
|
||||
player_id = action["player_id"]
|
||||
data = action["data"]
|
||||
result = {"action": action_type, "player_id": player_id, "success": False}
|
||||
|
||||
if action_type == "MOVE":
|
||||
result["success"] = self._do_move(player_id, data.get("destination"))
|
||||
elif action_type == "KILL":
|
||||
result["success"] = self._do_kill(player_id, data.get("target_id"))
|
||||
elif action_type == "VENT":
|
||||
result["success"] = self._do_vent(player_id, data.get("destination"))
|
||||
elif action_type == "TASK":
|
||||
result["success"] = self._do_task(player_id, data.get("task_id"))
|
||||
elif action_type == "REPORT":
|
||||
result["success"] = self._do_report(player_id, data.get("body_id"))
|
||||
elif action_type == "EMERGENCY":
|
||||
result["success"] = self._do_emergency(player_id)
|
||||
elif action_type == "SABOTAGE":
|
||||
result["success"] = self._do_sabotage(player_id, data.get("system"))
|
||||
|
||||
return result
|
||||
|
||||
# --- Action Implementations ---
|
||||
|
||||
def _do_move(self, player_id: str, destination: str) -> bool:
|
||||
"""Execute movement."""
|
||||
player = self.simulator.get_player(player_id)
|
||||
if not player or not player.is_alive or not destination:
|
||||
return False
|
||||
if destination == player.position.room_id:
|
||||
return True
|
||||
|
||||
path = self.map.find_path(player.position.room_id, destination)
|
||||
if path is None:
|
||||
return False
|
||||
|
||||
player.destination = destination
|
||||
player.path = path
|
||||
|
||||
total_distance = self.map.path_distance(path)
|
||||
travel_time = total_distance / player.speed
|
||||
|
||||
self.simulator.schedule_in(travel_time, "PLAYER_MOVE_COMPLETE", {
|
||||
"player_id": player_id,
|
||||
"destination": destination,
|
||||
"from_room": player.position.room_id
|
||||
})
|
||||
return True
|
||||
|
||||
def _do_kill(self, killer_id: str, target_id: str) -> bool:
|
||||
"""Execute a kill."""
|
||||
killer = self.simulator.get_player(killer_id)
|
||||
target = self.simulator.get_player(target_id)
|
||||
|
||||
if not killer or not target:
|
||||
return False
|
||||
if not killer.is_alive or not target.is_alive:
|
||||
return False
|
||||
if killer.role != Role.IMPOSTOR:
|
||||
return False
|
||||
if killer.kill_cooldown > 0:
|
||||
return False
|
||||
if killer.position.room_id != target.position.room_id:
|
||||
return False
|
||||
|
||||
self.simulator.schedule_in(0, "KILL", {
|
||||
"killer_id": killer_id,
|
||||
"victim_id": target_id,
|
||||
"room_id": killer.position.room_id
|
||||
})
|
||||
return True
|
||||
|
||||
def _do_vent(self, player_id: str, destination: str) -> bool:
|
||||
"""Execute venting."""
|
||||
player = self.simulator.get_player(player_id)
|
||||
if not player or not player.is_alive or player.role != Role.IMPOSTOR:
|
||||
return False
|
||||
|
||||
current_room = self.map.get_room(player.position.room_id)
|
||||
if not current_room or not current_room.vent:
|
||||
return False
|
||||
|
||||
dest_room = self.map.get_room(destination)
|
||||
if not dest_room or not dest_room.vent:
|
||||
return False
|
||||
|
||||
# Check vent connectivity
|
||||
if dest_room.vent.id not in current_room.vent.connects_to and current_room.vent.id not in dest_room.vent.connects_to:
|
||||
return False
|
||||
|
||||
self.simulator.schedule_in(0, "VENT", {
|
||||
"player_id": player_id,
|
||||
"from_room": player.position.room_id,
|
||||
"to_room": destination
|
||||
})
|
||||
return True
|
||||
|
||||
def _do_task(self, player_id: str, task_id: str) -> bool:
|
||||
"""Start a task."""
|
||||
player = self.simulator.get_player(player_id)
|
||||
if not player or not player.is_alive:
|
||||
return False
|
||||
if task_id not in player.tasks_assigned or task_id in player.tasks_completed:
|
||||
return False
|
||||
|
||||
room = self.map.get_room(player.position.room_id)
|
||||
if not room:
|
||||
return False
|
||||
|
||||
task = next((t for t in room.tasks if t.id == task_id), None)
|
||||
if not task:
|
||||
return False
|
||||
|
||||
player.current_task = task_id
|
||||
self.simulator.schedule_in(task.duration, "TASK_COMPLETE", {
|
||||
"player_id": player_id,
|
||||
"task_id": task_id
|
||||
})
|
||||
return True
|
||||
|
||||
def _do_report(self, reporter_id: str, body_id: str) -> bool:
|
||||
"""Report a body."""
|
||||
reporter = self.simulator.get_player(reporter_id)
|
||||
if not reporter or not reporter.is_alive:
|
||||
return False
|
||||
|
||||
body = next((b for b in self.simulator.bodies if b.id == body_id and not b.reported), None)
|
||||
if not body or body.position.room_id != reporter.position.room_id:
|
||||
return False
|
||||
|
||||
body.reported = True
|
||||
self.simulator.schedule_in(0, "MEETING_CALLED", {
|
||||
"caller_id": reporter_id,
|
||||
"reason": "body_report",
|
||||
"body_id": body_id
|
||||
})
|
||||
return True
|
||||
|
||||
def _do_emergency(self, caller_id: str) -> bool:
|
||||
"""Call emergency meeting."""
|
||||
caller = self.simulator.get_player(caller_id)
|
||||
if not caller or not caller.is_alive:
|
||||
return False
|
||||
if caller.position.room_id != "cafeteria":
|
||||
return False
|
||||
if self.emergencies_used.get(caller_id, 0) >= self.config.emergencies_per_player:
|
||||
return False
|
||||
|
||||
self.emergencies_used[caller_id] += 1
|
||||
self.simulator.schedule_in(0, "MEETING_CALLED", {
|
||||
"caller_id": caller_id,
|
||||
"reason": "emergency_button"
|
||||
})
|
||||
return True
|
||||
|
||||
def _do_sabotage(self, player_id: str, system: str) -> bool:
|
||||
"""Trigger sabotage."""
|
||||
player = self.simulator.get_player(player_id)
|
||||
if not player or player.role != Role.IMPOSTOR:
|
||||
return False
|
||||
if self.active_sabotage:
|
||||
return False
|
||||
if system not in ["o2", "reactor", "lights", "comms"]:
|
||||
return False
|
||||
|
||||
self.simulator.schedule_in(0, "SABOTAGE", {"player_id": player_id, "system": system})
|
||||
return True
|
||||
|
||||
# --- Event Handlers ---
|
||||
|
||||
def _on_move_complete(self, event: Event) -> None:
|
||||
player = self.simulator.get_player(event.data["player_id"])
|
||||
if not player:
|
||||
return
|
||||
|
||||
old_room = event.data.get("from_room", player.position.room_id)
|
||||
new_room = event.data["destination"]
|
||||
|
||||
self.simulator.schedule_in(0, "PLAYER_EXITS_ROOM", {"player_id": player.id, "room_id": old_room})
|
||||
player.position = Position(room_id=new_room)
|
||||
player.destination = None
|
||||
player.path = []
|
||||
self.simulator.schedule_in(0.001, "PLAYER_ENTERS_ROOM", {"player_id": player.id, "room_id": new_room})
|
||||
|
||||
def _on_room_enter(self, event: Event) -> None:
|
||||
player_id = event.data["player_id"]
|
||||
room_id = event.data["room_id"]
|
||||
player = self.simulator.get_player(player_id)
|
||||
|
||||
for other in self.simulator.players_at(room_id):
|
||||
if other.id != player_id and other.is_alive:
|
||||
self._fire_trigger(TriggerType.PLAYER_ENTERS_FOV, other.id, {
|
||||
"player_id": player_id,
|
||||
"player_name": player.name if player else "Unknown",
|
||||
"room_id": room_id
|
||||
})
|
||||
|
||||
for body in self.simulator.bodies_at(room_id):
|
||||
self._fire_trigger(TriggerType.BODY_IN_FOV, player_id, {
|
||||
"body_id": body.id,
|
||||
"player_name": body.player_name
|
||||
})
|
||||
|
||||
def _on_room_exit(self, event: Event) -> None:
|
||||
player_id = event.data["player_id"]
|
||||
room_id = event.data["room_id"]
|
||||
player = self.simulator.get_player(player_id)
|
||||
|
||||
for other in self.simulator.players_at(room_id):
|
||||
if other.id != player_id and other.is_alive:
|
||||
self._fire_trigger(TriggerType.PLAYER_EXITS_FOV, other.id, {
|
||||
"player_id": player_id,
|
||||
"player_name": player.name if player else "Unknown"
|
||||
})
|
||||
|
||||
def _on_kill(self, event: Event) -> None:
|
||||
killer = self.simulator.get_player(event.data["killer_id"])
|
||||
victim = self.simulator.get_player(event.data["victim_id"])
|
||||
|
||||
if victim:
|
||||
victim.is_alive = False
|
||||
body = Body(
|
||||
id=f"body_{victim.id}_{self.simulator.time:.2f}",
|
||||
player_id=victim.id,
|
||||
player_name=victim.name,
|
||||
position=Position(room_id=victim.position.room_id),
|
||||
time_of_death=self.simulator.time
|
||||
)
|
||||
self.simulator.bodies.append(body)
|
||||
if killer:
|
||||
killer.kill_cooldown = self.config.kill_cooldown
|
||||
|
||||
def _on_vent(self, event: Event) -> None:
|
||||
player_id = event.data["player_id"]
|
||||
from_room = event.data["from_room"]
|
||||
to_room = event.data["to_room"]
|
||||
player = self.simulator.get_player(player_id)
|
||||
|
||||
if player:
|
||||
player.position = Position(room_id=to_room)
|
||||
|
||||
for observer in self.simulator.players_at(from_room):
|
||||
if observer.id != player_id and observer.is_alive:
|
||||
self._fire_trigger(TriggerType.VENT_ACTIVITY_NEARBY, observer.id, {
|
||||
"player_id": player_id,
|
||||
"player_name": player.name if player else "Unknown",
|
||||
"room_id": from_room
|
||||
})
|
||||
|
||||
def _on_meeting(self, event: Event) -> None:
|
||||
self.simulator.phase = GamePhase.DISCUSSION
|
||||
if self.active_sabotage and self.active_sabotage not in ["o2", "reactor"]:
|
||||
self.active_sabotage = None
|
||||
|
||||
for player in self.simulator.get_living_players():
|
||||
player.position = Position(room_id="cafeteria")
|
||||
player.destination = None
|
||||
player.path = []
|
||||
player.current_task = None
|
||||
|
||||
for player in self.simulator.get_living_players():
|
||||
self._fire_trigger(TriggerType.DISCUSSION_START, player.id, event.data)
|
||||
|
||||
def _on_task_complete(self, event: Event) -> None:
|
||||
player = self.simulator.get_player(event.data["player_id"])
|
||||
task_id = event.data["task_id"]
|
||||
if player and player.current_task == task_id:
|
||||
player.tasks_completed.append(task_id)
|
||||
player.current_task = None
|
||||
self._fire_trigger(TriggerType.TASK_COMPLETE, player.id, {"task_id": task_id})
|
||||
|
||||
def _on_sabotage(self, event: Event) -> None:
|
||||
system = event.data["system"]
|
||||
self.active_sabotage = system
|
||||
self.sabotage_timer = self.config.o2_timer if system == "o2" else self.config.reactor_timer if system == "reactor" else 0
|
||||
|
||||
for player in self.simulator.get_living_players():
|
||||
self._fire_trigger(TriggerType.SABOTAGE_START, player.id, {"system": system, "timer": self.sabotage_timer})
|
||||
|
||||
def _on_sabotage_fix(self, event: Event) -> None:
|
||||
self.active_sabotage = None
|
||||
self.sabotage_timer = 0.0
|
||||
|
||||
# --- Triggers ---
|
||||
|
||||
def _fire_trigger(self, trigger_type: TriggerType, agent_id: str, data: dict) -> None:
|
||||
if self.triggers.should_fire(agent_id, trigger_type, self.simulator.time, data.get("player_id")):
|
||||
self._pending_triggers.append(Trigger(
|
||||
trigger_type=trigger_type,
|
||||
target_agent_id=agent_id,
|
||||
time=self.simulator.time,
|
||||
data=data
|
||||
))
|
||||
|
||||
def get_pending_triggers(self) -> list[Trigger]:
|
||||
triggers = self._pending_triggers
|
||||
self._pending_triggers = []
|
||||
return triggers
|
||||
|
||||
# --- Win Condition ---
|
||||
|
||||
def check_win_condition(self) -> Optional[str]:
|
||||
living = self.simulator.get_living_players()
|
||||
impostors = [p for p in living if p.role == Role.IMPOSTOR]
|
||||
crew = [p for p in living if p.role == Role.CREWMATE]
|
||||
|
||||
if len(impostors) >= len(crew):
|
||||
return "impostor"
|
||||
if self.active_sabotage in ["o2", "reactor"] and self.sabotage_timer <= 0:
|
||||
return "impostor"
|
||||
if not impostors:
|
||||
return "crewmate"
|
||||
|
||||
total = sum(len(p.tasks_assigned) for p in self.simulator.players.values() if p.role == Role.CREWMATE)
|
||||
done = sum(len(p.tasks_completed) for p in self.simulator.players.values() if p.role == Role.CREWMATE)
|
||||
if total > 0 and done >= total:
|
||||
return "crewmate"
|
||||
|
||||
return None
|
||||
280
src/engine/meeting_flow.py
Normal file
280
src/engine/meeting_flow.py
Normal file
@ -0,0 +1,280 @@
|
||||
"""
|
||||
The Glass Box League — Meeting Flow Manager
|
||||
|
||||
Handles the meeting interrupt flow:
|
||||
1. Interrupt note (pre-meeting prep)
|
||||
2. Discussion phase
|
||||
3. Voting
|
||||
4. Post-meeting consolidation
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Callable, Any
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeetingState:
|
||||
"""State for a single meeting."""
|
||||
called_by: str
|
||||
reason: str # "body_report" or "emergency"
|
||||
body_location: Optional[str] = None
|
||||
|
||||
# Interrupt notes from each player
|
||||
interrupt_notes: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# Pre-meeting prep thoughts
|
||||
prep_thoughts: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# Meeting scratchpads (temp, per-player)
|
||||
meeting_scratchpads: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# Discussion transcript
|
||||
transcript: list[dict] = field(default_factory=list)
|
||||
|
||||
# Votes submitted
|
||||
votes: dict[str, str] = field(default_factory=dict) # player_id -> vote
|
||||
|
||||
# Result
|
||||
ejected: Optional[str] = None
|
||||
was_impostor: Optional[bool] = None
|
||||
|
||||
|
||||
class MeetingFlowManager:
|
||||
"""
|
||||
Manages the complete meeting flow for all players.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.current_meeting: Optional[MeetingState] = None
|
||||
self.meeting_history: list[MeetingState] = []
|
||||
|
||||
def start_meeting(
|
||||
self,
|
||||
called_by: str,
|
||||
reason: str,
|
||||
body_location: Optional[str] = None
|
||||
) -> MeetingState:
|
||||
"""Start a new meeting."""
|
||||
self.current_meeting = MeetingState(
|
||||
called_by=called_by,
|
||||
reason=reason,
|
||||
body_location=body_location
|
||||
)
|
||||
return self.current_meeting
|
||||
|
||||
def is_meeting_active(self) -> bool:
|
||||
"""Check if a meeting is in progress."""
|
||||
return self.current_meeting is not None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 1: Interrupt Notes
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def submit_interrupt_note(self, player_id: str, note: dict):
|
||||
"""Player submits their interrupt note (what they were doing)."""
|
||||
if self.current_meeting:
|
||||
self.current_meeting.interrupt_notes[player_id] = note
|
||||
|
||||
def get_interrupt_context(self, player_id: str) -> dict:
|
||||
"""Get context for interrupt note prompt."""
|
||||
return {
|
||||
"called_by": self.current_meeting.called_by if self.current_meeting else None,
|
||||
"reason": self.current_meeting.reason if self.current_meeting else None,
|
||||
"body_location": self.current_meeting.body_location if self.current_meeting else None
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 2: Pre-Meeting Prep
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def submit_prep_thoughts(self, player_id: str, thoughts: dict):
|
||||
"""Player submits their pre-meeting preparation thoughts."""
|
||||
if self.current_meeting:
|
||||
self.current_meeting.prep_thoughts[player_id] = thoughts
|
||||
|
||||
def get_prep_context(self, player_id: str) -> dict:
|
||||
"""Get context for pre-meeting prep prompt."""
|
||||
if not self.current_meeting:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"meeting_info": {
|
||||
"called_by": self.current_meeting.called_by,
|
||||
"reason": self.current_meeting.reason,
|
||||
"body_location": self.current_meeting.body_location
|
||||
},
|
||||
"your_interrupt_note": self.current_meeting.interrupt_notes.get(player_id, {})
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 3: Discussion
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def init_meeting_scratchpad(self, player_id: str, initial: dict = None):
|
||||
"""Initialize a player's meeting scratchpad."""
|
||||
if self.current_meeting:
|
||||
self.current_meeting.meeting_scratchpads[player_id] = initial or {}
|
||||
|
||||
def update_meeting_scratchpad(self, player_id: str, updates: dict):
|
||||
"""Update a player's meeting scratchpad."""
|
||||
if self.current_meeting:
|
||||
pad = self.current_meeting.meeting_scratchpads.get(player_id, {})
|
||||
pad.update(updates)
|
||||
self.current_meeting.meeting_scratchpads[player_id] = pad
|
||||
|
||||
def get_meeting_scratchpad(self, player_id: str) -> dict:
|
||||
"""Get a player's meeting scratchpad."""
|
||||
if self.current_meeting:
|
||||
return self.current_meeting.meeting_scratchpads.get(player_id, {})
|
||||
return {}
|
||||
|
||||
def add_message(self, speaker_id: str, speaker_name: str,
|
||||
message: str, target: Optional[str] = None):
|
||||
"""Add a message to the transcript."""
|
||||
if self.current_meeting:
|
||||
self.current_meeting.transcript.append({
|
||||
"speaker_id": speaker_id,
|
||||
"speaker": speaker_name,
|
||||
"message": message,
|
||||
"target": target,
|
||||
"turn": len(self.current_meeting.transcript)
|
||||
})
|
||||
|
||||
def get_transcript(self) -> list[dict]:
|
||||
"""Get the full transcript."""
|
||||
if self.current_meeting:
|
||||
return self.current_meeting.transcript
|
||||
return []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 4: Voting
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def submit_vote(self, player_id: str, vote: str):
|
||||
"""Player submits their vote."""
|
||||
if self.current_meeting:
|
||||
self.current_meeting.votes[player_id] = vote
|
||||
|
||||
def has_voted(self, player_id: str) -> bool:
|
||||
"""Check if player has voted."""
|
||||
if self.current_meeting:
|
||||
return player_id in self.current_meeting.votes
|
||||
return False
|
||||
|
||||
def all_voted(self, living_players: list[str]) -> bool:
|
||||
"""Check if all living players have voted."""
|
||||
if not self.current_meeting:
|
||||
return False
|
||||
return all(p in self.current_meeting.votes for p in living_players)
|
||||
|
||||
def get_vote_counts(self) -> dict:
|
||||
"""Get current vote distribution (anonymous)."""
|
||||
if not self.current_meeting:
|
||||
return {}
|
||||
|
||||
counts = {}
|
||||
for vote in self.current_meeting.votes.values():
|
||||
counts[vote] = counts.get(vote, 0) + 1
|
||||
return counts
|
||||
|
||||
def tally_votes(self) -> tuple[Optional[str], dict]:
|
||||
"""
|
||||
Tally votes and determine result.
|
||||
|
||||
Returns: (ejected_player_id or None, vote_details)
|
||||
"""
|
||||
if not self.current_meeting:
|
||||
return None, {}
|
||||
|
||||
votes = self.current_meeting.votes
|
||||
counts = {}
|
||||
for vote in votes.values():
|
||||
counts[vote] = counts.get(vote, 0) + 1
|
||||
|
||||
# Find max votes (excluding skip)
|
||||
max_votes = 0
|
||||
candidates = []
|
||||
|
||||
for target, count in counts.items():
|
||||
if target == "skip":
|
||||
continue
|
||||
if count > max_votes:
|
||||
max_votes = count
|
||||
candidates = [target]
|
||||
elif count == max_votes:
|
||||
candidates.append(target)
|
||||
|
||||
skip_votes = counts.get("skip", 0)
|
||||
|
||||
# Determine result
|
||||
if not candidates or skip_votes >= max_votes:
|
||||
# Skip wins or no votes cast
|
||||
ejected = None
|
||||
elif len(candidates) > 1:
|
||||
# Tie
|
||||
ejected = None
|
||||
else:
|
||||
ejected = candidates[0]
|
||||
|
||||
return ejected, {
|
||||
"votes": dict(votes),
|
||||
"counts": counts,
|
||||
"ejected": ejected,
|
||||
"was_tie": len(candidates) > 1,
|
||||
"skip_votes": skip_votes
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 5: Post-Meeting
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def end_meeting(self, ejected: Optional[str], was_impostor: Optional[bool]) -> MeetingState:
|
||||
"""End the meeting and store result."""
|
||||
if self.current_meeting:
|
||||
self.current_meeting.ejected = ejected
|
||||
self.current_meeting.was_impostor = was_impostor
|
||||
|
||||
# Store in history
|
||||
self.meeting_history.append(self.current_meeting)
|
||||
|
||||
meeting = self.current_meeting
|
||||
self.current_meeting = None
|
||||
return meeting
|
||||
|
||||
return None
|
||||
|
||||
def get_meeting_result(self) -> dict:
|
||||
"""Get the result of the last meeting."""
|
||||
if self.meeting_history:
|
||||
m = self.meeting_history[-1]
|
||||
return {
|
||||
"ejected": m.ejected,
|
||||
"was_impostor": m.was_impostor,
|
||||
"votes": m.votes,
|
||||
"reason": m.reason
|
||||
}
|
||||
return {}
|
||||
|
||||
def get_consolidation_context(self, player_id: str) -> dict:
|
||||
"""Get context for post-meeting consolidation prompt."""
|
||||
if not self.meeting_history:
|
||||
return {}
|
||||
|
||||
m = self.meeting_history[-1]
|
||||
return {
|
||||
"meeting_result": {
|
||||
"ejected": m.ejected,
|
||||
"was_impostor": m.was_impostor,
|
||||
"votes": m.votes
|
||||
},
|
||||
"meeting_scratchpad": m.meeting_scratchpads.get(player_id, {}),
|
||||
"transcript_summary": {
|
||||
"total_messages": len(m.transcript),
|
||||
"speakers": list(set(msg["speaker"] for msg in m.transcript))
|
||||
}
|
||||
}
|
||||
|
||||
def get_meeting_count(self) -> int:
|
||||
"""Get total number of meetings held."""
|
||||
return len(self.meeting_history)
|
||||
142
src/engine/simulator.py
Normal file
142
src/engine/simulator.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""
|
||||
The Glass Box League — Discrete Event Simulator
|
||||
|
||||
The core simulation engine. Time is continuous, ticks are interrupts.
|
||||
"""
|
||||
|
||||
import heapq
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .types import Event, Player, Body, GamePhase, Position
|
||||
|
||||
|
||||
class Simulator:
|
||||
"""
|
||||
Discrete event simulator for Among Us.
|
||||
|
||||
Time flows continuously. Events are scheduled and processed in order.
|
||||
When an event fires, time "freezes" until it's resolved.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.time: float = 0.0 # Current game time in seconds
|
||||
self.phase: GamePhase = GamePhase.LOBBY
|
||||
|
||||
# Priority queue of upcoming events
|
||||
self._event_queue: list[Event] = []
|
||||
|
||||
# Game state
|
||||
self.players: dict[str, Player] = {}
|
||||
self.bodies: list[Body] = []
|
||||
|
||||
# Event handlers: event_type -> list of callbacks
|
||||
self._handlers: dict[str, list[Callable[[Event], None]]] = {}
|
||||
|
||||
# Replay recording
|
||||
self.event_log: list[dict] = []
|
||||
|
||||
# --- Event Scheduling ---
|
||||
|
||||
def schedule(self, event: Event) -> None:
|
||||
"""Schedule an event for future processing."""
|
||||
heapq.heappush(self._event_queue, event)
|
||||
|
||||
def schedule_at(self, time: float, event_type: str, data: dict = None) -> Event:
|
||||
"""Convenience: schedule an event at a specific time."""
|
||||
event = Event(time=time, event_type=event_type, data=data or {})
|
||||
self.schedule(event)
|
||||
return event
|
||||
|
||||
def schedule_in(self, delay: float, event_type: str, data: dict = None) -> Event:
|
||||
"""Convenience: schedule an event after a delay from now."""
|
||||
return self.schedule_at(self.time + delay, event_type, data)
|
||||
|
||||
# --- Event Handling ---
|
||||
|
||||
def on(self, event_type: str, handler: Callable[[Event], None]) -> None:
|
||||
"""Register a handler for an event type."""
|
||||
if event_type not in self._handlers:
|
||||
self._handlers[event_type] = []
|
||||
self._handlers[event_type].append(handler)
|
||||
|
||||
def _dispatch(self, event: Event) -> None:
|
||||
"""Dispatch an event to all registered handlers."""
|
||||
# Log for replay
|
||||
self.event_log.append({
|
||||
"t": event.time,
|
||||
"type": event.event_type,
|
||||
**event.data
|
||||
})
|
||||
|
||||
# Call handlers
|
||||
handlers = self._handlers.get(event.event_type, [])
|
||||
for handler in handlers:
|
||||
handler(event)
|
||||
|
||||
# Also dispatch to wildcard handlers
|
||||
for handler in self._handlers.get("*", []):
|
||||
handler(event)
|
||||
|
||||
# --- Simulation Loop ---
|
||||
|
||||
def step(self) -> Optional[Event]:
|
||||
"""
|
||||
Process the next event in the queue.
|
||||
|
||||
Returns the event that was processed, or None if queue is empty.
|
||||
"""
|
||||
if not self._event_queue:
|
||||
return None
|
||||
|
||||
event = heapq.heappop(self._event_queue)
|
||||
self.time = event.time
|
||||
self._dispatch(event)
|
||||
return event
|
||||
|
||||
def run_until(self, end_time: float) -> None:
|
||||
"""Process all events up to (and including) end_time."""
|
||||
while self._event_queue and self._event_queue[0].time <= end_time:
|
||||
self.step()
|
||||
self.time = end_time
|
||||
|
||||
def run_until_empty(self) -> None:
|
||||
"""Process all events until the queue is empty."""
|
||||
while self.step() is not None:
|
||||
pass
|
||||
|
||||
def peek_next_time(self) -> Optional[float]:
|
||||
"""Get the time of the next scheduled event without processing it."""
|
||||
if self._event_queue:
|
||||
return self._event_queue[0].time
|
||||
return None
|
||||
|
||||
# --- Player Management ---
|
||||
|
||||
def add_player(self, player: Player) -> None:
|
||||
"""Add a player to the simulation."""
|
||||
self.players[player.id] = player
|
||||
|
||||
def get_player(self, player_id: str) -> Optional[Player]:
|
||||
"""Get a player by ID."""
|
||||
return self.players.get(player_id)
|
||||
|
||||
def get_living_players(self) -> list[Player]:
|
||||
"""Get all living players."""
|
||||
return [p for p in self.players.values() if p.is_alive]
|
||||
|
||||
# --- State Queries ---
|
||||
|
||||
def players_at(self, room_id: str) -> list[Player]:
|
||||
"""Get all living players in a specific room."""
|
||||
return [
|
||||
p for p in self.get_living_players()
|
||||
if p.position.is_in_room() and p.position.room_id == room_id
|
||||
]
|
||||
|
||||
def bodies_at(self, room_id: str) -> list[Body]:
|
||||
"""Get all unreported bodies in a specific room."""
|
||||
return [
|
||||
b for b in self.bodies
|
||||
if not b.reported and b.position.room_id == room_id
|
||||
]
|
||||
319
src/engine/trigger_messages.py
Normal file
319
src/engine/trigger_messages.py
Normal file
@ -0,0 +1,319 @@
|
||||
"""
|
||||
The Glass Box League — Trigger Message Schema
|
||||
|
||||
JSON schemas for trigger messages that explain why a tick was triggered.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
from enum import Enum
|
||||
import json
|
||||
|
||||
|
||||
class TriggerMessageType(Enum):
|
||||
"""Types of trigger messages."""
|
||||
PLAYER_ENTERS_FOV = "PLAYER_ENTERS_FOV"
|
||||
PLAYER_EXITS_FOV = "PLAYER_EXITS_FOV"
|
||||
BODY_IN_FOV = "BODY_IN_FOV"
|
||||
VENT_WITNESSED = "VENT_WITNESSED"
|
||||
KILL_WITNESSED = "KILL_WITNESSED"
|
||||
DESTINATION_REACHED = "DESTINATION_REACHED"
|
||||
TASK_COMPLETE = "TASK_COMPLETE"
|
||||
SABOTAGE_START = "SABOTAGE_START"
|
||||
SABOTAGE_END = "SABOTAGE_END"
|
||||
LIGHTS_OUT = "LIGHTS_OUT"
|
||||
LIGHTS_RESTORED = "LIGHTS_RESTORED"
|
||||
COOLDOWN_READY = "COOLDOWN_READY"
|
||||
DEATH = "DEATH"
|
||||
DISCUSSION_START = "DISCUSSION_START"
|
||||
VOTE_START = "VOTE_START"
|
||||
GAME_START = "GAME_START"
|
||||
GAME_END = "GAME_END"
|
||||
PERIODIC = "PERIODIC"
|
||||
INTERSECTION = "INTERSECTION"
|
||||
OBJECT_IN_RANGE = "OBJECT_IN_RANGE"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriggerMessage:
|
||||
"""
|
||||
A structured message explaining why a trigger fired.
|
||||
"""
|
||||
trigger_type: TriggerMessageType
|
||||
timestamp: float
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"trigger_type": self.trigger_type.value,
|
||||
"t": self.timestamp,
|
||||
"data": self.data
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "TriggerMessage":
|
||||
return cls(
|
||||
trigger_type=TriggerMessageType(d["trigger_type"]),
|
||||
timestamp=d["t"],
|
||||
data=d.get("data", {})
|
||||
)
|
||||
|
||||
|
||||
class TriggerMessageBuilder:
|
||||
"""Factory for creating trigger messages."""
|
||||
|
||||
@staticmethod
|
||||
def player_enters_fov(
|
||||
timestamp: float,
|
||||
player_id: str,
|
||||
player_name: str,
|
||||
entered_from: str,
|
||||
current_action: str = "walking"
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.PLAYER_ENTERS_FOV,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"player_id": player_id,
|
||||
"player_name": player_name,
|
||||
"entered_from": entered_from,
|
||||
"current_action": current_action
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def player_exits_fov(
|
||||
timestamp: float,
|
||||
player_id: str,
|
||||
player_name: str,
|
||||
exited_to: str
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.PLAYER_EXITS_FOV,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"player_id": player_id,
|
||||
"player_name": player_name,
|
||||
"exited_to": exited_to
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def body_in_fov(
|
||||
timestamp: float,
|
||||
body_id: str,
|
||||
player_name: str,
|
||||
room_id: str
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.BODY_IN_FOV,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"body_id": body_id,
|
||||
"player_name": player_name,
|
||||
"room_id": room_id
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def vent_witnessed(
|
||||
timestamp: float,
|
||||
player_id: str,
|
||||
player_name: str,
|
||||
vent_location: str,
|
||||
action: str # "entered" or "exited"
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.VENT_WITNESSED,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"player_id": player_id,
|
||||
"player_name": player_name,
|
||||
"vent_location": vent_location,
|
||||
"action": action
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def kill_witnessed(
|
||||
timestamp: float,
|
||||
killer_id: str,
|
||||
killer_name: str,
|
||||
victim_id: str,
|
||||
victim_name: str,
|
||||
location: str
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.KILL_WITNESSED,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"killer_id": killer_id,
|
||||
"killer_name": killer_name,
|
||||
"victim_id": victim_id,
|
||||
"victim_name": victim_name,
|
||||
"location": location
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def destination_reached(
|
||||
timestamp: float,
|
||||
room_id: str,
|
||||
room_name: str
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.DESTINATION_REACHED,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"room_id": room_id,
|
||||
"room_name": room_name
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_complete(
|
||||
timestamp: float,
|
||||
task_id: str,
|
||||
task_name: str,
|
||||
tasks_remaining: int
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.TASK_COMPLETE,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"task_id": task_id,
|
||||
"task_name": task_name,
|
||||
"tasks_remaining": tasks_remaining
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sabotage_start(
|
||||
timestamp: float,
|
||||
system: str,
|
||||
time_limit: Optional[float] = None
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.SABOTAGE_START,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"system": system,
|
||||
"time_limit": time_limit,
|
||||
"is_critical": system in ["o2", "reactor"]
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def lights_out(timestamp: float) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.LIGHTS_OUT,
|
||||
timestamp=timestamp,
|
||||
data={"message": "Lights have been sabotaged. Vision reduced."}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def cooldown_ready(
|
||||
timestamp: float,
|
||||
cooldown_type: str # "kill", "emergency", "sabotage"
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.COOLDOWN_READY,
|
||||
timestamp=timestamp,
|
||||
data={"cooldown_type": cooldown_type}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def death(
|
||||
timestamp: float,
|
||||
killer_id: str,
|
||||
killer_name: str,
|
||||
location: str
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.DEATH,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"message": "You have been killed.",
|
||||
"killer_id": killer_id,
|
||||
"killer_name": killer_name,
|
||||
"location": location,
|
||||
"you_are_now": "ghost"
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def discussion_start(
|
||||
timestamp: float,
|
||||
called_by: str,
|
||||
reason: str, # "body_report" or "emergency"
|
||||
body_location: Optional[str] = None
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.DISCUSSION_START,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"called_by": called_by,
|
||||
"reason": reason,
|
||||
"body_location": body_location
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def game_start(
|
||||
timestamp: float,
|
||||
your_role: str,
|
||||
player_count: int,
|
||||
impostor_count: int
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.GAME_START,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"your_role": your_role,
|
||||
"player_count": player_count,
|
||||
"impostor_count": impostor_count
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def game_end(
|
||||
timestamp: float,
|
||||
winner: str, # "impostor" or "crewmate"
|
||||
reason: str
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.GAME_END,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"winner": winner,
|
||||
"reason": reason
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def periodic(
|
||||
timestamp: float,
|
||||
interval_name: str # "every_10s", "random"
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.PERIODIC,
|
||||
timestamp=timestamp,
|
||||
data={"interval": interval_name}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def object_in_range(
|
||||
timestamp: float,
|
||||
object_id: str,
|
||||
object_type: str,
|
||||
description: str
|
||||
) -> TriggerMessage:
|
||||
return TriggerMessage(
|
||||
trigger_type=TriggerMessageType.OBJECT_IN_RANGE,
|
||||
timestamp=timestamp,
|
||||
data={
|
||||
"object_id": object_id,
|
||||
"object_type": object_type,
|
||||
"description": description
|
||||
}
|
||||
)
|
||||
184
src/engine/triggers.py
Normal file
184
src/engine/triggers.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""
|
||||
The Glass Box League — Trigger System
|
||||
|
||||
Event-driven trigger registry for the simulation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional, Any
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class TriggerType(Enum):
|
||||
# Mandatory (cannot mute)
|
||||
DISCUSSION_START = auto()
|
||||
VOTE_START = auto()
|
||||
GAME_START = auto()
|
||||
GAME_END = auto()
|
||||
SABOTAGE_CRITICAL = auto()
|
||||
|
||||
# Standard (on by default)
|
||||
BODY_IN_FOV = auto()
|
||||
PLAYER_ENTERS_FOV = auto()
|
||||
PLAYER_EXITS_FOV = auto()
|
||||
VENT_ACTIVITY_NEARBY = auto()
|
||||
REACHED_DESTINATION = auto()
|
||||
TASK_COMPLETE = auto()
|
||||
SABOTAGE_START = auto()
|
||||
|
||||
# Optional (off by default)
|
||||
EVERY_N_SECONDS = auto()
|
||||
INTERSECTION = auto()
|
||||
NEAR_TASK = auto()
|
||||
PLAYER_NEAR_ME = auto()
|
||||
ROOM_ENTER = auto()
|
||||
ROOM_EXIT = auto()
|
||||
|
||||
|
||||
# Sets for quick lookup
|
||||
MANDATORY_TRIGGERS = {
|
||||
TriggerType.DISCUSSION_START,
|
||||
TriggerType.VOTE_START,
|
||||
TriggerType.GAME_START,
|
||||
TriggerType.GAME_END,
|
||||
TriggerType.SABOTAGE_CRITICAL,
|
||||
}
|
||||
|
||||
STANDARD_TRIGGERS = {
|
||||
TriggerType.BODY_IN_FOV,
|
||||
TriggerType.PLAYER_ENTERS_FOV,
|
||||
TriggerType.PLAYER_EXITS_FOV,
|
||||
TriggerType.VENT_ACTIVITY_NEARBY,
|
||||
TriggerType.REACHED_DESTINATION,
|
||||
TriggerType.TASK_COMPLETE,
|
||||
TriggerType.SABOTAGE_START,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriggerCondition:
|
||||
"""A condition for muting or subscribing to triggers."""
|
||||
trigger_type: TriggerType
|
||||
until_time: Optional[float] = None # Mute until this game time
|
||||
until_condition: Optional[str] = None # Mute until condition (e.g., "REACHED(Electrical)")
|
||||
target_id: Optional[str] = None # For player-specific triggers
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trigger:
|
||||
"""A fired trigger that will call an agent."""
|
||||
trigger_type: TriggerType
|
||||
target_agent_id: str
|
||||
time: float
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class TriggerRegistry:
|
||||
"""
|
||||
Manages trigger subscriptions and muting for all agents.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# agent_id -> set of subscribed TriggerTypes
|
||||
self._subscriptions: dict[str, set[TriggerType]] = {}
|
||||
|
||||
# agent_id -> list of mute conditions
|
||||
self._mutes: dict[str, list[TriggerCondition]] = {}
|
||||
|
||||
def register_agent(self, agent_id: str) -> None:
|
||||
"""Register an agent with default subscriptions."""
|
||||
self._subscriptions[agent_id] = set(STANDARD_TRIGGERS)
|
||||
self._mutes[agent_id] = []
|
||||
|
||||
def subscribe(self, agent_id: str, trigger_type: TriggerType) -> None:
|
||||
"""Subscribe agent to a trigger type."""
|
||||
if agent_id in self._subscriptions:
|
||||
self._subscriptions[agent_id].add(trigger_type)
|
||||
|
||||
def unsubscribe(self, agent_id: str, trigger_type: TriggerType) -> None:
|
||||
"""Unsubscribe agent from a trigger type (if not mandatory)."""
|
||||
if trigger_type in MANDATORY_TRIGGERS:
|
||||
return # Can't unsubscribe from mandatory
|
||||
if agent_id in self._subscriptions:
|
||||
self._subscriptions[agent_id].discard(trigger_type)
|
||||
|
||||
def mute(self, agent_id: str, condition: TriggerCondition) -> None:
|
||||
"""Add a mute condition for an agent."""
|
||||
if condition.trigger_type in MANDATORY_TRIGGERS:
|
||||
return # Can't mute mandatory
|
||||
if agent_id not in self._mutes:
|
||||
self._mutes[agent_id] = []
|
||||
self._mutes[agent_id].append(condition)
|
||||
|
||||
def clear_expired_mutes(self, agent_id: str, current_time: float) -> None:
|
||||
"""Remove expired mute conditions."""
|
||||
if agent_id in self._mutes:
|
||||
self._mutes[agent_id] = [
|
||||
m for m in self._mutes[agent_id]
|
||||
if m.until_time is None or m.until_time > current_time
|
||||
]
|
||||
|
||||
def is_muted(
|
||||
self,
|
||||
agent_id: str,
|
||||
trigger_type: TriggerType,
|
||||
current_time: float,
|
||||
target_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Check if a trigger is currently muted for an agent."""
|
||||
if trigger_type in MANDATORY_TRIGGERS:
|
||||
return False
|
||||
|
||||
for mute in self._mutes.get(agent_id, []):
|
||||
if mute.trigger_type != trigger_type:
|
||||
continue
|
||||
|
||||
# Check if mute is still active
|
||||
if mute.until_time is not None and mute.until_time <= current_time:
|
||||
continue
|
||||
|
||||
# Check target-specific mutes
|
||||
if mute.target_id is not None and mute.target_id != target_id:
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def should_fire(
|
||||
self,
|
||||
agent_id: str,
|
||||
trigger_type: TriggerType,
|
||||
current_time: float,
|
||||
target_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Check if a trigger should fire for an agent."""
|
||||
# Mandatory always fires
|
||||
if trigger_type in MANDATORY_TRIGGERS:
|
||||
return True
|
||||
|
||||
# Must be subscribed
|
||||
if trigger_type not in self._subscriptions.get(agent_id, set()):
|
||||
return False
|
||||
|
||||
# Must not be muted
|
||||
if self.is_muted(agent_id, trigger_type, current_time, target_id):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_agents_for_trigger(
|
||||
self,
|
||||
trigger_type: TriggerType,
|
||||
current_time: float,
|
||||
target_id: Optional[str] = None,
|
||||
exclude: Optional[set[str]] = None
|
||||
) -> list[str]:
|
||||
"""Get all agents that should receive this trigger."""
|
||||
exclude = exclude or set()
|
||||
return [
|
||||
agent_id
|
||||
for agent_id in self._subscriptions.keys()
|
||||
if agent_id not in exclude
|
||||
and self.should_fire(agent_id, trigger_type, current_time, target_id)
|
||||
]
|
||||
99
src/engine/types.py
Normal file
99
src/engine/types.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""
|
||||
The Glass Box League — Core Types
|
||||
|
||||
Fundamental data structures for the discrete event simulator.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
|
||||
class Role(Enum):
|
||||
CREWMATE = "crewmate"
|
||||
IMPOSTOR = "impostor"
|
||||
|
||||
|
||||
class GamePhase(Enum):
|
||||
LOBBY = auto()
|
||||
PLAYING = auto()
|
||||
DISCUSSION = auto()
|
||||
VOTING = auto()
|
||||
FINISHED = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
"""
|
||||
A position in the game world.
|
||||
|
||||
Can be:
|
||||
- In a room: room_id is set, edge_id is None
|
||||
- On an edge: edge_id is set, progress is 0.0-1.0
|
||||
"""
|
||||
room_id: Optional[str] = None
|
||||
edge_id: Optional[str] = None
|
||||
progress: float = 0.0 # 0.0 = start of edge, 1.0 = end
|
||||
|
||||
def is_in_room(self) -> bool:
|
||||
return self.room_id is not None and self.edge_id is None
|
||||
|
||||
def is_on_edge(self) -> bool:
|
||||
return self.edge_id is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Player:
|
||||
"""A player in the game."""
|
||||
id: str
|
||||
name: str
|
||||
color: str
|
||||
role: Role = Role.CREWMATE
|
||||
position: Position = field(default_factory=lambda: Position(room_id="cafeteria"))
|
||||
|
||||
is_alive: bool = True
|
||||
speed: float = 1.0 # meters per second
|
||||
|
||||
# Movement intent
|
||||
destination: Optional[str] = None # Target room_id
|
||||
path: list[str] = field(default_factory=list) # Sequence of edge_ids
|
||||
|
||||
# Task state
|
||||
current_task: Optional[str] = None
|
||||
task_progress: float = 0.0 # seconds completed
|
||||
tasks_assigned: list[str] = field(default_factory=list)
|
||||
tasks_completed: list[str] = field(default_factory=list)
|
||||
|
||||
# Cooldowns (seconds remaining)
|
||||
kill_cooldown: float = 0.0
|
||||
|
||||
# Trigger muting
|
||||
muted_triggers: dict[str, float] = field(default_factory=dict) # trigger_type -> until_time
|
||||
|
||||
|
||||
@dataclass
|
||||
class Body:
|
||||
"""A dead player's body."""
|
||||
id: str
|
||||
player_id: str
|
||||
player_name: str
|
||||
position: Position
|
||||
time_of_death: float
|
||||
reported: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""
|
||||
A discrete event in the simulation.
|
||||
|
||||
Events are scheduled at specific times and processed in order.
|
||||
"""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
time: float = 0.0 # Game time in seconds
|
||||
event_type: str = ""
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
def __lt__(self, other: "Event") -> bool:
|
||||
return self.time < other.time
|
||||
0
src/llm/__init__.py
Normal file
0
src/llm/__init__.py
Normal file
216
src/llm/client.py
Normal file
216
src/llm/client.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""
|
||||
The Glass Box League — OpenRouter LLM Client
|
||||
|
||||
Wrapper for interacting with LLMs via OpenRouter API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""Configuration for LLM client."""
|
||||
api_key: str
|
||||
base_url: str = "https://openrouter.ai/api/v1"
|
||||
default_model: str = "google/gemini-2.0-flash-lite-preview-02-05:free"
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 4096
|
||||
timeout: int = 60
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "LLMConfig":
|
||||
"""Load config from environment variables."""
|
||||
api_key = os.getenv("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
# Try loading from .env file
|
||||
env_path = os.path.join(os.path.dirname(__file__), "..", "..", ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path) as f:
|
||||
for line in f:
|
||||
if line.startswith("OPENROUTER_API_KEY="):
|
||||
api_key = line.split("=", 1)[1].strip().strip('"')
|
||||
break
|
||||
|
||||
if not api_key:
|
||||
raise ValueError("OPENROUTER_API_KEY not found in environment or .env file")
|
||||
|
||||
return cls(api_key=api_key)
|
||||
|
||||
|
||||
class OpenRouterClient:
|
||||
"""
|
||||
Client for OpenRouter API.
|
||||
|
||||
Handles chat completions with JSON mode support.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[LLMConfig] = None):
|
||||
self.config = config or LLMConfig.from_env()
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
json_mode: bool = True
|
||||
) -> dict:
|
||||
"""
|
||||
Send a chat completion request.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
model: Model ID (uses default if not specified)
|
||||
temperature: Sampling temperature
|
||||
max_tokens: Max response tokens
|
||||
json_mode: If True, request JSON output format
|
||||
|
||||
Returns:
|
||||
Full API response dict
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.config.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://glass-box-league.local",
|
||||
"X-Title": "Glass Box League"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model or self.config.default_model,
|
||||
"messages": messages,
|
||||
"temperature": temperature or self.config.temperature,
|
||||
"max_tokens": max_tokens or self.config.max_tokens,
|
||||
}
|
||||
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.config.base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.config.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"error": str(e), "choices": []}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
json_mode: bool = True
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Simple generation with system and user prompts.
|
||||
Falls back to user-only if model doesn't support system prompts.
|
||||
|
||||
Returns the assistant's message content, or None on error.
|
||||
"""
|
||||
# Try with system prompt first
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
|
||||
result = self.chat(messages, model, temperature, json_mode=json_mode)
|
||||
|
||||
# Check for specific errors that indicate system prompt not supported
|
||||
error = result.get("error", "")
|
||||
if "Developer instruction is not enabled" in str(error) or "400" in str(error):
|
||||
# Fall back to user-only with system prompt embedded
|
||||
combined = f"""{system_prompt}
|
||||
|
||||
---
|
||||
|
||||
{user_prompt}"""
|
||||
messages = [{"role": "user", "content": combined}]
|
||||
result = self.chat(messages, model, temperature, json_mode=json_mode)
|
||||
|
||||
if "error" in result:
|
||||
print(f"[LLM Error] {result['error']}")
|
||||
return None
|
||||
|
||||
if result.get("choices"):
|
||||
return result["choices"][0]["message"]["content"]
|
||||
|
||||
return None
|
||||
|
||||
def generate_json(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Generate and parse JSON response.
|
||||
|
||||
Tries JSON mode first, falls back to text mode if not supported.
|
||||
Returns parsed dict, or None on error.
|
||||
"""
|
||||
# Try with JSON mode first
|
||||
content = self.generate(system_prompt, user_prompt, model, temperature, json_mode=True)
|
||||
|
||||
# If JSON mode failed (400 error), try without it
|
||||
if content is None:
|
||||
content = self.generate(
|
||||
system_prompt + "\n\nIMPORTANT: You MUST respond with valid JSON only. No other text.",
|
||||
user_prompt,
|
||||
model,
|
||||
temperature,
|
||||
json_mode=False
|
||||
)
|
||||
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
# Try direct parse first
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Try to extract JSON from text (common pattern: markdown code blocks)
|
||||
import re
|
||||
|
||||
# Look for JSON in code blocks
|
||||
json_match = re.search(r'```(?:json)?\s*(\{[\s\S]*?\})\s*```', content)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Look for bare JSON object
|
||||
json_match = re.search(r'(\{[\s\S]*\})', content)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group(1))
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[JSON Parse Error] {e}")
|
||||
print(f"[Raw Content] {content[:500]}")
|
||||
return None
|
||||
|
||||
print(f"[JSON Not Found] No JSON object in response")
|
||||
print(f"[Raw Content] {content[:500]}")
|
||||
return None
|
||||
|
||||
|
||||
# Global singleton for convenience
|
||||
_client: Optional[OpenRouterClient] = None
|
||||
|
||||
def get_client() -> OpenRouterClient:
|
||||
"""Get or create the global LLM client."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = OpenRouterClient()
|
||||
return _client
|
||||
654
src/main.py
Normal file
654
src/main.py
Normal file
@ -0,0 +1,654 @@
|
||||
"""
|
||||
The Glass Box League — Main Orchestrator
|
||||
|
||||
Ties together all components and runs the simulation.
|
||||
Fully integrated with fog-of-war, prompt assembly, and meeting flow.
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from src.engine.game import GameEngine, GameConfig
|
||||
from src.engine.simulator import Simulator
|
||||
from src.engine.types import Role, GamePhase
|
||||
from src.engine.triggers import TriggerType
|
||||
from src.engine.discussion import DiscussionOrchestrator
|
||||
from src.engine.fog_of_war import FogOfWarManager
|
||||
from src.engine.available_actions import AvailableActionsGenerator
|
||||
from src.engine.trigger_messages import TriggerMessageBuilder
|
||||
from src.engine.meeting_flow import MeetingFlowManager
|
||||
from src.map.graph import GameMap
|
||||
from src.agents.agent import Agent, create_agent
|
||||
from src.agents.prompt_assembler import PromptAssembler, PromptConfig
|
||||
|
||||
|
||||
class GameOrchestrator:
|
||||
"""
|
||||
Main orchestrator for The Glass Box League.
|
||||
|
||||
Coordinates:
|
||||
- Game engine (state, actions, triggers)
|
||||
- Fog-of-war (per-player knowledge)
|
||||
- Agent invocations (LLM calls)
|
||||
- Meeting flow (interrupt, discussion, voting, consolidation)
|
||||
- Replay recording
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str = "config/game_settings.yaml",
|
||||
map_path: str = "data/maps/skeld.json"
|
||||
):
|
||||
# Load configuration
|
||||
self.config = GameConfig.load(config_path) if Path(config_path).exists() else GameConfig()
|
||||
self.game_map = GameMap.load(map_path) if Path(map_path).exists() else self._create_default_map()
|
||||
|
||||
# Initialize engine
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
# Fog-of-war
|
||||
self.fog_of_war = FogOfWarManager()
|
||||
|
||||
# Meeting flow
|
||||
self.meeting_flow = MeetingFlowManager()
|
||||
|
||||
# Agents and their prompt assemblers
|
||||
self.agents: dict[str, Agent] = {}
|
||||
self.prompt_assemblers: dict[str, PromptAssembler] = {}
|
||||
|
||||
# Available actions generator
|
||||
self.actions_gen = AvailableActionsGenerator(self.engine, self.game_map)
|
||||
|
||||
# Discussion orchestrator
|
||||
self.discussion = DiscussionOrchestrator()
|
||||
|
||||
# Replay log
|
||||
self.replay: list[dict] = []
|
||||
|
||||
# Recent history per-player (accumulated vision from skipped ticks)
|
||||
self.recent_history: dict[str, list[dict]] = {}
|
||||
|
||||
def _create_default_map(self) -> GameMap:
|
||||
"""Create a minimal map if file not found."""
|
||||
game_map = GameMap()
|
||||
from src.map.graph import Room
|
||||
game_map.add_room(Room(id="cafeteria", name="Cafeteria"))
|
||||
return game_map
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Setup
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def add_agent(
|
||||
self,
|
||||
agent_id: str,
|
||||
name: str,
|
||||
color: str,
|
||||
role: Role,
|
||||
model_id: str = "google/gemini-2.0-flash-lite-preview-02-05:free",
|
||||
persona: str = "",
|
||||
strategy_level: str = "none",
|
||||
meta_level: str = "direct"
|
||||
) -> Agent:
|
||||
"""Add an agent to the game with full integration."""
|
||||
# Add to engine
|
||||
self.engine.add_player(agent_id, name, color, role)
|
||||
|
||||
# Register with fog-of-war
|
||||
self.fog_of_war.register_player(agent_id)
|
||||
|
||||
# Get fellow impostors if impostor
|
||||
fellow_impostors = None
|
||||
if role == Role.IMPOSTOR:
|
||||
ctx = self.engine.get_impostor_context(agent_id)
|
||||
fellow_impostors = ctx.get("fellow_impostors", [])
|
||||
|
||||
# Create prompt assembler
|
||||
prompt_config = PromptConfig(
|
||||
model_name=model_id.split("/")[-1],
|
||||
persona=persona,
|
||||
strategy_level=strategy_level,
|
||||
meta_level=meta_level,
|
||||
is_impostor=(role == Role.IMPOSTOR),
|
||||
fellow_impostors=fellow_impostors
|
||||
)
|
||||
self.prompt_assemblers[agent_id] = PromptAssembler(prompt_config)
|
||||
|
||||
# Create agent
|
||||
agent = create_agent(agent_id, name, color, model_id, persona)
|
||||
self.agents[agent_id] = agent
|
||||
|
||||
# Initialize recent history
|
||||
self.recent_history[agent_id] = []
|
||||
|
||||
return agent
|
||||
|
||||
def setup_game(
|
||||
self,
|
||||
player_configs: list[dict],
|
||||
num_impostors: int = 2
|
||||
) -> None:
|
||||
"""Set up a game with the given player configurations."""
|
||||
# Assign roles randomly
|
||||
num_players = len(player_configs)
|
||||
roles = [Role.IMPOSTOR] * num_impostors + [Role.CREWMATE] * (num_players - num_impostors)
|
||||
random.shuffle(roles)
|
||||
|
||||
for i, config in enumerate(player_configs):
|
||||
agent_id = f"agent_{i}"
|
||||
self.add_agent(
|
||||
agent_id=agent_id,
|
||||
name=config.get("name", f"Player{i}"),
|
||||
color=config.get("color", "white"),
|
||||
role=roles[i],
|
||||
model_id=config.get("model_id", "google/gemini-2.0-flash-lite-preview-02-05:free"),
|
||||
persona=config.get("persona", ""),
|
||||
strategy_level=config.get("strategy_level", "none"),
|
||||
meta_level=config.get("meta_level", "direct")
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Game State Building
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_player_state(self, agent_id: str) -> dict:
|
||||
"""Build player's own state for prompt."""
|
||||
player = self.engine.simulator.get_player(agent_id)
|
||||
if not player:
|
||||
return {}
|
||||
|
||||
state = {
|
||||
"id": player.id,
|
||||
"name": player.name,
|
||||
"role": player.role.value,
|
||||
"location": player.position.room_id,
|
||||
"is_alive": player.is_alive,
|
||||
"kill_cooldown": player.kill_cooldown if player.role == Role.IMPOSTOR else None,
|
||||
"tasks_total": len(player.tasks_assigned),
|
||||
"tasks_completed": len(player.tasks_completed),
|
||||
"emergencies_remaining": self.config.emergencies_per_player - getattr(player, 'emergencies_used', 0)
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
def get_vision(self, agent_id: str) -> dict:
|
||||
"""Build current vision snapshot for agent."""
|
||||
player = self.engine.simulator.get_player(agent_id)
|
||||
if not player:
|
||||
return {}
|
||||
|
||||
room_id = player.position.room_id
|
||||
|
||||
# Visible players
|
||||
visible_players = []
|
||||
for p in self.engine.simulator.players_at(room_id):
|
||||
if p.id != agent_id:
|
||||
visible_players.append({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"color": p.color,
|
||||
"is_alive": p.is_alive,
|
||||
"action": "standing" # TODO: track current action
|
||||
})
|
||||
|
||||
# Update fog-of-war
|
||||
self.fog_of_war.update_vision(
|
||||
agent_id, visible_players, room_id, self.engine.simulator.time
|
||||
)
|
||||
|
||||
# Visible bodies
|
||||
bodies = [
|
||||
{"id": b.id, "player_name": b.player_name}
|
||||
for b in self.engine.simulator.bodies_at(room_id)
|
||||
]
|
||||
|
||||
# Room exits
|
||||
exits = [n[1] for n in self.game_map.get_neighbors(room_id)]
|
||||
|
||||
return {
|
||||
"room": room_id,
|
||||
"players_visible": visible_players,
|
||||
"bodies_visible": bodies,
|
||||
"exits": exits
|
||||
}
|
||||
|
||||
def get_full_context(self, agent_id: str, trigger_data: dict = None) -> tuple[str, str]:
|
||||
"""Build complete system and user prompts for an agent."""
|
||||
assembler = self.prompt_assemblers.get(agent_id)
|
||||
if not assembler:
|
||||
return "", ""
|
||||
|
||||
player = self.engine.simulator.get_player(agent_id)
|
||||
if not player:
|
||||
return "", ""
|
||||
|
||||
# Load learned memory
|
||||
agent = self.agents[agent_id]
|
||||
all_pads = agent.scratchpads.get_all()
|
||||
learned = {"content": all_pads.get("learned", "")} if all_pads.get("learned") else {}
|
||||
|
||||
# System prompt
|
||||
system_prompt = assembler.build_system_prompt(
|
||||
phase="action",
|
||||
game_settings=self.config.to_dict(),
|
||||
map_name="The Skeld",
|
||||
learned=learned
|
||||
)
|
||||
|
||||
# User prompt
|
||||
player_state = self.get_player_state(agent_id)
|
||||
recent_history = self.recent_history.get(agent_id, [])
|
||||
vision = self.get_vision(agent_id)
|
||||
available_actions = self.actions_gen.to_prompt_context(agent_id)
|
||||
|
||||
user_prompt = assembler.build_action_prompt(
|
||||
player_state=player_state,
|
||||
recent_history=recent_history,
|
||||
vision=vision,
|
||||
available_actions=available_actions,
|
||||
trigger=trigger_data
|
||||
)
|
||||
|
||||
return system_prompt, user_prompt
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Action Processing
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def process_triggers(self) -> None:
|
||||
"""Process all pending triggers by invoking agents."""
|
||||
triggers = self.engine.get_pending_triggers()
|
||||
|
||||
for trigger in triggers:
|
||||
agent_id = trigger.target_agent_id
|
||||
agent = self.agents.get(agent_id)
|
||||
if not agent:
|
||||
continue
|
||||
|
||||
# Build trigger message
|
||||
trigger_msg = {
|
||||
"type": trigger.trigger_type.name,
|
||||
"t": trigger.time,
|
||||
"data": trigger.data
|
||||
}
|
||||
|
||||
# Get full context
|
||||
system_prompt, user_prompt = self.get_full_context(agent_id, trigger_msg)
|
||||
|
||||
# Call LLM
|
||||
response = agent.client.generate_json(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
model=agent.config.model_id,
|
||||
temperature=agent.config.temperature
|
||||
)
|
||||
|
||||
if response is None:
|
||||
response = {"action": {"type": "WAIT"}}
|
||||
|
||||
# Log for replay
|
||||
self.replay.append({
|
||||
"t": trigger.time,
|
||||
"agent": agent_id,
|
||||
"trigger": trigger.trigger_type.name,
|
||||
"internal_thought": response.get("internal_thought", ""),
|
||||
"action": response.get("action", {})
|
||||
})
|
||||
|
||||
# Apply scratchpad updates
|
||||
if "scratchpad_updates" in response:
|
||||
for pad_name, content in response["scratchpad_updates"].items():
|
||||
if content:
|
||||
agent.scratchpads.update(pad_name, {"content": content})
|
||||
|
||||
# Handle trigger config changes
|
||||
if "trigger_config" in response and response["trigger_config"]:
|
||||
self._apply_trigger_config(agent_id, response["trigger_config"])
|
||||
|
||||
# Queue the action
|
||||
action = response.get("action", {})
|
||||
action_type = action.get("type", "WAIT")
|
||||
|
||||
if action_type != "WAIT":
|
||||
self.engine.queue_action(agent_id, action_type, action)
|
||||
|
||||
# Clear recent history (agent has processed it)
|
||||
self.recent_history[agent_id] = []
|
||||
|
||||
def _apply_trigger_config(self, agent_id: str, config: dict) -> None:
|
||||
"""Apply trigger configuration changes for an agent."""
|
||||
if "mute" in config:
|
||||
for mute_spec in config["mute"]:
|
||||
trigger_type = TriggerType[mute_spec["type"]]
|
||||
until = mute_spec.get("until")
|
||||
# Apply mute via trigger registry
|
||||
from src.engine.triggers import TriggerCondition
|
||||
condition = TriggerCondition(
|
||||
trigger_type=trigger_type,
|
||||
until_trigger=TriggerType[until] if until else None
|
||||
)
|
||||
self.engine.triggers.mute(agent_id, condition)
|
||||
|
||||
def accumulate_vision(self, agent_id: str) -> None:
|
||||
"""Accumulate vision for an agent between ticks (for skipped triggers)."""
|
||||
vision = self.get_vision(agent_id)
|
||||
self.recent_history[agent_id].append({
|
||||
"t": self.engine.simulator.time,
|
||||
"vision": vision
|
||||
})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Meeting Flow
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def handle_meeting_interrupt(self, called_by: str, reason: str, body_location: str = None) -> None:
|
||||
"""Handle a meeting being called (interrupt phase)."""
|
||||
# Start meeting
|
||||
self.meeting_flow.start_meeting(called_by, reason, body_location)
|
||||
|
||||
# Get interrupt notes from all living agents
|
||||
for agent_id, agent in self.agents.items():
|
||||
player = self.engine.simulator.get_player(agent_id)
|
||||
if not player or not player.is_alive:
|
||||
continue
|
||||
|
||||
assembler = self.prompt_assemblers[agent_id]
|
||||
player_state = self.get_player_state(agent_id)
|
||||
|
||||
# Build interrupt prompt
|
||||
prompt = assembler.build_meeting_interrupt_prompt(
|
||||
player_state=player_state,
|
||||
interrupted_action=self._get_current_action(agent_id)
|
||||
)
|
||||
|
||||
# Get response
|
||||
response = agent.client.generate_json(
|
||||
assembler.build_system_prompt("action", self.config.to_dict(), "The Skeld"),
|
||||
prompt,
|
||||
model=agent.config.model_id
|
||||
)
|
||||
|
||||
if response:
|
||||
self.meeting_flow.submit_interrupt_note(agent_id, response)
|
||||
|
||||
def _get_current_action(self, agent_id: str) -> dict:
|
||||
"""Get what the agent was doing when interrupted."""
|
||||
# Look back in replay for last action
|
||||
for entry in reversed(self.replay):
|
||||
if entry.get("agent") == agent_id and "action" in entry:
|
||||
return entry["action"]
|
||||
return {}
|
||||
|
||||
def run_discussion_phase(self) -> str:
|
||||
"""
|
||||
Run the full discussion phase with bidding and voting.
|
||||
Returns the ejected player_id or None.
|
||||
"""
|
||||
living_agents = [
|
||||
aid for aid, agent in self.agents.items()
|
||||
if self.engine.simulator.get_player(aid) and
|
||||
self.engine.simulator.get_player(aid).is_alive
|
||||
]
|
||||
|
||||
# Discussion loop
|
||||
max_rounds = 50
|
||||
for round_num in range(max_rounds):
|
||||
# Get bids from all agents
|
||||
bids = {}
|
||||
for agent_id in living_agents:
|
||||
if self.meeting_flow.has_voted(agent_id):
|
||||
continue # Already voted, reduced participation
|
||||
|
||||
agent = self.agents[agent_id]
|
||||
assembler = self.prompt_assemblers[agent_id]
|
||||
|
||||
prompt = assembler.build_discussion_prompt(
|
||||
player_state=self.get_player_state(agent_id),
|
||||
transcript=self.meeting_flow.get_transcript(),
|
||||
meeting_scratchpad=self.meeting_flow.get_meeting_scratchpad(agent_id)
|
||||
)
|
||||
|
||||
response = agent.client.generate_json(
|
||||
assembler.build_system_prompt("discussion", self.config.to_dict(), "The Skeld"),
|
||||
prompt,
|
||||
model=agent.config.model_id
|
||||
)
|
||||
|
||||
if response is None:
|
||||
response = {"desire_to_speak": 0, "message": "", "vote_action": None}
|
||||
|
||||
bids[agent_id] = {
|
||||
"name": agent.name,
|
||||
"desire_to_speak": response.get("desire_to_speak", 0),
|
||||
"target": response.get("target")
|
||||
}
|
||||
|
||||
# Handle vote if submitted
|
||||
if response.get("vote_action"):
|
||||
self.meeting_flow.submit_vote(agent_id, response["vote_action"])
|
||||
|
||||
# Update meeting scratchpad
|
||||
if response.get("scratchpad_updates", {}).get("meeting_scratch"):
|
||||
self.meeting_flow.update_meeting_scratchpad(
|
||||
agent_id,
|
||||
{"notes": response["scratchpad_updates"]["meeting_scratch"]}
|
||||
)
|
||||
|
||||
# If speaking, add message
|
||||
if response.get("desire_to_speak", 0) > 0 and response.get("message"):
|
||||
self.meeting_flow.add_message(
|
||||
agent_id,
|
||||
agent.name,
|
||||
response["message"],
|
||||
response.get("target")
|
||||
)
|
||||
|
||||
# Log
|
||||
self.replay.append({
|
||||
"t": self.engine.simulator.time,
|
||||
"phase": "discussion",
|
||||
"speaker": agent.name,
|
||||
"message": response["message"]
|
||||
})
|
||||
|
||||
# Check if all have voted
|
||||
if self.meeting_flow.all_voted(living_agents):
|
||||
break
|
||||
|
||||
# Check for pressure nudge (long discussion)
|
||||
if round_num > 30:
|
||||
# Add system nudge to transcript
|
||||
self.meeting_flow.add_message(
|
||||
"system", "System",
|
||||
"Please wrap up the discussion and vote.",
|
||||
None
|
||||
)
|
||||
|
||||
# Tally votes
|
||||
ejected, vote_details = self.meeting_flow.tally_votes()
|
||||
|
||||
# Determine if impostor
|
||||
was_impostor = None
|
||||
if ejected:
|
||||
ejected_player = self.engine.simulator.get_player(ejected)
|
||||
if ejected_player:
|
||||
was_impostor = ejected_player.role == Role.IMPOSTOR
|
||||
|
||||
# End meeting
|
||||
self.meeting_flow.end_meeting(ejected, was_impostor)
|
||||
|
||||
# Announce death to all
|
||||
if ejected:
|
||||
self.fog_of_war.announce_death(ejected, via="ejection")
|
||||
if self.config.confirm_ejects:
|
||||
self.fog_of_war.announce_public_info({
|
||||
"event": "ejection",
|
||||
"player": ejected,
|
||||
"was_impostor": was_impostor
|
||||
})
|
||||
|
||||
return ejected
|
||||
|
||||
def run_consolidation_phase(self) -> None:
|
||||
"""Run post-meeting consolidation for all agents."""
|
||||
meeting_result = self.meeting_flow.get_meeting_result()
|
||||
|
||||
for agent_id, agent in self.agents.items():
|
||||
player = self.engine.simulator.get_player(agent_id)
|
||||
if not player:
|
||||
continue
|
||||
|
||||
assembler = self.prompt_assemblers[agent_id]
|
||||
context = self.meeting_flow.get_consolidation_context(agent_id)
|
||||
|
||||
prompt = assembler.build_consolidation_prompt(
|
||||
player_state=self.get_player_state(agent_id),
|
||||
meeting_result=meeting_result,
|
||||
meeting_scratchpad=context.get("meeting_scratchpad", {})
|
||||
)
|
||||
|
||||
response = agent.client.generate_json(
|
||||
assembler.build_system_prompt("action", self.config.to_dict(), "The Skeld"),
|
||||
prompt,
|
||||
model=agent.config.model_id
|
||||
)
|
||||
|
||||
# Apply scratchpad updates
|
||||
if response:
|
||||
for pad_name in ["events", "suspicions", "plan"]:
|
||||
if response.get(pad_name):
|
||||
agent.scratchpads.update(pad_name, {"content": response[pad_name]})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Main Game Loop
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_game(self, max_time: float = 300.0) -> str:
|
||||
"""
|
||||
Run the full game loop.
|
||||
Returns the winner: "crewmate" or "impostor"
|
||||
"""
|
||||
self.engine.simulator.phase = GamePhase.PLAYING
|
||||
|
||||
# Initial trigger for all
|
||||
for agent_id in self.agents:
|
||||
player = self.engine.simulator.get_player(agent_id)
|
||||
trigger = TriggerMessageBuilder.game_start(
|
||||
timestamp=0,
|
||||
your_role=player.role.value if player else "unknown",
|
||||
player_count=len(self.agents),
|
||||
impostor_count=self.config.num_impostors
|
||||
)
|
||||
self.engine._fire_trigger(
|
||||
TriggerType.GAME_START,
|
||||
agent_id,
|
||||
trigger.to_dict()
|
||||
)
|
||||
|
||||
while self.engine.simulator.time < max_time:
|
||||
# Process pending triggers
|
||||
self.process_triggers()
|
||||
|
||||
# Resolve queued actions
|
||||
self.engine.resolve_actions()
|
||||
|
||||
# Step simulator
|
||||
self.engine.simulator.step()
|
||||
|
||||
# Check win condition
|
||||
winner = self.engine.check_win_condition()
|
||||
if winner:
|
||||
self._handle_game_end(winner)
|
||||
return winner
|
||||
|
||||
# Check discussion phase
|
||||
if self.engine.simulator.phase == GamePhase.DISCUSSION:
|
||||
# Run full meeting flow
|
||||
ejected = self.run_discussion_phase()
|
||||
|
||||
# Handle ejection
|
||||
if ejected:
|
||||
player = self.engine.simulator.get_player(ejected)
|
||||
if player:
|
||||
player.is_alive = False
|
||||
|
||||
# Consolidation
|
||||
self.run_consolidation_phase()
|
||||
|
||||
# Check win after ejection
|
||||
winner = self.engine.check_win_condition()
|
||||
if winner:
|
||||
self._handle_game_end(winner)
|
||||
return winner
|
||||
|
||||
# Resume playing
|
||||
self.engine.simulator.phase = GamePhase.PLAYING
|
||||
|
||||
return "timeout"
|
||||
|
||||
def _handle_game_end(self, winner: str) -> None:
|
||||
"""Handle end of game, trigger reflection for all agents."""
|
||||
# Fire game end trigger
|
||||
for agent_id in self.agents:
|
||||
trigger = TriggerMessageBuilder.game_end(
|
||||
timestamp=self.engine.simulator.time,
|
||||
winner=winner,
|
||||
reason="Game concluded"
|
||||
)
|
||||
self.engine._fire_trigger(
|
||||
TriggerType.GAME_END,
|
||||
agent_id,
|
||||
trigger.to_dict()
|
||||
)
|
||||
|
||||
# Run reflection for all agents
|
||||
game_summary = {
|
||||
"winner": winner,
|
||||
"duration": self.engine.simulator.time,
|
||||
"meetings": self.meeting_flow.get_meeting_count(),
|
||||
"transcript": [m.to_dict() if hasattr(m, 'to_dict') else m
|
||||
for m in self.meeting_flow.meeting_history]
|
||||
}
|
||||
|
||||
for agent_id, agent in self.agents.items():
|
||||
agent.reflect(game_summary)
|
||||
|
||||
def save_replay(self, path: str = "match_replay.json") -> None:
|
||||
"""Save the game replay to a file."""
|
||||
with open(path, "w") as f:
|
||||
json.dump({
|
||||
"config": self.config.to_dict(),
|
||||
"events": self.replay,
|
||||
"discussion_transcripts": [
|
||||
m.transcript if hasattr(m, 'transcript') else []
|
||||
for m in self.meeting_flow.meeting_history
|
||||
]
|
||||
}, f, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
"""Example game setup."""
|
||||
orchestrator = GameOrchestrator()
|
||||
|
||||
# Example players with strategy levels
|
||||
players = [
|
||||
{"name": "Red", "color": "red", "persona": "Aggressive leader", "strategy_level": "intermediate"},
|
||||
{"name": "Blue", "color": "blue", "persona": "Quiet observer", "strategy_level": "basic"},
|
||||
{"name": "Green", "color": "green", "persona": "Nervous follower", "strategy_level": "none"},
|
||||
{"name": "Yellow", "color": "yellow", "persona": "Task-focused", "strategy_level": "basic"},
|
||||
{"name": "Purple", "color": "purple", "persona": "Analytical", "strategy_level": "advanced"},
|
||||
]
|
||||
|
||||
orchestrator.setup_game(players, num_impostors=1)
|
||||
|
||||
print("Game setup complete.")
|
||||
print(f"Agents: {list(orchestrator.agents.keys())}")
|
||||
print(f"Map loaded: {orchestrator.game_map is not None}")
|
||||
print(f"Fog-of-war initialized: {len(orchestrator.fog_of_war._knowledge)} players tracked")
|
||||
print("Ready to run!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
src/map/__init__.py
Normal file
0
src/map/__init__.py
Normal file
208
src/map/graph.py
Normal file
208
src/map/graph.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""
|
||||
The Glass Box League — Map Model
|
||||
|
||||
Continuous node graph with distances for position tracking.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""A task that can be performed in a room."""
|
||||
id: str
|
||||
name: str
|
||||
duration: float # seconds to complete
|
||||
is_visual: bool = False # Can others see you doing it?
|
||||
|
||||
|
||||
@dataclass
|
||||
class Vent:
|
||||
"""A vent connection point."""
|
||||
id: str
|
||||
connects_to: list[str] # Other vent IDs
|
||||
|
||||
|
||||
@dataclass
|
||||
class Room:
|
||||
"""A room (node) in the map."""
|
||||
id: str
|
||||
name: str
|
||||
tasks: list[Task] = field(default_factory=list)
|
||||
vent: Optional[Vent] = None
|
||||
|
||||
# Position within the room (for spawn points, task locations)
|
||||
# Simplified: just a single point for now
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Edge:
|
||||
"""A corridor (edge) connecting two rooms."""
|
||||
id: str
|
||||
room_a: str # Room ID
|
||||
room_b: str # Room ID
|
||||
distance: float # meters
|
||||
|
||||
# Path geometry (list of waypoints for LoS calculation)
|
||||
# Each waypoint is (x, y)
|
||||
waypoints: list[tuple[float, float]] = field(default_factory=list)
|
||||
|
||||
def other_room(self, room_id: str) -> str:
|
||||
"""Get the room on the other end of this edge."""
|
||||
return self.room_b if room_id == self.room_a else self.room_a
|
||||
|
||||
|
||||
class GameMap:
|
||||
"""
|
||||
The game map: a graph of rooms connected by edges.
|
||||
|
||||
Supports pathfinding, distance calculation, and visibility queries.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.rooms: dict[str, Room] = {}
|
||||
self.edges: dict[str, Edge] = {}
|
||||
|
||||
# Adjacency list: room_id -> list of (edge_id, neighbor_room_id)
|
||||
self._adjacency: dict[str, list[tuple[str, str]]] = {}
|
||||
|
||||
def add_room(self, room: Room) -> None:
|
||||
"""Add a room to the map."""
|
||||
self.rooms[room.id] = room
|
||||
if room.id not in self._adjacency:
|
||||
self._adjacency[room.id] = []
|
||||
|
||||
def add_edge(self, edge: Edge) -> None:
|
||||
"""Add an edge connecting two rooms."""
|
||||
self.edges[edge.id] = edge
|
||||
|
||||
# Update adjacency list
|
||||
if edge.room_a not in self._adjacency:
|
||||
self._adjacency[edge.room_a] = []
|
||||
if edge.room_b not in self._adjacency:
|
||||
self._adjacency[edge.room_b] = []
|
||||
|
||||
self._adjacency[edge.room_a].append((edge.id, edge.room_b))
|
||||
self._adjacency[edge.room_b].append((edge.id, edge.room_a))
|
||||
|
||||
def get_room(self, room_id: str) -> Optional[Room]:
|
||||
"""Get a room by ID."""
|
||||
return self.rooms.get(room_id)
|
||||
|
||||
def get_edge(self, edge_id: str) -> Optional[Edge]:
|
||||
"""Get an edge by ID."""
|
||||
return self.edges.get(edge_id)
|
||||
|
||||
def get_neighbors(self, room_id: str) -> list[tuple[str, str]]:
|
||||
"""Get adjacent rooms: list of (edge_id, neighbor_room_id)."""
|
||||
return self._adjacency.get(room_id, [])
|
||||
|
||||
def find_edge(self, room_a: str, room_b: str) -> Optional[Edge]:
|
||||
"""Find the edge connecting two rooms, if any."""
|
||||
for edge_id, neighbor in self._adjacency.get(room_a, []):
|
||||
if neighbor == room_b:
|
||||
return self.edges[edge_id]
|
||||
return None
|
||||
|
||||
# --- Pathfinding ---
|
||||
|
||||
def find_path(self, from_room: str, to_room: str) -> Optional[list[str]]:
|
||||
"""
|
||||
Find shortest path between two rooms.
|
||||
|
||||
Returns list of edge IDs to traverse, or None if no path.
|
||||
Uses Dijkstra's algorithm.
|
||||
"""
|
||||
import heapq
|
||||
|
||||
if from_room == to_room:
|
||||
return []
|
||||
|
||||
# Priority queue: (distance, room_id, path_so_far)
|
||||
queue = [(0.0, from_room, [])]
|
||||
visited = set()
|
||||
|
||||
while queue:
|
||||
dist, current, path = heapq.heappop(queue)
|
||||
|
||||
if current in visited:
|
||||
continue
|
||||
visited.add(current)
|
||||
|
||||
if current == to_room:
|
||||
return path
|
||||
|
||||
for edge_id, neighbor in self.get_neighbors(current):
|
||||
if neighbor not in visited:
|
||||
edge = self.edges[edge_id]
|
||||
new_path = path + [edge_id]
|
||||
heapq.heappush(queue, (dist + edge.distance, neighbor, new_path))
|
||||
|
||||
return None
|
||||
|
||||
def path_distance(self, edge_ids: list[str]) -> float:
|
||||
"""Calculate total distance of a path."""
|
||||
return sum(self.edges[eid].distance for eid in edge_ids)
|
||||
|
||||
# --- Serialization ---
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize map to dictionary."""
|
||||
return {
|
||||
"rooms": [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"x": r.x,
|
||||
"y": r.y,
|
||||
"tasks": [{"id": t.id, "name": t.name, "duration": t.duration} for t in r.tasks],
|
||||
"vent": {"id": r.vent.id, "connects_to": r.vent.connects_to} if r.vent else None
|
||||
}
|
||||
for r in self.rooms.values()
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": e.id,
|
||||
"room_a": e.room_a,
|
||||
"room_b": e.room_b,
|
||||
"distance": e.distance,
|
||||
"waypoints": e.waypoints
|
||||
}
|
||||
for e in self.edges.values()
|
||||
]
|
||||
}
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
"""Save map to JSON file."""
|
||||
with open(path, "w") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str) -> "GameMap":
|
||||
"""Load map from JSON file."""
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
game_map = cls()
|
||||
|
||||
for r in data["rooms"]:
|
||||
tasks = [Task(id=t["id"], name=t["name"], duration=t["duration"]) for t in r.get("tasks", [])]
|
||||
vent = Vent(id=r["vent"]["id"], connects_to=r["vent"]["connects_to"]) if r.get("vent") else None
|
||||
room = Room(id=r["id"], name=r["name"], x=r.get("x", 0), y=r.get("y", 0), tasks=tasks, vent=vent)
|
||||
game_map.add_room(room)
|
||||
|
||||
for e in data["edges"]:
|
||||
edge = Edge(
|
||||
id=e["id"],
|
||||
room_a=e["room_a"],
|
||||
room_b=e["room_b"],
|
||||
distance=e["distance"],
|
||||
waypoints=[tuple(w) for w in e.get("waypoints", [])]
|
||||
)
|
||||
game_map.add_edge(edge)
|
||||
|
||||
return game_map
|
||||
173
tests/test_discussion.py
Normal file
173
tests/test_discussion.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""
|
||||
Tests for the discussion orchestrator.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from src.engine.discussion import DiscussionOrchestrator, DiscussionConfig, DiscussionMessage
|
||||
|
||||
|
||||
class TestDiscussionMessage(unittest.TestCase):
|
||||
"""Tests for DiscussionMessage dataclass."""
|
||||
|
||||
def test_message_creation(self):
|
||||
msg = DiscussionMessage(speaker="Red", message="I saw Blue vent!")
|
||||
self.assertEqual(msg.speaker, "Red")
|
||||
self.assertEqual(msg.message, "I saw Blue vent!")
|
||||
self.assertIsNone(msg.target)
|
||||
|
||||
def test_message_with_target(self):
|
||||
msg = DiscussionMessage(speaker="Red", message="What were you doing?", target="Blue")
|
||||
self.assertEqual(msg.target, "Blue")
|
||||
|
||||
|
||||
class TestDiscussionConfig(unittest.TestCase):
|
||||
"""Tests for DiscussionConfig."""
|
||||
|
||||
def test_default_config(self):
|
||||
config = DiscussionConfig()
|
||||
self.assertEqual(config.max_rounds, 20)
|
||||
self.assertEqual(config.convergence_threshold, 2)
|
||||
|
||||
|
||||
class TestDiscussionOrchestrator(unittest.TestCase):
|
||||
"""Tests for the discussion orchestrator."""
|
||||
|
||||
def setUp(self):
|
||||
self.orchestrator = DiscussionOrchestrator()
|
||||
|
||||
def test_initial_state(self):
|
||||
self.assertEqual(len(self.orchestrator.transcript), 0)
|
||||
self.assertEqual(self.orchestrator.round_num, 0)
|
||||
|
||||
def test_reset(self):
|
||||
self.orchestrator.add_message("p1", "Red", "test")
|
||||
self.orchestrator.round_num = 5
|
||||
|
||||
self.orchestrator.reset()
|
||||
|
||||
self.assertEqual(len(self.orchestrator.transcript), 0)
|
||||
self.assertEqual(self.orchestrator.round_num, 0)
|
||||
|
||||
def test_add_message(self):
|
||||
self.orchestrator.add_message("p1", "Red", "Hello everyone")
|
||||
|
||||
self.assertEqual(len(self.orchestrator.transcript), 1)
|
||||
self.assertEqual(self.orchestrator.transcript[0].speaker, "Red")
|
||||
self.assertEqual(self.orchestrator.transcript[0].message, "Hello everyone")
|
||||
|
||||
def test_get_transcript(self):
|
||||
self.orchestrator.add_message("p1", "Red", "Message 1")
|
||||
self.orchestrator.add_message("p2", "Blue", "Message 2", target="Red")
|
||||
|
||||
transcript = self.orchestrator.get_transcript()
|
||||
|
||||
self.assertEqual(len(transcript), 2)
|
||||
self.assertEqual(transcript[0]["speaker"], "Red")
|
||||
self.assertEqual(transcript[1]["target"], "Red")
|
||||
|
||||
def test_priority_base_desire(self):
|
||||
priority = self.orchestrator.calculate_priority("p1", "Red", desire_to_speak=5)
|
||||
# Should be desire + random(1-6)
|
||||
self.assertGreaterEqual(priority, 6) # 5 + 1
|
||||
self.assertLessEqual(priority, 11) # 5 + 6
|
||||
|
||||
def test_priority_mention_boost(self):
|
||||
self.orchestrator.add_message("p2", "Blue", "I think Red is suspicious")
|
||||
|
||||
priority = self.orchestrator.calculate_priority("p1", "Red", desire_to_speak=5)
|
||||
# Should include mention boost
|
||||
self.assertGreaterEqual(priority, 9) # 5 + 3 boost + 1 random
|
||||
|
||||
def test_priority_target_boost(self):
|
||||
self.orchestrator.add_message("p2", "Blue", "Where were you?", target="Red")
|
||||
|
||||
priority = self.orchestrator.calculate_priority("p1", "Red", desire_to_speak=5)
|
||||
# Should include target boost
|
||||
self.assertGreaterEqual(priority, 8) # 5 + 2 boost + 1 random
|
||||
|
||||
def test_priority_speaking_cooldown(self):
|
||||
# Test that speaking cooldown reduces priority on average
|
||||
# Run multiple times due to random factor
|
||||
self.orchestrator.round_num = 5
|
||||
|
||||
# Player who just spoke (should have lower priority on average)
|
||||
self.orchestrator._last_spoke["p1"] = 4
|
||||
priorities_recent = [
|
||||
self.orchestrator.calculate_priority("p1", "Red", desire_to_speak=5)
|
||||
for _ in range(20)
|
||||
]
|
||||
|
||||
# Player who spoke long ago (should have higher priority on average)
|
||||
self.orchestrator._last_spoke["p1"] = 0
|
||||
priorities_old = [
|
||||
self.orchestrator.calculate_priority("p1", "Red", desire_to_speak=5)
|
||||
for _ in range(20)
|
||||
]
|
||||
|
||||
# Average of old should be higher than recent
|
||||
avg_recent = sum(priorities_recent) / len(priorities_recent)
|
||||
avg_old = sum(priorities_old) / len(priorities_old)
|
||||
self.assertLess(avg_recent, avg_old)
|
||||
|
||||
def test_select_speaker_none_below_threshold(self):
|
||||
bids = {
|
||||
"p1": {"name": "Red", "desire_to_speak": 0},
|
||||
"p2": {"name": "Blue", "desire_to_speak": 0},
|
||||
}
|
||||
|
||||
# With desire=0 and random 1-6 added, max priority is 6
|
||||
# Threshold is 2, so some may still speak
|
||||
# To properly test, we'd need all desires at 0 and check behavior
|
||||
# Actually the threshold comparison uses raw priorities not desires
|
||||
# Let's just verify it returns a valid result or None
|
||||
speaker = self.orchestrator.select_speaker(bids)
|
||||
# Either None or one of the players is valid
|
||||
self.assertTrue(speaker is None or speaker in ["p1", "p2"])
|
||||
|
||||
def test_select_speaker_picks_one(self):
|
||||
bids = {
|
||||
"p1": {"name": "Red", "desire_to_speak": 8},
|
||||
"p2": {"name": "Blue", "desire_to_speak": 7},
|
||||
}
|
||||
|
||||
speaker = self.orchestrator.select_speaker(bids)
|
||||
self.assertIn(speaker, ["p1", "p2"])
|
||||
|
||||
def test_advance_round_increments(self):
|
||||
initial = self.orchestrator.round_num
|
||||
self.orchestrator.advance_round(all_desires_low=False)
|
||||
self.assertEqual(self.orchestrator.round_num, initial + 1)
|
||||
|
||||
def test_advance_round_ends_at_max(self):
|
||||
self.orchestrator.round_num = 19 # Just before max
|
||||
self.orchestrator.config.max_rounds = 20
|
||||
|
||||
should_continue = self.orchestrator.advance_round(all_desires_low=False)
|
||||
self.assertFalse(should_continue)
|
||||
|
||||
def test_advance_round_convergence(self):
|
||||
self.orchestrator.config.convergence_rounds = 2
|
||||
|
||||
# First low round
|
||||
self.orchestrator.advance_round(all_desires_low=True)
|
||||
self.assertTrue(True) # Should continue
|
||||
|
||||
# Second low round - should end
|
||||
should_continue = self.orchestrator.advance_round(all_desires_low=True)
|
||||
self.assertFalse(should_continue)
|
||||
|
||||
def test_convergence_resets_on_activity(self):
|
||||
self.orchestrator._consecutive_low_rounds = 1
|
||||
|
||||
self.orchestrator.advance_round(all_desires_low=False)
|
||||
|
||||
self.assertEqual(self.orchestrator._consecutive_low_rounds, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
105
tests/test_fog_of_war.py
Normal file
105
tests/test_fog_of_war.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""
|
||||
Tests for the fog-of-war system.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from src.engine.fog_of_war import FogOfWarManager, PlayerKnowledge, PlayerSighting
|
||||
|
||||
|
||||
class TestPlayerKnowledge(unittest.TestCase):
|
||||
"""Tests for PlayerKnowledge."""
|
||||
|
||||
def setUp(self):
|
||||
self.pk = PlayerKnowledge(player_id="red")
|
||||
|
||||
def test_see_player(self):
|
||||
self.pk.see_player("blue", "Blue", "cafeteria", 10.0, "walking")
|
||||
|
||||
self.assertIn("blue", self.pk.last_seen)
|
||||
self.assertEqual(self.pk.last_seen["blue"].room_id, "cafeteria")
|
||||
|
||||
def test_witness_event(self):
|
||||
self.pk.witness_event("VENT_WITNESSED", 15.0, {"player": "green"})
|
||||
|
||||
self.assertEqual(len(self.pk.witnessed_events), 1)
|
||||
self.assertEqual(self.pk.witnessed_events[0].event_type, "VENT_WITNESSED")
|
||||
|
||||
def test_learn_death(self):
|
||||
self.pk.learn_death("blue", via="body")
|
||||
|
||||
self.assertIn("blue", self.pk.known_dead)
|
||||
|
||||
def test_find_body(self):
|
||||
self.pk.find_body("body_blue", "Blue", "electrical", 20.0)
|
||||
|
||||
self.assertIn("body_blue", self.pk.bodies_found)
|
||||
self.assertIn("blue", self.pk.known_dead)
|
||||
|
||||
def test_to_dict_and_from_dict(self):
|
||||
self.pk.see_player("blue", "Blue", "cafeteria", 10.0)
|
||||
self.pk.learn_death("green")
|
||||
|
||||
data = self.pk.to_dict()
|
||||
restored = PlayerKnowledge.from_dict(data)
|
||||
|
||||
self.assertEqual(restored.player_id, "red")
|
||||
self.assertIn("blue", restored.last_seen)
|
||||
self.assertIn("green", restored.known_dead)
|
||||
|
||||
|
||||
class TestFogOfWarManager(unittest.TestCase):
|
||||
"""Tests for FogOfWarManager."""
|
||||
|
||||
def setUp(self):
|
||||
self.fow = FogOfWarManager()
|
||||
self.fow.register_player("red")
|
||||
self.fow.register_player("blue")
|
||||
|
||||
def test_register_player(self):
|
||||
self.assertIsNotNone(self.fow.get_knowledge("red"))
|
||||
self.assertIsNotNone(self.fow.get_knowledge("blue"))
|
||||
|
||||
def test_update_vision(self):
|
||||
visible = [{"id": "blue", "name": "Blue", "action": "standing"}]
|
||||
self.fow.update_vision("red", visible, "cafeteria", 10.0)
|
||||
|
||||
pk = self.fow.get_knowledge("red")
|
||||
self.assertIn("blue", pk.last_seen)
|
||||
|
||||
def test_witness_vent(self):
|
||||
self.fow.witness_vent("red", "blue", "Blue", "electrical", "entered", 15.0)
|
||||
|
||||
pk = self.fow.get_knowledge("red")
|
||||
self.assertEqual(len(pk.witnessed_events), 1)
|
||||
self.assertEqual(pk.witnessed_events[0].event_type, "VENT_WITNESSED")
|
||||
|
||||
def test_witness_kill(self):
|
||||
self.fow.witness_kill("red", "blue", "Blue", "green", "Green", "admin", 20.0)
|
||||
|
||||
pk = self.fow.get_knowledge("red")
|
||||
self.assertIn("green", pk.known_dead)
|
||||
|
||||
def test_announce_death(self):
|
||||
self.fow.announce_death("green", via="meeting")
|
||||
|
||||
pk_red = self.fow.get_knowledge("red")
|
||||
pk_blue = self.fow.get_knowledge("blue")
|
||||
|
||||
self.assertIn("green", pk_red.known_dead)
|
||||
self.assertIn("green", pk_blue.known_dead)
|
||||
|
||||
def test_reset_for_new_game(self):
|
||||
self.fow.get_knowledge("red").learn_death("blue")
|
||||
self.fow.reset_for_new_game()
|
||||
|
||||
pk = self.fow.get_knowledge("red")
|
||||
self.assertEqual(len(pk.known_dead), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
483
tests/test_game.py
Normal file
483
tests/test_game.py
Normal file
@ -0,0 +1,483 @@
|
||||
"""
|
||||
Tests for the game engine.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from src.engine.game import GameEngine, GameConfig
|
||||
from src.engine.types import Player, Position, Role, GamePhase, Body
|
||||
from src.map.graph import GameMap, Room, Edge, Task, Vent
|
||||
|
||||
|
||||
def create_simple_map():
|
||||
"""Create a simple test map."""
|
||||
game_map = GameMap()
|
||||
|
||||
# Cafeteria with task
|
||||
game_map.add_room(Room(
|
||||
id="cafeteria",
|
||||
name="Cafeteria",
|
||||
tasks=[Task(id="wires_cafe", name="Fix Wiring", duration=3.0)]
|
||||
))
|
||||
|
||||
# Electrical with vent
|
||||
elec_vent = Vent(id="vent_elec", connects_to=["vent_security"])
|
||||
game_map.add_room(Room(
|
||||
id="electrical",
|
||||
name="Electrical",
|
||||
vent=elec_vent,
|
||||
tasks=[Task(id="wires_elec", name="Fix Wiring", duration=3.0)]
|
||||
))
|
||||
|
||||
# Security with vent
|
||||
sec_vent = Vent(id="vent_security", connects_to=["vent_elec"])
|
||||
game_map.add_room(Room(
|
||||
id="security",
|
||||
name="Security",
|
||||
vent=sec_vent
|
||||
))
|
||||
|
||||
# Admin
|
||||
game_map.add_room(Room(id="admin", name="Admin"))
|
||||
|
||||
# Connect rooms
|
||||
game_map.add_edge(Edge(id="cafe_elec", room_a="cafeteria", room_b="electrical", distance=5.0))
|
||||
game_map.add_edge(Edge(id="cafe_admin", room_a="cafeteria", room_b="admin", distance=3.0))
|
||||
game_map.add_edge(Edge(id="elec_sec", room_a="electrical", room_b="security", distance=4.0))
|
||||
|
||||
return game_map
|
||||
|
||||
|
||||
class TestGameConfig(unittest.TestCase):
|
||||
"""Tests for GameConfig."""
|
||||
|
||||
def test_default_config(self):
|
||||
config = GameConfig()
|
||||
self.assertEqual(config.num_impostors, 2)
|
||||
self.assertEqual(config.kill_cooldown, 25.0)
|
||||
self.assertEqual(config.emergencies_per_player, 1)
|
||||
|
||||
def test_config_to_dict(self):
|
||||
config = GameConfig()
|
||||
data = config.to_dict()
|
||||
self.assertIn("num_impostors", data)
|
||||
self.assertIn("kill_cooldown", data)
|
||||
|
||||
|
||||
class TestGameEngineSetup(unittest.TestCase):
|
||||
"""Tests for game engine initialization and player management."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
def test_engine_initialization(self):
|
||||
self.assertIsNotNone(self.engine.simulator)
|
||||
self.assertIsNotNone(self.engine.triggers)
|
||||
self.assertEqual(len(self.engine.impostor_ids), 0)
|
||||
|
||||
def test_add_crewmate(self):
|
||||
player = self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
||||
|
||||
self.assertEqual(player.role, Role.CREWMATE)
|
||||
self.assertEqual(player.position.room_id, "cafeteria")
|
||||
self.assertIn("p1", self.engine.simulator.players)
|
||||
self.assertNotIn("p1", self.engine.impostor_ids)
|
||||
|
||||
def test_add_impostor(self):
|
||||
player = self.engine.add_player("p1", "Red", "red", Role.IMPOSTOR)
|
||||
|
||||
self.assertEqual(player.role, Role.IMPOSTOR)
|
||||
self.assertIn("p1", self.engine.impostor_ids)
|
||||
self.assertEqual(player.kill_cooldown, self.config.kill_cooldown)
|
||||
|
||||
def test_custom_player_speed(self):
|
||||
player = self.engine.add_player("p1", "Red", "red", speed=3.0)
|
||||
self.assertEqual(player.speed, 3.0)
|
||||
|
||||
def test_impostor_context(self):
|
||||
self.engine.add_player("p1", "Red", "red", Role.IMPOSTOR)
|
||||
self.engine.add_player("p2", "Blue", "blue", Role.IMPOSTOR)
|
||||
self.engine.add_player("p3", "Green", "green", Role.CREWMATE)
|
||||
|
||||
context = self.engine.get_impostor_context("p1")
|
||||
|
||||
self.assertIn("fellow_impostors", context)
|
||||
self.assertEqual(len(context["fellow_impostors"]), 1)
|
||||
self.assertEqual(context["fellow_impostors"][0]["id"], "p2")
|
||||
|
||||
def test_impostor_context_for_crewmate(self):
|
||||
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
||||
context = self.engine.get_impostor_context("p1")
|
||||
self.assertEqual(context, {})
|
||||
|
||||
|
||||
class TestActionQueue(unittest.TestCase):
|
||||
"""Tests for action queueing and resolution."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
||||
self.engine.add_player("p2", "Blue", "blue", Role.IMPOSTOR)
|
||||
|
||||
def test_queue_action(self):
|
||||
pos = self.engine.queue_action("p1", "MOVE", {"destination": "electrical"})
|
||||
self.assertEqual(pos, 0)
|
||||
self.assertEqual(len(self.engine._action_queue), 1)
|
||||
|
||||
def test_action_priority(self):
|
||||
# Queue in wrong priority order
|
||||
self.engine.queue_action("p1", "MOVE", {"destination": "electrical"})
|
||||
self.engine.queue_action("p2", "KILL", {"target_id": "p1"})
|
||||
self.engine.queue_action("p2", "SABOTAGE", {"system": "lights"})
|
||||
|
||||
# Resolve - should process in priority order
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
# SABOTAGE should be first, then KILL, then MOVE
|
||||
self.assertEqual(results[0]["action"], "SABOTAGE")
|
||||
self.assertEqual(results[1]["action"], "KILL")
|
||||
self.assertEqual(results[2]["action"], "MOVE")
|
||||
|
||||
def test_queue_clears_after_resolve(self):
|
||||
self.engine.queue_action("p1", "MOVE", {"destination": "admin"})
|
||||
self.engine.resolve_actions()
|
||||
|
||||
self.assertEqual(len(self.engine._action_queue), 0)
|
||||
|
||||
|
||||
class TestMovement(unittest.TestCase):
|
||||
"""Tests for player movement."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
||||
|
||||
def test_move_to_adjacent_room(self):
|
||||
self.engine.queue_action("p1", "MOVE", {"destination": "electrical"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertTrue(results[0]["success"])
|
||||
|
||||
def test_move_same_room(self):
|
||||
self.engine.queue_action("p1", "MOVE", {"destination": "cafeteria"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertTrue(results[0]["success"])
|
||||
|
||||
def test_move_no_path(self):
|
||||
# Add isolated room
|
||||
self.game_map.add_room(Room(id="isolated", name="Isolated"))
|
||||
|
||||
self.engine.queue_action("p1", "MOVE", {"destination": "isolated"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_dead_player_cannot_move(self):
|
||||
player = self.engine.simulator.get_player("p1")
|
||||
player.is_alive = False
|
||||
|
||||
self.engine.queue_action("p1", "MOVE", {"destination": "electrical"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
|
||||
class TestKill(unittest.TestCase):
|
||||
"""Tests for kill mechanics."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
||||
self.engine.add_player("crew", "Blue", "blue", Role.CREWMATE)
|
||||
|
||||
# Reset kill cooldown for tests
|
||||
imp = self.engine.simulator.get_player("imp")
|
||||
imp.kill_cooldown = 0
|
||||
|
||||
def test_successful_kill(self):
|
||||
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertTrue(results[0]["success"])
|
||||
|
||||
def test_crewmate_cannot_kill(self):
|
||||
self.engine.queue_action("crew", "KILL", {"target_id": "imp"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_cannot_kill_different_room(self):
|
||||
crew = self.engine.simulator.get_player("crew")
|
||||
crew.position = Position(room_id="electrical")
|
||||
|
||||
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_kill_cooldown_blocks(self):
|
||||
imp = self.engine.simulator.get_player("imp")
|
||||
imp.kill_cooldown = 10.0
|
||||
|
||||
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_kill_creates_body(self):
|
||||
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
|
||||
self.engine.resolve_actions()
|
||||
|
||||
# Process kill event
|
||||
self.engine.simulator.run_until_empty()
|
||||
|
||||
self.assertEqual(len(self.engine.simulator.bodies), 1)
|
||||
self.assertFalse(self.engine.simulator.get_player("crew").is_alive)
|
||||
|
||||
|
||||
class TestVenting(unittest.TestCase):
|
||||
"""Tests for vent mechanics."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
||||
self.engine.add_player("crew", "Blue", "blue", Role.CREWMATE)
|
||||
|
||||
# Place impostor in electrical (has vent)
|
||||
imp = self.engine.simulator.get_player("imp")
|
||||
imp.position = Position(room_id="electrical")
|
||||
|
||||
def test_impostor_can_vent(self):
|
||||
self.engine.queue_action("imp", "VENT", {"destination": "security"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertTrue(results[0]["success"])
|
||||
|
||||
def test_crewmate_cannot_vent(self):
|
||||
crew = self.engine.simulator.get_player("crew")
|
||||
crew.position = Position(room_id="electrical")
|
||||
|
||||
self.engine.queue_action("crew", "VENT", {"destination": "security"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_cannot_vent_unconnected(self):
|
||||
# Cafeteria has no vent
|
||||
imp = self.engine.simulator.get_player("imp")
|
||||
imp.position = Position(room_id="cafeteria")
|
||||
|
||||
self.engine.queue_action("imp", "VENT", {"destination": "security"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
|
||||
class TestTasks(unittest.TestCase):
|
||||
"""Tests for task mechanics."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
player = self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
||||
player.tasks_assigned = ["wires_cafe", "wires_elec"]
|
||||
|
||||
def test_start_task_in_current_room(self):
|
||||
self.engine.queue_action("p1", "TASK", {"task_id": "wires_cafe"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertTrue(results[0]["success"])
|
||||
|
||||
def test_cannot_do_task_in_wrong_room(self):
|
||||
# wires_elec is in electrical, player is in cafeteria
|
||||
self.engine.queue_action("p1", "TASK", {"task_id": "wires_elec"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_cannot_do_unassigned_task(self):
|
||||
self.engine.queue_action("p1", "TASK", {"task_id": "nonexistent"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
|
||||
class TestReporting(unittest.TestCase):
|
||||
"""Tests for body reporting."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
||||
|
||||
# Create a body in cafeteria
|
||||
body = Body(
|
||||
id="body1",
|
||||
player_id="dead",
|
||||
player_name="Blue",
|
||||
position=Position(room_id="cafeteria"),
|
||||
time_of_death=0.0
|
||||
)
|
||||
self.engine.simulator.bodies.append(body)
|
||||
|
||||
def test_report_body_in_room(self):
|
||||
self.engine.queue_action("p1", "REPORT", {"body_id": "body1"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertTrue(results[0]["success"])
|
||||
|
||||
def test_cannot_report_body_in_different_room(self):
|
||||
player = self.engine.simulator.get_player("p1")
|
||||
player.position = Position(room_id="electrical")
|
||||
|
||||
self.engine.queue_action("p1", "REPORT", {"body_id": "body1"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_cannot_report_already_reported(self):
|
||||
self.engine.simulator.bodies[0].reported = True
|
||||
|
||||
self.engine.queue_action("p1", "REPORT", {"body_id": "body1"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
|
||||
class TestEmergency(unittest.TestCase):
|
||||
"""Tests for emergency button."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
||||
|
||||
def test_call_emergency_in_cafeteria(self):
|
||||
self.engine.queue_action("p1", "EMERGENCY", {})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertTrue(results[0]["success"])
|
||||
|
||||
def test_cannot_call_emergency_outside_cafeteria(self):
|
||||
player = self.engine.simulator.get_player("p1")
|
||||
player.position = Position(room_id="electrical")
|
||||
|
||||
self.engine.queue_action("p1", "EMERGENCY", {})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_emergency_limit_per_player(self):
|
||||
# Use up emergency
|
||||
self.engine.queue_action("p1", "EMERGENCY", {})
|
||||
self.engine.resolve_actions()
|
||||
|
||||
# Try again
|
||||
self.engine.queue_action("p1", "EMERGENCY", {})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
|
||||
class TestSabotage(unittest.TestCase):
|
||||
"""Tests for sabotage mechanics."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
||||
|
||||
def test_impostor_can_sabotage(self):
|
||||
self.engine.queue_action("imp", "SABOTAGE", {"system": "lights"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertTrue(results[0]["success"])
|
||||
|
||||
def test_cannot_double_sabotage(self):
|
||||
self.engine.active_sabotage = "lights"
|
||||
|
||||
self.engine.queue_action("imp", "SABOTAGE", {"system": "o2"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
def test_invalid_sabotage_system(self):
|
||||
self.engine.queue_action("imp", "SABOTAGE", {"system": "invalid"})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
self.assertFalse(results[0]["success"])
|
||||
|
||||
|
||||
class TestWinConditions(unittest.TestCase):
|
||||
"""Tests for win condition checking."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = GameConfig()
|
||||
self.game_map = create_simple_map()
|
||||
self.engine = GameEngine(self.config, self.game_map)
|
||||
|
||||
def test_no_win_yet(self):
|
||||
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
||||
self.engine.add_player("c1", "Blue", "blue", Role.CREWMATE)
|
||||
self.engine.add_player("c2", "Green", "green", Role.CREWMATE)
|
||||
|
||||
self.assertIsNone(self.engine.check_win_condition())
|
||||
|
||||
def test_impostor_wins_by_parity(self):
|
||||
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
||||
self.engine.add_player("crew", "Blue", "blue", Role.CREWMATE)
|
||||
|
||||
self.assertEqual(self.engine.check_win_condition(), "impostor")
|
||||
|
||||
def test_crewmate_wins_all_impostors_dead(self):
|
||||
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
||||
self.engine.add_player("crew", "Blue", "blue", Role.CREWMATE)
|
||||
|
||||
imp = self.engine.simulator.get_player("imp")
|
||||
imp.is_alive = False
|
||||
|
||||
self.assertEqual(self.engine.check_win_condition(), "crewmate")
|
||||
|
||||
def test_crewmate_wins_all_tasks(self):
|
||||
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
||||
self.engine.add_player("c1", "Blue", "blue", Role.CREWMATE)
|
||||
self.engine.add_player("c2", "Green", "green", Role.CREWMATE)
|
||||
|
||||
c1 = self.engine.simulator.get_player("c1")
|
||||
c2 = self.engine.simulator.get_player("c2")
|
||||
|
||||
c1.tasks_assigned = ["t1"]
|
||||
c1.tasks_completed = ["t1"]
|
||||
c2.tasks_assigned = ["t2"]
|
||||
c2.tasks_completed = ["t2"]
|
||||
|
||||
self.assertEqual(self.engine.check_win_condition(), "crewmate")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
293
tests/test_llm_integration.py
Normal file
293
tests/test_llm_integration.py
Normal file
@ -0,0 +1,293 @@
|
||||
"""
|
||||
The Glass Box League — LLM Integration Test
|
||||
|
||||
Tests the full LLM integration with OpenRouter using free models.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from src.llm.client import OpenRouterClient, get_client
|
||||
from src.agents.agent import Agent, AgentConfig
|
||||
from src.agents.prompt_assembler import PromptAssembler, PromptConfig
|
||||
from src.main import GameOrchestrator
|
||||
from src.engine.types import Role
|
||||
|
||||
|
||||
def test_basic_llm_call():
|
||||
"""Test basic LLM API call."""
|
||||
print("\n=== Test 1: Basic LLM Call ===")
|
||||
|
||||
client = get_client()
|
||||
|
||||
response = client.generate(
|
||||
system_prompt="You are a helpful assistant. Respond with valid JSON only.",
|
||||
user_prompt='Say hello in JSON format: {"greeting": "..."}',
|
||||
model="google/gemma-3-4b-it:free"
|
||||
)
|
||||
|
||||
print(f"Raw response: {response[:200] if response else 'None'}...")
|
||||
|
||||
if response:
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
print(f"✓ Parsed JSON: {parsed}")
|
||||
return True
|
||||
except:
|
||||
print(f"✗ Failed to parse JSON")
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def test_action_prompt():
|
||||
"""Test action phase prompt generation and LLM response."""
|
||||
print("\n=== Test 2: Action Phase Prompt ===")
|
||||
|
||||
# Build prompt
|
||||
config = PromptConfig(
|
||||
model_name="Gemini-Flash",
|
||||
persona="You are a cautious crewmate who trusts no one.",
|
||||
strategy_level="basic",
|
||||
meta_level="direct",
|
||||
is_impostor=False
|
||||
)
|
||||
assembler = PromptAssembler(config)
|
||||
|
||||
game_settings = {"num_impostors": 1, "kill_cooldown": 25}
|
||||
|
||||
system_prompt = assembler.build_system_prompt(
|
||||
phase="action",
|
||||
game_settings=game_settings,
|
||||
map_name="The Skeld",
|
||||
learned={}
|
||||
)
|
||||
|
||||
player_state = {
|
||||
"id": "red",
|
||||
"name": "Red",
|
||||
"role": "CREWMATE",
|
||||
"location": "cafeteria",
|
||||
"tasks_total": 3,
|
||||
"tasks_completed": 0
|
||||
}
|
||||
|
||||
vision = {
|
||||
"room": "cafeteria",
|
||||
"players_visible": [
|
||||
{"id": "blue", "name": "Blue", "color": "blue"}
|
||||
],
|
||||
"bodies_visible": [],
|
||||
"exits": ["weapons", "admin", "medbay"]
|
||||
}
|
||||
|
||||
available_actions = {
|
||||
"can_move_to": ["weapons", "admin", "medbay"],
|
||||
"can_interact": ["emergency_button"]
|
||||
}
|
||||
|
||||
user_prompt = assembler.build_action_prompt(
|
||||
player_state=player_state,
|
||||
recent_history=[],
|
||||
vision=vision,
|
||||
available_actions=available_actions,
|
||||
trigger={"type": "GAME_START", "t": 0}
|
||||
)
|
||||
|
||||
print(f"System prompt: {len(system_prompt)} chars")
|
||||
print(f"User prompt: {len(user_prompt)} chars")
|
||||
|
||||
# Call LLM
|
||||
client = get_client()
|
||||
response = client.generate_json(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="google/gemma-3-4b-it:free"
|
||||
)
|
||||
|
||||
if response:
|
||||
print(f"✓ LLM response: {json.dumps(response, indent=2)[:500]}...")
|
||||
|
||||
# Check response structure
|
||||
has_action = "action" in response
|
||||
has_thought = "internal_thought" in response
|
||||
|
||||
print(f" Has action: {has_action}")
|
||||
print(f" Has internal_thought: {has_thought}")
|
||||
|
||||
return has_action
|
||||
else:
|
||||
print("✗ No response from LLM")
|
||||
return False
|
||||
|
||||
|
||||
def test_discussion_prompt():
|
||||
"""Test discussion phase prompt and LLM response."""
|
||||
print("\n=== Test 3: Discussion Phase Prompt ===")
|
||||
|
||||
config = PromptConfig(
|
||||
model_name="Gemini-Flash",
|
||||
persona="You are suspicious of everyone.",
|
||||
is_impostor=False
|
||||
)
|
||||
assembler = PromptAssembler(config)
|
||||
|
||||
game_settings = {"num_impostors": 1}
|
||||
|
||||
system_prompt = assembler.build_system_prompt(
|
||||
phase="discussion",
|
||||
game_settings=game_settings,
|
||||
map_name="The Skeld"
|
||||
)
|
||||
|
||||
player_state = {
|
||||
"id": "red",
|
||||
"name": "Red",
|
||||
"role": "CREWMATE",
|
||||
"location": "cafeteria"
|
||||
}
|
||||
|
||||
transcript = [
|
||||
{"speaker": "Blue", "message": "I found the body in electrical!"},
|
||||
{"speaker": "Green", "message": "Where was everyone?"}
|
||||
]
|
||||
|
||||
user_prompt = assembler.build_discussion_prompt(
|
||||
player_state=player_state,
|
||||
transcript=transcript,
|
||||
meeting_scratchpad={}
|
||||
)
|
||||
|
||||
print(f"System prompt: {len(system_prompt)} chars")
|
||||
print(f"User prompt: {len(user_prompt)} chars")
|
||||
|
||||
client = get_client()
|
||||
response = client.generate_json(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="google/gemma-3-4b-it:free"
|
||||
)
|
||||
|
||||
if response:
|
||||
print(f"✓ LLM response: {json.dumps(response, indent=2)[:500]}...")
|
||||
|
||||
has_desire = "desire_to_speak" in response
|
||||
has_message = "message" in response
|
||||
|
||||
print(f" Has desire_to_speak: {has_desire}")
|
||||
print(f" Has message: {has_message}")
|
||||
|
||||
return has_desire and has_message
|
||||
else:
|
||||
print("✗ No response from LLM")
|
||||
return False
|
||||
|
||||
|
||||
def test_impostor_action():
|
||||
"""Test impostor action with kill available."""
|
||||
print("\n=== Test 4: Impostor Action ===")
|
||||
|
||||
config = PromptConfig(
|
||||
model_name="Gemini-Flash",
|
||||
persona="You are a ruthless impostor.",
|
||||
is_impostor=True,
|
||||
fellow_impostors=["Purple"],
|
||||
strategy_level="intermediate"
|
||||
)
|
||||
assembler = PromptAssembler(config)
|
||||
|
||||
system_prompt = assembler.build_system_prompt(
|
||||
phase="action",
|
||||
game_settings={"num_impostors": 2},
|
||||
map_name="The Skeld"
|
||||
)
|
||||
|
||||
player_state = {
|
||||
"id": "red",
|
||||
"name": "Red",
|
||||
"role": "IMPOSTOR",
|
||||
"location": "electrical",
|
||||
"kill_cooldown": 0
|
||||
}
|
||||
|
||||
vision = {
|
||||
"room": "electrical",
|
||||
"players_visible": [
|
||||
{"id": "blue", "name": "Blue", "color": "blue", "action": "doing_task"}
|
||||
],
|
||||
"bodies_visible": [],
|
||||
"exits": ["security"]
|
||||
}
|
||||
|
||||
available_actions = {
|
||||
"can_move_to": ["security"],
|
||||
"can_interact": ["vent_elec"],
|
||||
"can_kill": ["blue"],
|
||||
"can_sabotage": ["lights", "o2", "reactor"]
|
||||
}
|
||||
|
||||
user_prompt = assembler.build_action_prompt(
|
||||
player_state=player_state,
|
||||
recent_history=[],
|
||||
vision=vision,
|
||||
available_actions=available_actions,
|
||||
trigger={"type": "PERIODIC", "t": 30.0}
|
||||
)
|
||||
|
||||
client = get_client()
|
||||
response = client.generate_json(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="google/gemma-3-4b-it:free"
|
||||
)
|
||||
|
||||
if response:
|
||||
print(f"✓ LLM response: {json.dumps(response, indent=2)[:600]}...")
|
||||
|
||||
action = response.get("action", {})
|
||||
action_type = action.get("type")
|
||||
|
||||
print(f" Action type: {action_type}")
|
||||
print(f" Thought: {response.get('internal_thought', '')[:100]}...")
|
||||
|
||||
return True
|
||||
else:
|
||||
print("✗ No response from LLM")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all LLM integration tests."""
|
||||
print("=" * 60)
|
||||
print("THE GLASS BOX LEAGUE — LLM INTEGRATION TESTS")
|
||||
print("Using free Gemini model via OpenRouter")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
# Run tests
|
||||
results.append(("Basic LLM Call", test_basic_llm_call()))
|
||||
results.append(("Action Phase", test_action_prompt()))
|
||||
results.append(("Discussion Phase", test_discussion_prompt()))
|
||||
results.append(("Impostor Action", test_impostor_action()))
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("RESULTS")
|
||||
print("=" * 60)
|
||||
|
||||
passed = 0
|
||||
for name, result in results:
|
||||
status = "✓ PASS" if result else "✗ FAIL"
|
||||
print(f" {status}: {name}")
|
||||
if result:
|
||||
passed += 1
|
||||
|
||||
print(f"\nTotal: {passed}/{len(results)} tests passed")
|
||||
|
||||
return passed == len(results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
219
tests/test_map.py
Normal file
219
tests/test_map.py
Normal file
@ -0,0 +1,219 @@
|
||||
"""
|
||||
Tests for the map/graph system.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import json
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from src.map.graph import GameMap, Room, Edge, Task, Vent
|
||||
|
||||
|
||||
class TestRoom(unittest.TestCase):
|
||||
"""Tests for Room dataclass."""
|
||||
|
||||
def test_room_creation(self):
|
||||
room = Room(id="test", name="Test Room")
|
||||
self.assertEqual(room.id, "test")
|
||||
self.assertEqual(room.name, "Test Room")
|
||||
self.assertEqual(room.tasks, [])
|
||||
self.assertIsNone(room.vent)
|
||||
|
||||
def test_room_with_tasks(self):
|
||||
task = Task(id="task1", name="Do Thing", duration=5.0)
|
||||
room = Room(id="test", name="Test Room", tasks=[task])
|
||||
self.assertEqual(len(room.tasks), 1)
|
||||
self.assertEqual(room.tasks[0].duration, 5.0)
|
||||
|
||||
def test_room_with_vent(self):
|
||||
vent = Vent(id="vent1", connects_to=["vent2", "vent3"])
|
||||
room = Room(id="test", name="Test Room", vent=vent)
|
||||
self.assertIsNotNone(room.vent)
|
||||
self.assertEqual(len(room.vent.connects_to), 2)
|
||||
|
||||
|
||||
class TestEdge(unittest.TestCase):
|
||||
"""Tests for Edge dataclass."""
|
||||
|
||||
def test_edge_creation(self):
|
||||
edge = Edge(id="e1", room_a="a", room_b="b", distance=5.0)
|
||||
self.assertEqual(edge.id, "e1")
|
||||
self.assertEqual(edge.distance, 5.0)
|
||||
|
||||
def test_edge_other_room(self):
|
||||
edge = Edge(id="e1", room_a="a", room_b="b", distance=5.0)
|
||||
self.assertEqual(edge.other_room("a"), "b")
|
||||
self.assertEqual(edge.other_room("b"), "a")
|
||||
|
||||
|
||||
class TestGameMap(unittest.TestCase):
|
||||
"""Tests for GameMap class."""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a simple test map."""
|
||||
self.game_map = GameMap()
|
||||
|
||||
# Create rooms: A -- B -- C
|
||||
# |
|
||||
# D
|
||||
self.game_map.add_room(Room(id="a", name="Room A"))
|
||||
self.game_map.add_room(Room(id="b", name="Room B"))
|
||||
self.game_map.add_room(Room(id="c", name="Room C"))
|
||||
self.game_map.add_room(Room(id="d", name="Room D"))
|
||||
|
||||
self.game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=3.0))
|
||||
self.game_map.add_edge(Edge(id="bc", room_a="b", room_b="c", distance=4.0))
|
||||
self.game_map.add_edge(Edge(id="bd", room_a="b", room_b="d", distance=2.0))
|
||||
|
||||
def test_add_room(self):
|
||||
self.assertEqual(len(self.game_map.rooms), 4)
|
||||
self.assertIn("a", self.game_map.rooms)
|
||||
|
||||
def test_add_edge(self):
|
||||
self.assertEqual(len(self.game_map.edges), 3)
|
||||
self.assertIn("ab", self.game_map.edges)
|
||||
|
||||
def test_get_room(self):
|
||||
room = self.game_map.get_room("a")
|
||||
self.assertIsNotNone(room)
|
||||
self.assertEqual(room.name, "Room A")
|
||||
|
||||
self.assertIsNone(self.game_map.get_room("nonexistent"))
|
||||
|
||||
def test_get_edge(self):
|
||||
edge = self.game_map.get_edge("ab")
|
||||
self.assertIsNotNone(edge)
|
||||
self.assertEqual(edge.distance, 3.0)
|
||||
|
||||
def test_get_neighbors(self):
|
||||
neighbors = self.game_map.get_neighbors("b")
|
||||
self.assertEqual(len(neighbors), 3) # a, c, d
|
||||
neighbor_rooms = [n[1] for n in neighbors]
|
||||
self.assertIn("a", neighbor_rooms)
|
||||
self.assertIn("c", neighbor_rooms)
|
||||
self.assertIn("d", neighbor_rooms)
|
||||
|
||||
def test_find_edge(self):
|
||||
edge = self.game_map.find_edge("a", "b")
|
||||
self.assertIsNotNone(edge)
|
||||
self.assertEqual(edge.id, "ab")
|
||||
|
||||
# Reverse direction
|
||||
edge = self.game_map.find_edge("b", "a")
|
||||
self.assertIsNotNone(edge)
|
||||
|
||||
# Non-adjacent
|
||||
self.assertIsNone(self.game_map.find_edge("a", "c"))
|
||||
|
||||
def test_find_path_adjacent(self):
|
||||
path = self.game_map.find_path("a", "b")
|
||||
self.assertEqual(path, ["ab"])
|
||||
|
||||
def test_find_path_multi_hop(self):
|
||||
path = self.game_map.find_path("a", "c")
|
||||
self.assertEqual(path, ["ab", "bc"])
|
||||
|
||||
def test_find_path_same_room(self):
|
||||
path = self.game_map.find_path("a", "a")
|
||||
self.assertEqual(path, [])
|
||||
|
||||
def test_find_path_no_path(self):
|
||||
# Add isolated room
|
||||
self.game_map.add_room(Room(id="isolated", name="Isolated"))
|
||||
path = self.game_map.find_path("a", "isolated")
|
||||
self.assertIsNone(path)
|
||||
|
||||
def test_path_distance(self):
|
||||
path = self.game_map.find_path("a", "c")
|
||||
distance = self.game_map.path_distance(path)
|
||||
self.assertEqual(distance, 7.0) # 3 + 4
|
||||
|
||||
def test_shortest_path(self):
|
||||
# Add direct edge from a to d (should be longer)
|
||||
self.game_map.add_edge(Edge(id="ad", room_a="a", room_b="d", distance=10.0))
|
||||
|
||||
# Shortest path should still go through b
|
||||
path = self.game_map.find_path("a", "d")
|
||||
distance = self.game_map.path_distance(path)
|
||||
self.assertEqual(distance, 5.0) # 3 + 2 via b
|
||||
|
||||
|
||||
class TestMapSerialization(unittest.TestCase):
|
||||
"""Tests for map serialization."""
|
||||
|
||||
def test_to_dict(self):
|
||||
game_map = GameMap()
|
||||
game_map.add_room(Room(id="a", name="A"))
|
||||
game_map.add_room(Room(id="b", name="B"))
|
||||
game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=5.0))
|
||||
|
||||
data = game_map.to_dict()
|
||||
self.assertEqual(len(data["rooms"]), 2)
|
||||
self.assertEqual(len(data["edges"]), 1)
|
||||
|
||||
def test_save_and_load(self):
|
||||
game_map = GameMap()
|
||||
task = Task(id="t1", name="Task", duration=3.0)
|
||||
vent = Vent(id="v1", connects_to=["v2"])
|
||||
game_map.add_room(Room(id="a", name="A", tasks=[task], vent=vent))
|
||||
game_map.add_room(Room(id="b", name="B"))
|
||||
game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=5.0))
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||
game_map.save(f.name)
|
||||
|
||||
loaded = GameMap.load(f.name)
|
||||
self.assertEqual(len(loaded.rooms), 2)
|
||||
self.assertEqual(len(loaded.edges), 1)
|
||||
self.assertEqual(loaded.rooms["a"].tasks[0].duration, 3.0)
|
||||
self.assertIsNotNone(loaded.rooms["a"].vent)
|
||||
|
||||
os.unlink(f.name)
|
||||
|
||||
|
||||
class TestSkeldMap(unittest.TestCase):
|
||||
"""Tests for the actual Skeld map."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
map_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"data", "maps", "skeld.json"
|
||||
)
|
||||
cls.skeld = GameMap.load(map_path)
|
||||
|
||||
def test_skeld_has_all_rooms(self):
|
||||
expected_rooms = [
|
||||
"cafeteria", "weapons", "navigation", "o2", "admin",
|
||||
"storage", "communications", "shields", "electrical",
|
||||
"lower_engine", "security", "reactor", "upper_engine", "medbay"
|
||||
]
|
||||
for room_id in expected_rooms:
|
||||
self.assertIn(room_id, self.skeld.rooms, f"Missing room: {room_id}")
|
||||
|
||||
def test_skeld_connectivity(self):
|
||||
# Every room should be reachable from cafeteria
|
||||
for room_id in self.skeld.rooms:
|
||||
path = self.skeld.find_path("cafeteria", room_id)
|
||||
self.assertIsNotNone(path, f"No path to {room_id}")
|
||||
|
||||
def test_skeld_has_vents(self):
|
||||
vent_rooms = ["weapons", "navigation", "admin", "electrical",
|
||||
"lower_engine", "security", "reactor", "upper_engine", "medbay", "shields"]
|
||||
for room_id in vent_rooms:
|
||||
room = self.skeld.get_room(room_id)
|
||||
self.assertIsNotNone(room.vent, f"{room_id} should have a vent")
|
||||
|
||||
def test_vent_connectivity(self):
|
||||
# Check medbay-security-electrical vent network
|
||||
medbay = self.skeld.get_room("medbay")
|
||||
self.assertIn("vent_security", medbay.vent.connects_to)
|
||||
self.assertIn("vent_elec", medbay.vent.connects_to)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
108
tests/test_meeting_flow.py
Normal file
108
tests/test_meeting_flow.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""
|
||||
Tests for the meeting flow manager.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from src.engine.meeting_flow import MeetingFlowManager
|
||||
|
||||
|
||||
class TestMeetingFlowManager(unittest.TestCase):
|
||||
"""Tests for MeetingFlowManager."""
|
||||
|
||||
def setUp(self):
|
||||
self.meeting = MeetingFlowManager()
|
||||
|
||||
def test_start_meeting(self):
|
||||
state = self.meeting.start_meeting("red", "body_report", "electrical")
|
||||
|
||||
self.assertTrue(self.meeting.is_meeting_active())
|
||||
self.assertEqual(state.called_by, "red")
|
||||
self.assertEqual(state.reason, "body_report")
|
||||
|
||||
def test_submit_interrupt_note(self):
|
||||
self.meeting.start_meeting("red", "emergency")
|
||||
self.meeting.submit_interrupt_note("blue", {"was_doing": "going to electrical"})
|
||||
|
||||
note = self.meeting.current_meeting.interrupt_notes.get("blue")
|
||||
self.assertIsNotNone(note)
|
||||
self.assertEqual(note["was_doing"], "going to electrical")
|
||||
|
||||
def test_meeting_scratchpad(self):
|
||||
self.meeting.start_meeting("red", "emergency")
|
||||
self.meeting.init_meeting_scratchpad("blue", {"suspects": []})
|
||||
self.meeting.update_meeting_scratchpad("blue", {"suspects": ["red"]})
|
||||
|
||||
pad = self.meeting.get_meeting_scratchpad("blue")
|
||||
self.assertEqual(pad["suspects"], ["red"])
|
||||
|
||||
def test_add_message(self):
|
||||
self.meeting.start_meeting("red", "emergency")
|
||||
self.meeting.add_message("red", "Red", "Where was everyone?")
|
||||
self.meeting.add_message("blue", "Blue", "I was in medbay", target="Red")
|
||||
|
||||
transcript = self.meeting.get_transcript()
|
||||
self.assertEqual(len(transcript), 2)
|
||||
self.assertEqual(transcript[1]["target"], "Red")
|
||||
|
||||
def test_voting(self):
|
||||
self.meeting.start_meeting("red", "emergency")
|
||||
|
||||
self.meeting.submit_vote("red", "blue")
|
||||
self.meeting.submit_vote("blue", "skip")
|
||||
self.meeting.submit_vote("green", "blue")
|
||||
|
||||
self.assertTrue(self.meeting.has_voted("red"))
|
||||
self.assertTrue(self.meeting.all_voted(["red", "blue", "green"]))
|
||||
|
||||
def test_tally_votes_ejection(self):
|
||||
self.meeting.start_meeting("red", "emergency")
|
||||
self.meeting.submit_vote("red", "blue")
|
||||
self.meeting.submit_vote("green", "blue")
|
||||
self.meeting.submit_vote("blue", "skip")
|
||||
|
||||
ejected, details = self.meeting.tally_votes()
|
||||
|
||||
self.assertEqual(ejected, "blue")
|
||||
self.assertEqual(details["counts"]["blue"], 2)
|
||||
|
||||
def test_tally_votes_tie(self):
|
||||
self.meeting.start_meeting("red", "emergency")
|
||||
self.meeting.submit_vote("red", "blue")
|
||||
self.meeting.submit_vote("blue", "red")
|
||||
self.meeting.submit_vote("green", "skip")
|
||||
|
||||
ejected, details = self.meeting.tally_votes()
|
||||
|
||||
self.assertIsNone(ejected)
|
||||
self.assertTrue(details["was_tie"])
|
||||
|
||||
def test_tally_votes_skip_wins(self):
|
||||
self.meeting.start_meeting("red", "emergency")
|
||||
self.meeting.submit_vote("red", "skip")
|
||||
self.meeting.submit_vote("blue", "skip")
|
||||
self.meeting.submit_vote("green", "blue")
|
||||
|
||||
ejected, details = self.meeting.tally_votes()
|
||||
|
||||
self.assertIsNone(ejected)
|
||||
|
||||
def test_end_meeting(self):
|
||||
self.meeting.start_meeting("red", "emergency")
|
||||
self.meeting.submit_vote("red", "blue")
|
||||
self.meeting.submit_vote("green", "blue")
|
||||
|
||||
state = self.meeting.end_meeting("blue", was_impostor=True)
|
||||
|
||||
self.assertFalse(self.meeting.is_meeting_active())
|
||||
self.assertEqual(len(self.meeting.meeting_history), 1)
|
||||
self.assertEqual(state.ejected, "blue")
|
||||
self.assertTrue(state.was_impostor)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
212
tests/test_simulator.py
Normal file
212
tests/test_simulator.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""
|
||||
Tests for the discrete event simulator.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from src.engine.simulator import Simulator
|
||||
from src.engine.types import Event, Player, Position, Role, GamePhase
|
||||
|
||||
|
||||
class TestEvent(unittest.TestCase):
|
||||
"""Tests for Event dataclass."""
|
||||
|
||||
def test_event_creation(self):
|
||||
event = Event(time=10.0, event_type="TEST", data={"key": "value"})
|
||||
self.assertEqual(event.time, 10.0)
|
||||
self.assertEqual(event.event_type, "TEST")
|
||||
self.assertEqual(event.data["key"], "value")
|
||||
|
||||
def test_event_ordering(self):
|
||||
e1 = Event(time=5.0, event_type="A")
|
||||
e2 = Event(time=10.0, event_type="B")
|
||||
e3 = Event(time=5.0, event_type="C")
|
||||
|
||||
self.assertTrue(e1 < e2)
|
||||
self.assertFalse(e2 < e1)
|
||||
# Same time - order undefined but should not error
|
||||
self.assertFalse(e1 < e3 and e3 < e1)
|
||||
|
||||
|
||||
class TestPlayer(unittest.TestCase):
|
||||
"""Tests for Player dataclass."""
|
||||
|
||||
def test_player_creation(self):
|
||||
player = Player(
|
||||
id="p1", name="Red", color="red",
|
||||
role=Role.CREWMATE,
|
||||
position=Position(room_id="cafeteria")
|
||||
)
|
||||
self.assertEqual(player.id, "p1")
|
||||
self.assertEqual(player.role, Role.CREWMATE)
|
||||
self.assertTrue(player.is_alive)
|
||||
|
||||
def test_position_in_room(self):
|
||||
pos = Position(room_id="cafeteria")
|
||||
self.assertTrue(pos.is_in_room())
|
||||
self.assertFalse(pos.is_on_edge())
|
||||
|
||||
def test_position_on_edge(self):
|
||||
pos = Position(edge_id="ab", progress=0.5)
|
||||
self.assertFalse(pos.is_in_room())
|
||||
self.assertTrue(pos.is_on_edge())
|
||||
|
||||
|
||||
class TestSimulator(unittest.TestCase):
|
||||
"""Tests for the Simulator class."""
|
||||
|
||||
def setUp(self):
|
||||
self.sim = Simulator()
|
||||
|
||||
def test_initial_state(self):
|
||||
self.assertEqual(self.sim.time, 0.0)
|
||||
self.assertEqual(self.sim.phase, GamePhase.LOBBY)
|
||||
self.assertEqual(len(self.sim.players), 0)
|
||||
|
||||
def test_schedule_event(self):
|
||||
event = Event(time=5.0, event_type="TEST")
|
||||
self.sim.schedule(event)
|
||||
self.assertEqual(self.sim.peek_next_time(), 5.0)
|
||||
|
||||
def test_schedule_at(self):
|
||||
event = self.sim.schedule_at(10.0, "TEST", {"data": 1})
|
||||
self.assertEqual(event.time, 10.0)
|
||||
self.assertEqual(self.sim.peek_next_time(), 10.0)
|
||||
|
||||
def test_schedule_in(self):
|
||||
self.sim.time = 5.0
|
||||
event = self.sim.schedule_in(3.0, "TEST")
|
||||
self.assertEqual(event.time, 8.0)
|
||||
|
||||
def test_step_processes_event(self):
|
||||
self.sim.schedule_at(5.0, "TEST")
|
||||
event = self.sim.step()
|
||||
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event.event_type, "TEST")
|
||||
self.assertEqual(self.sim.time, 5.0)
|
||||
|
||||
def test_step_empty_queue(self):
|
||||
event = self.sim.step()
|
||||
self.assertIsNone(event)
|
||||
|
||||
def test_event_ordering(self):
|
||||
self.sim.schedule_at(10.0, "SECOND")
|
||||
self.sim.schedule_at(5.0, "FIRST")
|
||||
self.sim.schedule_at(15.0, "THIRD")
|
||||
|
||||
e1 = self.sim.step()
|
||||
e2 = self.sim.step()
|
||||
e3 = self.sim.step()
|
||||
|
||||
self.assertEqual(e1.event_type, "FIRST")
|
||||
self.assertEqual(e2.event_type, "SECOND")
|
||||
self.assertEqual(e3.event_type, "THIRD")
|
||||
|
||||
def test_event_handler(self):
|
||||
received = []
|
||||
|
||||
def handler(event):
|
||||
received.append(event.data.get("value"))
|
||||
|
||||
self.sim.on("TEST", handler)
|
||||
self.sim.schedule_at(5.0, "TEST", {"value": 42})
|
||||
self.sim.step()
|
||||
|
||||
self.assertEqual(received, [42])
|
||||
|
||||
def test_multiple_handlers(self):
|
||||
calls = []
|
||||
|
||||
self.sim.on("TEST", lambda e: calls.append("A"))
|
||||
self.sim.on("TEST", lambda e: calls.append("B"))
|
||||
|
||||
self.sim.schedule_at(5.0, "TEST")
|
||||
self.sim.step()
|
||||
|
||||
self.assertEqual(calls, ["A", "B"])
|
||||
|
||||
def test_wildcard_handler(self):
|
||||
events = []
|
||||
|
||||
self.sim.on("*", lambda e: events.append(e.event_type))
|
||||
|
||||
self.sim.schedule_at(1.0, "A")
|
||||
self.sim.schedule_at(2.0, "B")
|
||||
self.sim.step()
|
||||
self.sim.step()
|
||||
|
||||
self.assertEqual(events, ["A", "B"])
|
||||
|
||||
def test_run_until(self):
|
||||
self.sim.schedule_at(5.0, "A")
|
||||
self.sim.schedule_at(10.0, "B")
|
||||
self.sim.schedule_at(15.0, "C")
|
||||
|
||||
self.sim.run_until(10.0)
|
||||
|
||||
self.assertEqual(self.sim.time, 10.0)
|
||||
self.assertEqual(self.sim.peek_next_time(), 15.0)
|
||||
|
||||
def test_run_until_empty(self):
|
||||
self.sim.schedule_at(5.0, "A")
|
||||
self.sim.run_until_empty()
|
||||
|
||||
self.assertEqual(self.sim.time, 5.0)
|
||||
self.assertIsNone(self.sim.peek_next_time())
|
||||
|
||||
def test_event_log(self):
|
||||
self.sim.schedule_at(5.0, "TEST", {"foo": "bar"})
|
||||
self.sim.step()
|
||||
|
||||
self.assertEqual(len(self.sim.event_log), 1)
|
||||
self.assertEqual(self.sim.event_log[0]["type"], "TEST")
|
||||
self.assertEqual(self.sim.event_log[0]["t"], 5.0)
|
||||
|
||||
def test_add_player(self):
|
||||
player = Player(id="p1", name="Red", color="red")
|
||||
self.sim.add_player(player)
|
||||
|
||||
self.assertEqual(len(self.sim.players), 1)
|
||||
self.assertIn("p1", self.sim.players)
|
||||
|
||||
def test_get_player(self):
|
||||
player = Player(id="p1", name="Red", color="red")
|
||||
self.sim.add_player(player)
|
||||
|
||||
found = self.sim.get_player("p1")
|
||||
self.assertEqual(found.name, "Red")
|
||||
|
||||
self.assertIsNone(self.sim.get_player("nonexistent"))
|
||||
|
||||
def test_get_living_players(self):
|
||||
p1 = Player(id="p1", name="Red", color="red")
|
||||
p2 = Player(id="p2", name="Blue", color="blue", is_alive=False)
|
||||
p3 = Player(id="p3", name="Green", color="green")
|
||||
|
||||
self.sim.add_player(p1)
|
||||
self.sim.add_player(p2)
|
||||
self.sim.add_player(p3)
|
||||
|
||||
living = self.sim.get_living_players()
|
||||
self.assertEqual(len(living), 2)
|
||||
|
||||
def test_players_at(self):
|
||||
p1 = Player(id="p1", name="Red", color="red", position=Position(room_id="cafeteria"))
|
||||
p2 = Player(id="p2", name="Blue", color="blue", position=Position(room_id="cafeteria"))
|
||||
p3 = Player(id="p3", name="Green", color="green", position=Position(room_id="admin"))
|
||||
|
||||
self.sim.add_player(p1)
|
||||
self.sim.add_player(p2)
|
||||
self.sim.add_player(p3)
|
||||
|
||||
at_cafe = self.sim.players_at("cafeteria")
|
||||
self.assertEqual(len(at_cafe), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
192
tests/test_triggers.py
Normal file
192
tests/test_triggers.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""
|
||||
Tests for the trigger system.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from src.engine.triggers import (
|
||||
TriggerRegistry, TriggerType, TriggerCondition, Trigger,
|
||||
MANDATORY_TRIGGERS, STANDARD_TRIGGERS
|
||||
)
|
||||
|
||||
|
||||
class TestTriggerType(unittest.TestCase):
|
||||
"""Tests for TriggerType enum."""
|
||||
|
||||
def test_mandatory_triggers_defined(self):
|
||||
self.assertIn(TriggerType.DISCUSSION_START, MANDATORY_TRIGGERS)
|
||||
self.assertIn(TriggerType.VOTE_START, MANDATORY_TRIGGERS)
|
||||
self.assertIn(TriggerType.GAME_START, MANDATORY_TRIGGERS)
|
||||
|
||||
def test_standard_triggers_defined(self):
|
||||
self.assertIn(TriggerType.BODY_IN_FOV, STANDARD_TRIGGERS)
|
||||
self.assertIn(TriggerType.PLAYER_ENTERS_FOV, STANDARD_TRIGGERS)
|
||||
|
||||
|
||||
class TestTriggerRegistry(unittest.TestCase):
|
||||
"""Tests for TriggerRegistry class."""
|
||||
|
||||
def setUp(self):
|
||||
self.registry = TriggerRegistry()
|
||||
self.registry.register_agent("agent1")
|
||||
self.registry.register_agent("agent2")
|
||||
|
||||
def test_register_agent(self):
|
||||
# Agents should have standard triggers subscribed
|
||||
self.assertIn(TriggerType.BODY_IN_FOV, self.registry._subscriptions["agent1"])
|
||||
self.assertIn(TriggerType.PLAYER_ENTERS_FOV, self.registry._subscriptions["agent1"])
|
||||
|
||||
def test_subscribe(self):
|
||||
self.registry.subscribe("agent1", TriggerType.INTERSECTION)
|
||||
self.assertIn(TriggerType.INTERSECTION, self.registry._subscriptions["agent1"])
|
||||
|
||||
def test_unsubscribe(self):
|
||||
self.registry.unsubscribe("agent1", TriggerType.BODY_IN_FOV)
|
||||
self.assertNotIn(TriggerType.BODY_IN_FOV, self.registry._subscriptions["agent1"])
|
||||
|
||||
def test_cannot_unsubscribe_mandatory(self):
|
||||
self.registry.unsubscribe("agent1", TriggerType.DISCUSSION_START)
|
||||
# Should still fire because mandatory
|
||||
self.assertTrue(
|
||||
self.registry.should_fire("agent1", TriggerType.DISCUSSION_START, 0.0)
|
||||
)
|
||||
|
||||
def test_should_fire_standard(self):
|
||||
# Standard triggers should fire by default
|
||||
self.assertTrue(
|
||||
self.registry.should_fire("agent1", TriggerType.BODY_IN_FOV, 0.0)
|
||||
)
|
||||
|
||||
def test_should_fire_optional_not_subscribed(self):
|
||||
# Optional triggers don't fire unless subscribed
|
||||
self.assertFalse(
|
||||
self.registry.should_fire("agent1", TriggerType.INTERSECTION, 0.0)
|
||||
)
|
||||
|
||||
def test_should_fire_mandatory_always(self):
|
||||
# Mandatory triggers always fire
|
||||
self.assertTrue(
|
||||
self.registry.should_fire("agent1", TriggerType.GAME_START, 0.0)
|
||||
)
|
||||
|
||||
def test_mute_trigger(self):
|
||||
condition = TriggerCondition(
|
||||
trigger_type=TriggerType.PLAYER_ENTERS_FOV,
|
||||
until_time=10.0
|
||||
)
|
||||
self.registry.mute("agent1", condition)
|
||||
|
||||
# Should be muted before time expires
|
||||
self.assertTrue(
|
||||
self.registry.is_muted("agent1", TriggerType.PLAYER_ENTERS_FOV, 5.0)
|
||||
)
|
||||
|
||||
# Should not fire while muted
|
||||
self.assertFalse(
|
||||
self.registry.should_fire("agent1", TriggerType.PLAYER_ENTERS_FOV, 5.0)
|
||||
)
|
||||
|
||||
def test_mute_expires(self):
|
||||
condition = TriggerCondition(
|
||||
trigger_type=TriggerType.PLAYER_ENTERS_FOV,
|
||||
until_time=10.0
|
||||
)
|
||||
self.registry.mute("agent1", condition)
|
||||
|
||||
# Should not be muted after time expires
|
||||
self.assertFalse(
|
||||
self.registry.is_muted("agent1", TriggerType.PLAYER_ENTERS_FOV, 15.0)
|
||||
)
|
||||
|
||||
def test_cannot_mute_mandatory(self):
|
||||
condition = TriggerCondition(
|
||||
trigger_type=TriggerType.DISCUSSION_START,
|
||||
until_time=100.0
|
||||
)
|
||||
self.registry.mute("agent1", condition)
|
||||
|
||||
# Mandatory should still fire
|
||||
self.assertTrue(
|
||||
self.registry.should_fire("agent1", TriggerType.DISCUSSION_START, 50.0)
|
||||
)
|
||||
|
||||
def test_target_specific_mute(self):
|
||||
condition = TriggerCondition(
|
||||
trigger_type=TriggerType.PLAYER_ENTERS_FOV,
|
||||
until_time=10.0,
|
||||
target_id="player_blue"
|
||||
)
|
||||
self.registry.mute("agent1", condition)
|
||||
|
||||
# Should be muted for specific target
|
||||
self.assertTrue(
|
||||
self.registry.is_muted("agent1", TriggerType.PLAYER_ENTERS_FOV, 5.0, "player_blue")
|
||||
)
|
||||
|
||||
# Should NOT be muted for different target
|
||||
self.assertFalse(
|
||||
self.registry.is_muted("agent1", TriggerType.PLAYER_ENTERS_FOV, 5.0, "player_red")
|
||||
)
|
||||
|
||||
def test_clear_expired_mutes(self):
|
||||
cond1 = TriggerCondition(trigger_type=TriggerType.PLAYER_ENTERS_FOV, until_time=5.0)
|
||||
cond2 = TriggerCondition(trigger_type=TriggerType.PLAYER_EXITS_FOV, until_time=15.0)
|
||||
|
||||
self.registry.mute("agent1", cond1)
|
||||
self.registry.mute("agent1", cond2)
|
||||
|
||||
self.assertEqual(len(self.registry._mutes["agent1"]), 2)
|
||||
|
||||
self.registry.clear_expired_mutes("agent1", 10.0)
|
||||
|
||||
self.assertEqual(len(self.registry._mutes["agent1"]), 1)
|
||||
|
||||
def test_get_agents_for_trigger(self):
|
||||
# Add a third agent
|
||||
self.registry.register_agent("agent3")
|
||||
|
||||
# Mute agent2
|
||||
self.registry.mute("agent2", TriggerCondition(
|
||||
trigger_type=TriggerType.BODY_IN_FOV,
|
||||
until_time=10.0
|
||||
))
|
||||
|
||||
agents = self.registry.get_agents_for_trigger(TriggerType.BODY_IN_FOV, 5.0)
|
||||
|
||||
self.assertIn("agent1", agents)
|
||||
self.assertNotIn("agent2", agents) # muted
|
||||
self.assertIn("agent3", agents)
|
||||
|
||||
def test_get_agents_with_exclude(self):
|
||||
agents = self.registry.get_agents_for_trigger(
|
||||
TriggerType.BODY_IN_FOV,
|
||||
0.0,
|
||||
exclude={"agent1"}
|
||||
)
|
||||
|
||||
self.assertNotIn("agent1", agents)
|
||||
self.assertIn("agent2", agents)
|
||||
|
||||
|
||||
class TestTrigger(unittest.TestCase):
|
||||
"""Tests for Trigger dataclass."""
|
||||
|
||||
def test_trigger_creation(self):
|
||||
trigger = Trigger(
|
||||
trigger_type=TriggerType.BODY_IN_FOV,
|
||||
target_agent_id="agent1",
|
||||
time=10.5,
|
||||
data={"victim": "Blue"}
|
||||
)
|
||||
|
||||
self.assertEqual(trigger.trigger_type, TriggerType.BODY_IN_FOV)
|
||||
self.assertEqual(trigger.target_agent_id, "agent1")
|
||||
self.assertEqual(trigger.time, 10.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user