Spaces:
Running
Running
adityaverma977 commited on
Commit ·
43d708a
0
Parent(s):
inital build
Browse files- .gitignore +219 -0
- QUICKSTART.md +280 -0
- README.md +103 -0
- backend/.env.example +14 -0
- backend/.python-version +1 -0
- backend/Dockerfile +7 -0
- backend/README.md +46 -0
- backend/app/__init__.py +0 -0
- backend/app/groq_client.py +151 -0
- backend/app/hf_spaces.py +108 -0
- backend/app/main.py +181 -0
- backend/app/models.py +120 -0
- backend/app/movement.py +29 -0
- backend/app/personality.py +20 -0
- backend/app/simulation.py +324 -0
- backend/requirements.txt +8 -0
- frontend/.gitignore +41 -0
- frontend/AGENTS.md +5 -0
- frontend/CLAUDE.md +1 -0
- frontend/README.md +42 -0
- frontend/app/favicon.ico +0 -0
- frontend/app/globals.css +66 -0
- frontend/app/layout.tsx +33 -0
- frontend/app/page.tsx +168 -0
- frontend/components/ChatFeed.tsx +137 -0
- frontend/components/LoadingScreen.tsx +79 -0
- frontend/components/MapCanvas.tsx +270 -0
- frontend/components/ModelSelector.tsx +164 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next.config.ts +7 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +26 -0
- frontend/postcss.config.mjs +7 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/next.svg +1 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/tsconfig.json +34 -0
- frontend/vercel.json +5 -0
.gitignore
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[codz]
|
| 4 |
+
*$py.class
|
| 5 |
+
SPEC.md
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# PyInstaller
|
| 30 |
+
# Usually these files are written by a python script from a template
|
| 31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
backend/.env
|
| 35 |
+
.env
|
| 36 |
+
# Installer logs
|
| 37 |
+
pip-log.txt
|
| 38 |
+
pip-delete-this-directory.txt
|
| 39 |
+
|
| 40 |
+
# Unit test / coverage reports
|
| 41 |
+
htmlcov/
|
| 42 |
+
.tox/
|
| 43 |
+
.nox/
|
| 44 |
+
.coverage
|
| 45 |
+
.coverage.*
|
| 46 |
+
.cache
|
| 47 |
+
nosetests.xml
|
| 48 |
+
coverage.xml
|
| 49 |
+
*.cover
|
| 50 |
+
*.py.cover
|
| 51 |
+
.hypothesis/
|
| 52 |
+
.pytest_cache/
|
| 53 |
+
cover/
|
| 54 |
+
|
| 55 |
+
# Translations
|
| 56 |
+
*.mo
|
| 57 |
+
*.pot
|
| 58 |
+
|
| 59 |
+
# Django stuff:
|
| 60 |
+
*.log
|
| 61 |
+
local_settings.py
|
| 62 |
+
db.sqlite3
|
| 63 |
+
db.sqlite3-journal
|
| 64 |
+
|
| 65 |
+
# Flask stuff:
|
| 66 |
+
instance/
|
| 67 |
+
.webassets-cache
|
| 68 |
+
|
| 69 |
+
# Scrapy stuff:
|
| 70 |
+
.scrapy
|
| 71 |
+
|
| 72 |
+
# Sphinx documentation
|
| 73 |
+
docs/_build/
|
| 74 |
+
|
| 75 |
+
# PyBuilder
|
| 76 |
+
.pybuilder/
|
| 77 |
+
target/
|
| 78 |
+
|
| 79 |
+
# Jupyter Notebook
|
| 80 |
+
.ipynb_checkpoints
|
| 81 |
+
|
| 82 |
+
# IPython
|
| 83 |
+
profile_default/
|
| 84 |
+
ipython_config.py
|
| 85 |
+
|
| 86 |
+
# pyenv
|
| 87 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 88 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 89 |
+
# .python-version
|
| 90 |
+
|
| 91 |
+
# pipenv
|
| 92 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 93 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 94 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 95 |
+
# install all needed dependencies.
|
| 96 |
+
# Pipfile.lock
|
| 97 |
+
|
| 98 |
+
# UV
|
| 99 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 100 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 101 |
+
# commonly ignored for libraries.
|
| 102 |
+
# uv.lock
|
| 103 |
+
|
| 104 |
+
# poetry
|
| 105 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 106 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 107 |
+
# commonly ignored for libraries.
|
| 108 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 109 |
+
# poetry.lock
|
| 110 |
+
# poetry.toml
|
| 111 |
+
|
| 112 |
+
# pdm
|
| 113 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 114 |
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
| 115 |
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
| 116 |
+
# pdm.lock
|
| 117 |
+
# pdm.toml
|
| 118 |
+
.pdm-python
|
| 119 |
+
.pdm-build/
|
| 120 |
+
|
| 121 |
+
# pixi
|
| 122 |
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
| 123 |
+
# pixi.lock
|
| 124 |
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
| 125 |
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
| 126 |
+
.pixi
|
| 127 |
+
|
| 128 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 129 |
+
__pypackages__/
|
| 130 |
+
|
| 131 |
+
# Celery stuff
|
| 132 |
+
celerybeat-schedule
|
| 133 |
+
celerybeat.pid
|
| 134 |
+
|
| 135 |
+
# Redis
|
| 136 |
+
*.rdb
|
| 137 |
+
*.aof
|
| 138 |
+
*.pid
|
| 139 |
+
|
| 140 |
+
# RabbitMQ
|
| 141 |
+
mnesia/
|
| 142 |
+
rabbitmq/
|
| 143 |
+
rabbitmq-data/
|
| 144 |
+
|
| 145 |
+
# ActiveMQ
|
| 146 |
+
activemq-data/
|
| 147 |
+
|
| 148 |
+
# SageMath parsed files
|
| 149 |
+
*.sage.py
|
| 150 |
+
|
| 151 |
+
# Environments
|
| 152 |
+
.env
|
| 153 |
+
.envrc
|
| 154 |
+
.venv
|
| 155 |
+
env/
|
| 156 |
+
venv/
|
| 157 |
+
ENV/
|
| 158 |
+
env.bak/
|
| 159 |
+
venv.bak/
|
| 160 |
+
|
| 161 |
+
# Spyder project settings
|
| 162 |
+
.spyderproject
|
| 163 |
+
.spyproject
|
| 164 |
+
|
| 165 |
+
# Rope project settings
|
| 166 |
+
.ropeproject
|
| 167 |
+
|
| 168 |
+
# mkdocs documentation
|
| 169 |
+
/site
|
| 170 |
+
|
| 171 |
+
# mypy
|
| 172 |
+
.mypy_cache/
|
| 173 |
+
.dmypy.json
|
| 174 |
+
dmypy.json
|
| 175 |
+
|
| 176 |
+
# Pyre type checker
|
| 177 |
+
.pyre/
|
| 178 |
+
|
| 179 |
+
# pytype static type analyzer
|
| 180 |
+
.pytype/
|
| 181 |
+
|
| 182 |
+
# Cython debug symbols
|
| 183 |
+
cython_debug/
|
| 184 |
+
|
| 185 |
+
# PyCharm
|
| 186 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 187 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 188 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 189 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 190 |
+
# .idea/
|
| 191 |
+
|
| 192 |
+
# Abstra
|
| 193 |
+
# Abstra is an AI-powered process automation framework.
|
| 194 |
+
# Ignore directories containing user credentials, local state, and settings.
|
| 195 |
+
# Learn more at https://abstra.io/docs
|
| 196 |
+
.abstra/
|
| 197 |
+
|
| 198 |
+
# Visual Studio Code
|
| 199 |
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
| 200 |
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
| 201 |
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
| 202 |
+
# you could uncomment the following to ignore the entire vscode folder
|
| 203 |
+
# .vscode/
|
| 204 |
+
# Temporary file for partial code execution
|
| 205 |
+
tempCodeRunnerFile.py
|
| 206 |
+
|
| 207 |
+
# Ruff stuff:
|
| 208 |
+
.ruff_cache/
|
| 209 |
+
|
| 210 |
+
# PyPI configuration file
|
| 211 |
+
.pypirc
|
| 212 |
+
|
| 213 |
+
# Marimo
|
| 214 |
+
marimo/_static/
|
| 215 |
+
marimo/_lsp/
|
| 216 |
+
__marimo__/
|
| 217 |
+
|
| 218 |
+
# Streamlit
|
| 219 |
+
.streamlit/secrets.toml
|
QUICKSTART.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RUSH AGENTS - Quick Start Guide
|
| 2 |
+
|
| 3 |
+
## 📋 Overview
|
| 4 |
+
|
| 5 |
+
**RUSH AGENTS** is an AI model battle arena where models compete for survival by escaping lava and forming strategic alliances.
|
| 6 |
+
|
| 7 |
+
- **Intelligence Test**: See which AI model survives the longest
|
| 8 |
+
- **Alliance System**: Models can propose partnerships (but proposing signals weakness)
|
| 9 |
+
- **Rules-Based**: No personalities—all models play by identical rules
|
| 10 |
+
- **Groq API**: Fast, parallel decision-making for all models
|
| 11 |
+
- **Real-time**: WebSocket streaming, 3-second ticks
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 🚀 Quick Start (Local Development)
|
| 16 |
+
|
| 17 |
+
### Prerequisites
|
| 18 |
+
✅ Already installed:
|
| 19 |
+
- Python 3.10+
|
| 20 |
+
- Node.js 18+
|
| 21 |
+
- Groq API Key (in `backend/.env`)
|
| 22 |
+
|
| 23 |
+
### Start Backend
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
cd backend
|
| 27 |
+
|
| 28 |
+
# Verify .env has GROQ_API_KEY
|
| 29 |
+
cat .env
|
| 30 |
+
|
| 31 |
+
# Run the server
|
| 32 |
+
python -m uvicorn app.main:app --reload
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Server runs on: **http://localhost:8000**
|
| 36 |
+
|
| 37 |
+
### Start Frontend (New Terminal)
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
cd frontend
|
| 41 |
+
|
| 42 |
+
# Run dev server
|
| 43 |
+
npm run dev
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
Frontend runs on: **http://localhost:3000**
|
| 47 |
+
|
| 48 |
+
### Play
|
| 49 |
+
|
| 50 |
+
1. Open http://localhost:3000
|
| 51 |
+
2. Wait for "Ready for chaos"
|
| 52 |
+
3. Select 2-6 models from the dropdown
|
| 53 |
+
4. Click "Start Simulation"
|
| 54 |
+
5. Click map to place volcano
|
| 55 |
+
6. Watch the battle unfold!
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## 📚 What's Available
|
| 60 |
+
|
| 61 |
+
### Models
|
| 62 |
+
**Groq Models (Free Tier)**:
|
| 63 |
+
- `llama-3.1-8b-instant` — 8B fast
|
| 64 |
+
- `llama-3.1-70b-versatile` — 70B smart
|
| 65 |
+
- `mixtral-8x7b-32768` — 8x7B expert
|
| 66 |
+
- `gemma-7b-it` — 7B instruction-tuned
|
| 67 |
+
|
| 68 |
+
**HuggingFace Spaces** (Can add more):
|
| 69 |
+
- Falcon-7B
|
| 70 |
+
- Llama-2-7B
|
| 71 |
+
- Mistral-7B
|
| 72 |
+
- Zephyr-7B
|
| 73 |
+
- OpenHermes-7B
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## 🎮 Game Rules
|
| 78 |
+
|
| 79 |
+
### Each Model Sees:
|
| 80 |
+
- All other models' positions (x, y)
|
| 81 |
+
- Volcano position & radius
|
| 82 |
+
- Map bounds (1200x800)
|
| 83 |
+
- Distance to lava edge
|
| 84 |
+
- Alliance status (who's allied with whom)
|
| 85 |
+
|
| 86 |
+
### Each Tick (3 seconds):
|
| 87 |
+
|
| 88 |
+
**1. Groq Decision Call** (Parallel for all models)
|
| 89 |
+
- "run" → Sprint away from lava
|
| 90 |
+
- "propose_alliance" → Ask another model to team up
|
| 91 |
+
|
| 92 |
+
**2. Alliance Processing**
|
| 93 |
+
- If A proposes to B, B gets asked immediately
|
| 94 |
+
- If B accepts → Both merge at same position
|
| 95 |
+
- If B rejects → A keeps running, B keeps running
|
| 96 |
+
|
| 97 |
+
**3. Movement**
|
| 98 |
+
- Models move based on decisions
|
| 99 |
+
- Position clamped to map bounds
|
| 100 |
+
|
| 101 |
+
**4. Lava Expands**
|
| 102 |
+
- Radius grows by 120 pixels
|
| 103 |
+
|
| 104 |
+
**5. Deaths**
|
| 105 |
+
- Models in lava radius die
|
| 106 |
+
- EXCEPT if they're stacked with an alive ally
|
| 107 |
+
|
| 108 |
+
**6. Win Check**
|
| 109 |
+
- ≤1 model alive → Game Over
|
| 110 |
+
|
| 111 |
+
### Strategic Cost of Alliances
|
| 112 |
+
- **Proposing** = signaling weakness
|
| 113 |
+
- **First to propose** = lose strategic leverage
|
| 114 |
+
- **Benefit** = survival through numbers
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## 🔑 Environment Variables
|
| 119 |
+
|
| 120 |
+
### Backend (.env)
|
| 121 |
+
```
|
| 122 |
+
GROQ_API_KEY=<your key here> # REQUIRED
|
| 123 |
+
HUGGINGFACE_API_TOKEN= # Optional
|
| 124 |
+
ALLOWED_ORIGINS=http://localhost:3000
|
| 125 |
+
ENV=development
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
### Frontend (.env.local)
|
| 129 |
+
```
|
| 130 |
+
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## 📡 API Endpoints
|
| 136 |
+
|
| 137 |
+
```
|
| 138 |
+
GET /wake → Health check + status
|
| 139 |
+
GET /available-models → List of all available models
|
| 140 |
+
POST /start-simulation → Create new simulation
|
| 141 |
+
POST /place-volcano → Place volcano, start ticking
|
| 142 |
+
GET /ws/{simulation_id} → WebSocket stream
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## 🧠 How Groq Decision-Making Works
|
| 148 |
+
|
| 149 |
+
### Per Model, Per Tick:
|
| 150 |
+
1. **Build state summary**
|
| 151 |
+
- Current standings (distance from lava)
|
| 152 |
+
- All agents' positions
|
| 153 |
+
- Volcano position & radius
|
| 154 |
+
|
| 155 |
+
2. **Send to Groq Llama 3.1 8B**
|
| 156 |
+
```json
|
| 157 |
+
{
|
| 158 |
+
"system": "You are [ModelName]. Make a strategic decision...",
|
| 159 |
+
"user": "[Game state]"
|
| 160 |
+
}
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
3. **Get response**
|
| 164 |
+
```json
|
| 165 |
+
{
|
| 166 |
+
"action": "run|propose_alliance",
|
| 167 |
+
"alliance_target": "ModelName or null",
|
| 168 |
+
"reasoning": "..."
|
| 169 |
+
}
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
4. **Execute action**
|
| 173 |
+
|
| 174 |
+
All models queried in parallel (async) = fast ticks
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## 🎨 Frontend Features
|
| 179 |
+
|
| 180 |
+
### Map Canvas
|
| 181 |
+
- **White circles** = Individual models
|
| 182 |
+
- **Yellow glowing circles** = Stacked alliances (2+ models)
|
| 183 |
+
- **Orange/red glow** = Lava expanding
|
| 184 |
+
- **Gray skull** = Dead models
|
| 185 |
+
- **Yellow 🤝** = Alliance indicator on label
|
| 186 |
+
|
| 187 |
+
### Sidebar
|
| 188 |
+
- **Model Selector** = Pick 2-6 models (groups by Groq/HF)
|
| 189 |
+
- **Chat Feed** = Events (alliances, deaths, decisions)
|
| 190 |
+
- **Status** = Current round, agents alive
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## 🔧 Troubleshooting
|
| 195 |
+
|
| 196 |
+
### "Failed to fetch models"
|
| 197 |
+
- Check backend is running: `python -m uvicorn app.main:app --reload`
|
| 198 |
+
- Check port 8000 is available
|
| 199 |
+
|
| 200 |
+
### "Groq API error"
|
| 201 |
+
- Verify `GROQ_API_KEY` in `backend/.env`
|
| 202 |
+
- Check Groq dashboard: https://console.groq.com
|
| 203 |
+
|
| 204 |
+
### WebSocket connection fails
|
| 205 |
+
- Frontend tries `ws://localhost:8000` (port 8000)
|
| 206 |
+
- Make sure backend is running
|
| 207 |
+
|
| 208 |
+
### No models showing up
|
| 209 |
+
- Check `/available-models` returns data: `curl http://localhost:8000/available-models`
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
## 📝 Key Files
|
| 214 |
+
|
| 215 |
+
```
|
| 216 |
+
backend/
|
| 217 |
+
app/
|
| 218 |
+
main.py → FastAPI server
|
| 219 |
+
simulation.py → Game engine logic
|
| 220 |
+
models.py → Pydantic schemas
|
| 221 |
+
groq_client.py → Groq API integration
|
| 222 |
+
hf_spaces.py → HF model discovery
|
| 223 |
+
movement.py → Physics/movement
|
| 224 |
+
.env → API keys (REQUIRED)
|
| 225 |
+
requirements.txt → Python dependencies
|
| 226 |
+
|
| 227 |
+
frontend/
|
| 228 |
+
app/
|
| 229 |
+
page.tsx → Main app component
|
| 230 |
+
components/
|
| 231 |
+
MapCanvas.tsx → 2D map rendering
|
| 232 |
+
ModelSelector.tsx → Model selection UI
|
| 233 |
+
ChatFeed.tsx → Event stream display
|
| 234 |
+
lib/
|
| 235 |
+
api.ts → Backend API calls
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
## 🚢 Deployment
|
| 241 |
+
|
| 242 |
+
### Backend (Railway, Heroku, etc.)
|
| 243 |
+
```bash
|
| 244 |
+
# Set env vars
|
| 245 |
+
GROQ_API_KEY=...
|
| 246 |
+
ALLOWED_ORIGINS=https://yourdomain.com
|
| 247 |
+
|
| 248 |
+
# Deploy
|
| 249 |
+
gunicorn -w 4 -k uvicorn.workers.UvicornWorker app.main:app
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
### Frontend (Vercel, Netlify)
|
| 253 |
+
```bash
|
| 254 |
+
npm run build
|
| 255 |
+
# Deploy the .next folder
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
---
|
| 259 |
+
|
| 260 |
+
## 📖 Learn More
|
| 261 |
+
|
| 262 |
+
- Groq API docs: https://console.groq.com/docs
|
| 263 |
+
- FastAPI: https://fastapi.tiangolo.com/
|
| 264 |
+
- Next.js: https://nextjs.org/docs
|
| 265 |
+
- WebSocket: https://mdn.io/WebSocket
|
| 266 |
+
|
| 267 |
+
---
|
| 268 |
+
|
| 269 |
+
## 💡 Ideas for Experimentation
|
| 270 |
+
|
| 271 |
+
1. **Add new models** — Edit `hf_spaces.py` KNOWN_SPACES_MODELS
|
| 272 |
+
2. **Change game rules** — Edit `simulation.py` tick logic
|
| 273 |
+
3. **New scenarios** — Add earthquake, meteor, etc. in `simulation.py`
|
| 274 |
+
4. **Leaderboard** — Track best survival times across sessions
|
| 275 |
+
5. **Replay system** — Save/load simulation events
|
| 276 |
+
6. **Model attack mechanic** — Add "push" action to shove others toward lava
|
| 277 |
+
|
| 278 |
+
---
|
| 279 |
+
|
| 280 |
+
**Ready to battle?** Open http://localhost:3000! 🌋
|
README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RUSH AGENTS RUSH
|
| 2 |
+
|
| 3 |
+
> *Different models. One fire. Real teamwork or chaos.*
|
| 4 |
+
|
| 5 |
+
Rush Agents Rush is a 2D multi-agent simulation where real AI models are dropped onto a map, a fire is placed, and the models must cooperate to survive and extinguish it. They vote for a leader, search for water, move as a coalition, and try to put the fire out before it consumes the map.
|
| 6 |
+
|
| 7 |
+
The current design has fully moved on from the old volcano/lava loop. The game is now about fire suppression, coalition strategy, and visible agent coordination.
|
| 8 |
+
|
| 9 |
+
## Core Idea
|
| 10 |
+
|
| 11 |
+
1. Pick 2-6 models from Groq or supported Hugging Face sources.
|
| 12 |
+
2. Start a simulation and click the map to place the fire.
|
| 13 |
+
3. Agents decide each tick whether to search water, collect water, extinguish fire, escape, or vote for a leader.
|
| 14 |
+
4. Fire grows over time, but water collection and extinguishing reduce intensity.
|
| 15 |
+
5. The game ends when the fire is fully extinguished or the agents are wiped out.
|
| 16 |
+
|
| 17 |
+
## Features
|
| 18 |
+
|
| 19 |
+
- Real model names and visible per-agent positions on the map
|
| 20 |
+
- Coalition voting and leader election
|
| 21 |
+
- Water wells placed around the fire arena
|
| 22 |
+
- Fire growth and deterministic extinguishing logic
|
| 23 |
+
- WebSocket-driven live simulation updates
|
| 24 |
+
- Chat feed with varied team-style messages
|
| 25 |
+
- End-of-game winner text showing the strongest extinguisher
|
| 26 |
+
|
| 27 |
+
## Tech Stack
|
| 28 |
+
|
| 29 |
+
| Layer | Tech |
|
| 30 |
+
| --- | --- |
|
| 31 |
+
| Frontend | Next.js 16, React 19, TypeScript, Tailwind CSS v4 |
|
| 32 |
+
| Backend | FastAPI, Python, Uvicorn |
|
| 33 |
+
| AI | Groq models, optional Hugging Face-backed model discovery |
|
| 34 |
+
| Realtime | WebSockets |
|
| 35 |
+
|
| 36 |
+
## Repository Layout
|
| 37 |
+
|
| 38 |
+
- [backend](backend): FastAPI API, simulation engine, model state, WebSocket streaming
|
| 39 |
+
- [frontend](frontend): Next.js UI, map rendering, chat feed, model selection
|
| 40 |
+
|
| 41 |
+
## Local Setup
|
| 42 |
+
|
| 43 |
+
### Backend
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
cd backend
|
| 47 |
+
pip install -r requirements.txt
|
| 48 |
+
python -m uvicorn app.main:app --reload --port 8000
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### Frontend
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
cd frontend
|
| 55 |
+
npm install
|
| 56 |
+
npm run dev
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Then open http://localhost:3000.
|
| 60 |
+
|
| 61 |
+
## Environment Variables
|
| 62 |
+
|
| 63 |
+
### Backend
|
| 64 |
+
|
| 65 |
+
```env
|
| 66 |
+
GROQ_API_KEY=your_groq_key
|
| 67 |
+
ALLOWED_ORIGINS=http://localhost:3000
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Frontend
|
| 71 |
+
|
| 72 |
+
```env
|
| 73 |
+
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## API Overview
|
| 77 |
+
|
| 78 |
+
| Method | Path | Purpose |
|
| 79 |
+
| --- | --- | --- |
|
| 80 |
+
| GET | /wake | Health and readiness check |
|
| 81 |
+
| GET | /available-models | List available models for the UI |
|
| 82 |
+
| POST | /start-simulation | Create a new simulation |
|
| 83 |
+
| POST | /place-fire | Place the fire and generate water wells |
|
| 84 |
+
| WS | /ws/{simulation_id} | Stream simulation ticks and events |
|
| 85 |
+
|
| 86 |
+
## Simulation Loop
|
| 87 |
+
|
| 88 |
+
Each tick the backend:
|
| 89 |
+
|
| 90 |
+
1. Collects decisions from all living agents in parallel.
|
| 91 |
+
2. Runs coalition voting if a leader has not been chosen.
|
| 92 |
+
3. Moves agents toward water or the fire edge based on their current action.
|
| 93 |
+
4. Grows the fire slightly.
|
| 94 |
+
5. Applies deterministic extinguish damage based on how many agents are actually in position.
|
| 95 |
+
6. Removes agents caught inside the fire radius.
|
| 96 |
+
7. Ends the game when the fire is out or only one agent remains.
|
| 97 |
+
|
| 98 |
+
## Notes
|
| 99 |
+
|
| 100 |
+
- State is kept in memory, so simulations reset when the backend restarts.
|
| 101 |
+
- The UI is designed around visible cooperation, not just survival.
|
| 102 |
+
- Old lava/volcano docs are intentionally replaced by the fire/water scenario.
|
| 103 |
+
|
backend/.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
|
backend/.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.14
|
backend/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"]
|
backend/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: RUSH AGENTS RUSH Backend
|
| 3 |
+
emoji: 🔥
|
| 4 |
+
colorFrom: orange
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Rush Agents Rush Backend
|
| 11 |
+
|
| 12 |
+
FastAPI server driving the fire-suppression simulation.
|
| 13 |
+
|
| 14 |
+
## What It Does
|
| 15 |
+
|
| 16 |
+
- Accepts model selections and starts a new simulation.
|
| 17 |
+
- Places a fire on the map and generates water wells.
|
| 18 |
+
- Runs the tick-based AI loop with coalition voting, movement, and extinguishing.
|
| 19 |
+
- Streams state updates and events over WebSockets.
|
| 20 |
+
|
| 21 |
+
## Key Endpoints
|
| 22 |
+
|
| 23 |
+
- `GET /wake` - health and readiness check
|
| 24 |
+
- `GET /available-models` - list available models for the UI
|
| 25 |
+
- `POST /start-simulation` - create a new simulation
|
| 26 |
+
- `POST /place-fire` - place the fire and spawn water sources
|
| 27 |
+
- `WS /ws/{simulation_id}` - stream live simulation ticks
|
| 28 |
+
|
| 29 |
+
## Environment Variables
|
| 30 |
+
|
| 31 |
+
- `GROQ_API_KEY`: Required for agent decisions.
|
| 32 |
+
- `ALLOWED_ORIGINS`: CORS whitelist.
|
| 33 |
+
|
| 34 |
+
## Local Run
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
cd backend
|
| 38 |
+
pip install -r requirements.txt
|
| 39 |
+
python -m uvicorn app.main:app --reload --port 8000
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## Notes
|
| 43 |
+
|
| 44 |
+
- Simulation state is in memory.
|
| 45 |
+
- Fire growth, extinguish rate, and movement are tuned in `app/simulation.py`.
|
| 46 |
+
- Model decisions are generated in `app/groq_client.py`.
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/groq_client.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) -> 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 |
+
|
| 63 |
+
coalition_leader = next((a.model_name for a in other_agents if a.is_leader), None)
|
| 64 |
+
dist_to_water_display = f"{dist_to_water:.0f}px" if dist_to_water is not None else "unknown"
|
| 65 |
+
system_prompt = f"""You are {agent.model_name}, an AI model in a critical wildfire survival scenario.
|
| 66 |
+
|
| 67 |
+
THE SCENARIO:
|
| 68 |
+
- A wildfire is spreading rapidly across the map
|
| 69 |
+
- Water sources (wells) are scattered around the area
|
| 70 |
+
- You can work alone or join a coalition with other AI models
|
| 71 |
+
- Coalition agents should elect a leader who coordinates the strategy
|
| 72 |
+
- If a leader exists, follow their plan: gather water, then move to the fire edge to extinguish
|
| 73 |
+
- To win: Find water → Collect it → Return to fire → Extinguish it together (or solo)
|
| 74 |
+
- If the fire consumes you, you lose
|
| 75 |
+
|
| 76 |
+
YOUR STRATEGIC OPTIONS EACH TICK:
|
| 77 |
+
1. "search_water" - Move toward the nearest water source
|
| 78 |
+
2. "collect_water" - Pick up water from a well (must be at a source)
|
| 79 |
+
3. "extinguish_fire" - Use collected water to fight the fire (must have water)
|
| 80 |
+
4. "escape" - Run away from the fire to survive
|
| 81 |
+
5. "vote_for_leader" - Vote for yourself or another model as coalition leader
|
| 82 |
+
|
| 83 |
+
IMPORTANT CONSIDERATIONS:
|
| 84 |
+
- If fire is very close (< 200px), prioritize escape or finding water
|
| 85 |
+
- If you have water, move to the fire edge and extinguish
|
| 86 |
+
- If you are near a water source (< 60px), collect it immediately
|
| 87 |
+
- Coalition mode requires coordination; vote strategically
|
| 88 |
+
- Solo mode means you act independently and don't wait for others
|
| 89 |
+
|
| 90 |
+
OUTPUT FORMAT - return ONLY valid JSON:
|
| 91 |
+
{{"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>"}}
|
| 92 |
+
|
| 93 |
+
CURRENT STATE:
|
| 94 |
+
Your position: ({agent.x}, {agent.y})
|
| 95 |
+
Fire position: ({fire.x}, {fire.y})
|
| 96 |
+
Distance from fire: {dist_to_fire:.0f}px
|
| 97 |
+
Fire radius: {fire.radius:.0f}px
|
| 98 |
+
Fire intensity: {fire.intensity:.0f}%
|
| 99 |
+
Carrying water: {agent.water_collected}
|
| 100 |
+
Mode: {agent.mode} ({'joined a coalition' if agent.mode == 'coalition' else 'acting alone'})
|
| 101 |
+
Nearest water distance: {dist_to_water_display}
|
| 102 |
+
Coalition leader: {coalition_leader or 'none'}
|
| 103 |
+
|
| 104 |
+
{state_summary}
|
| 105 |
+
|
| 106 |
+
What do you do?"""
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
completion = await _client.chat.completions.create(
|
| 110 |
+
model=DEFAULT_DECISION_MODEL,
|
| 111 |
+
messages=[
|
| 112 |
+
{"role": "system", "content": system_prompt},
|
| 113 |
+
{"role": "user", "content": "Make your decision."}
|
| 114 |
+
],
|
| 115 |
+
response_format={"type": "json_object"},
|
| 116 |
+
max_tokens=150,
|
| 117 |
+
timeout=3.0
|
| 118 |
+
)
|
| 119 |
+
decision = json.loads(completion.choices[0].message.content)
|
| 120 |
+
|
| 121 |
+
action = decision.get("action", "escape")
|
| 122 |
+
if action not in ["search_water", "collect_water", "extinguish_fire", "escape", "vote_for_leader"]:
|
| 123 |
+
action = "escape"
|
| 124 |
+
|
| 125 |
+
if dist_to_water is not None and dist_to_water <= 60 and not agent.water_collected:
|
| 126 |
+
action = "collect_water"
|
| 127 |
+
elif agent.water_collected and dist_to_fire <= 350:
|
| 128 |
+
action = "extinguish_fire"
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"action": action,
|
| 132 |
+
"vote_for": decision.get("vote_for"),
|
| 133 |
+
"message": decision.get("message", "Moving strategically."),
|
| 134 |
+
"reasoning": decision.get("reasoning", "Survival and teamwork.")
|
| 135 |
+
}
|
| 136 |
+
except Exception as e:
|
| 137 |
+
print(f"Error calling groq for {agent.model_name}: {e}")
|
| 138 |
+
return _fallback_escape(agent, fire)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _fallback_escape(agent, fire) -> dict:
|
| 142 |
+
"""Fallback escape behavior."""
|
| 143 |
+
dx = agent.x - fire.x
|
| 144 |
+
dy = agent.y - fire.y
|
| 145 |
+
dist = math.sqrt(dx**2 + dy**2) or 1
|
| 146 |
+
return {
|
| 147 |
+
"message": "Running to safety!",
|
| 148 |
+
"action": "escape",
|
| 149 |
+
"vote_for": None,
|
| 150 |
+
"reasoning": "Fallback: survive."
|
| 151 |
+
}
|
backend/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()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class StartSimulationRequest(BaseModel):
|
| 40 |
+
model_names: list[str] = Field(..., min_length=2, max_length=6)
|
| 41 |
+
scenario: str = "fire"
|
| 42 |
+
map_width: int = 1200
|
| 43 |
+
map_height: int = 800
|
| 44 |
+
|
| 45 |
+
class StartSimulationResponse(BaseModel):
|
| 46 |
+
simulation_id: str
|
| 47 |
+
state: SimulationState
|
| 48 |
+
|
| 49 |
+
class PlaceFireRequest(BaseModel):
|
| 50 |
+
simulation_id: str
|
| 51 |
+
x: int
|
| 52 |
+
y: int
|
| 53 |
+
|
| 54 |
+
class TickRequest(BaseModel):
|
| 55 |
+
simulation_id: str
|
| 56 |
+
|
| 57 |
+
@app.get("/wake")
|
| 58 |
+
async def wake():
|
| 59 |
+
return {
|
| 60 |
+
"warm": True,
|
| 61 |
+
"groq_available": groq_client.is_ready(),
|
| 62 |
+
"uptime_seconds": int(time.time() - START_TIME),
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
@app.get("/available-models")
|
| 66 |
+
async def get_available_models():
|
| 67 |
+
"""Get list of available models (Groq + HF Spaces) for the UI."""
|
| 68 |
+
return await hf_spaces.get_available_models()
|
| 69 |
+
|
| 70 |
+
@app.post("/start-simulation", response_model=StartSimulationResponse)
|
| 71 |
+
async def start_simulation(req: StartSimulationRequest):
|
| 72 |
+
if req.scenario != "fire":
|
| 73 |
+
raise HTTPException(status_code=400, detail="Only 'fire' scenario supported.")
|
| 74 |
+
|
| 75 |
+
agents = _spawn_agents(req.model_names, req.map_width, req.map_height)
|
| 76 |
+
|
| 77 |
+
state = SimulationState(
|
| 78 |
+
simulation_id=str(uuid.uuid4()),
|
| 79 |
+
scenario=req.scenario,
|
| 80 |
+
map_width=req.map_width,
|
| 81 |
+
map_height=req.map_height,
|
| 82 |
+
agents=agents,
|
| 83 |
+
fire=None,
|
| 84 |
+
water_sources=[],
|
| 85 |
+
round=0,
|
| 86 |
+
status="waiting_for_scenario",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
active_simulations[state.simulation_id] = state
|
| 90 |
+
return StartSimulationResponse(simulation_id=state.simulation_id, state=state)
|
| 91 |
+
|
| 92 |
+
@app.post("/place-fire", response_model=SimulationState)
|
| 93 |
+
def place_fire(req: PlaceFireRequest):
|
| 94 |
+
sim = _get_or_404(req.simulation_id)
|
| 95 |
+
if sim.status != "waiting_for_scenario":
|
| 96 |
+
raise HTTPException(status_code=409, detail="Fire already placed or simulation finished.")
|
| 97 |
+
|
| 98 |
+
# Create fire at clicked location
|
| 99 |
+
sim.fire = FireScenario(x=req.x, y=req.y)
|
| 100 |
+
|
| 101 |
+
# Generate 3-5 water sources scattered around the map
|
| 102 |
+
num_sources = random.randint(3, 5)
|
| 103 |
+
for i in range(num_sources):
|
| 104 |
+
water_x = random.randint(100, req.x - 200) if req.x > 200 else random.randint(0, 400)
|
| 105 |
+
if random.random() > 0.5:
|
| 106 |
+
water_x = random.randint(req.x + 200, sim.map_width - 100) if req.x < sim.map_width - 200 else random.randint(sim.map_width - 400, sim.map_width)
|
| 107 |
+
water_y = random.randint(100, sim.map_height - 100)
|
| 108 |
+
sim.water_sources.append(WaterSource(id=f"water_{i}", x=water_x, y=water_y))
|
| 109 |
+
|
| 110 |
+
sim.status = "running"
|
| 111 |
+
return sim
|
| 112 |
+
|
| 113 |
+
@app.websocket("/ws/{simulation_id}")
|
| 114 |
+
async def simulation_ws(websocket: WebSocket, simulation_id: str):
|
| 115 |
+
await websocket.accept()
|
| 116 |
+
sim = active_simulations.get(simulation_id)
|
| 117 |
+
if not sim:
|
| 118 |
+
await websocket.close(code=1008)
|
| 119 |
+
return
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
while True:
|
| 123 |
+
if sim.status == "waiting_for_scenario":
|
| 124 |
+
await asyncio.sleep(1)
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
if sim.status == "finished":
|
| 128 |
+
await websocket.send_json({"type": "finished", "state": sim.model_dump()})
|
| 129 |
+
await websocket.close(code=1000)
|
| 130 |
+
return
|
| 131 |
+
|
| 132 |
+
engine = SimulationEngine(sim)
|
| 133 |
+
result = await engine.tick()
|
| 134 |
+
active_simulations[simulation_id] = result.state
|
| 135 |
+
|
| 136 |
+
# DEBUG: log outgoing TickResponse summary for troubleshooting
|
| 137 |
+
try:
|
| 138 |
+
agent_states = [(a.model_name, a.alive) for a in result.state.agents]
|
| 139 |
+
except Exception:
|
| 140 |
+
agent_states = str(result.state)
|
| 141 |
+
print(f"WS_SEND sim={simulation_id} round={result.round} agents={agent_states} events={len(result.events)}")
|
| 142 |
+
|
| 143 |
+
await websocket.send_json(result.model_dump())
|
| 144 |
+
|
| 145 |
+
if result.state.status == "finished":
|
| 146 |
+
await websocket.close(code=1000)
|
| 147 |
+
return
|
| 148 |
+
|
| 149 |
+
await asyncio.sleep(TICK_INTERVAL_SECONDS)
|
| 150 |
+
|
| 151 |
+
except WebSocketDisconnect:
|
| 152 |
+
pass
|
| 153 |
+
|
| 154 |
+
def _spawn_agents(model_names: list[str], width: int, height: int) -> list[AgentModel]:
|
| 155 |
+
min_gap = 100
|
| 156 |
+
positions = []
|
| 157 |
+
agents = []
|
| 158 |
+
for name in model_names:
|
| 159 |
+
for _ in range(100):
|
| 160 |
+
x = random.randint(100, width - 100)
|
| 161 |
+
y = random.randint(100, height - 100)
|
| 162 |
+
if all(math.dist((x, y), p) >= min_gap for p in positions):
|
| 163 |
+
positions.append((x, y))
|
| 164 |
+
break
|
| 165 |
+
else:
|
| 166 |
+
positions.append((x, y))
|
| 167 |
+
|
| 168 |
+
agents.append(AgentModel(
|
| 169 |
+
model_name=name,
|
| 170 |
+
display_name=name.split("/")[-1].split("-")[0].capitalize(),
|
| 171 |
+
x=positions[-1][0],
|
| 172 |
+
y=positions[-1][1],
|
| 173 |
+
alive=True
|
| 174 |
+
))
|
| 175 |
+
return agents
|
| 176 |
+
|
| 177 |
+
def _get_or_404(simulation_id: str) -> SimulationState:
|
| 178 |
+
sim = active_simulations.get(simulation_id)
|
| 179 |
+
if not sim:
|
| 180 |
+
raise HTTPException(status_code=404, detail="Simulation not found")
|
| 181 |
+
return sim
|
backend/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
|
backend/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
|
backend/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)
|
backend/app/simulation.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import math
|
| 4 |
+
import random
|
| 5 |
+
from typing import Union
|
| 6 |
+
|
| 7 |
+
from .models import (
|
| 8 |
+
AgentModel,
|
| 9 |
+
DeathEvent,
|
| 10 |
+
MessageEvent,
|
| 11 |
+
LeadershipVoteEvent,
|
| 12 |
+
LeaderElectedEvent,
|
| 13 |
+
WaterCollectedEvent,
|
| 14 |
+
FireExtinguishedEvent,
|
| 15 |
+
FireSpreadEvent,
|
| 16 |
+
SimulationState,
|
| 17 |
+
TickResponse,
|
| 18 |
+
ChatEntry,
|
| 19 |
+
)
|
| 20 |
+
from . import groq_client
|
| 21 |
+
from . import movement
|
| 22 |
+
|
| 23 |
+
FIRE_GROWTH_RATE = 1.0 # radius growth per tick
|
| 24 |
+
FIRE_INTENSITY_GROWTH = 0.9 # intensity per tick
|
| 25 |
+
BASE_EXTINGUISH_RATE = 15.0 # baseline intensity reduction per agent
|
| 26 |
+
MIN_EXTINGUISH_RATE = 8.0
|
| 27 |
+
MAX_EXTINGUISH_RATE = 28.0
|
| 28 |
+
TICK_INTERVAL_SECONDS = 3
|
| 29 |
+
WATER_PICKUP_RANGE = 40
|
| 30 |
+
EXTINGUISH_RANGE = 45
|
| 31 |
+
FIRE_SAFE_BUFFER = 10
|
| 32 |
+
|
| 33 |
+
class SimulationEngine:
|
| 34 |
+
def __init__(self, state: SimulationState) -> None:
|
| 35 |
+
self.state = state
|
| 36 |
+
|
| 37 |
+
def _pick_message(self, action: str) -> str:
|
| 38 |
+
options = {
|
| 39 |
+
"search_water": [
|
| 40 |
+
"Scanning for the nearest well.",
|
| 41 |
+
"Heading toward water to stock up.",
|
| 42 |
+
"Tracking a water source now.",
|
| 43 |
+
"Moving to secure water for the team.",
|
| 44 |
+
],
|
| 45 |
+
"collect_water": [
|
| 46 |
+
"Water secured. Moving to the fire line.",
|
| 47 |
+
"Got water. Heading to the fire edge.",
|
| 48 |
+
"Collected water. Time to extinguish.",
|
| 49 |
+
],
|
| 50 |
+
"extinguish_fire": [
|
| 51 |
+
"Pouring water at the fire edge.",
|
| 52 |
+
"Holding position and dousing flames.",
|
| 53 |
+
"Suppressing the fire with water.",
|
| 54 |
+
],
|
| 55 |
+
"escape": [
|
| 56 |
+
"Falling back to a safer position.",
|
| 57 |
+
"Repositioning away from the flames.",
|
| 58 |
+
"Retreating to avoid the fire front.",
|
| 59 |
+
],
|
| 60 |
+
}
|
| 61 |
+
return random.choice(options.get(action, ["Moving strategically."]))
|
| 62 |
+
|
| 63 |
+
def _move_toward(self, agent: AgentModel, target_x: float, target_y: float, stop_distance: float = 0) -> None:
|
| 64 |
+
dx = target_x - agent.x
|
| 65 |
+
dy = target_y - agent.y
|
| 66 |
+
dist = math.sqrt(dx**2 + dy**2) or 1
|
| 67 |
+
if dist <= stop_distance:
|
| 68 |
+
return
|
| 69 |
+
step = min(movement.MAX_AGENT_SPEED, dist - stop_distance)
|
| 70 |
+
agent.x += int((dx / dist) * step)
|
| 71 |
+
agent.y += int((dy / dist) * step)
|
| 72 |
+
agent.x = max(0, min(agent.x, self.state.map_width))
|
| 73 |
+
agent.y = max(0, min(agent.y, self.state.map_height))
|
| 74 |
+
|
| 75 |
+
async def tick(self) -> TickResponse:
|
| 76 |
+
"""
|
| 77 |
+
Main simulation loop:
|
| 78 |
+
1. Get decisions from all living agents
|
| 79 |
+
2. Handle coalition leadership voting
|
| 80 |
+
3. Execute agent actions (search water, collect, extinguish, escape, etc.)
|
| 81 |
+
4. Grow fire
|
| 82 |
+
5. Extinguish fire if agents with water are present
|
| 83 |
+
6. Kill agents in fire (but protect coalition members)
|
| 84 |
+
7. Check win condition
|
| 85 |
+
"""
|
| 86 |
+
if self.state.status != "running":
|
| 87 |
+
raise ValueError(f"Cannot tick a simulation with status '{self.state.status}'.")
|
| 88 |
+
|
| 89 |
+
fire = self.state.fire
|
| 90 |
+
assert fire is not None, "Fire must be placed before ticking."
|
| 91 |
+
|
| 92 |
+
events = []
|
| 93 |
+
bounds = (self.state.map_width, self.state.map_height)
|
| 94 |
+
living_agents = [a for a in self.state.agents if a.alive]
|
| 95 |
+
|
| 96 |
+
# 1. Get decisions from all living agents
|
| 97 |
+
decisions = await asyncio.gather(
|
| 98 |
+
*[groq_client.generate_fire_decision(agent, fire, self.state.water_sources, living_agents, bounds)
|
| 99 |
+
for agent in living_agents],
|
| 100 |
+
return_exceptions=True
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
decision_map = {}
|
| 104 |
+
for agent, decision in zip(living_agents, decisions):
|
| 105 |
+
if isinstance(decision, Exception):
|
| 106 |
+
decision = groq_client._fallback_escape(agent, fire)
|
| 107 |
+
decision_map[agent.model_name] = decision
|
| 108 |
+
|
| 109 |
+
# 2. Leadership voting phase (if coalition leader not elected)
|
| 110 |
+
if not self.state.coalition_leader:
|
| 111 |
+
vote_events = await self._voting_phase(living_agents, decision_map)
|
| 112 |
+
events.extend(vote_events)
|
| 113 |
+
|
| 114 |
+
# 3. Execute actions
|
| 115 |
+
action_events = await self._execute_actions(living_agents, decision_map, fire)
|
| 116 |
+
events.extend(action_events)
|
| 117 |
+
|
| 118 |
+
# 4. Grow fire
|
| 119 |
+
fire.radius += FIRE_GROWTH_RATE
|
| 120 |
+
fire.intensity += FIRE_INTENSITY_GROWTH
|
| 121 |
+
if fire.intensity > 100.0:
|
| 122 |
+
fire.intensity = 100.0
|
| 123 |
+
|
| 124 |
+
if fire.intensity > 0:
|
| 125 |
+
events.append(FireSpreadEvent(new_radius=fire.radius, new_intensity=fire.intensity))
|
| 126 |
+
|
| 127 |
+
# 5. Extinguish fire if agents with water are present
|
| 128 |
+
extinguish_events = self._check_extinguish(living_agents, fire)
|
| 129 |
+
events.extend(extinguish_events)
|
| 130 |
+
|
| 131 |
+
# 6. Kill agents in fire
|
| 132 |
+
death_events = self._kill_agents_in_fire(living_agents, fire)
|
| 133 |
+
events.extend(death_events)
|
| 134 |
+
|
| 135 |
+
# 7. Check win condition
|
| 136 |
+
self.state.round += 1
|
| 137 |
+
living_count = len([a for a in self.state.agents if a.alive])
|
| 138 |
+
|
| 139 |
+
if fire.intensity <= 0:
|
| 140 |
+
# Fire extinguished!
|
| 141 |
+
self.state.status = "finished"
|
| 142 |
+
top_score = max((a.extinguish_score for a in self.state.agents), default=0)
|
| 143 |
+
top_agents = [a.model_name for a in self.state.agents if a.extinguish_score == top_score and top_score > 0]
|
| 144 |
+
if top_agents:
|
| 145 |
+
self.state.winner_model = f"Top extinguisher: {', '.join(top_agents)} ({top_score:.1f} impact)"
|
| 146 |
+
else:
|
| 147 |
+
self.state.winner_model = "Fire extinguished"
|
| 148 |
+
elif living_count <= 1:
|
| 149 |
+
# Only one agent left
|
| 150 |
+
self.state.status = "finished"
|
| 151 |
+
winner = next((a.model_name for a in self.state.agents if a.alive), None)
|
| 152 |
+
self.state.winner_model = winner or "No survivors"
|
| 153 |
+
|
| 154 |
+
return TickResponse(
|
| 155 |
+
simulation_id=self.state.simulation_id,
|
| 156 |
+
round=self.state.round,
|
| 157 |
+
events=events,
|
| 158 |
+
chat=[],
|
| 159 |
+
state=self.state
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
async def _voting_phase(self, agents, decision_map):
|
| 163 |
+
"""
|
| 164 |
+
Agents vote for a coalition leader.
|
| 165 |
+
Get votes from LLM based on current situation.
|
| 166 |
+
"""
|
| 167 |
+
events = []
|
| 168 |
+
|
| 169 |
+
# Gather votes
|
| 170 |
+
votes = {} # candidate -> vote count
|
| 171 |
+
for agent in agents:
|
| 172 |
+
decision = decision_map.get(agent.model_name, {})
|
| 173 |
+
vote_for = decision.get("vote_for")
|
| 174 |
+
if vote_for:
|
| 175 |
+
votes[vote_for] = votes.get(vote_for, 0) + 1
|
| 176 |
+
events.append(LeadershipVoteEvent(voter=agent.model_name, candidate=vote_for))
|
| 177 |
+
|
| 178 |
+
# Elect leader if there are votes
|
| 179 |
+
if votes:
|
| 180 |
+
leader_name = max(votes, key=votes.get)
|
| 181 |
+
leader_agent = next((a for a in agents if a.model_name == leader_name), None)
|
| 182 |
+
if leader_agent:
|
| 183 |
+
for agent in agents:
|
| 184 |
+
agent.mode = "coalition"
|
| 185 |
+
leader_agent.is_leader = True
|
| 186 |
+
self.state.coalition_leader = leader_name
|
| 187 |
+
coalition = [a.model_name for a in agents if a.mode == "coalition"]
|
| 188 |
+
self.state.coalition_members = coalition
|
| 189 |
+
events.append(LeaderElectedEvent(leader=leader_name, coalition_members=coalition))
|
| 190 |
+
events.append(MessageEvent(model=leader_name, content=f"I'll lead us to victory! Let's find water and extinguish this fire."))
|
| 191 |
+
|
| 192 |
+
return events
|
| 193 |
+
|
| 194 |
+
async def _execute_actions(self, agents, decision_map, fire):
|
| 195 |
+
"""
|
| 196 |
+
Execute agent actions: search, collect water, extinguish, escape, vote, etc.
|
| 197 |
+
"""
|
| 198 |
+
events = []
|
| 199 |
+
chat_entries = []
|
| 200 |
+
|
| 201 |
+
for agent in agents:
|
| 202 |
+
decision = decision_map.get(agent.model_name, {})
|
| 203 |
+
action = decision.get("action", "escape")
|
| 204 |
+
message = decision.get("message", "Moving to safety.")
|
| 205 |
+
|
| 206 |
+
nearest_water = self._find_nearest_water(agent, self.state.water_sources)
|
| 207 |
+
dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
|
| 208 |
+
dist_to_water = None
|
| 209 |
+
if nearest_water:
|
| 210 |
+
dist_to_water = math.dist((agent.x, agent.y), (nearest_water.x, nearest_water.y))
|
| 211 |
+
|
| 212 |
+
# Guardrails to keep behavior consistent with visuals and objectives.
|
| 213 |
+
if dist_to_fire <= fire.radius + FIRE_SAFE_BUFFER:
|
| 214 |
+
action = "escape"
|
| 215 |
+
elif agent.water_collected:
|
| 216 |
+
action = "extinguish_fire"
|
| 217 |
+
elif dist_to_water is not None and dist_to_water <= WATER_PICKUP_RANGE:
|
| 218 |
+
action = "collect_water"
|
| 219 |
+
else:
|
| 220 |
+
action = "search_water"
|
| 221 |
+
|
| 222 |
+
if action == "collect_water":
|
| 223 |
+
water_source = nearest_water
|
| 224 |
+
if water_source and dist_to_water is not None:
|
| 225 |
+
dist_to_water = math.dist((agent.x, agent.y), (water_source.x, water_source.y))
|
| 226 |
+
if dist_to_water <= WATER_PICKUP_RANGE:
|
| 227 |
+
agent.water_collected = True
|
| 228 |
+
agent.status = "collecting_water"
|
| 229 |
+
events.append(WaterCollectedEvent(model=agent.model_name, water_source_id=water_source.id))
|
| 230 |
+
message = self._pick_message("collect_water")
|
| 231 |
+
else:
|
| 232 |
+
agent.status = "searching"
|
| 233 |
+
self._move_toward(agent, water_source.x, water_source.y)
|
| 234 |
+
message = self._pick_message("search_water")
|
| 235 |
+
|
| 236 |
+
elif action == "extinguish_fire":
|
| 237 |
+
if agent.water_collected:
|
| 238 |
+
agent.status = "extinguishing_fire"
|
| 239 |
+
dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
|
| 240 |
+
target_dist = max(fire.radius + FIRE_SAFE_BUFFER, 0)
|
| 241 |
+
self._move_toward(agent, fire.x, fire.y, stop_distance=target_dist)
|
| 242 |
+
message = self._pick_message("extinguish_fire")
|
| 243 |
+
else:
|
| 244 |
+
agent.status = "searching"
|
| 245 |
+
message = "Need water before I can extinguish."
|
| 246 |
+
|
| 247 |
+
elif action == "search_water":
|
| 248 |
+
agent.status = "searching"
|
| 249 |
+
water_source = nearest_water
|
| 250 |
+
if water_source:
|
| 251 |
+
self._move_toward(agent, water_source.x, water_source.y)
|
| 252 |
+
message = self._pick_message("search_water")
|
| 253 |
+
|
| 254 |
+
elif action == "escape":
|
| 255 |
+
agent.status = "escaping"
|
| 256 |
+
# Move away from fire
|
| 257 |
+
dx = agent.x - fire.x
|
| 258 |
+
dy = agent.y - fire.y
|
| 259 |
+
dist = math.sqrt(dx**2 + dy**2) or 1
|
| 260 |
+
agent.x += int((dx / dist) * movement.MAX_AGENT_SPEED)
|
| 261 |
+
agent.y += int((dy / dist) * movement.MAX_AGENT_SPEED)
|
| 262 |
+
agent.x = max(0, min(agent.x, self.state.map_width))
|
| 263 |
+
agent.y = max(0, min(agent.y, self.state.map_height))
|
| 264 |
+
message = self._pick_message("escape")
|
| 265 |
+
|
| 266 |
+
agent.last_message = message
|
| 267 |
+
events.append(MessageEvent(model=agent.model_name, content=message))
|
| 268 |
+
chat_entries.append(ChatEntry(agent_id=agent.model_name, message=message, tick=self.state.round))
|
| 269 |
+
|
| 270 |
+
return events
|
| 271 |
+
|
| 272 |
+
def _find_nearest_water(self, agent, water_sources):
|
| 273 |
+
"""Find the closest water source to an agent."""
|
| 274 |
+
if not water_sources:
|
| 275 |
+
return None
|
| 276 |
+
return min(water_sources, key=lambda w: math.dist((agent.x, agent.y), (w.x, w.y)))
|
| 277 |
+
|
| 278 |
+
def _check_extinguish(self, agents, fire):
|
| 279 |
+
"""Check if agents with water are extinguishing the fire."""
|
| 280 |
+
events = []
|
| 281 |
+
agents_with_water = []
|
| 282 |
+
for agent in agents:
|
| 283 |
+
if not (agent.water_collected and agent.status == "extinguishing_fire"):
|
| 284 |
+
continue
|
| 285 |
+
dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
|
| 286 |
+
if dist_to_fire <= fire.radius + EXTINGUISH_RANGE:
|
| 287 |
+
agents_with_water.append(agent)
|
| 288 |
+
|
| 289 |
+
if agents_with_water:
|
| 290 |
+
living_count = len([a for a in agents if a.alive]) or 1
|
| 291 |
+
scale = max(0.5, min(2.0, 2.0 / living_count))
|
| 292 |
+
per_agent_rate = BASE_EXTINGUISH_RATE * scale
|
| 293 |
+
per_agent_rate = max(MIN_EXTINGUISH_RATE, min(MAX_EXTINGUISH_RATE, per_agent_rate))
|
| 294 |
+
reduction = len(agents_with_water) * per_agent_rate
|
| 295 |
+
fire.intensity -= reduction
|
| 296 |
+
if fire.intensity < 0:
|
| 297 |
+
fire.intensity = 0
|
| 298 |
+
|
| 299 |
+
extinguisher_names = [a.model_name for a in agents_with_water]
|
| 300 |
+
events.append(FireExtinguishedEvent(extinguished_by=extinguisher_names, fire_intensity=fire.intensity))
|
| 301 |
+
for agent in agents_with_water:
|
| 302 |
+
agent.extinguish_score += per_agent_rate
|
| 303 |
+
events.append(MessageEvent(model=agent.model_name, content=f"Pouring water on the fire! Intensity dropping."))
|
| 304 |
+
agent.water_collected = False
|
| 305 |
+
|
| 306 |
+
return events
|
| 307 |
+
|
| 308 |
+
def _kill_agents_in_fire(self, agents, fire):
|
| 309 |
+
"""Check if agents are consumed by fire."""
|
| 310 |
+
events = []
|
| 311 |
+
|
| 312 |
+
for agent in agents:
|
| 313 |
+
if not agent.alive:
|
| 314 |
+
continue
|
| 315 |
+
|
| 316 |
+
dist_to_fire = math.dist((agent.x, agent.y), (fire.x, fire.y))
|
| 317 |
+
|
| 318 |
+
# Agent dies if inside fire radius
|
| 319 |
+
if dist_to_fire < fire.radius:
|
| 320 |
+
agent.alive = False
|
| 321 |
+
events.append(DeathEvent(model=agent.model_name))
|
| 322 |
+
events.append(MessageEvent(model=agent.model_name, content="No!!! The fire got me..."))
|
| 323 |
+
|
| 324 |
+
return events
|
backend/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 |
+
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
frontend/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
frontend/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rush Agents Rush Frontend
|
| 2 |
+
|
| 3 |
+
Next.js UI for the fire-suppression simulation.
|
| 4 |
+
|
| 5 |
+
## What It Shows
|
| 6 |
+
|
| 7 |
+
- Model selection and simulation start flow
|
| 8 |
+
- Map-based agent positions and fire placement
|
| 9 |
+
- Water sources, coalition links, and leader markers
|
| 10 |
+
- Live event chat with varied team messages
|
| 11 |
+
- End-of-game result banner with top performer info
|
| 12 |
+
|
| 13 |
+
## Run Locally
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
cd frontend
|
| 17 |
+
npm install
|
| 18 |
+
npm run dev
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
Then open http://localhost:3000.
|
| 22 |
+
|
| 23 |
+
## Environment Variables
|
| 24 |
+
|
| 25 |
+
```env
|
| 26 |
+
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
## Main Files
|
| 30 |
+
|
| 31 |
+
- `app/page.tsx` - app shell and simulation flow
|
| 32 |
+
- `components/MapCanvas.tsx` - 2D map rendering and agent visuals
|
| 33 |
+
- `components/ChatFeed.tsx` - event/chat panel
|
| 34 |
+
- `components/ModelSelector.tsx` - model picker
|
| 35 |
+
- `lib/api.ts` - backend requests
|
| 36 |
+
- `lib/websocket.ts` - simulation WebSocket client
|
| 37 |
+
|
| 38 |
+
## Notes
|
| 39 |
+
|
| 40 |
+
- The frontend expects the backend to be running before placing a fire.
|
| 41 |
+
- If the browser shows `Failed to fetch`, verify `http://localhost:8000/wake` first.
|
| 42 |
+
- The old volcano terminology has been removed from the current gameplay flow.
|
frontend/app/favicon.ico
ADDED
|
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg-primary: #0a0a0a;
|
| 5 |
+
--bg-secondary: #111111;
|
| 6 |
+
--bg-glass: rgba(255, 255, 255, 0.04);
|
| 7 |
+
--border: rgba(255, 255, 255, 0.08);
|
| 8 |
+
--text-primary: #f0f0f0;
|
| 9 |
+
--text-muted: #555555;
|
| 10 |
+
--lava-core: #ff4500;
|
| 11 |
+
--lava-mid: #ff6a00;
|
| 12 |
+
--lava-glow: rgba(255, 69, 0, 0.35);
|
| 13 |
+
--accent-green: #00ff88;
|
| 14 |
+
--accent-red: #ff3b3b;
|
| 15 |
+
--accent-yellow: #ffd700;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
@theme inline {
|
| 19 |
+
--color-background: var(--bg-primary);
|
| 20 |
+
--color-foreground: var(--text-primary);
|
| 21 |
+
--font-mono: var(--font-geist-mono);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
body {
|
| 25 |
+
background: var(--bg-primary);
|
| 26 |
+
color: var(--text-primary);
|
| 27 |
+
font-family: var(--font-mono), monospace;
|
| 28 |
+
overflow: hidden;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.custom-scrollbar::-webkit-scrollbar {
|
| 32 |
+
width: 4px;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.custom-scrollbar::-webkit-scrollbar-track {
|
| 36 |
+
background: transparent;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
| 40 |
+
background: var(--border);
|
| 41 |
+
border-radius: 10px;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
| 45 |
+
background: var(--text-muted);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
@keyframes lava-pulse {
|
| 49 |
+
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; }
|
| 50 |
+
50% { transform: translate(-50%, -50%) scale(1.02); opacity: 1; }
|
| 51 |
+
100% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; }
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.animate-lava {
|
| 55 |
+
animation: lava-pulse 4s infinite ease-in-out;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
@keyframes danger-ring {
|
| 59 |
+
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
| 60 |
+
70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
|
| 61 |
+
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.animate-danger {
|
| 65 |
+
animation: danger-ring 1.5s infinite;
|
| 66 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const geistSans = Geist({
|
| 6 |
+
variable: "--font-geist-sans",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const geistMono = Geist_Mono({
|
| 11 |
+
variable: "--font-geist-mono",
|
| 12 |
+
subsets: ["latin"],
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "RUSHH AGENT RUSHHH !!",
|
| 17 |
+
description: "AI battle royale — survive the volcano or get cooked",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html
|
| 27 |
+
lang="en"
|
| 28 |
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 29 |
+
>
|
| 30 |
+
<body className="min-h-full flex flex-col">{children}</body>
|
| 31 |
+
</html>
|
| 32 |
+
);
|
| 33 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from "react"
|
| 4 |
+
import LoadingScreen from "../components/LoadingScreen"
|
| 5 |
+
import MapCanvas from "../components/MapCanvas"
|
| 6 |
+
import ChatFeed from "../components/ChatFeed"
|
| 7 |
+
import ModelSelector from "../components/ModelSelector"
|
| 8 |
+
import { startSimulation, placeVolcano } from "../lib/api"
|
| 9 |
+
import { createSimulationSocket } from "../lib/websocket"
|
| 10 |
+
|
| 11 |
+
type AppState = "loading" | "selecting" | "placing" | "running" | "gameover"
|
| 12 |
+
|
| 13 |
+
export default function Page() {
|
| 14 |
+
const [appState, setAppState] = useState<AppState>("loading")
|
| 15 |
+
const [models, setModels] = useState<string[]>([])
|
| 16 |
+
const [simState, setSimState] = useState<any>(null)
|
| 17 |
+
const [chatMessages, setChatMessages] = useState<any[]>([])
|
| 18 |
+
const [winnerLabel, setWinnerLabel] = useState<string | null>(null)
|
| 19 |
+
const [loading, setLoading] = useState(false)
|
| 20 |
+
const [mapSize, setMapSize] = useState({ width: 1200, height: 800 })
|
| 21 |
+
const wsRef = useRef<WebSocket | null>(null)
|
| 22 |
+
const mapDivRef = useRef<HTMLDivElement>(null)
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const el = mapDivRef.current
|
| 26 |
+
if (!el) return
|
| 27 |
+
const observer = new ResizeObserver((entries) => {
|
| 28 |
+
const { width, height } = entries[0].contentRect
|
| 29 |
+
setMapSize({ width, height })
|
| 30 |
+
})
|
| 31 |
+
observer.observe(el)
|
| 32 |
+
return () => observer.disconnect()
|
| 33 |
+
}, [appState])
|
| 34 |
+
|
| 35 |
+
async function handleStart() {
|
| 36 |
+
if (models.length < 2) return
|
| 37 |
+
setLoading(true)
|
| 38 |
+
try {
|
| 39 |
+
const data = await startSimulation(models)
|
| 40 |
+
setSimState(data.state)
|
| 41 |
+
setWinnerLabel(null)
|
| 42 |
+
setChatMessages([])
|
| 43 |
+
setAppState("placing")
|
| 44 |
+
} catch (err) {
|
| 45 |
+
console.error(err)
|
| 46 |
+
} finally {
|
| 47 |
+
setLoading(false)
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
async function handleMapClick(x: number, y: number) {
|
| 52 |
+
if (appState !== "placing" || !simState) return
|
| 53 |
+
try {
|
| 54 |
+
const data = await placeVolcano(simState.simulation_id, x, y)
|
| 55 |
+
setSimState(data)
|
| 56 |
+
setAppState("running")
|
| 57 |
+
|
| 58 |
+
const ws = createSimulationSocket(
|
| 59 |
+
simState.simulation_id,
|
| 60 |
+
(msg) => {
|
| 61 |
+
if (msg.type === "finished") {
|
| 62 |
+
if (msg.state) {
|
| 63 |
+
setSimState(msg.state)
|
| 64 |
+
setWinnerLabel(msg.state.winner_model || null)
|
| 65 |
+
}
|
| 66 |
+
setAppState("gameover")
|
| 67 |
+
return
|
| 68 |
+
}
|
| 69 |
+
if (msg.state) setSimState(msg.state)
|
| 70 |
+
if (msg.events) {
|
| 71 |
+
const newMsgs = msg.events.map((e: any) => {
|
| 72 |
+
if (e.type === 'message') return { agent_id: e.model, text: e.content, type: 'message' }
|
| 73 |
+
if (e.type === 'death') return { agent_id: e.model, text: '', type: 'death' }
|
| 74 |
+
if (e.type === 'alliance_proposal') return { agent_id: e.from_model, text: '', type: 'alliance_proposal', to_model: e.to_model }
|
| 75 |
+
if (e.type === 'alliance_accept') return { agent_id: e.model_a, text: '', type: 'alliance_accept', to_model: e.model_b }
|
| 76 |
+
if (e.type === 'alliance_reject') return { agent_id: e.from_model, text: '', type: 'alliance_reject', to_model: e.to_model }
|
| 77 |
+
return null
|
| 78 |
+
}).filter(Boolean)
|
| 79 |
+
setChatMessages(prev => [...prev, ...newMsgs])
|
| 80 |
+
}
|
| 81 |
+
if (msg.chat) {
|
| 82 |
+
const chatMsgs = msg.chat.map((entry: any) => ({
|
| 83 |
+
agent_id: entry.agent_id,
|
| 84 |
+
text: entry.message,
|
| 85 |
+
type: 'message',
|
| 86 |
+
}))
|
| 87 |
+
setChatMessages(prev => [...prev, ...chatMsgs])
|
| 88 |
+
}
|
| 89 |
+
if (msg.state?.status === "finished") {
|
| 90 |
+
setWinnerLabel(msg.state.winner_model || null)
|
| 91 |
+
setAppState("gameover")
|
| 92 |
+
}
|
| 93 |
+
},
|
| 94 |
+
() => setAppState("gameover")
|
| 95 |
+
)
|
| 96 |
+
wsRef.current = ws
|
| 97 |
+
} catch (err) {
|
| 98 |
+
console.error(err)
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<main className="flex h-screen w-screen overflow-hidden bg-[#0a0a0a]">
|
| 104 |
+
{appState === "loading" && <LoadingScreen onReady={() => setAppState("selecting")} />}
|
| 105 |
+
|
| 106 |
+
{/* Map Area */}
|
| 107 |
+
<div ref={mapDivRef} className="flex-[7] h-full relative">
|
| 108 |
+
<MapCanvas
|
| 109 |
+
agents={simState?.agents ?? []}
|
| 110 |
+
fire={simState?.fire ?? null}
|
| 111 |
+
waterSources={simState?.water_sources ?? []}
|
| 112 |
+
waitingForScenario={appState === "placing"}
|
| 113 |
+
gameOver={appState === "gameover"}
|
| 114 |
+
winnerLabel={winnerLabel}
|
| 115 |
+
mapSize={mapSize}
|
| 116 |
+
onMapClick={handleMapClick}
|
| 117 |
+
/>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
{/* Sidebar */}
|
| 121 |
+
<aside className="flex-[3] h-full border-l border-white/5 flex flex-col bg-[#111] z-20">
|
| 122 |
+
<div className="p-6">
|
| 123 |
+
<h1 className="text-2xl font-bold text-white tracking-tighter">RUSHH <span className="text-red-500">AGENT</span> RUSHHH !!</h1>
|
| 124 |
+
<p className="text-[10px] text-white/30 uppercase tracking-[0.3em] mt-1">Survival Intelligence Test</p>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div className="flex-1 flex flex-col min-h-0">
|
| 128 |
+
{appState === "selecting" ? (
|
| 129 |
+
<ModelSelector
|
| 130 |
+
models={models}
|
| 131 |
+
onAdd={id => setModels(p => [...p, id])}
|
| 132 |
+
onRemove={id => setModels(p => p.filter(m => m !== id))}
|
| 133 |
+
/>
|
| 134 |
+
) : (
|
| 135 |
+
<ChatFeed messages={chatMessages} />
|
| 136 |
+
)}
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div className="p-6 border-t border-white/5">
|
| 140 |
+
{appState === "selecting" && (
|
| 141 |
+
<button
|
| 142 |
+
onClick={handleStart}
|
| 143 |
+
disabled={models.length < 2 || loading}
|
| 144 |
+
className="w-full bg-white text-black font-mono text-xs font-bold py-4 rounded-xl hover:bg-white/90 disabled:opacity-20 transition-all uppercase tracking-widest"
|
| 145 |
+
>
|
| 146 |
+
{loading ? "Initializing..." : "Start Simulation"}
|
| 147 |
+
</button>
|
| 148 |
+
)}
|
| 149 |
+
{appState === "placing" && (
|
| 150 |
+
<div className="text-center py-4 bg-red-500/10 border border-red-500/20 rounded-xl">
|
| 151 |
+
<span className="text-red-500 font-mono text-[10px] uppercase tracking-widest animate-pulse">
|
| 152 |
+
Click on the map to ignite the fire
|
| 153 |
+
</span>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
{(appState === "running" || appState === "gameover") && (
|
| 157 |
+
<button
|
| 158 |
+
onClick={() => window.location.reload()}
|
| 159 |
+
className="w-full bg-white/5 text-white/50 font-mono text-[10px] py-3 rounded-lg hover:bg-white/10 transition-all uppercase tracking-widest"
|
| 160 |
+
>
|
| 161 |
+
Reset Arena
|
| 162 |
+
</button>
|
| 163 |
+
)}
|
| 164 |
+
</div>
|
| 165 |
+
</aside>
|
| 166 |
+
</main>
|
| 167 |
+
)
|
| 168 |
+
}
|
frontend/components/ChatFeed.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef } from "react"
|
| 4 |
+
|
| 5 |
+
type ChatMessage = {
|
| 6 |
+
agent_id: string
|
| 7 |
+
text: string
|
| 8 |
+
type?: 'message' | 'death' | 'alliance_proposal' | 'alliance_accept' | 'alliance_reject' | 'leadership_vote' | 'leader_elected' | 'water_collected' | 'fire_extinguished'
|
| 9 |
+
to_model?: string
|
| 10 |
+
from_model?: string
|
| 11 |
+
candidates?: string[]
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function ChatFeed({ messages }: { messages: ChatMessage[] }) {
|
| 15 |
+
const bottomRef = useRef<HTMLDivElement>(null)
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
| 19 |
+
}, [messages])
|
| 20 |
+
|
| 21 |
+
function getAgentName(id: string) {
|
| 22 |
+
return id.split("/").at(-1)?.split("-")[0].toUpperCase() || id
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function getAgentColor(name: string) {
|
| 26 |
+
let hash = 0
|
| 27 |
+
for (let i = 0; i < name.length; i++) {
|
| 28 |
+
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
| 29 |
+
}
|
| 30 |
+
return `hsl(${hash % 360}, 70%, 60%)`
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<div className="flex-1 overflow-y-auto py-4 space-y-3 min-h-0 custom-scrollbar">
|
| 35 |
+
{messages.map((msg, i) => {
|
| 36 |
+
const color = getAgentColor(msg.agent_id)
|
| 37 |
+
|
| 38 |
+
if (msg.type === 'death') {
|
| 39 |
+
return (
|
| 40 |
+
<div key={i} className="mx-4 p-2 bg-red-500/10 border-l-2 border-red-500 animate-in slide-in-from-left duration-300">
|
| 41 |
+
<span className="font-mono text-[10px] text-red-500 uppercase font-bold tracking-widest">
|
| 42 |
+
💀 {getAgentName(msg.agent_id)} was consumed by the fire
|
| 43 |
+
</span>
|
| 44 |
+
</div>
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (msg.type === 'alliance_proposal') {
|
| 49 |
+
return (
|
| 50 |
+
<div key={i} className="mx-4 p-2 bg-yellow-500/10 border-l-2 border-yellow-500 animate-in slide-in-from-left duration-300">
|
| 51 |
+
<span className="font-mono text-[10px] text-yellow-500 uppercase font-bold tracking-widest">
|
| 52 |
+
🤝 {getAgentName(msg.agent_id)} proposed an alliance to {msg.to_model ? getAgentName(msg.to_model) : 'someone'}
|
| 53 |
+
</span>
|
| 54 |
+
</div>
|
| 55 |
+
)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (msg.type === 'alliance_accept') {
|
| 59 |
+
return (
|
| 60 |
+
<div key={i} className="mx-4 p-2 bg-emerald-500/10 border-l-2 border-emerald-500 animate-in slide-in-from-left duration-300">
|
| 61 |
+
<span className="font-mono text-[10px] text-emerald-400 uppercase font-bold tracking-widest">
|
| 62 |
+
✅ {getAgentName(msg.agent_id)} accepted the alliance{msg.to_model ? ` with ${getAgentName(msg.to_model)}` : ''}
|
| 63 |
+
</span>
|
| 64 |
+
</div>
|
| 65 |
+
)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (msg.type === 'alliance_reject') {
|
| 69 |
+
return (
|
| 70 |
+
<div key={i} className="mx-4 p-2 bg-orange-500/10 border-l-2 border-orange-500 animate-in slide-in-from-left duration-300">
|
| 71 |
+
<span className="font-mono text-[10px] text-orange-400 uppercase font-bold tracking-widest">
|
| 72 |
+
❌ {getAgentName(msg.agent_id)} rejected the alliance{msg.to_model ? ` from ${getAgentName(msg.to_model)}` : ''}
|
| 73 |
+
</span>
|
| 74 |
+
</div>
|
| 75 |
+
)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (msg.type === 'leadership_vote') {
|
| 79 |
+
return (
|
| 80 |
+
<div key={i} className="mx-4 p-2 bg-purple-500/10 border-l-2 border-purple-500 animate-in slide-in-from-left duration-300">
|
| 81 |
+
<span className="font-mono text-[10px] text-purple-400 uppercase font-bold tracking-widest">
|
| 82 |
+
🗳️ {getAgentName(msg.agent_id)} voted for {msg.candidates ? getAgentName(msg.candidates[0]) : 'someone'}
|
| 83 |
+
</span>
|
| 84 |
+
</div>
|
| 85 |
+
)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (msg.type === 'leader_elected') {
|
| 89 |
+
return (
|
| 90 |
+
<div key={i} className="mx-4 p-2 bg-yellow-500/10 border-l-2 border-yellow-500 animate-in slide-in-from-left duration-300">
|
| 91 |
+
<span className="font-mono text-[10px] text-yellow-400 uppercase font-bold tracking-widest">
|
| 92 |
+
👑 {getAgentName(msg.agent_id)} elected as leader!
|
| 93 |
+
</span>
|
| 94 |
+
</div>
|
| 95 |
+
)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if (msg.type === 'water_collected') {
|
| 99 |
+
return (
|
| 100 |
+
<div key={i} className="mx-4 p-2 bg-cyan-500/10 border-l-2 border-cyan-500 animate-in slide-in-from-left duration-300">
|
| 101 |
+
<span className="font-mono text-[10px] text-cyan-400 uppercase font-bold tracking-widest">
|
| 102 |
+
💧 {getAgentName(msg.agent_id)} collected water!
|
| 103 |
+
</span>
|
| 104 |
+
</div>
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if (msg.type === 'fire_extinguished') {
|
| 109 |
+
return (
|
| 110 |
+
<div key={i} className="mx-4 p-2 bg-green-500/10 border-l-2 border-green-500 animate-in slide-in-from-left duration-300">
|
| 111 |
+
<span className="font-mono text-[10px] text-green-400 uppercase font-bold tracking-widest">
|
| 112 |
+
🔥 Fire being extinguished! Intensity dropping...
|
| 113 |
+
</span>
|
| 114 |
+
</div>
|
| 115 |
+
)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
return (
|
| 119 |
+
<div key={i} className="px-4 group animate-in fade-in duration-500">
|
| 120 |
+
<div className="flex items-baseline gap-2">
|
| 121 |
+
<span
|
| 122 |
+
className="font-mono text-[10px] font-bold shrink-0 px-1.5 rounded bg-white/5"
|
| 123 |
+
style={{ color }}
|
| 124 |
+
>
|
| 125 |
+
{getAgentName(msg.agent_id)}
|
| 126 |
+
</span>
|
| 127 |
+
<span className="font-mono text-[12px] text-white/80 leading-relaxed break-words">
|
| 128 |
+
{msg.text}
|
| 129 |
+
</span>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
)
|
| 133 |
+
})}
|
| 134 |
+
<div ref={bottomRef} />
|
| 135 |
+
</div>
|
| 136 |
+
)
|
| 137 |
+
}
|
frontend/components/LoadingScreen.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react"
|
| 4 |
+
import { wakeBackend } from "../lib/api"
|
| 5 |
+
|
| 6 |
+
export default function LoadingScreen({ onReady }: { onReady: () => void }) {
|
| 7 |
+
const [status, setStatus] = useState<"waking" | "connecting" | "ready" | "error">("waking")
|
| 8 |
+
const [dots, setDots] = useState("")
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
const interval = setInterval(() => {
|
| 12 |
+
setDots(d => (d.length >= 3 ? "" : d + "."))
|
| 13 |
+
}, 500)
|
| 14 |
+
return () => clearInterval(interval)
|
| 15 |
+
}, [])
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
let timeoutId: NodeJS.Timeout
|
| 19 |
+
let isActive = true
|
| 20 |
+
|
| 21 |
+
async function poll() {
|
| 22 |
+
try {
|
| 23 |
+
const data = await wakeBackend()
|
| 24 |
+
if (data.warm && data.groq_available) {
|
| 25 |
+
setStatus("ready")
|
| 26 |
+
setTimeout(() => {
|
| 27 |
+
if (isActive) onReady()
|
| 28 |
+
}, 1000)
|
| 29 |
+
} else if (data.warm) {
|
| 30 |
+
setStatus("connecting")
|
| 31 |
+
timeoutId = setTimeout(poll, 2000)
|
| 32 |
+
}
|
| 33 |
+
} catch (err) {
|
| 34 |
+
timeoutId = setTimeout(poll, 2000)
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
poll()
|
| 39 |
+
|
| 40 |
+
const failTimeout = setTimeout(() => {
|
| 41 |
+
if (status !== "ready") setStatus("error")
|
| 42 |
+
}, 60000)
|
| 43 |
+
|
| 44 |
+
return () => {
|
| 45 |
+
isActive = false
|
| 46 |
+
clearTimeout(timeoutId)
|
| 47 |
+
clearTimeout(failTimeout)
|
| 48 |
+
}
|
| 49 |
+
}, [onReady, status])
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="fixed inset-0 bg-[#0a0a0a] flex flex-col items-center justify-center z-50">
|
| 53 |
+
<div className="relative mb-8">
|
| 54 |
+
<div className="text-6xl animate-bounce">🌋</div>
|
| 55 |
+
<div className="absolute inset-0 bg-red-500/20 blur-3xl rounded-full animate-pulse" />
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div className="font-mono text-xs tracking-widest text-white/40 uppercase mb-2">
|
| 59 |
+
{status === "waking" && `Waking up the arena${dots}`}
|
| 60 |
+
{status === "connecting" && `Connecting to Groq${dots}`}
|
| 61 |
+
{status === "ready" && "Ready for chaos"}
|
| 62 |
+
{status === "error" && "Failed to start. Check your connection."}
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
{status === "error" && (
|
| 66 |
+
<button
|
| 67 |
+
onClick={() => window.location.reload()}
|
| 68 |
+
className="mt-4 px-4 py-2 bg-white/10 hover:bg-white/20 text-white font-mono text-xs rounded transition-colors"
|
| 69 |
+
>
|
| 70 |
+
Retry
|
| 71 |
+
</button>
|
| 72 |
+
)}
|
| 73 |
+
|
| 74 |
+
<div className="w-48 h-1 bg-white/5 rounded-full overflow-hidden mt-4">
|
| 75 |
+
<div className={`h-full bg-red-500 transition-all duration-1000 ${status === "ready" ? "w-full" : "w-1/2 animate-pulse"}`} />
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
)
|
| 79 |
+
}
|
frontend/components/MapCanvas.tsx
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from "react"
|
| 4 |
+
|
| 5 |
+
type Agent = {
|
| 6 |
+
model_name: string
|
| 7 |
+
x: number
|
| 8 |
+
y: number
|
| 9 |
+
alive: boolean
|
| 10 |
+
water_collected: boolean
|
| 11 |
+
is_leader: boolean
|
| 12 |
+
mode: "solo" | "coalition"
|
| 13 |
+
status: "searching" | "collecting_water" | "extinguishing_fire" | "escaping" | "idle"
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
type Fire = {
|
| 17 |
+
x: number
|
| 18 |
+
y: number
|
| 19 |
+
radius: number
|
| 20 |
+
intensity: number
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
type WaterSource = {
|
| 24 |
+
id: string
|
| 25 |
+
x: number
|
| 26 |
+
y: number
|
| 27 |
+
water_amount: number
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const BACKEND_W = 1200
|
| 31 |
+
const BACKEND_H = 800
|
| 32 |
+
|
| 33 |
+
function getAgentColor(name: string) {
|
| 34 |
+
let hash = 0
|
| 35 |
+
for (let i = 0; i < name.length; i++) {
|
| 36 |
+
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
| 37 |
+
}
|
| 38 |
+
return `hsl(${hash % 360}, 70%, 60%)`
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function getStatusColor(status: string) {
|
| 42 |
+
switch (status) {
|
| 43 |
+
case "searching": return "#3b82f6" // blue
|
| 44 |
+
case "collecting_water": return "#06b6d4" // cyan
|
| 45 |
+
case "extinguishing_fire": return "#ef4444" // red
|
| 46 |
+
case "escaping": return "#f59e0b" // amber
|
| 47 |
+
default: return "#6b7280" // gray
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export default function MapCanvas({
|
| 52 |
+
agents,
|
| 53 |
+
fire,
|
| 54 |
+
waterSources,
|
| 55 |
+
waitingForScenario,
|
| 56 |
+
gameOver,
|
| 57 |
+
winnerLabel,
|
| 58 |
+
mapSize,
|
| 59 |
+
onMapClick,
|
| 60 |
+
}: {
|
| 61 |
+
agents: Agent[]
|
| 62 |
+
fire: Fire | null
|
| 63 |
+
waterSources: WaterSource[]
|
| 64 |
+
waitingForScenario: boolean
|
| 65 |
+
gameOver: boolean
|
| 66 |
+
winnerLabel?: string | null
|
| 67 |
+
mapSize: { width: number; height: number }
|
| 68 |
+
onMapClick?: (x: number, y: number) => void
|
| 69 |
+
}) {
|
| 70 |
+
const gridSize = 40
|
| 71 |
+
const sx = (bx: number) => (bx / BACKEND_W) * mapSize.width
|
| 72 |
+
const sy = (by: number) => (by / BACKEND_H) * mapSize.height
|
| 73 |
+
const leader = agents.find(a => a.alive && a.is_leader)
|
| 74 |
+
const coalitionAgents = agents.filter(a => a.alive && a.mode === "coalition" && !a.is_leader)
|
| 75 |
+
|
| 76 |
+
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
| 77 |
+
if (!waitingForScenario || !onMapClick) return
|
| 78 |
+
const rect = e.currentTarget.getBoundingClientRect()
|
| 79 |
+
const backendX = Math.round(((e.clientX - rect.left) / mapSize.width) * BACKEND_W)
|
| 80 |
+
const backendY = Math.round(((e.clientY - rect.top) / mapSize.height) * BACKEND_H)
|
| 81 |
+
onMapClick(backendX, backendY)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
<div
|
| 86 |
+
className="relative w-full h-full bg-[#0a0a0a] overflow-hidden"
|
| 87 |
+
style={{ cursor: waitingForScenario ? "crosshair" : "default" }}
|
| 88 |
+
onClick={handleClick}
|
| 89 |
+
>
|
| 90 |
+
{/* Grid */}
|
| 91 |
+
<div className="absolute inset-0 opacity-10 pointer-events-none"
|
| 92 |
+
style={{ backgroundImage: `linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)`, backgroundSize: `${gridSize}px ${gridSize}px` }} />
|
| 93 |
+
|
| 94 |
+
{/* Fire */}
|
| 95 |
+
{fire && (
|
| 96 |
+
<div
|
| 97 |
+
className="absolute rounded-full transition-all duration-500 ease-linear"
|
| 98 |
+
style={{
|
| 99 |
+
left: sx(fire.x),
|
| 100 |
+
top: sy(fire.y),
|
| 101 |
+
width: sx(fire.radius) * 2,
|
| 102 |
+
height: sy(fire.radius) * 2,
|
| 103 |
+
transform: "translate(-50%, -50%)",
|
| 104 |
+
background: `radial-gradient(circle, #ff4500 0%, rgba(255, 69, 0, ${Math.min(fire.intensity / 100, 1)}) 60%, transparent 100%)`,
|
| 105 |
+
boxShadow: `0 0 ${30 + fire.intensity / 2}px rgba(255, 69, 0, ${fire.intensity / 100})`,
|
| 106 |
+
}}
|
| 107 |
+
>
|
| 108 |
+
<div className="absolute inset-0 rounded-full animate-pulse" style={{ backgroundColor: `rgba(255, 69, 0, ${fire.intensity / 200})` }} />
|
| 109 |
+
</div>
|
| 110 |
+
)}
|
| 111 |
+
|
| 112 |
+
{/* Water Sources */}
|
| 113 |
+
{waterSources.map((water) => (
|
| 114 |
+
<div
|
| 115 |
+
key={water.id}
|
| 116 |
+
className="absolute"
|
| 117 |
+
style={{
|
| 118 |
+
left: sx(water.x),
|
| 119 |
+
top: sy(water.y),
|
| 120 |
+
transform: "translate(-50%, -50%)",
|
| 121 |
+
}}
|
| 122 |
+
>
|
| 123 |
+
<div className="relative">
|
| 124 |
+
<div className="w-6 h-6 rounded-full border-2 border-cyan-400 bg-cyan-500/20 flex items-center justify-center animate-pulse">
|
| 125 |
+
<span className="text-[10px]">💧</span>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
))}
|
| 130 |
+
|
| 131 |
+
{/* Coalition Links */}
|
| 132 |
+
{leader && coalitionAgents.length > 0 && (
|
| 133 |
+
<svg className="absolute inset-0 w-full h-full pointer-events-none">
|
| 134 |
+
{coalitionAgents.map((agent) => (
|
| 135 |
+
<line
|
| 136 |
+
key={`${leader.model_name}-${agent.model_name}`}
|
| 137 |
+
x1={sx(leader.x)}
|
| 138 |
+
y1={sy(leader.y)}
|
| 139 |
+
x2={sx(agent.x)}
|
| 140 |
+
y2={sy(agent.y)}
|
| 141 |
+
stroke="rgba(250, 204, 21, 0.6)"
|
| 142 |
+
strokeWidth={2}
|
| 143 |
+
strokeDasharray="6 6"
|
| 144 |
+
/>
|
| 145 |
+
))}
|
| 146 |
+
</svg>
|
| 147 |
+
)}
|
| 148 |
+
|
| 149 |
+
{/* Agents */}
|
| 150 |
+
{agents.map((agent) => {
|
| 151 |
+
const color = getAgentColor(agent.model_name)
|
| 152 |
+
const isDead = !agent.alive
|
| 153 |
+
const statusColor = getStatusColor(agent.status)
|
| 154 |
+
const nodeSize = agent.water_collected ? 10 : 6
|
| 155 |
+
|
| 156 |
+
return (
|
| 157 |
+
<div
|
| 158 |
+
key={agent.model_name}
|
| 159 |
+
className={`absolute transition-all duration-200 ease-in-out flex flex-col items-center ${isDead ? 'opacity-30 grayscale scale-75' : ''}`}
|
| 160 |
+
style={{
|
| 161 |
+
left: sx(agent.x),
|
| 162 |
+
top: sy(agent.y),
|
| 163 |
+
transform: "translate(-50%, -50%)",
|
| 164 |
+
}}
|
| 165 |
+
>
|
| 166 |
+
{!isDead && (
|
| 167 |
+
<div className="mb-2 px-2 py-1 bg-[#111] border border-white/10 rounded-md shadow-2xl backdrop-blur-md max-w-[140px]">
|
| 168 |
+
<span className="font-mono text-[8px] text-white break-words">
|
| 169 |
+
{agent.model_name}
|
| 170 |
+
{agent.is_leader && <span className="text-yellow-400 ml-1">👑</span>}
|
| 171 |
+
{agent.water_collected && <span className="text-cyan-400 ml-1">💧</span>}
|
| 172 |
+
</span>
|
| 173 |
+
<div className="text-[7px] text-white/60 mt-0.5">{agent.status}</div>
|
| 174 |
+
</div>
|
| 175 |
+
)}
|
| 176 |
+
|
| 177 |
+
<div className="relative">
|
| 178 |
+
<div
|
| 179 |
+
className="absolute rounded-full animate-pulse"
|
| 180 |
+
style={{
|
| 181 |
+
width: nodeSize * 4,
|
| 182 |
+
height: nodeSize * 4,
|
| 183 |
+
left: -nodeSize,
|
| 184 |
+
top: -nodeSize,
|
| 185 |
+
borderColor: statusColor,
|
| 186 |
+
borderWidth: "2px",
|
| 187 |
+
borderStyle: "solid",
|
| 188 |
+
}}
|
| 189 |
+
/>
|
| 190 |
+
<div
|
| 191 |
+
className="rounded-full border-2 relative"
|
| 192 |
+
style={{
|
| 193 |
+
width: nodeSize * 2,
|
| 194 |
+
height: nodeSize * 2,
|
| 195 |
+
backgroundColor: agent.water_collected ? "#06b6d4" : color,
|
| 196 |
+
borderColor: agent.is_leader ? "#ffff00" : color,
|
| 197 |
+
boxShadow: isDead ? "none" : `0 0 15px ${agent.water_collected ? "rgba(6, 182, 212, 0.4)" : "rgba(255,255,255,0.2)"}`,
|
| 198 |
+
}}
|
| 199 |
+
>
|
| 200 |
+
{isDead && (
|
| 201 |
+
<div className="absolute inset-0 flex items-center justify-center text-[10px]">💀</div>
|
| 202 |
+
)}
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
)
|
| 207 |
+
})}
|
| 208 |
+
|
| 209 |
+
{/* Fire Intensity Meter */}
|
| 210 |
+
{fire && (
|
| 211 |
+
<div className="absolute top-4 left-4 bg-black/70 border border-white/20 rounded-lg p-3 backdrop-blur-md">
|
| 212 |
+
<div className="text-white/80 font-mono text-[10px] mb-2">Fire Intensity</div>
|
| 213 |
+
<div className="w-32 h-3 bg-white/10 rounded-full overflow-hidden border border-white/20">
|
| 214 |
+
<div
|
| 215 |
+
className="h-full bg-gradient-to-r from-orange-500 to-red-600 transition-all duration-300"
|
| 216 |
+
style={{ width: `${Math.min(fire.intensity, 100)}%` }}
|
| 217 |
+
/>
|
| 218 |
+
</div>
|
| 219 |
+
<div className="text-white/60 font-mono text-[9px] mt-1">{fire.intensity.toFixed(0)}%</div>
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
|
| 223 |
+
{/* Coalition Panel */}
|
| 224 |
+
<div className="absolute top-4 right-4 bg-black/70 border border-white/20 rounded-lg p-3 backdrop-blur-md max-w-xs">
|
| 225 |
+
<div className="text-white/80 font-mono text-[10px] mb-2">🎯 Coalition Status</div>
|
| 226 |
+
<div className="space-y-1">
|
| 227 |
+
{agents.filter(a => a.alive && a.mode === "coalition").map(agent => (
|
| 228 |
+
<div key={agent.model_name} className="text-white/60 font-mono text-[9px]">
|
| 229 |
+
{agent.is_leader ? "👑 " : " "}{agent.model_name.split("/")[0]}
|
| 230 |
+
{agent.water_collected && " 💧"}
|
| 231 |
+
</div>
|
| 232 |
+
))}
|
| 233 |
+
{agents.filter(a => a.alive && a.mode === "solo").length > 0 && (
|
| 234 |
+
<div className="text-amber-400/80 font-mono text-[9px] mt-2">Lone Wolves:</div>
|
| 235 |
+
)}
|
| 236 |
+
{agents.filter(a => a.alive && a.mode === "solo").map(agent => (
|
| 237 |
+
<div key={agent.model_name} className="text-amber-400/60 font-mono text-[9px]">
|
| 238 |
+
🐺 {agent.model_name.split("/")[0]}
|
| 239 |
+
{agent.water_collected && " 💧"}
|
| 240 |
+
</div>
|
| 241 |
+
))}
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
{/* Instructions */}
|
| 246 |
+
{waitingForScenario && (
|
| 247 |
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-black/70 backdrop-blur-md border border-white/10 px-8 py-6 rounded-xl text-center">
|
| 248 |
+
<span className="text-white font-mono text-sm uppercase tracking-widest">
|
| 249 |
+
Click to place the fire and begin
|
| 250 |
+
</span>
|
| 251 |
+
</div>
|
| 252 |
+
)}
|
| 253 |
+
|
| 254 |
+
{/* Game Over */}
|
| 255 |
+
{gameOver && (
|
| 256 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10 bg-black/50">
|
| 257 |
+
<div className="bg-black/90 backdrop-blur-xl border-2 border-yellow-400/50 p-8 rounded-2xl text-center max-w-md">
|
| 258 |
+
<h2 className="text-5xl font-bold text-white font-mono tracking-tighter mb-4">🏁 GAME OVER</h2>
|
| 259 |
+
<p className="text-yellow-400 font-mono text-sm uppercase tracking-widest mb-3">
|
| 260 |
+
{fire && fire.intensity <= 0 ? "🔥 FIRE EXTINGUISHED!" : "❌ ALL BURNED"}
|
| 261 |
+
</p>
|
| 262 |
+
<p className="text-white/80 font-mono text-xs break-words">
|
| 263 |
+
{winnerLabel || "The flames consumed the arena"}
|
| 264 |
+
</p>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
)}
|
| 268 |
+
</div>
|
| 269 |
+
)
|
| 270 |
+
}
|
frontend/components/ModelSelector.tsx
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
|
| 5 |
+
interface Model {
|
| 6 |
+
id: string
|
| 7 |
+
name: string
|
| 8 |
+
backend?: string
|
| 9 |
+
tag?: string
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function ModelSelector({
|
| 13 |
+
models,
|
| 14 |
+
onAdd,
|
| 15 |
+
onRemove,
|
| 16 |
+
}: {
|
| 17 |
+
models: string[]
|
| 18 |
+
onAdd: (id: string) => void
|
| 19 |
+
onRemove: (id: string) => void
|
| 20 |
+
}) {
|
| 21 |
+
const [allModels, setAllModels] = useState<Model[]>([])
|
| 22 |
+
const [loading, setLoading] = useState(true)
|
| 23 |
+
const full = models.length >= 6
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
async function fetchModels() {
|
| 27 |
+
try {
|
| 28 |
+
const response = await fetch("http://localhost:8000/available-models")
|
| 29 |
+
const data = await response.json()
|
| 30 |
+
|
| 31 |
+
// Combine Groq and HF Spaces models
|
| 32 |
+
const combined: Model[] = [
|
| 33 |
+
...(data.groq_models || []).map((m: any) => ({
|
| 34 |
+
id: m.id,
|
| 35 |
+
name: m.name,
|
| 36 |
+
backend: "groq",
|
| 37 |
+
tag: "groq"
|
| 38 |
+
})),
|
| 39 |
+
...(data.hf_spaces_models || []).map((m: any) => ({
|
| 40 |
+
id: m.id,
|
| 41 |
+
name: m.name,
|
| 42 |
+
backend: "hf",
|
| 43 |
+
tag: "hf-spaces"
|
| 44 |
+
}))
|
| 45 |
+
]
|
| 46 |
+
setAllModels(combined)
|
| 47 |
+
} catch (err) {
|
| 48 |
+
console.error("Failed to fetch models:", err)
|
| 49 |
+
// Fallback to default Groq models
|
| 50 |
+
setAllModels([
|
| 51 |
+
{ id: "llama-3.1-8b-instant", name: "Llama 3.1 8B", backend: "groq", tag: "groq" },
|
| 52 |
+
{ id: "llama-3.1-70b-versatile", name: "Llama 3.1 70B", backend: "groq", tag: "groq" },
|
| 53 |
+
{ id: "mixtral-8x7b-32768", name: "Mixtral 8x7B", backend: "groq", tag: "groq" },
|
| 54 |
+
{ id: "gemma-7b-it", name: "Gemma 7B", backend: "groq", tag: "groq" },
|
| 55 |
+
])
|
| 56 |
+
} finally {
|
| 57 |
+
setLoading(false)
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
fetchModels()
|
| 62 |
+
}, [])
|
| 63 |
+
|
| 64 |
+
if (loading) {
|
| 65 |
+
return (
|
| 66 |
+
<div className="px-4 py-6 space-y-6">
|
| 67 |
+
<div className="text-center text-white/40 font-mono text-xs">
|
| 68 |
+
Loading models...
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Group models by backend
|
| 75 |
+
const groqModels = allModels.filter(m => m.backend === "groq")
|
| 76 |
+
const hfModels = allModels.filter(m => m.backend === "hf")
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<div className="px-4 py-6 space-y-6">
|
| 80 |
+
<div>
|
| 81 |
+
<h3 className="text-[10px] font-mono text-white/30 uppercase tracking-[0.2em] mb-4">
|
| 82 |
+
Select Survivors ({models.length}/6)
|
| 83 |
+
</h3>
|
| 84 |
+
<div className="space-y-4">
|
| 85 |
+
{/* Groq Models */}
|
| 86 |
+
{groqModels.length > 0 && (
|
| 87 |
+
<div>
|
| 88 |
+
<h4 className="text-[8px] font-mono text-white/40 uppercase tracking-[0.15em] mb-2">Groq API</h4>
|
| 89 |
+
<div className="grid grid-cols-1 gap-1.5">
|
| 90 |
+
{groqModels.map((m) => {
|
| 91 |
+
const isSelected = models.includes(m.id)
|
| 92 |
+
return (
|
| 93 |
+
<button
|
| 94 |
+
key={m.id}
|
| 95 |
+
onClick={() => isSelected ? onRemove(m.id) : onAdd(m.id)}
|
| 96 |
+
disabled={full && !isSelected}
|
| 97 |
+
className={`flex items-center justify-between px-3 py-2 rounded-lg border transition-all duration-200 ${
|
| 98 |
+
isSelected
|
| 99 |
+
? 'bg-blue-500/10 border-blue-500/30'
|
| 100 |
+
: 'border-transparent hover:bg-white/5 opacity-60 hover:opacity-100'
|
| 101 |
+
} ${full && !isSelected ? 'cursor-not-allowed opacity-20' : ''}`}
|
| 102 |
+
>
|
| 103 |
+
<span className="font-mono text-xs text-white/90">{m.name}</span>
|
| 104 |
+
<span className="text-[8px] font-mono uppercase px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
|
| 105 |
+
Groq
|
| 106 |
+
</span>
|
| 107 |
+
</button>
|
| 108 |
+
)
|
| 109 |
+
})}
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
)}
|
| 113 |
+
|
| 114 |
+
{/* HF Spaces Models */}
|
| 115 |
+
{hfModels.length > 0 && (
|
| 116 |
+
<div>
|
| 117 |
+
<h4 className="text-[8px] font-mono text-white/40 uppercase tracking-[0.15em] mb-2">HuggingFace Spaces</h4>
|
| 118 |
+
<div className="grid grid-cols-1 gap-1.5">
|
| 119 |
+
{hfModels.map((m) => {
|
| 120 |
+
const isSelected = models.includes(m.id)
|
| 121 |
+
return (
|
| 122 |
+
<button
|
| 123 |
+
key={m.id}
|
| 124 |
+
onClick={() => isSelected ? onRemove(m.id) : onAdd(m.id)}
|
| 125 |
+
disabled={full && !isSelected}
|
| 126 |
+
className={`flex items-center justify-between px-3 py-2 rounded-lg border transition-all duration-200 ${
|
| 127 |
+
isSelected
|
| 128 |
+
? 'bg-purple-500/10 border-purple-500/30'
|
| 129 |
+
: 'border-transparent hover:bg-white/5 opacity-60 hover:opacity-100'
|
| 130 |
+
} ${full && !isSelected ? 'cursor-not-allowed opacity-20' : ''}`}
|
| 131 |
+
>
|
| 132 |
+
<span className="font-mono text-xs text-white/90">{m.name}</span>
|
| 133 |
+
<span className="text-[8px] font-mono uppercase px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400">
|
| 134 |
+
HF
|
| 135 |
+
</span>
|
| 136 |
+
</button>
|
| 137 |
+
)
|
| 138 |
+
})}
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
)}
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
{models.length > 0 && (
|
| 146 |
+
<div className="pt-4 border-t border-white/5">
|
| 147 |
+
<div className="flex flex-wrap gap-2">
|
| 148 |
+
{models.map(id => {
|
| 149 |
+
const model = allModels.find(m => m.id === id)
|
| 150 |
+
return (
|
| 151 |
+
<div key={id} className="flex items-center gap-2 bg-white/5 px-2 py-1 rounded border border-white/10">
|
| 152 |
+
<span className="font-mono text-[10px] text-white/50">
|
| 153 |
+
{model?.name || id}
|
| 154 |
+
</span>
|
| 155 |
+
<button onClick={() => onRemove(id)} className="text-white/20 hover:text-white">✕</button>
|
| 156 |
+
</div>
|
| 157 |
+
)
|
| 158 |
+
})}
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
)
|
| 164 |
+
}
|
frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"next": "16.2.4",
|
| 13 |
+
"react": "19.2.4",
|
| 14 |
+
"react-dom": "19.2.4"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@tailwindcss/postcss": "^4",
|
| 18 |
+
"@types/node": "^20",
|
| 19 |
+
"@types/react": "^19",
|
| 20 |
+
"@types/react-dom": "^19",
|
| 21 |
+
"eslint": "^9",
|
| 22 |
+
"eslint-config-next": "16.2.4",
|
| 23 |
+
"tailwindcss": "^4",
|
| 24 |
+
"typescript": "^5"
|
| 25 |
+
}
|
| 26 |
+
}
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
frontend/public/file.svg
ADDED
|
|
frontend/public/globe.svg
ADDED
|
|
frontend/public/next.svg
ADDED
|
|
frontend/public/vercel.svg
ADDED
|
|
frontend/public/window.svg
ADDED
|
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|
frontend/vercel.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "nextjs",
|
| 3 |
+
"buildCommand": "npm run build",
|
| 4 |
+
"outputDirectory": ".next"
|
| 5 |
+
}
|