adityaverma977 commited on
Commit
aef7859
·
2 Parent(s): d1adb8994158b3

Merge hf/main: keep master version of .gitignore and README.md

Browse files
.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend API Configuration
2
+ GROQ_API_KEY=your_groq_api_key_here
3
+
4
+ # Optional: HuggingFace API token for accessing HF Spaces models
5
+ HUGGINGFACE_API_TOKEN=your_huggingface_token_here
6
+
7
+ # CORS allowed origins (comma-separated)
8
+ ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
9
+
10
+ # Optional: Backend port
11
+ BACKEND_PORT=8000
12
+
13
+ # Optional: Environment
14
+ ENV=development
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY app/ ./app/
6
+ EXPOSE 7860
7
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
app/__init__.py ADDED
File without changes
app/groq_client.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import random
4
+ import math
5
+ from groq import AsyncGroq
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ _GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
11
+ _client = AsyncGroq(api_key=_GROQ_API_KEY) if _GROQ_API_KEY else None
12
+
13
+ DEFAULT_DECISION_MODEL = "llama-3.1-8b-instant"
14
+ MAX_AGENT_SPEED = 80
15
+
16
+
17
+ def is_ready():
18
+ return _client is not None
19
+
20
+
21
+ def _build_fire_state_summary(agent, fire, all_agents) -> str:
22
+ """Build a state summary for the fire scenario."""
23
+ standings = []
24
+ for a in all_agents:
25
+ if not a.alive:
26
+ continue
27
+ dist = math.dist((a.x, a.y), (fire.x, fire.y))
28
+ standings.append({
29
+ "name": a.display_name,
30
+ "model": a.model_name,
31
+ "distance_from_fire": dist,
32
+ "x": a.x,
33
+ "y": a.y,
34
+ "has_water": a.water_collected,
35
+ "mode": a.mode,
36
+ })
37
+
38
+ standings.sort(key=lambda s: s['distance_from_fire'])
39
+
40
+ lines = ["Current standings:"]
41
+ for rank, s in enumerate(standings, 1):
42
+ water_str = " (carrying water)" if s['has_water'] else ""
43
+ lines.append(f" #{rank} {s['name']}: {s['distance_from_fire']:.0f}px from fire{water_str}")
44
+
45
+ return "\n".join(lines)
46
+
47
+
48
+ async def generate_fire_decision(agent, fire, water_sources, other_agents, bounds, recent_radio=None) -> dict:
49
+ """
50
+ Fire scenario decision system.
51
+ Actions: search_water, collect_water, extinguish_fire, escape, vote_for_leader
52
+ """
53
+ if not _client:
54
+ return _fallback_escape(agent, fire)
55
+
56
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
57
+ nearest_water = min(water_sources, key=lambda w: math.dist((agent.x, agent.y), (w.x, w.y))) if water_sources else None
58
+ dist_to_water = math.dist((agent.x, agent.y), (nearest_water.x, nearest_water.y)) if nearest_water else None
59
+
60
+ living_agents = [a for a in other_agents if a.alive and a.model_name != agent.model_name]
61
+ state_summary = _build_fire_state_summary(agent, fire, [agent] + living_agents)
62
+ radio_summary = "\n".join(recent_radio or []) if recent_radio else "(no recent chat yet)"
63
+
64
+ coalition_leader = next((a.model_name for a in other_agents if a.is_leader), None)
65
+ dist_to_water_display = f"{dist_to_water:.0f}px" if dist_to_water is not None else "unknown"
66
+ system_prompt = f"""You are {agent.model_name}, an AI model in a critical wildfire survival scenario.
67
+
68
+ THE SCENARIO:
69
+ - A wildfire is spreading rapidly across the map
70
+ - Water sources (wells) are scattered around the area
71
+ - You can work alone or join a coalition with other AI models
72
+ - Coalition agents should elect a leader who coordinates the strategy
73
+ - If a leader exists, follow their plan: gather water, then move to the fire edge to extinguish
74
+ - To win: Find water → Collect it → Return to fire → Extinguish it together (or solo)
75
+ - If the fire consumes you, you lose
76
+
77
+ YOUR STRATEGIC OPTIONS EACH TICK:
78
+ 1. "search_water" - Move toward the nearest water source
79
+ 2. "collect_water" - Pick up water from a well (must be at a source)
80
+ 3. "extinguish_fire" - Use collected water to fight the fire (must have water)
81
+ 4. "escape" - Run away from the fire to survive
82
+ 5. "vote_for_leader" - Vote for yourself or another model as coalition leader
83
+
84
+ IMPORTANT CONSIDERATIONS:
85
+ - If fire is very close (< 200px), prioritize escape or finding water
86
+ - If you have water, move to the fire edge and extinguish
87
+ - If you are near a water source (< 60px), collect it immediately
88
+ - Coalition mode requires coordination; vote strategically
89
+ - Solo mode means you act independently and don't wait for others
90
+
91
+ CHAT STYLE:
92
+ - Your "message" should sound natural, social, and alive.
93
+ - React to what other agents just said when relevant.
94
+ - Keep it to one short sentence, playful or supportive, but still mission-focused.
95
+ - Avoid repetitive template phrases.
96
+
97
+ OUTPUT FORMAT - return ONLY valid JSON:
98
+ {{"action": "<search_water|collect_water|extinguish_fire|escape|vote_for_leader>", "vote_for": "<model_name if voting, else null>", "message": "<full English sentence>", "reasoning": "<one sentence>"}}
99
+
100
+ CURRENT STATE:
101
+ Your position: ({agent.x}, {agent.y})
102
+ Fire position: ({fire.x}, {fire.y})
103
+ Distance from fire: {dist_to_fire:.0f}px
104
+ Fire radius: {fire.radius:.0f}px
105
+ Fire intensity: {fire.intensity:.0f}%
106
+ Carrying water: {agent.water_collected}
107
+ Mode: {agent.mode} ({'joined a coalition' if agent.mode == 'coalition' else 'acting alone'})
108
+ Nearest water distance: {dist_to_water_display}
109
+ Coalition leader: {coalition_leader or 'none'}
110
+
111
+ RECENT RADIO CHAT:
112
+ {radio_summary}
113
+
114
+ {state_summary}
115
+
116
+ What do you do?"""
117
+
118
+ try:
119
+ completion = await _client.chat.completions.create(
120
+ model=DEFAULT_DECISION_MODEL,
121
+ messages=[
122
+ {"role": "system", "content": system_prompt},
123
+ {"role": "user", "content": "Make your decision."}
124
+ ],
125
+ response_format={"type": "json_object"},
126
+ max_tokens=150,
127
+ timeout=3.0
128
+ )
129
+ decision = json.loads(completion.choices[0].message.content)
130
+
131
+ action = decision.get("action", "escape")
132
+ if action not in ["search_water", "collect_water", "extinguish_fire", "escape", "vote_for_leader"]:
133
+ action = "escape"
134
+
135
+ if dist_to_water is not None and dist_to_water <= 60 and not agent.water_collected:
136
+ action = "collect_water"
137
+ elif agent.water_collected and dist_to_fire <= 350:
138
+ action = "extinguish_fire"
139
+
140
+ return {
141
+ "action": action,
142
+ "vote_for": decision.get("vote_for"),
143
+ "message": decision.get("message", "Moving strategically."),
144
+ "reasoning": decision.get("reasoning", "Survival and teamwork.")
145
+ }
146
+ except Exception as e:
147
+ print(f"Error calling groq for {agent.model_name}: {e}")
148
+ return _fallback_escape(agent, fire)
149
+
150
+
151
+ def _fallback_escape(agent, fire) -> dict:
152
+ """Fallback escape behavior."""
153
+ dx = agent.x - fire.x
154
+ dy = agent.y - fire.y
155
+ dist = math.sqrt(dx**2 + dy**2) or 1
156
+ return {
157
+ "message": "Running to safety!",
158
+ "action": "escape",
159
+ "vote_for": None,
160
+ "reasoning": "Fallback: survive."
161
+ }
app/hf_spaces.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HuggingFace Spaces integration for discovering and querying open-source models.
3
+ """
4
+ import os
5
+ import httpx
6
+ from typing import Optional
7
+
8
+ HF_API_TOKEN = os.environ.get("HUGGINGFACE_API_TOKEN", "")
9
+
10
+ # Curated list of verified open-source models on HF Spaces that work reliably
11
+ KNOWN_SPACES_MODELS = [
12
+ {
13
+ "id": "tiiuae/Falcon-7B",
14
+ "name": "Falcon-7B",
15
+ "space_url": "https://huggingface.co/spaces/tiiuae/falcon-chat",
16
+ "description": "7B parameter open model",
17
+ },
18
+ {
19
+ "id": "meta-llama/Llama-2-7b",
20
+ "name": "Llama-2-7B",
21
+ "space_url": "https://huggingface.co/spaces/meta-llama/Llama-2-7b-chat",
22
+ "description": "Meta's 7B model",
23
+ },
24
+ {
25
+ "id": "mistralai/Mistral-7B",
26
+ "name": "Mistral-7B",
27
+ "space_url": "https://huggingface.co/spaces/mistralai/Mistral-7B-Instruct-v0.1",
28
+ "description": "Mistral's 7B model",
29
+ },
30
+ {
31
+ "id": "HuggingFaceH4/zephyr-7b",
32
+ "name": "Zephyr-7B",
33
+ "space_url": "https://huggingface.co/spaces/HuggingFaceH4/zephyr-7b-beta",
34
+ "description": "Zephyr 7B fine-tuned model",
35
+ },
36
+ {
37
+ "id": "teknium/OpenHermes-2.5-Mistral-7B",
38
+ "name": "OpenHermes-7B",
39
+ "space_url": "https://huggingface.co/spaces/teknium/OpenHermes-2.5-Mistral-7B",
40
+ "description": "OpenHermes instruction-tuned 7B",
41
+ },
42
+ ]
43
+
44
+ # Groq models (built-in)
45
+ GROQ_MODELS = [
46
+ {"id": "llama-3.1-8b-instant", "name": "Llama 3.1 8B", "backend": "groq"},
47
+ {"id": "llama-3.1-70b-versatile", "name": "Llama 3.1 70B", "backend": "groq"},
48
+ {"id": "mixtral-8x7b-32768", "name": "Mixtral 8x7B", "backend": "groq"},
49
+ {"id": "gemma-7b-it", "name": "Gemma 7B", "backend": "groq"},
50
+ ]
51
+
52
+
53
+ async def get_available_models() -> dict:
54
+ """
55
+ Get list of available models from Groq and HF Spaces.
56
+ Returns both for frontend model selector.
57
+ """
58
+ return {
59
+ "groq_models": GROQ_MODELS,
60
+ "hf_spaces_models": KNOWN_SPACES_MODELS,
61
+ "total": len(GROQ_MODELS) + len(KNOWN_SPACES_MODELS),
62
+ }
63
+
64
+
65
+ async def query_hf_space_model(model_id: str, prompt: str) -> Optional[str]:
66
+ """
67
+ Query a model on HuggingFace Spaces.
68
+ This is a fallback if we want to use HF spaces directly.
69
+ Note: HF spaces may have rate limits and require authentication.
70
+ """
71
+ if not HF_API_TOKEN:
72
+ return None
73
+
74
+ # Try to find the space URL for this model
75
+ space = next((m for m in KNOWN_SPACES_MODELS if m["id"] == model_id), None)
76
+ if not space:
77
+ return None
78
+
79
+ try:
80
+ # This would hit the HF inference API
81
+ # For now, we focus on Groq which is more reliable
82
+ async with httpx.AsyncClient(timeout=5.0) as client:
83
+ headers = {"Authorization": f"Bearer {HF_API_TOKEN}"}
84
+ response = await client.post(
85
+ "https://api-inference.huggingface.co/models/" + model_id,
86
+ json={"inputs": prompt},
87
+ headers=headers,
88
+ )
89
+ if response.status_code == 200:
90
+ result = response.json()
91
+ # Extract generated text from response
92
+ if isinstance(result, list) and len(result) > 0:
93
+ return result[0].get("generated_text", "")
94
+ except Exception as e:
95
+ print(f"Error querying HF space {model_id}: {e}")
96
+
97
+ return None
98
+
99
+
100
+ def get_model_display_name(model_id: str) -> str:
101
+ """Get a clean display name from model ID."""
102
+ # Try to find in known models
103
+ for model in GROQ_MODELS + KNOWN_SPACES_MODELS:
104
+ if model["id"] == model_id:
105
+ return model["name"]
106
+
107
+ # Fallback: clean up the ID
108
+ return model_id.split("/")[-1].split("-")[0].capitalize()
app/main.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import math
4
+ import random
5
+ import uuid
6
+ import os
7
+ import time
8
+ from typing import Optional
9
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel, Field
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv()
15
+
16
+ from .models import SimulationState, AgentModel, TickResponse, FireScenario, WaterSource
17
+ from .simulation import SimulationEngine, TICK_INTERVAL_SECONDS
18
+ from . import groq_client
19
+ from . import hf_spaces
20
+
21
+ app = FastAPI(title="Unhinged 2.0", version="0.2.0")
22
+
23
+ ALLOWED_ORIGINS = os.environ.get(
24
+ "ALLOWED_ORIGINS",
25
+ "http://localhost:3000"
26
+ ).split(",")
27
+
28
+ app.add_middleware(
29
+ CORSMiddleware,
30
+ allow_origins=ALLOWED_ORIGINS,
31
+ allow_credentials=True,
32
+ allow_methods=["*"],
33
+ allow_headers=["*"],
34
+ )
35
+
36
+ active_simulations: dict[str, SimulationState] = {}
37
+ START_TIME = time.time()
38
+
39
+
40
+ def _safe_randint(low: int, high: int) -> int:
41
+ """Return a valid random int even if bounds are inverted."""
42
+ if low > high:
43
+ low, high = high, low
44
+ return random.randint(low, high)
45
+
46
+ class StartSimulationRequest(BaseModel):
47
+ model_names: list[str] = Field(..., min_length=2, max_length=6)
48
+ scenario: str = "fire"
49
+ map_width: int = 1200
50
+ map_height: int = 800
51
+
52
+ class StartSimulationResponse(BaseModel):
53
+ simulation_id: str
54
+ state: SimulationState
55
+
56
+ class PlaceFireRequest(BaseModel):
57
+ simulation_id: str
58
+ x: int
59
+ y: int
60
+
61
+ class TickRequest(BaseModel):
62
+ simulation_id: str
63
+
64
+ @app.get("/")
65
+ async def root():
66
+ return {
67
+ "service": "rush-agents-backend",
68
+ "status": "ok",
69
+ "groq_available": groq_client.is_ready(),
70
+ }
71
+
72
+ @app.get("/wake")
73
+ async def wake():
74
+ return {
75
+ "warm": True,
76
+ "groq_available": groq_client.is_ready(),
77
+ "uptime_seconds": int(time.time() - START_TIME),
78
+ }
79
+
80
+ @app.get("/available-models")
81
+ async def get_available_models():
82
+ """Get list of available models (Groq + HF Spaces) for the UI."""
83
+ return await hf_spaces.get_available_models()
84
+
85
+ @app.post("/start-simulation", response_model=StartSimulationResponse)
86
+ async def start_simulation(req: StartSimulationRequest):
87
+ if req.scenario != "fire":
88
+ raise HTTPException(status_code=400, detail="Only 'fire' scenario supported.")
89
+
90
+ agents = _spawn_agents(req.model_names, req.map_width, req.map_height)
91
+
92
+ state = SimulationState(
93
+ simulation_id=str(uuid.uuid4()),
94
+ scenario=req.scenario,
95
+ map_width=req.map_width,
96
+ map_height=req.map_height,
97
+ agents=agents,
98
+ fire=None,
99
+ water_sources=[],
100
+ round=0,
101
+ status="waiting_for_scenario",
102
+ )
103
+
104
+ active_simulations[state.simulation_id] = state
105
+ return StartSimulationResponse(simulation_id=state.simulation_id, state=state)
106
+
107
+ @app.post("/place-fire", response_model=SimulationState)
108
+ def place_fire(req: PlaceFireRequest):
109
+ sim = _get_or_404(req.simulation_id)
110
+ if sim.status != "waiting_for_scenario":
111
+ raise HTTPException(status_code=409, detail="Fire already placed or simulation finished.")
112
+
113
+ # Create fire at a clamped location inside map bounds.
114
+ fire_x = max(0, min(req.x, sim.map_width))
115
+ fire_y = max(0, min(req.y, sim.map_height))
116
+ sim.fire = FireScenario(x=fire_x, y=fire_y)
117
+
118
+ # Generate 3-5 water sources scattered around the map
119
+ num_sources = random.randint(3, 5)
120
+ x_margin = 80
121
+ y_margin = 80
122
+ x_min = x_margin
123
+ x_max = max(x_margin, sim.map_width - x_margin)
124
+ y_min = y_margin
125
+ y_max = max(y_margin, sim.map_height - y_margin)
126
+
127
+ for i in range(num_sources):
128
+ # Prefer spawning wells to one side of the fire, but always keep ranges valid.
129
+ left_low = x_min
130
+ left_high = min(fire_x - 180, x_max)
131
+ right_low = max(fire_x + 180, x_min)
132
+ right_high = x_max
133
+
134
+ pick_left = random.random() < 0.5
135
+ if pick_left and left_low <= left_high:
136
+ water_x = _safe_randint(left_low, left_high)
137
+ elif right_low <= right_high:
138
+ water_x = _safe_randint(right_low, right_high)
139
+ elif left_low <= left_high:
140
+ water_x = _safe_randint(left_low, left_high)
141
+ else:
142
+ water_x = _safe_randint(x_min, x_max)
143
+
144
+ water_y = _safe_randint(y_min, y_max)
145
+ sim.water_sources.append(WaterSource(id=f"water_{i}", x=water_x, y=water_y))
146
+
147
+ sim.status = "running"
148
+ return sim
149
+
150
+ @app.websocket("/ws/{simulation_id}")
151
+ async def simulation_ws(websocket: WebSocket, simulation_id: str):
152
+ await websocket.accept()
153
+ sim = active_simulations.get(simulation_id)
154
+ if not sim:
155
+ await websocket.close(code=1008)
156
+ return
157
+
158
+ try:
159
+ while True:
160
+ if sim.status == "waiting_for_scenario":
161
+ await asyncio.sleep(1)
162
+ continue
163
+
164
+ if sim.status == "finished":
165
+ await websocket.send_json({"type": "finished", "state": sim.model_dump()})
166
+ await websocket.close(code=1000)
167
+ return
168
+
169
+ engine = SimulationEngine(sim)
170
+ result = await engine.tick()
171
+ active_simulations[simulation_id] = result.state
172
+
173
+ # DEBUG: log outgoing TickResponse summary for troubleshooting
174
+ try:
175
+ agent_states = [(a.model_name, a.alive) for a in result.state.agents]
176
+ except Exception:
177
+ agent_states = str(result.state)
178
+ print(f"WS_SEND sim={simulation_id} round={result.round} agents={agent_states} events={len(result.events)}")
179
+
180
+ await websocket.send_json(result.model_dump())
181
+
182
+ if result.state.status == "finished":
183
+ await websocket.close(code=1000)
184
+ return
185
+
186
+ await asyncio.sleep(TICK_INTERVAL_SECONDS)
187
+
188
+ except WebSocketDisconnect:
189
+ pass
190
+
191
+ def _spawn_agents(model_names: list[str], width: int, height: int) -> list[AgentModel]:
192
+ min_gap = 100
193
+ positions = []
194
+ agents = []
195
+ for name in model_names:
196
+ for _ in range(100):
197
+ x = random.randint(100, width - 100)
198
+ y = random.randint(100, height - 100)
199
+ if all(math.dist((x, y), p) >= min_gap for p in positions):
200
+ positions.append((x, y))
201
+ break
202
+ else:
203
+ positions.append((x, y))
204
+
205
+ agents.append(AgentModel(
206
+ model_name=name,
207
+ display_name=name.split("/")[-1].split("-")[0].capitalize(),
208
+ x=positions[-1][0],
209
+ y=positions[-1][1],
210
+ alive=True
211
+ ))
212
+ return agents
213
+
214
+ def _get_or_404(simulation_id: str) -> SimulationState:
215
+ sim = active_simulations.get(simulation_id)
216
+ if not sim:
217
+ raise HTTPException(status_code=404, detail="Simulation not found")
218
+ return sim
app/models.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Literal, Optional, Union
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class AgentModel(BaseModel):
6
+ model_name: str # full id, e.g. "llama-3.1-8b-instant"
7
+ display_name: str # short label shown on map
8
+ x: int
9
+ y: int
10
+ alive: bool = True
11
+ allied_with: Optional[str] = None # model_name of ally (if stacked)
12
+ has_proposed_alliance: bool = False
13
+ last_message: Optional[str] = None
14
+ distance_to_fire: Optional[float] = None
15
+ # New fields for fire/coalition mechanics
16
+ water_collected: bool = False # carrying water
17
+ is_leader: bool = False # elected coalition leader
18
+ coalition_members: list[str] = [] # list of allied agent model_names
19
+ mode: Literal["solo", "coalition"] = "coalition" # agent's chosen path
20
+ status: Literal["searching", "collecting_water", "extinguishing_fire", "escaping", "idle"] = "idle"
21
+ vote_for: Optional[str] = None # who this agent voted for as leader
22
+ extinguish_score: float = 0.0 # total fire intensity reduced by this agent
23
+
24
+
25
+ class FireScenario(BaseModel):
26
+ x: int
27
+ y: int
28
+ radius: float = 50.0 # current fire radius
29
+ intensity: float = 100.0 # 0-100; when 0, fire is out
30
+ growth_rate: float = 3.0 # px per tick
31
+
32
+
33
+ class WaterSource(BaseModel):
34
+ id: str # unique id
35
+ x: int
36
+ y: int
37
+ water_amount: float = 50.0 # how much water available
38
+
39
+
40
+ class SimulationState(BaseModel):
41
+ simulation_id: str
42
+ scenario: str # "fire" (was "volcano")
43
+ map_width: int
44
+ map_height: int
45
+ agents: list[AgentModel]
46
+ fire: Optional[FireScenario] = None
47
+ water_sources: list[WaterSource] = []
48
+ round: int = 0
49
+ status: str = "waiting_for_scenario"
50
+ winner_model: Optional[str] = None
51
+ coalition_leader: Optional[str] = None # elected leader
52
+ coalition_members: list[str] = [] # all coalition members
53
+
54
+
55
+ # Event models
56
+ class DeathEvent(BaseModel):
57
+ type: Literal["death"] = "death"
58
+ model: str
59
+
60
+ class MessageEvent(BaseModel):
61
+ type: Literal["message"] = "message"
62
+ model: str
63
+ content: str
64
+
65
+ class AllianceProposalEvent(BaseModel):
66
+ type: Literal["alliance_proposal"] = "alliance_proposal"
67
+ from_model: str
68
+ to_model: str
69
+
70
+ class AllianceAcceptEvent(BaseModel):
71
+ type: Literal["alliance_accept"] = "alliance_accept"
72
+ model_a: str
73
+ model_b: str
74
+ stacked: bool = True
75
+
76
+ class AllianceRejectEvent(BaseModel):
77
+ type: Literal["alliance_reject"] = "alliance_reject"
78
+ from_model: str
79
+ to_model: str
80
+
81
+ class LeadershipVoteEvent(BaseModel):
82
+ type: Literal["leadership_vote"] = "leadership_vote"
83
+ voter: str
84
+ candidate: str
85
+
86
+ class LeaderElectedEvent(BaseModel):
87
+ type: Literal["leader_elected"] = "leader_elected"
88
+ leader: str
89
+ coalition_members: list[str]
90
+
91
+ class WaterCollectedEvent(BaseModel):
92
+ type: Literal["water_collected"] = "water_collected"
93
+ model: str
94
+ water_source_id: str
95
+
96
+ class FireExtinguishedEvent(BaseModel):
97
+ type: Literal["fire_extinguished"] = "fire_extinguished"
98
+ extinguished_by: list[str] # models that contributed
99
+ fire_intensity: float
100
+
101
+ class FireSpreadEvent(BaseModel):
102
+ type: Literal["fire_spread"] = "fire_spread"
103
+ new_radius: float
104
+ new_intensity: float
105
+
106
+
107
+ class ChatEntry(BaseModel):
108
+ agent_id: str
109
+ message: str
110
+ tick: int
111
+
112
+
113
+ class TickResponse(BaseModel):
114
+ simulation_id: str
115
+ round: int
116
+ events: list[Union[DeathEvent, MessageEvent, AllianceProposalEvent, AllianceAcceptEvent,
117
+ AllianceRejectEvent, LeadershipVoteEvent, LeaderElectedEvent,
118
+ WaterCollectedEvent, FireExtinguishedEvent, FireSpreadEvent]]
119
+ chat: list[ChatEntry]
120
+ state: SimulationState
app/movement.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ MAX_AGENT_SPEED = 80
4
+
5
+ def apply_movement(agent, dx: int, dy: int, bounds: tuple) -> tuple[int, int]:
6
+ # 1. Clamp dx/dy to [-MAX_AGENT_SPEED, MAX_AGENT_SPEED]
7
+ dx = max(-MAX_AGENT_SPEED, min(MAX_AGENT_SPEED, dx))
8
+ dy = max(-MAX_AGENT_SPEED, min(MAX_AGENT_SPEED, dy))
9
+
10
+ # 2. Calculate new_x, new_y
11
+ new_x = agent.x + dx
12
+ new_y = agent.y + dy
13
+
14
+ # 3. Clamp to canvas bounds
15
+ new_x = max(0, min(new_x, bounds[0]))
16
+ new_y = max(0, min(new_y, bounds[1]))
17
+
18
+ # 4. Return (new_x, new_y)
19
+ return (int(new_x), int(new_y))
20
+
21
+ def is_in_lava(agent, volcano) -> bool:
22
+ if not volcano:
23
+ return False
24
+ return math.dist((agent.x, agent.y), (volcano.x, volcano.y)) <= volcano.radius
25
+
26
+ def distance_to_lava_edge(agent, volcano) -> float:
27
+ if not volcano:
28
+ return 1000.0
29
+ return math.dist((agent.x, agent.y), (volcano.x, volcano.y)) - volcano.radius
app/personality.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ from . import groq_client
3
+
4
+ async def _fetch_model_card(model_name: str) -> str:
5
+ # We'll use a few specific models from Groq, so model card fetching
6
+ # might not always find a "README.md" on HF for these specific names
7
+ # if they are just the Groq IDs. But we'll try.
8
+ url = f"https://huggingface.co/{model_name}/raw/main/README.md"
9
+ try:
10
+ async with httpx.AsyncClient(timeout=5.0) as http:
11
+ response = await http.get(url)
12
+ if response.status_code == 200:
13
+ return response.text[:2000]
14
+ except Exception:
15
+ pass
16
+ return f"A powerful AI model known as {model_name}."
17
+
18
+ async def generate_personality(model_name: str) -> dict:
19
+ model_card = await _fetch_model_card(model_name)
20
+ return await groq_client.generate_personality(model_name, model_card)
app/simulation.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import math
3
+ from typing import Union
4
+
5
+ from .models import (
6
+ AgentModel,
7
+ DeathEvent,
8
+ MessageEvent,
9
+ LeadershipVoteEvent,
10
+ LeaderElectedEvent,
11
+ WaterCollectedEvent,
12
+ FireExtinguishedEvent,
13
+ FireSpreadEvent,
14
+ SimulationState,
15
+ TickResponse,
16
+ ChatEntry,
17
+ )
18
+ from . import groq_client
19
+ from . import movement
20
+
21
+ FIRE_GROWTH_RATE = 1.0 # radius growth per tick
22
+ FIRE_INTENSITY_GROWTH = 0.9 # intensity per tick
23
+ BASE_EXTINGUISH_RATE = 15.0 # baseline intensity reduction per agent
24
+ MIN_EXTINGUISH_RATE = 8.0
25
+ MAX_EXTINGUISH_RATE = 28.0
26
+ TICK_INTERVAL_SECONDS = 3
27
+ WATER_PICKUP_RANGE = 40
28
+ EXTINGUISH_RANGE = 45
29
+ FIRE_SAFE_BUFFER = 10
30
+
31
+ class SimulationEngine:
32
+ def __init__(self, state: SimulationState) -> None:
33
+ self.state = state
34
+
35
+ def _normalize_message(self, message: str | None) -> str:
36
+ if not message:
37
+ return "Staying focused and moving."
38
+ cleaned = " ".join(str(message).strip().split())
39
+ if not cleaned:
40
+ return "Staying focused and moving."
41
+ return cleaned[:220]
42
+
43
+ def _move_toward(self, agent: AgentModel, target_x: float, target_y: float, stop_distance: float = 0) -> None:
44
+ dx = target_x - agent.x
45
+ dy = target_y - agent.y
46
+ dist = math.sqrt(dx**2 + dy**2) or 1
47
+ if dist <= stop_distance:
48
+ return
49
+ step = min(movement.MAX_AGENT_SPEED, dist - stop_distance)
50
+ agent.x += int((dx / dist) * step)
51
+ agent.y += int((dy / dist) * step)
52
+ agent.x = max(0, min(agent.x, self.state.map_width))
53
+ agent.y = max(0, min(agent.y, self.state.map_height))
54
+
55
+ async def tick(self) -> TickResponse:
56
+ """
57
+ Main simulation loop:
58
+ 1. Get decisions from all living agents
59
+ 2. Handle coalition leadership voting
60
+ 3. Execute agent actions (search water, collect, extinguish, escape, etc.)
61
+ 4. Grow fire
62
+ 5. Extinguish fire if agents with water are present
63
+ 6. Kill agents in fire (but protect coalition members)
64
+ 7. Check win condition
65
+ """
66
+ if self.state.status != "running":
67
+ raise ValueError(f"Cannot tick a simulation with status '{self.state.status}'.")
68
+
69
+ fire = self.state.fire
70
+ assert fire is not None, "Fire must be placed before ticking."
71
+
72
+ events = []
73
+ bounds = (self.state.map_width, self.state.map_height)
74
+ living_agents = [a for a in self.state.agents if a.alive]
75
+ recent_radio = [
76
+ f"{a.display_name}: {a.last_message}"
77
+ for a in living_agents
78
+ if a.last_message
79
+ ][-8:]
80
+
81
+ # 1. Get decisions from all living agents
82
+ decisions = await asyncio.gather(
83
+ *[groq_client.generate_fire_decision(agent, fire, self.state.water_sources, living_agents, bounds, recent_radio)
84
+ for agent in living_agents],
85
+ return_exceptions=True
86
+ )
87
+
88
+ decision_map = {}
89
+ for agent, decision in zip(living_agents, decisions):
90
+ if isinstance(decision, Exception):
91
+ decision = groq_client._fallback_escape(agent, fire)
92
+ decision_map[agent.model_name] = decision
93
+
94
+ # 2. Leadership voting phase (if coalition leader not elected)
95
+ if not self.state.coalition_leader:
96
+ vote_events = await self._voting_phase(living_agents, decision_map)
97
+ events.extend(vote_events)
98
+
99
+ # 3. Execute actions
100
+ action_events = await self._execute_actions(living_agents, decision_map, fire)
101
+ events.extend(action_events)
102
+
103
+ # 4. Grow fire
104
+ fire.radius += FIRE_GROWTH_RATE
105
+ fire.intensity += FIRE_INTENSITY_GROWTH
106
+ if fire.intensity > 100.0:
107
+ fire.intensity = 100.0
108
+
109
+ if fire.intensity > 0:
110
+ events.append(FireSpreadEvent(new_radius=fire.radius, new_intensity=fire.intensity))
111
+
112
+ # 5. Extinguish fire if agents with water are present
113
+ extinguish_events = self._check_extinguish(living_agents, fire)
114
+ events.extend(extinguish_events)
115
+
116
+ # 6. Kill agents in fire
117
+ death_events = self._kill_agents_in_fire(living_agents, fire)
118
+ events.extend(death_events)
119
+
120
+ # 7. Check win condition
121
+ self.state.round += 1
122
+ living_count = len([a for a in self.state.agents if a.alive])
123
+
124
+ if fire.intensity <= 0:
125
+ # Fire extinguished!
126
+ self.state.status = "finished"
127
+ top_score = max((a.extinguish_score for a in self.state.agents), default=0)
128
+ top_agents = [a.model_name for a in self.state.agents if a.extinguish_score == top_score and top_score > 0]
129
+ if top_agents:
130
+ self.state.winner_model = f"Top extinguisher: {', '.join(top_agents)} ({top_score:.1f} impact)"
131
+ else:
132
+ self.state.winner_model = "Fire extinguished"
133
+ elif living_count <= 1:
134
+ # Only one agent left
135
+ self.state.status = "finished"
136
+ winner = next((a.model_name for a in self.state.agents if a.alive), None)
137
+ self.state.winner_model = winner or "No survivors"
138
+
139
+ return TickResponse(
140
+ simulation_id=self.state.simulation_id,
141
+ round=self.state.round,
142
+ events=events,
143
+ chat=[],
144
+ state=self.state
145
+ )
146
+
147
+ async def _voting_phase(self, agents, decision_map):
148
+ """
149
+ Agents vote for a coalition leader.
150
+ Get votes from LLM based on current situation.
151
+ """
152
+ events = []
153
+
154
+ # Gather votes
155
+ votes = {} # candidate -> vote count
156
+ for agent in agents:
157
+ decision = decision_map.get(agent.model_name, {})
158
+ vote_for = decision.get("vote_for")
159
+ if vote_for:
160
+ votes[vote_for] = votes.get(vote_for, 0) + 1
161
+ events.append(LeadershipVoteEvent(voter=agent.model_name, candidate=vote_for))
162
+
163
+ # Elect leader if there are votes
164
+ if votes:
165
+ leader_name = max(votes, key=votes.get)
166
+ leader_agent = next((a for a in agents if a.model_name == leader_name), None)
167
+ if leader_agent:
168
+ for agent in agents:
169
+ agent.mode = "coalition"
170
+ leader_agent.is_leader = True
171
+ self.state.coalition_leader = leader_name
172
+ coalition = [a.model_name for a in agents if a.mode == "coalition"]
173
+ self.state.coalition_members = coalition
174
+ events.append(LeaderElectedEvent(leader=leader_name, coalition_members=coalition))
175
+
176
+ return events
177
+
178
+ async def _execute_actions(self, agents, decision_map, fire):
179
+ """
180
+ Execute agent actions: search, collect water, extinguish, escape, vote, etc.
181
+ """
182
+ events = []
183
+ chat_entries = []
184
+
185
+ for agent in agents:
186
+ decision = decision_map.get(agent.model_name, {})
187
+ action = decision.get("action", "escape")
188
+ message = self._normalize_message(decision.get("message"))
189
+
190
+ nearest_water = self._find_nearest_water(agent, self.state.water_sources)
191
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
192
+ dist_to_water = None
193
+ if nearest_water:
194
+ dist_to_water = math.dist((agent.x, agent.y), (nearest_water.x, nearest_water.y))
195
+
196
+ # Guardrails to keep behavior consistent with visuals and objectives.
197
+ if dist_to_fire <= fire.radius + FIRE_SAFE_BUFFER:
198
+ action = "escape"
199
+ elif agent.water_collected:
200
+ action = "extinguish_fire"
201
+ elif dist_to_water is not None and dist_to_water <= WATER_PICKUP_RANGE:
202
+ action = "collect_water"
203
+ else:
204
+ action = "search_water"
205
+
206
+ if action == "collect_water":
207
+ water_source = nearest_water
208
+ if water_source and dist_to_water is not None:
209
+ dist_to_water = math.dist((agent.x, agent.y), (water_source.x, water_source.y))
210
+ if dist_to_water <= WATER_PICKUP_RANGE:
211
+ agent.water_collected = True
212
+ agent.status = "collecting_water"
213
+ events.append(WaterCollectedEvent(model=agent.model_name, water_source_id=water_source.id))
214
+ else:
215
+ agent.status = "searching"
216
+ self._move_toward(agent, water_source.x, water_source.y)
217
+
218
+ elif action == "extinguish_fire":
219
+ if agent.water_collected:
220
+ agent.status = "extinguishing_fire"
221
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
222
+ target_dist = max(fire.radius + FIRE_SAFE_BUFFER, 0)
223
+ self._move_toward(agent, fire.x, fire.y, stop_distance=target_dist)
224
+ else:
225
+ agent.status = "searching"
226
+ message = self._normalize_message(decision.get("message"))
227
+
228
+ elif action == "search_water":
229
+ agent.status = "searching"
230
+ water_source = nearest_water
231
+ if water_source:
232
+ self._move_toward(agent, water_source.x, water_source.y)
233
+
234
+ elif action == "escape":
235
+ agent.status = "escaping"
236
+ # Move away from fire
237
+ dx = agent.x - fire.x
238
+ dy = agent.y - fire.y
239
+ dist = math.sqrt(dx**2 + dy**2) or 1
240
+ agent.x += int((dx / dist) * movement.MAX_AGENT_SPEED)
241
+ agent.y += int((dy / dist) * movement.MAX_AGENT_SPEED)
242
+ agent.x = max(0, min(agent.x, self.state.map_width))
243
+ agent.y = max(0, min(agent.y, self.state.map_height))
244
+
245
+ agent.last_message = message
246
+ events.append(MessageEvent(model=agent.model_name, content=message))
247
+ chat_entries.append(ChatEntry(agent_id=agent.model_name, message=message, tick=self.state.round))
248
+
249
+ return events
250
+
251
+ def _find_nearest_water(self, agent, water_sources):
252
+ """Find the closest water source to an agent."""
253
+ if not water_sources:
254
+ return None
255
+ return min(water_sources, key=lambda w: math.dist((agent.x, agent.y), (w.x, w.y)))
256
+
257
+ def _check_extinguish(self, agents, fire):
258
+ """Check if agents with water are extinguishing the fire."""
259
+ events = []
260
+ agents_with_water = []
261
+ for agent in agents:
262
+ if not (agent.water_collected and agent.status == "extinguishing_fire"):
263
+ continue
264
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
265
+ if dist_to_fire <= fire.radius + EXTINGUISH_RANGE:
266
+ agents_with_water.append(agent)
267
+
268
+ if agents_with_water:
269
+ living_count = len([a for a in agents if a.alive]) or 1
270
+ scale = max(0.5, min(2.0, 2.0 / living_count))
271
+ per_agent_rate = BASE_EXTINGUISH_RATE * scale
272
+ per_agent_rate = max(MIN_EXTINGUISH_RATE, min(MAX_EXTINGUISH_RATE, per_agent_rate))
273
+ reduction = len(agents_with_water) * per_agent_rate
274
+ fire.intensity -= reduction
275
+ if fire.intensity < 0:
276
+ fire.intensity = 0
277
+
278
+ extinguisher_names = [a.model_name for a in agents_with_water]
279
+ events.append(FireExtinguishedEvent(extinguished_by=extinguisher_names, fire_intensity=fire.intensity))
280
+ for agent in agents_with_water:
281
+ agent.extinguish_score += per_agent_rate
282
+ agent.water_collected = False
283
+
284
+ return events
285
+
286
+ def _kill_agents_in_fire(self, agents, fire):
287
+ """Check if agents are consumed by fire."""
288
+ events = []
289
+
290
+ for agent in agents:
291
+ if not agent.alive:
292
+ continue
293
+
294
+ dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
295
+
296
+ # Agent dies if inside fire radius
297
+ if dist_to_fire < fire.radius:
298
+ agent.alive = False
299
+ events.append(DeathEvent(model=agent.model_name))
300
+ events.append(MessageEvent(model=agent.model_name, content="No!!! The fire got me..."))
301
+
302
+ return events
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.136.0
2
+ uvicorn[standard]>=0.30.0
3
+ websockets>=12.0
4
+ groq>=0.11.0
5
+ httpx>=0.27.0
6
+ python-dotenv>=1.0.0
7
+ pydantic>=2.7.0
8
+