diff --git a/.gitignore b/.gitignore index 99f6d79..a4f17d4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..35869de --- /dev/null +++ b/README.md @@ -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 diff --git a/config/game_settings.json b/config/game_settings.json new file mode 100644 index 0000000..9d1dd8c --- /dev/null +++ b/config/game_settings.json @@ -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 +} \ No newline at end of file diff --git a/config/game_settings.yaml b/config/game_settings.yaml new file mode 100644 index 0000000..faa5d46 --- /dev/null +++ b/config/game_settings.yaml @@ -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 diff --git a/config/prompts/action.md b/config/prompts/action.md new file mode 100644 index 0000000..04dc543 --- /dev/null +++ b/config/prompts/action.md @@ -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. diff --git a/config/prompts/discussion.md b/config/prompts/discussion.md new file mode 100644 index 0000000..abc7572 --- /dev/null +++ b/config/prompts/discussion.md @@ -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. diff --git a/config/prompts/reflection.md b/config/prompts/reflection.md new file mode 100644 index 0000000..bd3d377 --- /dev/null +++ b/config/prompts/reflection.md @@ -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. diff --git a/config/prompts/strategies.md b/config/prompts/strategies.md new file mode 100644 index 0000000..f9f1396 --- /dev/null +++ b/config/prompts/strategies.md @@ -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. diff --git a/config/prompts/voting.md b/config/prompts/voting.md new file mode 100644 index 0000000..0c97eb5 --- /dev/null +++ b/config/prompts/voting.md @@ -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. diff --git a/config/triggers.yaml b/config/triggers.yaml new file mode 100644 index 0000000..de24fcf --- /dev/null +++ b/config/triggers.yaml @@ -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 diff --git a/data/maps/skeld.json b/data/maps/skeld.json new file mode 100644 index 0000000..e2f59f7 --- /dev/null +++ b/data/maps/skeld.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..535bfc0 --- /dev/null +++ b/docs/api.md @@ -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}, + ... + ] +} +``` diff --git a/docs/design_discussion.md b/docs/design_discussion.md new file mode 100644 index 0000000..a3db2a1 --- /dev/null +++ b/docs/design_discussion.md @@ -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. diff --git a/docs/design_main_game.md b/docs/design_main_game.md new file mode 100644 index 0000000..96d6c8b --- /dev/null +++ b/docs/design_main_game.md @@ -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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..489881a --- /dev/null +++ b/docs/development.md @@ -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`) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..cf23128 --- /dev/null +++ b/docs/index.md @@ -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/` diff --git a/docs/openrouter_api.md b/docs/openrouter_api.md new file mode 100644 index 0000000..ba8cd4c --- /dev/null +++ b/docs/openrouter_api.md @@ -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 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/__init__.py b/src/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/agent.py b/src/agents/agent.py new file mode 100644 index 0000000..6159e41 --- /dev/null +++ b/src/agents/agent.py @@ -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) diff --git a/src/agents/prompt_assembler.py b/src/agents/prompt_assembler.py new file mode 100644 index 0000000..152d077 --- /dev/null +++ b/src/agents/prompt_assembler.py @@ -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": "..." +}} +```""" diff --git a/src/agents/scratchpads.py b/src/agents/scratchpads.py new file mode 100644 index 0000000..df7fd7c --- /dev/null +++ b/src/agents/scratchpads.py @@ -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) diff --git a/src/engine/__init__.py b/src/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/engine/available_actions.py b/src/engine/available_actions.py new file mode 100644 index 0000000..e5aadb0 --- /dev/null +++ b/src/engine/available_actions.py @@ -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) + } diff --git a/src/engine/discussion.py b/src/engine/discussion.py new file mode 100644 index 0000000..39fceeb --- /dev/null +++ b/src/engine/discussion.py @@ -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) diff --git a/src/engine/fog_of_war.py b/src/engine/fog_of_war.py new file mode 100644 index 0000000..721f976 --- /dev/null +++ b/src/engine/fog_of_war.py @@ -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 diff --git a/src/engine/game.py b/src/engine/game.py new file mode 100644 index 0000000..76125df --- /dev/null +++ b/src/engine/game.py @@ -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 diff --git a/src/engine/meeting_flow.py b/src/engine/meeting_flow.py new file mode 100644 index 0000000..b07d53d --- /dev/null +++ b/src/engine/meeting_flow.py @@ -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) diff --git a/src/engine/simulator.py b/src/engine/simulator.py new file mode 100644 index 0000000..8a3b3bc --- /dev/null +++ b/src/engine/simulator.py @@ -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 + ] diff --git a/src/engine/trigger_messages.py b/src/engine/trigger_messages.py new file mode 100644 index 0000000..9090be9 --- /dev/null +++ b/src/engine/trigger_messages.py @@ -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 + } + ) diff --git a/src/engine/triggers.py b/src/engine/triggers.py new file mode 100644 index 0000000..437983c --- /dev/null +++ b/src/engine/triggers.py @@ -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) + ] diff --git a/src/engine/types.py b/src/engine/types.py new file mode 100644 index 0000000..3051535 --- /dev/null +++ b/src/engine/types.py @@ -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 diff --git a/src/llm/__init__.py b/src/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/llm/client.py b/src/llm/client.py new file mode 100644 index 0000000..7158a01 --- /dev/null +++ b/src/llm/client.py @@ -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 diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..42be3bc --- /dev/null +++ b/src/main.py @@ -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() diff --git a/src/map/__init__.py b/src/map/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/map/graph.py b/src/map/graph.py new file mode 100644 index 0000000..acbf844 --- /dev/null +++ b/src/map/graph.py @@ -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 diff --git a/tests/test_discussion.py b/tests/test_discussion.py new file mode 100644 index 0000000..6d1ca4e --- /dev/null +++ b/tests/test_discussion.py @@ -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() diff --git a/tests/test_fog_of_war.py b/tests/test_fog_of_war.py new file mode 100644 index 0000000..8ce61cc --- /dev/null +++ b/tests/test_fog_of_war.py @@ -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() diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..7a122ee --- /dev/null +++ b/tests/test_game.py @@ -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() diff --git a/tests/test_llm_integration.py b/tests/test_llm_integration.py new file mode 100644 index 0000000..3c6658d --- /dev/null +++ b/tests/test_llm_integration.py @@ -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) diff --git a/tests/test_map.py b/tests/test_map.py new file mode 100644 index 0000000..aa25fb1 --- /dev/null +++ b/tests/test_map.py @@ -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() diff --git a/tests/test_meeting_flow.py b/tests/test_meeting_flow.py new file mode 100644 index 0000000..7494368 --- /dev/null +++ b/tests/test_meeting_flow.py @@ -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() diff --git a/tests/test_simulator.py b/tests/test_simulator.py new file mode 100644 index 0000000..7ee19e2 --- /dev/null +++ b/tests/test_simulator.py @@ -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() diff --git a/tests/test_triggers.py b/tests/test_triggers.py new file mode 100644 index 0000000..f4b6904 --- /dev/null +++ b/tests/test_triggers.py @@ -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()