Commit ·
bcad26c
0
Parent(s):
Initial commit: Grid Royale game backend and frontend.
Browse files- .gitignore +3 -0
- api.py +140 -0
- backend/__init__.py +0 -0
- backend/agent/__init__.py +3 -0
- backend/agent/agent.py +64 -0
- backend/agent/llm_client.py +104 -0
- backend/agent/state.py +51 -0
- backend/engine.py +187 -0
- backend/environment/__init__.py +19 -0
- backend/environment/ability_registry.py +127 -0
- backend/environment/config.py +20 -0
- backend/environment/environment.py +53 -0
- backend/environment/loot_tables.py +118 -0
- backend/environment/scoring.py +18 -0
- backend/environment/tile.py +18 -0
- backend/tools/__init__.py +19 -0
- backend/tools/activate_ability.py +53 -0
- backend/tools/move.py +71 -0
- backend/tools/observe.py +83 -0
- backend/tools/think.py +31 -0
- design.md +222 -0
- frontend/static/index.html +936 -0
- main.py +8 -0
- modal_vllm.py +330 -0
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.env
|
| 2 |
+
*.venv/
|
| 3 |
+
*pycache_*/
|
api.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from backend.environment import GameConfig
|
| 5 |
+
from backend.engine import Engine
|
| 6 |
+
from backend.tools import TOOL_SCHEMAS
|
| 7 |
+
|
| 8 |
+
app = FastAPI(title="Grid Royale API")
|
| 9 |
+
|
| 10 |
+
app.add_middleware(
|
| 11 |
+
CORSMiddleware,
|
| 12 |
+
allow_origins=["*"],
|
| 13 |
+
allow_methods=["*"],
|
| 14 |
+
allow_headers=["*"],
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
engine: Engine | None = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class StartGameRequest(BaseModel):
|
| 21 |
+
grid_size: int = 20
|
| 22 |
+
max_turns: int = 50
|
| 23 |
+
max_agents: int = 8
|
| 24 |
+
num_chests: int = 15
|
| 25 |
+
agent_names: list[str] | None = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _serialize(aid: str) -> dict:
|
| 29 |
+
if not engine:
|
| 30 |
+
return {}
|
| 31 |
+
a = engine.env.agents[aid]
|
| 32 |
+
return {
|
| 33 |
+
"id": a.id,
|
| 34 |
+
"name": a.name,
|
| 35 |
+
"x": a.pos[0],
|
| 36 |
+
"y": a.pos[1],
|
| 37 |
+
"hp": a.hp,
|
| 38 |
+
"alive": a.alive,
|
| 39 |
+
"score": a.score,
|
| 40 |
+
"kills": a.kills,
|
| 41 |
+
"shielded": a.shielded,
|
| 42 |
+
"abilities": [
|
| 43 |
+
{"name": ab.name, "last_used_turn": ab.last_used_turn, "uses_remaining": ab.uses_remaining}
|
| 44 |
+
for ab in a.abilities
|
| 45 |
+
],
|
| 46 |
+
"messages": a.messages[-24:],
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _game_state() -> dict:
|
| 51 |
+
if not engine:
|
| 52 |
+
return {}
|
| 53 |
+
alive = engine.env.alive_agents()
|
| 54 |
+
tiles = {}
|
| 55 |
+
for (x, y), tile in engine.env.grid.items():
|
| 56 |
+
tiles[f"{x},{y}"] = {
|
| 57 |
+
"terrain": tile.terrain,
|
| 58 |
+
"loot": tile.loot is not None,
|
| 59 |
+
}
|
| 60 |
+
return {
|
| 61 |
+
"turn": engine.env.turn,
|
| 62 |
+
"grid_size": engine.env.config.grid_size,
|
| 63 |
+
"max_turns": engine.env.config.max_turns,
|
| 64 |
+
"agents": [_serialize(aid) for aid in engine.env.agents],
|
| 65 |
+
"tiles": tiles,
|
| 66 |
+
"game_over": len(alive) <= 1 or engine.env.turn >= engine.env.config.max_turns,
|
| 67 |
+
"winner": alive[0].name if len(alive) == 1 else None,
|
| 68 |
+
"turn_log": engine.last_turn_log,
|
| 69 |
+
"tool_schemas": TOOL_SCHEMAS,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
BOT_NAMES = [
|
| 74 |
+
"Vex", "Nyx", "Zara", "Kael",
|
| 75 |
+
"Rook", "Lyra", "Orin", "Sage",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _default_names(count: int) -> list[str]:
|
| 80 |
+
return BOT_NAMES[:count]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@app.post("/api/game/start")
|
| 84 |
+
async def start_game(req: StartGameRequest):
|
| 85 |
+
global engine
|
| 86 |
+
config = GameConfig(
|
| 87 |
+
grid_size=req.grid_size,
|
| 88 |
+
max_turns=req.max_turns,
|
| 89 |
+
max_agents=req.max_agents,
|
| 90 |
+
num_chests=req.num_chests,
|
| 91 |
+
)
|
| 92 |
+
engine = Engine(config=config)
|
| 93 |
+
engine.generate_grid()
|
| 94 |
+
engine.scatter_chests()
|
| 95 |
+
|
| 96 |
+
names = req.agent_names or _default_names(req.max_agents)
|
| 97 |
+
for i, name in enumerate(names):
|
| 98 |
+
if i >= req.max_agents:
|
| 99 |
+
break
|
| 100 |
+
engine.spawn_agent(agent_id=f"agent_{i}", name=name)
|
| 101 |
+
|
| 102 |
+
return {"message": f"Game started with {len(names)} agents", "turn": 0}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@app.post("/api/game/step")
|
| 106 |
+
async def game_step():
|
| 107 |
+
if not engine:
|
| 108 |
+
raise HTTPException(400, "No game running")
|
| 109 |
+
await engine.step()
|
| 110 |
+
return _game_state()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@app.get("/api/game/state")
|
| 114 |
+
async def game_state():
|
| 115 |
+
if not engine:
|
| 116 |
+
raise HTTPException(400, "No game running")
|
| 117 |
+
return _game_state()
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@app.post("/api/game/run")
|
| 121 |
+
async def run_full_game(req: StartGameRequest):
|
| 122 |
+
global engine
|
| 123 |
+
config = GameConfig(
|
| 124 |
+
grid_size=req.grid_size,
|
| 125 |
+
max_turns=req.max_turns,
|
| 126 |
+
max_agents=req.max_agents,
|
| 127 |
+
num_chests=req.num_chests,
|
| 128 |
+
)
|
| 129 |
+
engine = Engine(config=config)
|
| 130 |
+
engine.generate_grid()
|
| 131 |
+
engine.scatter_chests()
|
| 132 |
+
|
| 133 |
+
names = req.agent_names or _default_names(req.max_agents)
|
| 134 |
+
for i, name in enumerate(names):
|
| 135 |
+
if i >= req.max_agents:
|
| 136 |
+
break
|
| 137 |
+
engine.spawn_agent(agent_id=f"agent_{i}", name=name)
|
| 138 |
+
|
| 139 |
+
await engine.run_game()
|
| 140 |
+
return _game_state()
|
backend/__init__.py
ADDED
|
File without changes
|
backend/agent/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .state import AgentState, AbilityInstance
|
| 2 |
+
|
| 3 |
+
__all__ = ["AgentState", "AbilityInstance"]
|
backend/agent/agent.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import time
|
| 3 |
+
from openai import AsyncOpenAI
|
| 4 |
+
|
| 5 |
+
from .llm_client import get_llm_client
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
ToolCall = dict
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Agent:
|
| 12 |
+
def __init__(
|
| 13 |
+
self,
|
| 14 |
+
agent_id: str,
|
| 15 |
+
model: str = "google/gemma-4-26B-A4B-it",
|
| 16 |
+
provider: str = "modal",
|
| 17 |
+
system_prompt: str | None = None,
|
| 18 |
+
):
|
| 19 |
+
self.id = agent_id
|
| 20 |
+
self.model = model
|
| 21 |
+
self.provider = provider
|
| 22 |
+
self.client: AsyncOpenAI = get_llm_client(provider)
|
| 23 |
+
self.system_prompt = system_prompt or (
|
| 24 |
+
"You are an AI agent in a grid-based battle royale game on a "
|
| 25 |
+
f"{'20x20' if not hasattr(self, 'grid_size') else str(self.grid_size)} grid. "
|
| 26 |
+
"Your goal: survive, collect loot, eliminate other agents, be the last one standing. "
|
| 27 |
+
"Use observe() to see your surroundings AND check your current HP, abilities, and cooldowns. "
|
| 28 |
+
"Abilities are found in loot chests scattered across the map — walk over a tile with loot to pick it up. "
|
| 29 |
+
"Once obtained, activate abilities via activate_ability(name, args). "
|
| 30 |
+
"Available abilities: attack(x,y), dash(dx,dy), shield(), heal(). "
|
| 31 |
+
"Coordinate your moves to maximize score and survival."
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
async def decide(self, messages: list, tools: list[dict]) -> list[ToolCall]:
|
| 35 |
+
import json
|
| 36 |
+
system_msg = {"role": "system", "content": self.system_prompt}
|
| 37 |
+
full_messages = [system_msg] + messages
|
| 38 |
+
|
| 39 |
+
t0 = time.time()
|
| 40 |
+
response = await self.client.chat.completions.create(
|
| 41 |
+
model=self.model,
|
| 42 |
+
messages=full_messages,
|
| 43 |
+
tools=tools,
|
| 44 |
+
tool_choice="auto",
|
| 45 |
+
)
|
| 46 |
+
elapsed = round((time.time() - t0) * 1000)
|
| 47 |
+
|
| 48 |
+
choice = response.choices[0]
|
| 49 |
+
if not choice.message.tool_calls:
|
| 50 |
+
return {"calls": [], "time_ms": elapsed, "raw": response.usage.model_dump() if response.usage else None}
|
| 51 |
+
|
| 52 |
+
results = []
|
| 53 |
+
for tc in choice.message.tool_calls:
|
| 54 |
+
try:
|
| 55 |
+
args = json.loads(tc.function.arguments)
|
| 56 |
+
except json.JSONDecodeError:
|
| 57 |
+
args = {}
|
| 58 |
+
results.append({"name": tc.function.name, "args": args})
|
| 59 |
+
|
| 60 |
+
return {
|
| 61 |
+
"calls": results,
|
| 62 |
+
"time_ms": elapsed,
|
| 63 |
+
"raw": response.usage.model_dump() if response.usage else None,
|
| 64 |
+
}
|
backend/agent/llm_client.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from openai import AsyncOpenAI
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
PROVIDER_CONFIG = {
|
| 8 |
+
"local": {
|
| 9 |
+
"base_url": "http://localhost:11434/v1",
|
| 10 |
+
"api_key": "OLLAMA_API_KEY",
|
| 11 |
+
"models": [
|
| 12 |
+
"qwen2.5:7b",
|
| 13 |
+
"qwen2.5:3b",
|
| 14 |
+
"llama3.1:8b",
|
| 15 |
+
],
|
| 16 |
+
},
|
| 17 |
+
"modal": {
|
| 18 |
+
"base_url": "https://lokeshreddypolu2004--example-vllm-inference-serve.modal.run/v1",
|
| 19 |
+
"api_key": "MODAL_API_KEY",
|
| 20 |
+
"models": [
|
| 21 |
+
"google/gemma-4-26B-A4B-it",
|
| 22 |
+
],
|
| 23 |
+
},
|
| 24 |
+
"nvidia": {
|
| 25 |
+
"base_url": "https://integrate.api.nvidia.com/v1",
|
| 26 |
+
"api_key": "NVIDIA_NIM_API_KEY",
|
| 27 |
+
"models": [
|
| 28 |
+
"minimaxai/minimax-m2.7",
|
| 29 |
+
"stepfun-ai/step-3.5-flash",
|
| 30 |
+
"z-ai/glm4.7",
|
| 31 |
+
"deepseek-ai/deepseek-v3.2",
|
| 32 |
+
"moonshotai/kimi-k2-thinking",
|
| 33 |
+
"mistralai/devstral-2-123b-instruct-2512",
|
| 34 |
+
"mistralai/mistral-large-3-675b-instruct-2512",
|
| 35 |
+
"bytedance/seed-oss-36b-instruct",
|
| 36 |
+
"qwen/qwen3-coder-480b-a35b-instruct",
|
| 37 |
+
"nvidia/nemotron-3-ultra-550b-a55b",
|
| 38 |
+
],
|
| 39 |
+
},
|
| 40 |
+
"openrouter": {
|
| 41 |
+
"base_url": "https://openrouter.ai/api/v1",
|
| 42 |
+
"api_key": "OPENROUTER_API_KEY",
|
| 43 |
+
"models": [
|
| 44 |
+
"moonshotai/kimi-k2.6",
|
| 45 |
+
"tencent/hy3-preview:free",
|
| 46 |
+
"inclusionai/ling-2.6-1t:free",
|
| 47 |
+
"inclusionai/ling-2.6-flash:free",
|
| 48 |
+
"google/gemma-4-26b-a4b-it:free",
|
| 49 |
+
"google/gemma-4-31b-it:free",
|
| 50 |
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 51 |
+
"minimax/minimax-m2.5:free",
|
| 52 |
+
"liquid/lfm-2.5-1.2b-instruct:free",
|
| 53 |
+
"liquid/lfm-2.5-1.2b-thinking:free",
|
| 54 |
+
"nvidia/nemotron-3-nano-30b-a3b:free",
|
| 55 |
+
"nvidia/nemotron-nano-12b-v2-vl:free",
|
| 56 |
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
| 57 |
+
"openai/gpt-oss-120b:free",
|
| 58 |
+
"nvidia/nemotron-nano-9b-v2:free",
|
| 59 |
+
"openai/gpt-oss-20b:free",
|
| 60 |
+
"z-ai/glm-4.5-air:free",
|
| 61 |
+
"qwen/qwen3-coder:free",
|
| 62 |
+
],
|
| 63 |
+
},
|
| 64 |
+
"groq": {
|
| 65 |
+
"base_url": "https://api.groq.com/openai/v1",
|
| 66 |
+
"api_key": "GROQ_API_KEY",
|
| 67 |
+
"models": [
|
| 68 |
+
"llama-3.1-8b-instant",
|
| 69 |
+
"llama-3.3-70b-versatile",
|
| 70 |
+
"openai/gpt-oss-120b",
|
| 71 |
+
"openai/gpt-oss-20b",
|
| 72 |
+
],
|
| 73 |
+
},
|
| 74 |
+
"gemini": {
|
| 75 |
+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
| 76 |
+
"api_key": "GEMINI_API_KEY",
|
| 77 |
+
"models": [
|
| 78 |
+
"gemini-3-flash-preview",
|
| 79 |
+
"gemini-3.1-flash-lite-preview",
|
| 80 |
+
"gemini-2.5-pro",
|
| 81 |
+
"gemini-2.5-flash",
|
| 82 |
+
"gemini-2.5-flash-lite",
|
| 83 |
+
"gemma-4-31b-it",
|
| 84 |
+
"gemma-4-26b-a4b-it",
|
| 85 |
+
],
|
| 86 |
+
},
|
| 87 |
+
"zai": {
|
| 88 |
+
"base_url": "https://api.z.ai/api/paas/v4/",
|
| 89 |
+
"api_key": "ZAI_API_KEY",
|
| 90 |
+
"models": [
|
| 91 |
+
"glm-4.7-flash",
|
| 92 |
+
"glm-4.6v-flash",
|
| 93 |
+
],
|
| 94 |
+
},
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
DEFAULT_PROVIDER = "modal"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def get_llm_client(provider: str | None = None) -> AsyncOpenAI:
|
| 101 |
+
provider = provider or DEFAULT_PROVIDER
|
| 102 |
+
cfg = PROVIDER_CONFIG.get(provider) or PROVIDER_CONFIG[DEFAULT_PROVIDER]
|
| 103 |
+
api_key = os.getenv(cfg["api_key"]) or "ollama"
|
| 104 |
+
return AsyncOpenAI(base_url=cfg["base_url"], api_key=api_key)
|
backend/agent/state.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@dataclass
|
| 6 |
+
class AbilityInstance:
|
| 7 |
+
name: str
|
| 8 |
+
last_used_turn: int = -1
|
| 9 |
+
uses_remaining: int | None = None
|
| 10 |
+
|
| 11 |
+
def can_use(self, current_turn: int) -> bool:
|
| 12 |
+
if self.last_used_turn == current_turn:
|
| 13 |
+
return False
|
| 14 |
+
if self.uses_remaining is not None and self.uses_remaining <= 0:
|
| 15 |
+
return False
|
| 16 |
+
return True
|
| 17 |
+
|
| 18 |
+
def use(self, current_turn: int):
|
| 19 |
+
self.last_used_turn = current_turn
|
| 20 |
+
if self.uses_remaining is not None:
|
| 21 |
+
self.uses_remaining -= 1
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class Message:
|
| 26 |
+
content: str
|
| 27 |
+
sender: str | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class AgentState:
|
| 32 |
+
id: str
|
| 33 |
+
name: str
|
| 34 |
+
pos: list[int]
|
| 35 |
+
hp: int = 100
|
| 36 |
+
alive: bool = True
|
| 37 |
+
score: int = 0
|
| 38 |
+
kills: int = 0
|
| 39 |
+
shielded: bool = False
|
| 40 |
+
abilities: list[AbilityInstance] = field(default_factory=list)
|
| 41 |
+
messages: list[dict] = field(default_factory=list)
|
| 42 |
+
mailbox: list[Message] = field(default_factory=list)
|
| 43 |
+
|
| 44 |
+
def get_ability(self, name: str) -> AbilityInstance | None:
|
| 45 |
+
for ab in self.abilities:
|
| 46 |
+
if ab.name == name:
|
| 47 |
+
return ab
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
def has_ability(self, name: str) -> bool:
|
| 51 |
+
return self.get_ability(name) is not None
|
backend/engine.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import random
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from .environment import Environment, GameConfig
|
| 8 |
+
from .environment.tile import Tile
|
| 9 |
+
from .environment.loot_tables import generate_chest_loot
|
| 10 |
+
from .agent import AgentState
|
| 11 |
+
from .agent.agent import Agent
|
| 12 |
+
from .agent.state import AbilityInstance
|
| 13 |
+
from .tools import ALL_TOOLS, TOOL_SCHEMAS
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Engine:
|
| 17 |
+
def __init__(self, config: GameConfig | None = None):
|
| 18 |
+
self.env = Environment(config=config or GameConfig())
|
| 19 |
+
self.agents: dict[str, Agent] = {}
|
| 20 |
+
self.last_turn_log: dict | None = None
|
| 21 |
+
|
| 22 |
+
def generate_grid(self):
|
| 23 |
+
gs = self.env.config.grid_size
|
| 24 |
+
for x in range(gs):
|
| 25 |
+
for y in range(gs):
|
| 26 |
+
self.env.grid[(x, y)] = Tile(terrain="grass")
|
| 27 |
+
|
| 28 |
+
def scatter_chests(self, count: int | None = None):
|
| 29 |
+
count = count or self.env.config.num_chests
|
| 30 |
+
gs = self.env.config.grid_size
|
| 31 |
+
positions = random.sample(
|
| 32 |
+
[(x, y) for x in range(gs) for y in range(gs)],
|
| 33 |
+
count,
|
| 34 |
+
)
|
| 35 |
+
for pos in positions:
|
| 36 |
+
if self.env.is_empty(pos[0], pos[1]):
|
| 37 |
+
tile = self.env.get_tile(pos[0], pos[1])
|
| 38 |
+
tile.loot = generate_chest_loot()
|
| 39 |
+
|
| 40 |
+
def spawn_agent(
|
| 41 |
+
self,
|
| 42 |
+
agent_id: str,
|
| 43 |
+
name: str,
|
| 44 |
+
model: str | None = None,
|
| 45 |
+
provider: str | None = None,
|
| 46 |
+
system_prompt: str | None = None,
|
| 47 |
+
):
|
| 48 |
+
gs = self.env.config.grid_size
|
| 49 |
+
while True:
|
| 50 |
+
x = random.randint(0, gs - 1)
|
| 51 |
+
y = random.randint(0, gs - 1)
|
| 52 |
+
if self.env.is_empty(x, y):
|
| 53 |
+
break
|
| 54 |
+
|
| 55 |
+
state = AgentState(
|
| 56 |
+
id=agent_id,
|
| 57 |
+
name=name,
|
| 58 |
+
pos=[x, y],
|
| 59 |
+
hp=self.env.config.base_hp,
|
| 60 |
+
)
|
| 61 |
+
brain = Agent(
|
| 62 |
+
agent_id=agent_id,
|
| 63 |
+
model=model or "google/gemma-4-26B-A4B-it",
|
| 64 |
+
provider=provider or "modal",
|
| 65 |
+
system_prompt=system_prompt,
|
| 66 |
+
)
|
| 67 |
+
state.messages.append({"role": "system", "content": brain.system_prompt})
|
| 68 |
+
self.env.agents[agent_id] = state
|
| 69 |
+
self.agents[agent_id] = brain
|
| 70 |
+
self._send_start_message(agent_id)
|
| 71 |
+
return state
|
| 72 |
+
|
| 73 |
+
async def step(self):
|
| 74 |
+
t0 = time.time()
|
| 75 |
+
alive = self.env.alive_agents()
|
| 76 |
+
if len(alive) <= 1:
|
| 77 |
+
if len(alive) == 1:
|
| 78 |
+
winner = alive[0]
|
| 79 |
+
winner.score += self.env.config.win_bonus
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
decisions: dict[str, dict] = {}
|
| 83 |
+
turn_log: dict[str, list[dict]] = {}
|
| 84 |
+
|
| 85 |
+
tasks = []
|
| 86 |
+
for aid, state in self.env.agents.items():
|
| 87 |
+
if not state.alive:
|
| 88 |
+
decisions[aid] = []
|
| 89 |
+
turn_log[aid] = []
|
| 90 |
+
continue
|
| 91 |
+
brain = self.agents.get(aid)
|
| 92 |
+
if brain:
|
| 93 |
+
tasks.append((aid, brain.decide(state.messages, TOOL_SCHEMAS)))
|
| 94 |
+
else:
|
| 95 |
+
decisions[aid] = []
|
| 96 |
+
turn_log[aid] = []
|
| 97 |
+
|
| 98 |
+
if tasks:
|
| 99 |
+
results = await asyncio.gather(*[t for _, t in tasks])
|
| 100 |
+
for (aid, _), result in zip(tasks, results):
|
| 101 |
+
calls = result["calls"]
|
| 102 |
+
decisions[aid] = calls
|
| 103 |
+
turn_log[aid] = [{
|
| 104 |
+
"phase": "llm",
|
| 105 |
+
"turn": self.env.turn,
|
| 106 |
+
"time_ms": result["time_ms"],
|
| 107 |
+
"usage": result.get("raw"),
|
| 108 |
+
}]
|
| 109 |
+
if calls:
|
| 110 |
+
state = self.env.agents[aid]
|
| 111 |
+
state.messages.append({
|
| 112 |
+
"role": "assistant",
|
| 113 |
+
"content": None,
|
| 114 |
+
"tool_calls": [
|
| 115 |
+
{"id": f"call_{i}", "type": "function",
|
| 116 |
+
"function": {"name": c["name"], "arguments": json.dumps(c.get("args", {}))}}
|
| 117 |
+
for i, c in enumerate(calls)
|
| 118 |
+
],
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
for rnd in range(3):
|
| 122 |
+
for aid in list(self.env.agents.keys()):
|
| 123 |
+
state = self.env.agents[aid]
|
| 124 |
+
if not state.alive:
|
| 125 |
+
continue
|
| 126 |
+
calls = decisions.get(aid, [])
|
| 127 |
+
if rnd < len(calls):
|
| 128 |
+
call = calls[rnd]
|
| 129 |
+
exec_result = self.env.execute(aid, call["name"], call["args"])
|
| 130 |
+
state.messages.append({
|
| 131 |
+
"role": "tool",
|
| 132 |
+
"content": exec_result["text"],
|
| 133 |
+
"tool_call_id": f"call_{rnd}",
|
| 134 |
+
})
|
| 135 |
+
turn_log[aid].append({
|
| 136 |
+
"phase": "exec",
|
| 137 |
+
"round": rnd,
|
| 138 |
+
"tool": call["name"],
|
| 139 |
+
"args": call["args"],
|
| 140 |
+
"result": exec_result["text"],
|
| 141 |
+
"time_ms": exec_result["time_ms"],
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
self.env.turn += 1
|
| 145 |
+
step_total = round((time.time() - t0) * 1000)
|
| 146 |
+
|
| 147 |
+
for state in self.env.agents.values():
|
| 148 |
+
if state.alive:
|
| 149 |
+
state.score += self.env.config.survival_score_per_turn
|
| 150 |
+
|
| 151 |
+
if self.env.turn % self.env.config.chest_respawn_interval == 0:
|
| 152 |
+
self.scatter_chests(count=max(1, self.env.config.num_chests // 3))
|
| 153 |
+
|
| 154 |
+
self.last_turn_log = {
|
| 155 |
+
"time_ms": step_total,
|
| 156 |
+
"agents": turn_log,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
def _send_start_message(self, aid: str):
|
| 160 |
+
agent = self.env.agents[aid]
|
| 161 |
+
agent.messages.append({
|
| 162 |
+
"role": "user",
|
| 163 |
+
"content": (
|
| 164 |
+
f"You are {agent.name}. You have been dropped into a battle royale on a "
|
| 165 |
+
f"{self.env.config.grid_size}x{self.env.config.grid_size} grid. "
|
| 166 |
+
f"Your HP is {agent.hp}. There are {len(self.env.alive_agents())} agents alive. "
|
| 167 |
+
"Use observe() to see your surroundings, move() to explore, "
|
| 168 |
+
"and activate_ability() once you find abilities in loot chests. "
|
| 169 |
+
"Good luck."
|
| 170 |
+
),
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
async def run_game(self) -> dict[str, AgentState]:
|
| 174 |
+
self.generate_grid()
|
| 175 |
+
self.scatter_chests()
|
| 176 |
+
|
| 177 |
+
while self.env.turn < self.env.config.max_turns:
|
| 178 |
+
alive = self.env.alive_agents()
|
| 179 |
+
if len(alive) <= 1:
|
| 180 |
+
break
|
| 181 |
+
await self.step()
|
| 182 |
+
|
| 183 |
+
if len(self.env.alive_agents()) == 1:
|
| 184 |
+
winner = self.env.alive_agents()[0]
|
| 185 |
+
winner.score += self.env.config.win_bonus
|
| 186 |
+
|
| 187 |
+
return self.env.agents
|
backend/environment/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .config import GameConfig
|
| 2 |
+
from .tile import Tile, LootItem
|
| 3 |
+
from .environment import Environment
|
| 4 |
+
from .ability_registry import ABILITY_REGISTRY, AbilityDef
|
| 5 |
+
from .scoring import calculate_score
|
| 6 |
+
from .loot_tables import generate_loot, generate_chest_loot, generate_death_loot
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"GameConfig",
|
| 10 |
+
"Tile",
|
| 11 |
+
"LootItem",
|
| 12 |
+
"Environment",
|
| 13 |
+
"ABILITY_REGISTRY",
|
| 14 |
+
"AbilityDef",
|
| 15 |
+
"calculate_score",
|
| 16 |
+
"generate_loot",
|
| 17 |
+
"generate_chest_loot",
|
| 18 |
+
"generate_death_loot",
|
| 19 |
+
]
|
backend/environment/ability_registry.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Callable, TYPE_CHECKING
|
| 4 |
+
|
| 5 |
+
if TYPE_CHECKING:
|
| 6 |
+
from .environment import Environment
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class AbilityDef:
|
| 11 |
+
name: str
|
| 12 |
+
cooldown: int
|
| 13 |
+
max_uses: int | None
|
| 14 |
+
description: str
|
| 15 |
+
handler: Callable
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def attack_handler(env: Environment, aid: str, args: dict) -> str:
|
| 19 |
+
agent = env.agents[aid]
|
| 20 |
+
x, y = agent.pos
|
| 21 |
+
tx = args.get("x", args.get("target_x"))
|
| 22 |
+
ty = args.get("y", args.get("target_y"))
|
| 23 |
+
|
| 24 |
+
if tx is None or ty is None:
|
| 25 |
+
return "Attack requires target coordinates (x, y)"
|
| 26 |
+
|
| 27 |
+
dist = abs(tx - x) + abs(ty - y)
|
| 28 |
+
if dist > env.config.attack_range:
|
| 29 |
+
return f"Target at ({tx},{ty}) is out of attack range (max {env.config.attack_range})"
|
| 30 |
+
|
| 31 |
+
target = None
|
| 32 |
+
for other in env.agents.values():
|
| 33 |
+
if other.pos == [tx, ty] and other.alive:
|
| 34 |
+
target = other
|
| 35 |
+
break
|
| 36 |
+
|
| 37 |
+
if not target:
|
| 38 |
+
return f"No agent at ({tx},{ty}) to attack"
|
| 39 |
+
|
| 40 |
+
if target.shielded:
|
| 41 |
+
target.shielded = False
|
| 42 |
+
return f"{target.name} blocked your attack with a shield!"
|
| 43 |
+
|
| 44 |
+
damage = env.config.attack_damage
|
| 45 |
+
target.hp -= damage
|
| 46 |
+
|
| 47 |
+
if target.hp <= 0:
|
| 48 |
+
target.alive = False
|
| 49 |
+
target.hp = 0
|
| 50 |
+
agent.score += env.config.kill_score
|
| 51 |
+
agent.kills += 1
|
| 52 |
+
|
| 53 |
+
from .loot_tables import generate_death_loot
|
| 54 |
+
tile = env.get_tile(tx, ty)
|
| 55 |
+
tile.loot = generate_death_loot(target)
|
| 56 |
+
|
| 57 |
+
return f"Attacked {target.name} for {damage} damage — ELIMINATED! +{env.config.kill_score} points"
|
| 58 |
+
else:
|
| 59 |
+
return f"Attacked {target.name} for {damage} damage (HP left: {target.hp})"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def dash_handler(env: Environment, aid: str, args: dict) -> str:
|
| 63 |
+
agent = env.agents[aid]
|
| 64 |
+
x, y = agent.pos
|
| 65 |
+
dx = args.get("dx", 0)
|
| 66 |
+
dy = args.get("dy", 0)
|
| 67 |
+
|
| 68 |
+
if abs(dx) > 3 or abs(dy) > 3:
|
| 69 |
+
return "Dash max distance is 3 tiles"
|
| 70 |
+
|
| 71 |
+
nx, ny = x + dx, y + dy
|
| 72 |
+
|
| 73 |
+
if not env.is_in_bounds(nx, ny):
|
| 74 |
+
return "Cannot dash out of bounds"
|
| 75 |
+
|
| 76 |
+
if not env.is_empty(nx, ny):
|
| 77 |
+
return "Cannot dash into an occupied tile"
|
| 78 |
+
|
| 79 |
+
agent.pos = [nx, ny]
|
| 80 |
+
return f"Dashed from ({x},{y}) to ({nx},{ny})"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def shield_handler(env: Environment, aid: str, args: dict) -> str:
|
| 84 |
+
agent = env.agents[aid]
|
| 85 |
+
agent.shielded = True
|
| 86 |
+
return "Shield activated — you will block the next attack against you"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def heal_handler(env: Environment, aid: str, args: dict) -> str:
|
| 90 |
+
agent = env.agents[aid]
|
| 91 |
+
heal_amount = 40
|
| 92 |
+
old_hp = agent.hp
|
| 93 |
+
agent.hp = min(agent.hp + heal_amount, env.config.base_hp)
|
| 94 |
+
actual = agent.hp - old_hp
|
| 95 |
+
return f"Healed for {actual} HP (HP: {old_hp} -> {agent.hp})"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
ABILITY_REGISTRY: dict[str, AbilityDef] = {
|
| 99 |
+
"attack": AbilityDef(
|
| 100 |
+
name="attack",
|
| 101 |
+
cooldown=1,
|
| 102 |
+
max_uses=None,
|
| 103 |
+
description="Attack an adjacent agent for 25 damage",
|
| 104 |
+
handler=attack_handler,
|
| 105 |
+
),
|
| 106 |
+
"dash": AbilityDef(
|
| 107 |
+
name="dash",
|
| 108 |
+
cooldown=2,
|
| 109 |
+
max_uses=3,
|
| 110 |
+
description="Teleport up to 3 tiles in a direction",
|
| 111 |
+
handler=dash_handler,
|
| 112 |
+
),
|
| 113 |
+
"shield": AbilityDef(
|
| 114 |
+
name="shield",
|
| 115 |
+
cooldown=3,
|
| 116 |
+
max_uses=None,
|
| 117 |
+
description="Become immune to the next attack",
|
| 118 |
+
handler=shield_handler,
|
| 119 |
+
),
|
| 120 |
+
"heal": AbilityDef(
|
| 121 |
+
name="heal",
|
| 122 |
+
cooldown=2,
|
| 123 |
+
max_uses=2,
|
| 124 |
+
description="Restore 40 HP",
|
| 125 |
+
handler=heal_handler,
|
| 126 |
+
),
|
| 127 |
+
}
|
backend/environment/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import Literal
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@dataclass
|
| 6 |
+
class GameConfig:
|
| 7 |
+
mode: Literal["battle_royale"] = "battle_royale"
|
| 8 |
+
grid_size: int = 20
|
| 9 |
+
max_turns: int = 50
|
| 10 |
+
max_agents: int = 8
|
| 11 |
+
base_hp: int = 100
|
| 12 |
+
attack_damage: int = 25
|
| 13 |
+
attack_range: int = 1
|
| 14 |
+
num_chests: int = 15
|
| 15 |
+
chest_respawn_interval: int = 5
|
| 16 |
+
chest_value_range: tuple = (10, 50)
|
| 17 |
+
kill_score: int = 50
|
| 18 |
+
survival_score_per_turn: int = 5
|
| 19 |
+
win_bonus: int = 100
|
| 20 |
+
observation_radius: int = 5
|
backend/environment/environment.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import time
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
|
| 5 |
+
from .config import GameConfig
|
| 6 |
+
from .tile import Tile
|
| 7 |
+
from ..agent.state import AgentState
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class Environment:
|
| 12 |
+
grid: dict[tuple[int, int], Tile] = field(default_factory=dict)
|
| 13 |
+
agents: dict[str, AgentState] = field(default_factory=dict)
|
| 14 |
+
turn: int = 0
|
| 15 |
+
config: GameConfig = field(default_factory=GameConfig)
|
| 16 |
+
|
| 17 |
+
def get_tile(self, x: int, y: int) -> Tile:
|
| 18 |
+
return self.grid.get((x, y), Tile(terrain="void"))
|
| 19 |
+
|
| 20 |
+
def is_in_bounds(self, x: int, y: int) -> bool:
|
| 21 |
+
return 0 <= x < self.config.grid_size and 0 <= y < self.config.grid_size
|
| 22 |
+
|
| 23 |
+
def is_empty(self, x: int, y: int) -> bool:
|
| 24 |
+
return self.is_in_bounds(x, y) and not any(
|
| 25 |
+
a.pos == [x, y] and a.alive for a in self.agents.values()
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def alive_agents(self) -> list[AgentState]:
|
| 29 |
+
return [a for a in self.agents.values() if a.alive]
|
| 30 |
+
|
| 31 |
+
def get_ability_cooldown(self, name: str) -> int:
|
| 32 |
+
from .ability_registry import ABILITY_REGISTRY
|
| 33 |
+
|
| 34 |
+
ab = ABILITY_REGISTRY.get(name)
|
| 35 |
+
return ab.cooldown if ab else 0
|
| 36 |
+
|
| 37 |
+
def get_ability_description(self, name: str) -> str:
|
| 38 |
+
from .ability_registry import ABILITY_REGISTRY
|
| 39 |
+
|
| 40 |
+
ab = ABILITY_REGISTRY.get(name)
|
| 41 |
+
return ab.description if ab else ""
|
| 42 |
+
|
| 43 |
+
def execute(self, aid: str, tool_name: str, args: dict) -> dict:
|
| 44 |
+
from ..tools import ALL_TOOLS
|
| 45 |
+
|
| 46 |
+
t0 = time.time()
|
| 47 |
+
tool_cls = ALL_TOOLS.get(tool_name)
|
| 48 |
+
if not tool_cls:
|
| 49 |
+
elapsed = round((time.time() - t0) * 1000)
|
| 50 |
+
return {"text": f"Unknown tool: {tool_name}", "time_ms": elapsed}
|
| 51 |
+
text = tool_cls.run(self, aid, args)
|
| 52 |
+
elapsed = round((time.time() - t0) * 1000)
|
| 53 |
+
return {"text": text, "time_ms": elapsed}
|
backend/environment/loot_tables.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import random
|
| 3 |
+
from typing import TYPE_CHECKING
|
| 4 |
+
|
| 5 |
+
from .tile import LootItem
|
| 6 |
+
|
| 7 |
+
if TYPE_CHECKING:
|
| 8 |
+
from ..agent.state import AgentState
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
AVAILABLE_ABILITIES = ["attack", "dash", "shield", "heal"]
|
| 12 |
+
|
| 13 |
+
RARITY_WEIGHTS = {
|
| 14 |
+
"common": 0.6,
|
| 15 |
+
"rare": 0.3,
|
| 16 |
+
"epic": 0.1,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
ABILITY_RARITY_POOL = {
|
| 20 |
+
"common": ["dash", "heal"],
|
| 21 |
+
"rare": ["dash", "shield", "heal"],
|
| 22 |
+
"epic": ["attack", "shield"],
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
POINTS_BY_RARITY = {
|
| 26 |
+
"common": (10, 20),
|
| 27 |
+
"rare": (25, 40),
|
| 28 |
+
"epic": (50, 100),
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
NUM_ITEMS_PER_CHEST = {
|
| 32 |
+
"common": (1, 1),
|
| 33 |
+
"rare": (1, 2),
|
| 34 |
+
"epic": (2, 3),
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _pick_rarity() -> str:
|
| 39 |
+
r = random.random()
|
| 40 |
+
cumulative = 0
|
| 41 |
+
for rarity, weight in RARITY_WEIGHTS.items():
|
| 42 |
+
cumulative += weight
|
| 43 |
+
if r <= cumulative:
|
| 44 |
+
return rarity
|
| 45 |
+
return "common"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _pick_ability(rarity: str) -> str | None:
|
| 49 |
+
pool = ABILITY_RARITY_POOL.get(rarity, [])
|
| 50 |
+
if not pool:
|
| 51 |
+
return None
|
| 52 |
+
return random.choice(pool)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def generate_chest_loot() -> list[LootItem]:
|
| 56 |
+
rarity = _pick_rarity()
|
| 57 |
+
num_items = random.randint(*NUM_ITEMS_PER_CHEST[rarity])
|
| 58 |
+
items = []
|
| 59 |
+
|
| 60 |
+
for _ in range(num_items):
|
| 61 |
+
if random.random() < 0.5:
|
| 62 |
+
ability = _pick_ability(rarity)
|
| 63 |
+
if ability:
|
| 64 |
+
items.append(LootItem(
|
| 65 |
+
type="ability",
|
| 66 |
+
ability_name=ability,
|
| 67 |
+
rarity=rarity,
|
| 68 |
+
source="chest",
|
| 69 |
+
))
|
| 70 |
+
else:
|
| 71 |
+
pts = random.randint(*POINTS_BY_RARITY[rarity])
|
| 72 |
+
items.append(LootItem(
|
| 73 |
+
type="points",
|
| 74 |
+
points=pts,
|
| 75 |
+
rarity=rarity,
|
| 76 |
+
source="chest",
|
| 77 |
+
))
|
| 78 |
+
else:
|
| 79 |
+
pts = random.randint(*POINTS_BY_RARITY[rarity])
|
| 80 |
+
items.append(LootItem(
|
| 81 |
+
type="points",
|
| 82 |
+
points=pts,
|
| 83 |
+
rarity=rarity,
|
| 84 |
+
source="chest",
|
| 85 |
+
))
|
| 86 |
+
|
| 87 |
+
return items
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def generate_death_loot(agent: AgentState) -> list[LootItem]:
|
| 91 |
+
items = []
|
| 92 |
+
|
| 93 |
+
transferrable = [ab for ab in agent.abilities if ab.name in AVAILABLE_ABILITIES]
|
| 94 |
+
if transferrable:
|
| 95 |
+
num_to_drop = min(len(transferrable), random.randint(1, 2))
|
| 96 |
+
dropped = random.sample(transferrable, num_to_drop)
|
| 97 |
+
for ab in dropped:
|
| 98 |
+
items.append(LootItem(
|
| 99 |
+
type="ability",
|
| 100 |
+
ability_name=ab.name,
|
| 101 |
+
rarity="common",
|
| 102 |
+
source=agent.id,
|
| 103 |
+
))
|
| 104 |
+
|
| 105 |
+
dropped_points = agent.score // 2
|
| 106 |
+
if dropped_points > 0:
|
| 107 |
+
items.append(LootItem(
|
| 108 |
+
type="points",
|
| 109 |
+
points=dropped_points,
|
| 110 |
+
rarity="common",
|
| 111 |
+
source=agent.id,
|
| 112 |
+
))
|
| 113 |
+
|
| 114 |
+
return items
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def generate_loot(count: int) -> list[list[LootItem]]:
|
| 118 |
+
return [generate_chest_loot() for _ in range(count)]
|
backend/environment/scoring.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import TYPE_CHECKING
|
| 3 |
+
|
| 4 |
+
if TYPE_CHECKING:
|
| 5 |
+
from .environment import Environment
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def calculate_score(env: Environment) -> dict[str, int]:
|
| 9 |
+
scores = {}
|
| 10 |
+
for aid, agent in env.agents.items():
|
| 11 |
+
scores[aid] = agent.score
|
| 12 |
+
return scores
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def rank_agents(env: Environment) -> list[tuple[str, int]]:
|
| 16 |
+
scored = [(aid, agent.score) for aid, agent in env.agents.items()]
|
| 17 |
+
scored.sort(key=lambda x: x[1], reverse=True)
|
| 18 |
+
return scored
|
backend/environment/tile.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class LootItem:
|
| 8 |
+
type: Literal["ability", "points"]
|
| 9 |
+
ability_name: str | None = None
|
| 10 |
+
points: int | None = None
|
| 11 |
+
rarity: str = "common"
|
| 12 |
+
source: str = "chest"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class Tile:
|
| 17 |
+
terrain: str = "grass"
|
| 18 |
+
loot: list[LootItem] | None = None
|
backend/tools/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .move import MoveTool
|
| 2 |
+
from .think import ThinkTool
|
| 3 |
+
from .observe import ObserveTool
|
| 4 |
+
from .activate_ability import ActivateAbilityTool
|
| 5 |
+
|
| 6 |
+
ALL_TOOLS: dict[str, type] = {
|
| 7 |
+
t.name: t
|
| 8 |
+
for t in [MoveTool, ThinkTool, ObserveTool, ActivateAbilityTool]
|
| 9 |
+
}
|
| 10 |
+
TOOL_SCHEMAS: list[dict] = [t.schema for t in ALL_TOOLS.values()]
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"MoveTool",
|
| 14 |
+
"ThinkTool",
|
| 15 |
+
"ObserveTool",
|
| 16 |
+
"ActivateAbilityTool",
|
| 17 |
+
"ALL_TOOLS",
|
| 18 |
+
"TOOL_SCHEMAS",
|
| 19 |
+
]
|
backend/tools/activate_ability.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import TYPE_CHECKING
|
| 3 |
+
|
| 4 |
+
if TYPE_CHECKING:
|
| 5 |
+
from ..environment.environment import Environment
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ActivateAbilityTool:
|
| 9 |
+
name = "activate_ability"
|
| 10 |
+
schema = {
|
| 11 |
+
"type": "function",
|
| 12 |
+
"function": {
|
| 13 |
+
"name": "activate_ability",
|
| 14 |
+
"description": "Activate one of your acquired abilities. Each ability has different effects and args.",
|
| 15 |
+
"parameters": {
|
| 16 |
+
"type": "object",
|
| 17 |
+
"properties": {
|
| 18 |
+
"ability": {
|
| 19 |
+
"type": "string",
|
| 20 |
+
"description": "Name of the ability to activate",
|
| 21 |
+
},
|
| 22 |
+
"args": {
|
| 23 |
+
"type": "object",
|
| 24 |
+
"description": "Arguments for the ability (varies per ability)",
|
| 25 |
+
},
|
| 26 |
+
},
|
| 27 |
+
"required": ["ability", "args"],
|
| 28 |
+
},
|
| 29 |
+
},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def run(env: Environment, aid: str, args: dict) -> str:
|
| 34 |
+
ability_name = args.get("ability", "")
|
| 35 |
+
ability_args = args.get("args", {})
|
| 36 |
+
|
| 37 |
+
agent = env.agents[aid]
|
| 38 |
+
ability = agent.get_ability(ability_name)
|
| 39 |
+
if not ability:
|
| 40 |
+
return f"You don't have the ability '{ability_name}'"
|
| 41 |
+
|
| 42 |
+
if not ability.can_use(env.turn):
|
| 43 |
+
return f"Ability '{ability_name}' cannot be used right now (cooldown or no uses left)"
|
| 44 |
+
|
| 45 |
+
from ..environment.ability_registry import ABILITY_REGISTRY
|
| 46 |
+
|
| 47 |
+
ability_def = ABILITY_REGISTRY.get(ability_name)
|
| 48 |
+
if not ability_def:
|
| 49 |
+
return f"Unknown ability: {ability_name}"
|
| 50 |
+
|
| 51 |
+
result = ability_def.handler(env, aid, ability_args)
|
| 52 |
+
ability.use(env.turn)
|
| 53 |
+
return result
|
backend/tools/move.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import TYPE_CHECKING
|
| 3 |
+
|
| 4 |
+
if TYPE_CHECKING:
|
| 5 |
+
from ..environment.environment import Environment
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class MoveTool:
|
| 9 |
+
name = "move"
|
| 10 |
+
schema = {
|
| 11 |
+
"type": "function",
|
| 12 |
+
"function": {
|
| 13 |
+
"name": "move",
|
| 14 |
+
"description": "Move to an adjacent tile. Auto-loots any items on the destination tile.",
|
| 15 |
+
"parameters": {
|
| 16 |
+
"type": "object",
|
| 17 |
+
"properties": {
|
| 18 |
+
"dx": {"type": "integer", "description": "Change in x (-1, 0, or 1)"},
|
| 19 |
+
"dy": {"type": "integer", "description": "Change in y (-1, 0, or 1)"},
|
| 20 |
+
},
|
| 21 |
+
"required": ["dx", "dy"],
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
def run(env: Environment, aid: str, args: dict) -> str:
|
| 28 |
+
agent = env.agents[aid]
|
| 29 |
+
x, y = agent.pos
|
| 30 |
+
dx, dy = args.get("dx", 0), args.get("dy", 0)
|
| 31 |
+
|
| 32 |
+
if abs(dx) > 1 or abs(dy) > 1:
|
| 33 |
+
return "Invalid move: can only move to adjacent tiles (±1 in x or y)"
|
| 34 |
+
|
| 35 |
+
nx, ny = x + dx, y + dy
|
| 36 |
+
|
| 37 |
+
if not env.is_in_bounds(nx, ny):
|
| 38 |
+
return "Invalid move: out of bounds"
|
| 39 |
+
|
| 40 |
+
if not env.is_empty(nx, ny):
|
| 41 |
+
return "Invalid move: tile is occupied"
|
| 42 |
+
|
| 43 |
+
agent.pos = [nx, ny]
|
| 44 |
+
tile = env.get_tile(nx, ny)
|
| 45 |
+
|
| 46 |
+
loot_results = []
|
| 47 |
+
if tile.loot:
|
| 48 |
+
for item in tile.loot:
|
| 49 |
+
if item.type == "ability":
|
| 50 |
+
from ..agent.state import AbilityInstance
|
| 51 |
+
from ..environment.ability_registry import ABILITY_REGISTRY
|
| 52 |
+
agent.abilities.append(
|
| 53 |
+
AbilityInstance(name=item.ability_name)
|
| 54 |
+
)
|
| 55 |
+
desc = env.get_ability_description(item.ability_name)
|
| 56 |
+
cd = env.get_ability_cooldown(item.ability_name)
|
| 57 |
+
ab_def = ABILITY_REGISTRY.get(item.ability_name)
|
| 58 |
+
uses_str = "unlimited" if ab_def and ab_def.max_uses is None else (f"{ab_def.max_uses} use(s)" if ab_def else "?")
|
| 59 |
+
loot_results.append(
|
| 60 |
+
f"picked up ability '{item.ability_name}': {desc} "
|
| 61 |
+
f"(cooldown {cd} turn(s), {uses_str})"
|
| 62 |
+
)
|
| 63 |
+
elif item.type == "points":
|
| 64 |
+
agent.score += item.points or 0
|
| 65 |
+
loot_results.append(f"picked up {item.points} points")
|
| 66 |
+
tile.loot = None
|
| 67 |
+
|
| 68 |
+
msg = f"Moved to ({nx}, {ny})"
|
| 69 |
+
if loot_results:
|
| 70 |
+
msg += ". Loot: " + ", ".join(loot_results)
|
| 71 |
+
return msg
|
backend/tools/observe.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import TYPE_CHECKING
|
| 3 |
+
|
| 4 |
+
if TYPE_CHECKING:
|
| 5 |
+
from ..environment.environment import Environment
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ObserveTool:
|
| 9 |
+
name = "observe"
|
| 10 |
+
schema = {
|
| 11 |
+
"type": "function",
|
| 12 |
+
"function": {
|
| 13 |
+
"name": "observe",
|
| 14 |
+
"description": "Observe your surroundings. Returns visible tiles and agents within your observation radius.",
|
| 15 |
+
"parameters": {
|
| 16 |
+
"type": "object",
|
| 17 |
+
"properties": {},
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
def run(env: Environment, aid: str, args: dict) -> str:
|
| 24 |
+
agent = env.agents[aid]
|
| 25 |
+
x, y = agent.pos
|
| 26 |
+
radius = env.config.observation_radius
|
| 27 |
+
|
| 28 |
+
lines = []
|
| 29 |
+
lines.append(f"=== Status ===")
|
| 30 |
+
lines.append(f"HP: {agent.hp}/{env.config.base_hp} | Score: {agent.score} | Kills: {agent.kills}")
|
| 31 |
+
lines.append(f"Shielded: {agent.shielded}")
|
| 32 |
+
if agent.abilities:
|
| 33 |
+
lines.append("Abilities:")
|
| 34 |
+
for ab in agent.abilities:
|
| 35 |
+
cd = env.get_ability_cooldown(ab.name)
|
| 36 |
+
if ab.last_used_turn >= 0 and env.turn < ab.last_used_turn + cd:
|
| 37 |
+
cd_str = f"cooldown {ab.last_used_turn + cd - env.turn} turn(s)"
|
| 38 |
+
else:
|
| 39 |
+
cd_str = "ready"
|
| 40 |
+
uses_str = f"unlimited" if ab.uses_remaining is None else f"{ab.uses_remaining} use(s)"
|
| 41 |
+
desc = env.get_ability_description(ab.name)
|
| 42 |
+
lines.append(f" [{ab.name}] {desc}")
|
| 43 |
+
lines.append(f" uses: {uses_str} | cooldown: {cd_str}")
|
| 44 |
+
else:
|
| 45 |
+
lines.append("Abilities: none")
|
| 46 |
+
lines.append("")
|
| 47 |
+
|
| 48 |
+
visible_tiles = []
|
| 49 |
+
for dx in range(-radius, radius + 1):
|
| 50 |
+
for dy in range(-radius, radius + 1):
|
| 51 |
+
nx, ny = x + dx, y + dy
|
| 52 |
+
if not env.is_in_bounds(nx, ny):
|
| 53 |
+
continue
|
| 54 |
+
dist = abs(dx) + abs(dy)
|
| 55 |
+
if dist > radius:
|
| 56 |
+
continue
|
| 57 |
+
tile = env.get_tile(nx, ny)
|
| 58 |
+
entry = f"({nx},{ny}): {tile.terrain}"
|
| 59 |
+
if tile.loot:
|
| 60 |
+
entry += " [has loot]"
|
| 61 |
+
if dist <= 1:
|
| 62 |
+
visible_tiles.append(entry)
|
| 63 |
+
|
| 64 |
+
visible_agents = []
|
| 65 |
+
for other in env.agents.values():
|
| 66 |
+
if other.id == aid or not other.alive:
|
| 67 |
+
continue
|
| 68 |
+
ox, oy = other.pos
|
| 69 |
+
dist = abs(ox - x) + abs(oy - y)
|
| 70 |
+
if dist <= radius:
|
| 71 |
+
visible_agents.append(
|
| 72 |
+
f"{other.name} at ({ox},{oy}) [HP:{other.hp}]"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
lines.append("--- Nearby tiles ---")
|
| 76 |
+
lines.extend(visible_tiles)
|
| 77 |
+
lines.append("--- Visible agents ---")
|
| 78 |
+
if visible_agents:
|
| 79 |
+
lines.extend(visible_agents)
|
| 80 |
+
else:
|
| 81 |
+
lines.append("No agents visible")
|
| 82 |
+
|
| 83 |
+
return "\n".join(lines)
|
backend/tools/think.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import TYPE_CHECKING
|
| 3 |
+
|
| 4 |
+
if TYPE_CHECKING:
|
| 5 |
+
from ..environment.environment import Environment
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ThinkTool:
|
| 9 |
+
name = "think"
|
| 10 |
+
schema = {
|
| 11 |
+
"type": "function",
|
| 12 |
+
"function": {
|
| 13 |
+
"name": "think",
|
| 14 |
+
"description": "Internal monologue. Use this to reason about your strategy without any external effect.",
|
| 15 |
+
"parameters": {
|
| 16 |
+
"type": "object",
|
| 17 |
+
"properties": {
|
| 18 |
+
"content": {
|
| 19 |
+
"type": "string",
|
| 20 |
+
"description": "Your internal thoughts and reasoning",
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
"required": ["content"],
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
@staticmethod
|
| 29 |
+
def run(env: Environment, aid: str, args: dict) -> str:
|
| 30 |
+
content = args.get("content", "")
|
| 31 |
+
return "Thought Acknowledged"
|
design.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Grid Royale — Design Doc
|
| 2 |
+
|
| 3 |
+
## Game Mode: Battle Royale (MVP)
|
| 4 |
+
|
| 5 |
+
8 agents drop onto a 20×20 grid. Last agent standing wins. Loot chests
|
| 6 |
+
contain abilities and points. Killing an agent drops loot — pick up
|
| 7 |
+
their leftover abilities and score.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Core Abstraction
|
| 12 |
+
|
| 13 |
+
### Environment — single source of truth for ALL state
|
| 14 |
+
|
| 15 |
+
```python
|
| 16 |
+
@dataclass
|
| 17 |
+
class Environment:
|
| 18 |
+
grid: dict[tuple[int, int], Tile]
|
| 19 |
+
agents: dict[str, AgentState]
|
| 20 |
+
turn: int
|
| 21 |
+
config: GameConfig
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
### Tile — lightweight, holds terrain + optional loot
|
| 25 |
+
|
| 26 |
+
```python
|
| 27 |
+
@dataclass
|
| 28 |
+
class LootItem:
|
| 29 |
+
type: Literal["ability", "points"]
|
| 30 |
+
ability_name: str | None = None
|
| 31 |
+
points: int | None = None
|
| 32 |
+
rarity: str = "common"
|
| 33 |
+
source: str = "chest" # "chest" or the agent_id who dropped it
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class Tile:
|
| 37 |
+
terrain: str
|
| 38 |
+
loot: list[LootItem] | None = None
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
No wrapper classes. No separate `DeathCache` / `Chest`. Just a list of `LootItem` on the tile — whether it came from a chest or a dead agent.
|
| 42 |
+
|
| 43 |
+
### Game Config
|
| 44 |
+
|
| 45 |
+
```python
|
| 46 |
+
@dataclass
|
| 47 |
+
class GameConfig:
|
| 48 |
+
mode: Literal["battle_royale"] = "battle_royale"
|
| 49 |
+
grid_size: int = 20
|
| 50 |
+
max_turns: int = 50
|
| 51 |
+
max_agents: int = 8
|
| 52 |
+
base_hp: int = 100
|
| 53 |
+
attack_damage: int = 25
|
| 54 |
+
attack_range: int = 1
|
| 55 |
+
num_chests: int = 15
|
| 56 |
+
chest_respawn_interval: int = 5
|
| 57 |
+
chest_value_range: tuple = (10, 50)
|
| 58 |
+
kill_score: int = 50
|
| 59 |
+
survival_score_per_turn: int = 5
|
| 60 |
+
win_bonus: int = 100
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### Agent State
|
| 64 |
+
|
| 65 |
+
```python
|
| 66 |
+
@dataclass
|
| 67 |
+
class AgentState:
|
| 68 |
+
id: str
|
| 69 |
+
name: str
|
| 70 |
+
pos: list[int]
|
| 71 |
+
hp: int
|
| 72 |
+
alive: bool
|
| 73 |
+
score: int
|
| 74 |
+
kills: int
|
| 75 |
+
shielded: bool # passive buff from shield ability
|
| 76 |
+
abilities: list[AbilityInstance] # acquired abilities with runtime state
|
| 77 |
+
messages: list[dict]
|
| 78 |
+
mailbox: list[Message]
|
| 79 |
+
|
| 80 |
+
@dataclass
|
| 81 |
+
class AbilityInstance:
|
| 82 |
+
name: str
|
| 83 |
+
last_used_turn: int = -1 # for cooldown
|
| 84 |
+
uses_remaining: int | None = None # None = infinite
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### Agent — stateless brain (owns LLM client)
|
| 88 |
+
|
| 89 |
+
```python
|
| 90 |
+
class Agent:
|
| 91 |
+
id: str
|
| 92 |
+
model: str
|
| 93 |
+
provider: str
|
| 94 |
+
client: AsyncOpenAI
|
| 95 |
+
|
| 96 |
+
async def decide(self, messages: list, tools: list) -> list[ToolCall]:
|
| 97 |
+
# one LLM call
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
The brain is ephemeral — owns how the agent thinks (model, provider, client).
|
| 101 |
+
The state of what the agent knows (messages, position, HP, abilities) lives
|
| 102 |
+
in `AgentState` inside `Environment`.
|
| 103 |
+
|
| 104 |
+
### Tools — static schemas, shared by all agents
|
| 105 |
+
|
| 106 |
+
Exactly 4 tools. Never changes. No cache misses.
|
| 107 |
+
|
| 108 |
+
| Tool | Effect |
|
| 109 |
+
|---|---|
|
| 110 |
+
| `move(dx, dy)` | Move to adjacent tile. Auto-loots loot on tile if present. |
|
| 111 |
+
| `observe()` | Return visible tiles + agents within observation radius. |
|
| 112 |
+
| `think(content)` | Internal monologue — appended to conversation, no side effect. |
|
| 113 |
+
| `activate_ability(name, args)` | Dispatch to ability handler. Checks cooldown + remaining uses. |
|
| 114 |
+
|
| 115 |
+
`activate_ability` takes a flexible `args: object` that each handler
|
| 116 |
+
interprets differently. Example abilities:
|
| 117 |
+
|
| 118 |
+
```python
|
| 119 |
+
# attack — needs a position target
|
| 120 |
+
activate_ability(name="attack", args={"x": 5, "y": 7})
|
| 121 |
+
|
| 122 |
+
# shield — self-cast, no args
|
| 123 |
+
activate_ability(name="shield", args={})
|
| 124 |
+
|
| 125 |
+
# swap (hypothetical rare) — needs a target agent ID
|
| 126 |
+
activate_ability(name="swap", args={"target_id": "agent_3"})
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
### Abilities — registered internally, not as separate tools
|
| 130 |
+
|
| 131 |
+
```python
|
| 132 |
+
ABILITY_REGISTRY = {
|
| 133 |
+
"attack": {"cooldown": 1, "uses": None, "handler": attack_handler},
|
| 134 |
+
"dash": {"cooldown": 2, "uses": 3, "handler": dash_handler},
|
| 135 |
+
"shield": {"cooldown": 3, "uses": None, "handler": shield_handler},
|
| 136 |
+
"heal": {"cooldown": 2, "uses": 2, "handler": heal_handler},
|
| 137 |
+
}
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
`activate_ability` checks: does agent have this ability? Can it be used
|
| 141 |
+
this turn (cooldown + remaining uses)? Then dispatches to the handler.
|
| 142 |
+
|
| 143 |
+
### Scoring
|
| 144 |
+
|
| 145 |
+
| Event | Points |
|
| 146 |
+
|---|---|
|
| 147 |
+
| Kill | +50 |
|
| 148 |
+
| Per turn alive | +5 |
|
| 149 |
+
| Loot collected | 10–100 (by rarity) |
|
| 150 |
+
| Win (last alive) | +100 |
|
| 151 |
+
|
| 152 |
+
### Engine — orchestrates the turn loop
|
| 153 |
+
|
| 154 |
+
```python
|
| 155 |
+
class Engine:
|
| 156 |
+
env: Environment
|
| 157 |
+
agents: dict[str, Agent]
|
| 158 |
+
|
| 159 |
+
async def step(self):
|
| 160 |
+
# Phase 1: all agents decide (parallel)
|
| 161 |
+
# Phase 2: execute up to 3 tool calls round-robin
|
| 162 |
+
# Post-turn: check eliminations → drop loot, respawn chests
|
| 163 |
+
self.env.turn += 1
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
Elimination flow:
|
| 167 |
+
1. `attack_handler` reduces HP → if HP ≤ 0, mark `alive=False`
|
| 168 |
+
2. Place `LootItem` entries on their tile (1-2 random abilities + 50% score,
|
| 169 |
+
`source = dead_agent_id`)
|
| 170 |
+
3. Credit killer with +50 score, increment kills
|
| 171 |
+
|
| 172 |
+
Respawn (not in BR — only for hypothetical DM mode later): revived agents
|
| 173 |
+
start at random empty tile with base HP and **no abilities**.
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## File Layout
|
| 178 |
+
|
| 179 |
+
```
|
| 180 |
+
grid-royale/
|
| 181 |
+
backend/
|
| 182 |
+
environment/
|
| 183 |
+
__init__.py
|
| 184 |
+
environment.py # Environment dataclass
|
| 185 |
+
tile.py # Tile, LootItem
|
| 186 |
+
config.py # GameConfig
|
| 187 |
+
ability_registry.py # ABILITY_REGISTRY + handlers
|
| 188 |
+
scoring.py # Score logic
|
| 189 |
+
loot_tables.py # Loot generation
|
| 190 |
+
agent/
|
| 191 |
+
__init__.py
|
| 192 |
+
state.py # AgentState, AbilityInstance
|
| 193 |
+
agent.py # Agent brain: decide(messages, tools)
|
| 194 |
+
llm_client.py # Provider configs + factory
|
| 195 |
+
tools/
|
| 196 |
+
__init__.py # ALL_TOOLS, TOOL_SCHEMAS (4 tools)
|
| 197 |
+
move.py
|
| 198 |
+
think.py
|
| 199 |
+
observe.py
|
| 200 |
+
activate_ability.py
|
| 201 |
+
engine.py # Engine (turn loop + eliminations + scoring)
|
| 202 |
+
api.py # FastAPI routes
|
| 203 |
+
main.py # uvicorn entry point
|
| 204 |
+
frontend/
|
| 205 |
+
dashboard.py # Gradio app — grid view, game log, agent cards
|
| 206 |
+
|
| 207 |
+
No `static/index.html`. Gradio serves the UI server-side (Python-native,
|
| 208 |
+
fast for hackathon). Frontend is a thin visualization layer on top of the
|
| 209 |
+
API — reads `env` snapshot and renders it.
|
| 210 |
+
|
| 211 |
+
## Key Design Properties
|
| 212 |
+
|
| 213 |
+
| Concept | Lives where | Mutated by |
|
| 214 |
+
|---|---|---|
|
| 215 |
+
| Grid + terrain | `env.grid` | tools |
|
| 216 |
+
| Loot on tiles | `env.grid[x,y].loot` | engine (spawn), MoveTool (pickup) |
|
| 217 |
+
| Agent HP, score, abilities | `env.agents[aid]` | tools |
|
| 218 |
+
| LLM conversation | `env.agents[aid].messages` | agent.decide(), tools |
|
| 219 |
+
| Tool schemas | `tools/__init__.py` | never (static, 4 tools) |
|
| 220 |
+
| Ability logic | `environment/ability_registry.py` | handlers |
|
| 221 |
+
| Scoring logic | `environment/scoring.py` | engine |
|
| 222 |
+
| Loot tables | `environment/loot_tables.py` | engine |
|
frontend/static/index.html
ADDED
|
@@ -0,0 +1,936 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Grid Royale</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap">
|
| 10 |
+
<script type="importmap">
|
| 11 |
+
{
|
| 12 |
+
"imports": {
|
| 13 |
+
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
</script>
|
| 17 |
+
<style>
|
| 18 |
+
:root {
|
| 19 |
+
--bg: #0c0a08;
|
| 20 |
+
--surface: #14110f;
|
| 21 |
+
--border: #231f1c;
|
| 22 |
+
--border2: #1a1714;
|
| 23 |
+
--faint: #5a5048;
|
| 24 |
+
--dim: #7a6f63;
|
| 25 |
+
--muted: #9a8f81;
|
| 26 |
+
--cream: #f4ecde;
|
| 27 |
+
--accent: #e74c3c;
|
| 28 |
+
--accent-dim: rgba(231, 76, 60, 0.15);
|
| 29 |
+
}
|
| 30 |
+
* { box-sizing: border-box; }
|
| 31 |
+
html, body { margin: 0; padding: 0; height: 100%; }
|
| 32 |
+
body {
|
| 33 |
+
background: var(--bg);
|
| 34 |
+
color: var(--cream);
|
| 35 |
+
font-family: 'Geist', system-ui, sans-serif;
|
| 36 |
+
-webkit-font-smoothing: antialiased;
|
| 37 |
+
overflow: hidden;
|
| 38 |
+
}
|
| 39 |
+
.serif { font-family: 'Instrument Serif', serif; }
|
| 40 |
+
.mono { font-family: 'Geist Mono', monospace; }
|
| 41 |
+
|
| 42 |
+
/* ── Lobby ── */
|
| 43 |
+
#lobby {
|
| 44 |
+
position: fixed; inset: 0; z-index: 100;
|
| 45 |
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
| 46 |
+
gap: 2rem; padding: 2rem;
|
| 47 |
+
background: radial-gradient(ellipse 60% 50% at 50% 40%, #14100c, var(--bg) 70%);
|
| 48 |
+
transition: opacity 0.6s ease, transform 0.6s ease;
|
| 49 |
+
}
|
| 50 |
+
#lobby.hidden { opacity: 0; transform: scale(0.96); pointer-events: none; }
|
| 51 |
+
.lobby-title {
|
| 52 |
+
font-family: 'Instrument Serif', serif;
|
| 53 |
+
font-size: clamp(40px, 6vw, 64px);
|
| 54 |
+
letter-spacing: -1px;
|
| 55 |
+
margin: 0;
|
| 56 |
+
text-align: center;
|
| 57 |
+
}
|
| 58 |
+
.lobby-title em { font-style: italic; color: var(--accent); }
|
| 59 |
+
.lobby-sub {
|
| 60 |
+
font-family: 'Geist Mono', monospace;
|
| 61 |
+
font-size: 11px; text-transform: uppercase;
|
| 62 |
+
letter-spacing: 0.22em; color: var(--dim);
|
| 63 |
+
margin: -1rem 0 0 0;
|
| 64 |
+
text-align: center;
|
| 65 |
+
}
|
| 66 |
+
.lobby-controls {
|
| 67 |
+
display: flex; flex-direction: column; gap: 1.25rem;
|
| 68 |
+
width: 100%; max-width: 360px;
|
| 69 |
+
}
|
| 70 |
+
.lobby-row {
|
| 71 |
+
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
|
| 72 |
+
}
|
| 73 |
+
.lobby-row label {
|
| 74 |
+
font-family: 'Geist Mono', monospace; font-size: 12px;
|
| 75 |
+
text-transform: uppercase; letter-spacing: 0.1em; color: var(--dim);
|
| 76 |
+
}
|
| 77 |
+
.lobby-row input {
|
| 78 |
+
width: 80px; background: var(--surface);
|
| 79 |
+
border: 1px solid var(--border); border-radius: 6px;
|
| 80 |
+
color: var(--cream); font-family: 'Geist Mono', monospace;
|
| 81 |
+
font-size: 14px; padding: 8px 12px; text-align: center;
|
| 82 |
+
outline: none; transition: border-color 0.15s;
|
| 83 |
+
}
|
| 84 |
+
.lobby-row input:focus { border-color: var(--accent); }
|
| 85 |
+
.lobby-row input[type=range] {
|
| 86 |
+
width: 120px; -webkit-appearance: none; appearance: none;
|
| 87 |
+
background: transparent; height: 4px;
|
| 88 |
+
}
|
| 89 |
+
.lobby-row input[type=range]::-webkit-slider-runnable-track {
|
| 90 |
+
height: 2px; background: var(--border); border-radius: 1px;
|
| 91 |
+
}
|
| 92 |
+
.lobby-row input[type=range]::-webkit-slider-thumb {
|
| 93 |
+
-webkit-appearance: none; width: 14px; height: 14px;
|
| 94 |
+
border-radius: 50%; background: var(--accent); border: none;
|
| 95 |
+
cursor: pointer; margin-top: -6px;
|
| 96 |
+
}
|
| 97 |
+
.lobby-start {
|
| 98 |
+
width: 100%; padding: 14px; border: none; border-radius: 9999px;
|
| 99 |
+
background: var(--accent); color: #fff;
|
| 100 |
+
font-family: 'Geist Mono', monospace; font-size: 13px;
|
| 101 |
+
text-transform: uppercase; letter-spacing: 0.18em;
|
| 102 |
+
cursor: pointer; transition: opacity 0.15s, transform 0.15s;
|
| 103 |
+
margin-top: 0.5rem;
|
| 104 |
+
}
|
| 105 |
+
.lobby-start:hover { opacity: 0.9; transform: translateY(-1px); }
|
| 106 |
+
.lobby-start:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
| 107 |
+
|
| 108 |
+
/* ── Game UI ── */
|
| 109 |
+
#game {
|
| 110 |
+
display: none; flex-direction: column; height: 100vh;
|
| 111 |
+
opacity: 0; transition: opacity 0.5s ease;
|
| 112 |
+
}
|
| 113 |
+
#game.visible { display: flex; opacity: 1; }
|
| 114 |
+
|
| 115 |
+
.status-bar {
|
| 116 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 117 |
+
padding: 8px 16px; background: var(--surface);
|
| 118 |
+
border-bottom: 1px solid var(--border);
|
| 119 |
+
font-size: 12px; letter-spacing: 0.02em;
|
| 120 |
+
flex-shrink: 0;
|
| 121 |
+
}
|
| 122 |
+
.status-left { color: var(--accent); font-weight: 500; font-family: 'Geist Mono', monospace; }
|
| 123 |
+
|
| 124 |
+
.main-layout {
|
| 125 |
+
display: flex; flex: 1; min-height: 0;
|
| 126 |
+
}
|
| 127 |
+
.scene-wrap {
|
| 128 |
+
flex: 1; position: relative; background: #080a08;
|
| 129 |
+
min-height: 0;
|
| 130 |
+
}
|
| 131 |
+
.scene-canvas { position: absolute; inset: 0; }
|
| 132 |
+
.scene-canvas canvas { display: block; width: 100% !important; height: 100% !important; }
|
| 133 |
+
|
| 134 |
+
.sidebar {
|
| 135 |
+
width: 300px; flex-shrink: 0; display: flex; flex-direction: column;
|
| 136 |
+
gap: 6px; padding: 6px; background: var(--surface);
|
| 137 |
+
border-left: 1px solid var(--border); overflow-y: auto;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.panel {
|
| 141 |
+
background: var(--bg); border: 1px solid var(--border2);
|
| 142 |
+
border-radius: 8px; padding: 10px 12px;
|
| 143 |
+
}
|
| 144 |
+
.panel.grow { flex: 1; min-height: 0; }
|
| 145 |
+
.panel h3 {
|
| 146 |
+
font-size: 10px; color: var(--dim); margin: 0 0 8px 0;
|
| 147 |
+
text-transform: uppercase; letter-spacing: 0.18em;
|
| 148 |
+
font-family: 'Geist Mono', monospace;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.detail-panel {
|
| 152 |
+
background: var(--bg); border: 1px solid var(--border2);
|
| 153 |
+
border-left: 3px solid var(--ac, var(--accent));
|
| 154 |
+
border-radius: 8px; overflow: hidden;
|
| 155 |
+
}
|
| 156 |
+
.detail-panel.empty {
|
| 157 |
+
border-left-color: var(--border2); color: var(--faint);
|
| 158 |
+
padding: 20px 16px; text-align: center; font-size: 12px; font-style: italic;
|
| 159 |
+
}
|
| 160 |
+
.dp-header {
|
| 161 |
+
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
| 162 |
+
background: linear-gradient(135deg, #141210, var(--bg));
|
| 163 |
+
}
|
| 164 |
+
.dp-avatar {
|
| 165 |
+
width: 34px; height: 34px; border-radius: 50%;
|
| 166 |
+
background: var(--ac, var(--accent));
|
| 167 |
+
display: flex; align-items: center; justify-content: center;
|
| 168 |
+
font-weight: 700; color: #000; font-size: 14px;
|
| 169 |
+
box-shadow: 0 0 14px var(--ac, var(--accent));
|
| 170 |
+
}
|
| 171 |
+
.dp-name { font-weight: 600; font-size: 13.5px; }
|
| 172 |
+
.dp-loc { font-size: 10.5px; color: var(--dim); font-family: 'Geist Mono', monospace; }
|
| 173 |
+
.dp-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
| 174 |
+
.dp-section { display: flex; justify-content: space-between; align-items: flex-start; gap: 6px; }
|
| 175 |
+
.dp-section label { color: var(--dim); min-width: 48px; }
|
| 176 |
+
.dp-hp { color: var(--accent); }
|
| 177 |
+
.dp-score { color: #f9ca24; }
|
| 178 |
+
.skills-list { display: flex; flex-wrap: wrap; gap: 3px; justify-content: flex-end; }
|
| 179 |
+
.skill-badge { background: #1a1714; padding: 2px 6px; border-radius: 4px; font-size: 10px; color: var(--accent); }
|
| 180 |
+
.skill-badge.none { color: #555; }
|
| 181 |
+
|
| 182 |
+
.lb-row {
|
| 183 |
+
display: flex; align-items: center; gap: 8px;
|
| 184 |
+
padding: 5px 6px; border-radius: 4px; cursor: pointer;
|
| 185 |
+
font-size: 12px; transition: background 0.12s;
|
| 186 |
+
}
|
| 187 |
+
.lb-row:hover { background: #1a1714; }
|
| 188 |
+
.lb-row.dead { opacity: 0.35; }
|
| 189 |
+
.lb-token {
|
| 190 |
+
width: 20px; height: 20px; border-radius: 50%;
|
| 191 |
+
display: flex; align-items: center; justify-content: center;
|
| 192 |
+
font-weight: 700; color: #000;
|
| 193 |
+
font-size: 10px; flex-shrink: 0;
|
| 194 |
+
}
|
| 195 |
+
.lb-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 196 |
+
.lb-score { color: var(--accent); font-weight: 600; font-family: 'Geist Mono', monospace; }
|
| 197 |
+
.lb-status { font-size: 11px; }
|
| 198 |
+
|
| 199 |
+
.ev-feed { max-height: 100%; overflow-y: auto; font-size: 10.5px; line-height: 1.5; }
|
| 200 |
+
.ev-feed::-webkit-scrollbar { width: 3px; }
|
| 201 |
+
.ev-feed::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
| 202 |
+
.ev-row { padding: 3px 0; border-bottom: 1px solid #1a1714; color: #b8a89a; }
|
| 203 |
+
.ev-row.muted { color: #444; font-style: italic; }
|
| 204 |
+
.ev-row.kill { color: var(--accent); }
|
| 205 |
+
.ev-row.loot { color: #f9ca24; }
|
| 206 |
+
.msg-row { font-size: 9.5px; }
|
| 207 |
+
.tab-btn { user-select: none; }
|
| 208 |
+
.tab-btn:hover { background: #1a1714 !important; }
|
| 209 |
+
|
| 210 |
+
.controls-bar {
|
| 211 |
+
display: flex; align-items: center; gap: 8px;
|
| 212 |
+
padding: 8px 16px; background: var(--surface);
|
| 213 |
+
border-top: 1px solid var(--border); flex-shrink: 0;
|
| 214 |
+
}
|
| 215 |
+
.controls-bar button {
|
| 216 |
+
background: #1a1714; border: 1px solid var(--border);
|
| 217 |
+
color: var(--cream); padding: 8px 16px; border-radius: 6px;
|
| 218 |
+
cursor: pointer; font-family: 'Geist Mono', monospace;
|
| 219 |
+
font-size: 12px; transition: all 0.12s;
|
| 220 |
+
}
|
| 221 |
+
.controls-bar button:hover { background: #2a2420; border-color: var(--accent); }
|
| 222 |
+
.controls-bar button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
| 223 |
+
.controls-bar button.primary:hover { opacity: 0.9; }
|
| 224 |
+
.controls-bar button:disabled { opacity: 0.3; cursor: not-allowed; }
|
| 225 |
+
.controls-bar .hint { font-size: 10.5px; color: var(--faint); margin-left: auto; font-family: 'Geist Mono', monospace; }
|
| 226 |
+
|
| 227 |
+
.loading-overlay {
|
| 228 |
+
position: absolute; inset: 0; z-index: 10;
|
| 229 |
+
display: flex; align-items: center; justify-content: center;
|
| 230 |
+
background: rgba(8, 10, 8, 0.75);
|
| 231 |
+
opacity: 0; pointer-events: none;
|
| 232 |
+
transition: opacity 0.2s ease;
|
| 233 |
+
}
|
| 234 |
+
.loading-overlay.active { opacity: 1; pointer-events: auto; }
|
| 235 |
+
.loading-spinner {
|
| 236 |
+
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
| 237 |
+
color: var(--dim); font-family: 'Geist Mono', monospace; font-size: 12px;
|
| 238 |
+
}
|
| 239 |
+
.loading-spinner .spin {
|
| 240 |
+
width: 28px; height: 28px; border: 2px solid var(--border);
|
| 241 |
+
border-top-color: var(--accent); border-radius: 50%;
|
| 242 |
+
animation: spin 0.7s linear infinite;
|
| 243 |
+
}
|
| 244 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 245 |
+
|
| 246 |
+
::-webkit-scrollbar { width: 5px; }
|
| 247 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 248 |
+
::-webkit-scrollbar-thumb { background: #2a2420; border-radius: 3px; }
|
| 249 |
+
</style>
|
| 250 |
+
</head>
|
| 251 |
+
<body>
|
| 252 |
+
|
| 253 |
+
<div id="lobby">
|
| 254 |
+
<div>
|
| 255 |
+
<h1 class="lobby-title">Grid <em>Royale</em></h1>
|
| 256 |
+
<p class="lobby-sub">Battle Royale · LLM Agents</p>
|
| 257 |
+
</div>
|
| 258 |
+
<div class="lobby-controls">
|
| 259 |
+
<div class="lobby-row">
|
| 260 |
+
<label>Grid</label>
|
| 261 |
+
<input type="range" id="gridSize" min="10" max="30" value="20" step="1">
|
| 262 |
+
<span class="mono" id="gridSizeVal" style="color:var(--cream);font-size:14px;width:32px;text-align:center">20</span>
|
| 263 |
+
</div>
|
| 264 |
+
<div class="lobby-row">
|
| 265 |
+
<label>Agents</label>
|
| 266 |
+
<input type="range" id="numAgents" min="2" max="8" value="4" step="1">
|
| 267 |
+
<span class="mono" id="numAgentsVal" style="color:var(--cream);font-size:14px;width:32px;text-align:center">4</span>
|
| 268 |
+
</div>
|
| 269 |
+
<div class="lobby-row">
|
| 270 |
+
<label>Chests</label>
|
| 271 |
+
<input type="range" id="numChests" min="5" max="50" value="15" step="1">
|
| 272 |
+
<span class="mono" id="numChestsVal" style="color:var(--cream);font-size:14px;width:32px;text-align:center">15</span>
|
| 273 |
+
</div>
|
| 274 |
+
<button class="lobby-start" id="startBtn">▶ Start Game</button>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div id="game">
|
| 279 |
+
<div class="status-bar">
|
| 280 |
+
<span class="status-left" id="statusLeft">⏱ Turn 0/50 · Alive 0/0</span>
|
| 281 |
+
</div>
|
| 282 |
+
<div class="main-layout">
|
| 283 |
+
<div class="scene-wrap" id="sceneWrap">
|
| 284 |
+
<div class="scene-canvas" id="sceneCanvas"></div>
|
| 285 |
+
<div class="loading-overlay" id="loadingOverlay">
|
| 286 |
+
<div class="loading-spinner">
|
| 287 |
+
<div class="spin"></div>
|
| 288 |
+
<span id="loadingText">Thinking...</span>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
<div class="sidebar" id="sidebar"></div>
|
| 293 |
+
</div>
|
| 294 |
+
<div class="controls-bar">
|
| 295 |
+
<button class="primary" id="stepBtn">▶ Step</button>
|
| 296 |
+
<button id="autoBtn">⏩ Auto</button>
|
| 297 |
+
<span class="hint">drag to rotate · scroll to zoom · click an agent</span>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
<script>
|
| 302 |
+
const API = ''; // same origin
|
| 303 |
+
|
| 304 |
+
let state = null;
|
| 305 |
+
let selectedId = null;
|
| 306 |
+
let gameStarted = false;
|
| 307 |
+
let autoRunning = false;
|
| 308 |
+
|
| 309 |
+
const AGENT_COLORS = {
|
| 310 |
+
agent_0: '#ff6b6b', agent_1: '#4ecdc4', agent_2: '#45b7d1',
|
| 311 |
+
agent_3: '#f9ca24', agent_4: '#a29bfe', agent_5: '#fd79a8',
|
| 312 |
+
agent_6: '#00b894', agent_7: '#e17055',
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
/* ── Lobby controls ── */
|
| 316 |
+
['gridSize','numAgents','numChests'].forEach(id => {
|
| 317 |
+
const el = document.getElementById(id);
|
| 318 |
+
const val = document.getElementById(id + 'Val');
|
| 319 |
+
el.addEventListener('input', () => { val.textContent = el.value; });
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
/* ── Three.js setup ── */
|
| 323 |
+
const TILE = 1.6;
|
| 324 |
+
|
| 325 |
+
async function loadThree() {
|
| 326 |
+
if (window.__gr3d && window.__gr3d.THREE) return;
|
| 327 |
+
window.__gr3d = {};
|
| 328 |
+
const THREE_mod = await import('three');
|
| 329 |
+
const { OrbitControls } = await import('https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js');
|
| 330 |
+
window.__gr3d.THREE = THREE_mod;
|
| 331 |
+
window.__gr3d.OrbitControls = OrbitControls;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
let app = null;
|
| 335 |
+
function initScene() {
|
| 336 |
+
if (app) return;
|
| 337 |
+
const THREE = window.__gr3d.THREE;
|
| 338 |
+
const OrbitControls = window.__gr3d.OrbitControls;
|
| 339 |
+
const wrap = document.getElementById('sceneCanvas');
|
| 340 |
+
if (!wrap) return;
|
| 341 |
+
|
| 342 |
+
const scene = new THREE.Scene();
|
| 343 |
+
scene.background = new THREE.Color(0x080a08);
|
| 344 |
+
scene.fog = new THREE.FogExp2(0x080a08, 0.015);
|
| 345 |
+
|
| 346 |
+
const w = wrap.clientWidth || 800;
|
| 347 |
+
const h = wrap.clientHeight || 500;
|
| 348 |
+
const camera = new THREE.PerspectiveCamera(40, w/h, 0.1, 1000);
|
| 349 |
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
| 350 |
+
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
|
| 351 |
+
renderer.setSize(w, h);
|
| 352 |
+
renderer.shadowMap.enabled = true;
|
| 353 |
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
| 354 |
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
| 355 |
+
renderer.toneMappingExposure = 1.0;
|
| 356 |
+
wrap.appendChild(renderer.domElement);
|
| 357 |
+
|
| 358 |
+
const hemi = new THREE.HemisphereLight(0x8a9ab0, 0x0a0806, 0.6);
|
| 359 |
+
scene.add(hemi);
|
| 360 |
+
const sun = new THREE.DirectionalLight(0xffe8c8, 1.2);
|
| 361 |
+
sun.position.set(18, 30, 14);
|
| 362 |
+
sun.castShadow = true;
|
| 363 |
+
sun.shadow.mapSize.set(2048, 2048);
|
| 364 |
+
sun.shadow.camera.left = -25; sun.shadow.camera.right = 25;
|
| 365 |
+
sun.shadow.camera.top = 25; sun.shadow.camera.bottom = -25;
|
| 366 |
+
sun.shadow.camera.near = 1; sun.shadow.camera.far = 80;
|
| 367 |
+
scene.add(sun);
|
| 368 |
+
const fill = new THREE.DirectionalLight(0x506878, 0.3);
|
| 369 |
+
fill.position.set(-12, 18, -8);
|
| 370 |
+
scene.add(fill);
|
| 371 |
+
|
| 372 |
+
const ground = new THREE.Mesh(
|
| 373 |
+
new THREE.PlaneGeometry(200, 200),
|
| 374 |
+
new THREE.MeshStandardMaterial({ color: 0x060806, roughness: 1 })
|
| 375 |
+
);
|
| 376 |
+
ground.rotation.x = -Math.PI/2;
|
| 377 |
+
ground.position.y = -0.5;
|
| 378 |
+
ground.receiveShadow = true;
|
| 379 |
+
scene.add(ground);
|
| 380 |
+
|
| 381 |
+
const tileGroup = new THREE.Group(); scene.add(tileGroup);
|
| 382 |
+
const objGroup = new THREE.Group(); scene.add(objGroup);
|
| 383 |
+
const agentGroup = new THREE.Group(); scene.add(agentGroup);
|
| 384 |
+
|
| 385 |
+
const particleCount = 200;
|
| 386 |
+
const pg = new THREE.BufferGeometry();
|
| 387 |
+
const pa = new Float32Array(particleCount * 3);
|
| 388 |
+
const sa = new Float32Array(particleCount);
|
| 389 |
+
for (let i = 0; i < particleCount; i++) {
|
| 390 |
+
pa[i*3] = (Math.random() - 0.5) * 28;
|
| 391 |
+
pa[i*3+1] = Math.random() * 10 + 1;
|
| 392 |
+
pa[i*3+2] = (Math.random() - 0.5) * 28;
|
| 393 |
+
sa[i] = Math.random() * Math.PI * 2;
|
| 394 |
+
}
|
| 395 |
+
pg.setAttribute('position', new THREE.BufferAttribute(pa, 3));
|
| 396 |
+
pg.setAttribute('seed', new THREE.BufferAttribute(sa, 1));
|
| 397 |
+
const particles = new THREE.Points(pg, new THREE.PointsMaterial({
|
| 398 |
+
color: 0x603030, size: 0.05, transparent: true, opacity: 0.3,
|
| 399 |
+
blending: THREE.AdditiveBlending, depthWrite: false,
|
| 400 |
+
}));
|
| 401 |
+
scene.add(particles);
|
| 402 |
+
|
| 403 |
+
const controls = new OrbitControls(camera, renderer.domElement);
|
| 404 |
+
controls.enableDamping = true;
|
| 405 |
+
controls.dampingFactor = 0.08;
|
| 406 |
+
controls.minDistance = 10;
|
| 407 |
+
controls.maxDistance = 45;
|
| 408 |
+
controls.maxPolarAngle = Math.PI / 2.3;
|
| 409 |
+
controls.target.set(0, 0, 0);
|
| 410 |
+
camera.position.set(18, 20, 18);
|
| 411 |
+
controls.update();
|
| 412 |
+
|
| 413 |
+
function resize() {
|
| 414 |
+
const ww = wrap.clientWidth || 800;
|
| 415 |
+
const hh = wrap.clientHeight || 500;
|
| 416 |
+
renderer.setSize(ww, hh);
|
| 417 |
+
camera.aspect = ww / hh;
|
| 418 |
+
camera.updateProjectionMatrix();
|
| 419 |
+
}
|
| 420 |
+
window.addEventListener('resize', resize);
|
| 421 |
+
|
| 422 |
+
const raycaster = new THREE.Raycaster();
|
| 423 |
+
const mouse = new THREE.Vector2();
|
| 424 |
+
renderer.domElement.addEventListener('click', e => {
|
| 425 |
+
const rect = renderer.domElement.getBoundingClientRect();
|
| 426 |
+
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
| 427 |
+
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
| 428 |
+
raycaster.setFromCamera(mouse, camera);
|
| 429 |
+
const meshes = [];
|
| 430 |
+
agentGroup.traverse(o => { if (o.isMesh && o.userData.aid) meshes.push(o); });
|
| 431 |
+
const hits = raycaster.intersectObjects(meshes);
|
| 432 |
+
selectAgent(hits.length ? hits[0].object.userData.aid : null);
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
const clock = new THREE.Clock();
|
| 436 |
+
function animate() {
|
| 437 |
+
requestAnimationFrame(animate);
|
| 438 |
+
const dt = clock.getDelta();
|
| 439 |
+
const t = clock.elapsedTime;
|
| 440 |
+
controls.update();
|
| 441 |
+
const pPos = particles.geometry.attributes.position;
|
| 442 |
+
const pSeed = particles.geometry.attributes.seed;
|
| 443 |
+
for (let i = 0; i < particleCount; i++) {
|
| 444 |
+
const s = pSeed.array[i];
|
| 445 |
+
pPos.array[i*3+1] += Math.sin(t * 0.5 + s) * 0.002;
|
| 446 |
+
pPos.array[i*3] += Math.cos(t * 0.25 + s) * 0.003;
|
| 447 |
+
}
|
| 448 |
+
pPos.needsUpdate = true;
|
| 449 |
+
agentGroup.children.forEach(a => {
|
| 450 |
+
if (a.userData.baseY !== undefined) {
|
| 451 |
+
a.position.y = a.userData.baseY + Math.sin(t * 2 + a.userData.phase) * 0.08;
|
| 452 |
+
a.rotation.y += dt * 0.5;
|
| 453 |
+
}
|
| 454 |
+
});
|
| 455 |
+
renderer.render(scene, camera);
|
| 456 |
+
}
|
| 457 |
+
animate();
|
| 458 |
+
|
| 459 |
+
function clearGroup(g) {
|
| 460 |
+
while (g.children.length) {
|
| 461 |
+
const c = g.children.pop();
|
| 462 |
+
c.traverse && c.traverse(o => {
|
| 463 |
+
if (o.geometry) o.geometry.dispose();
|
| 464 |
+
if (o.material) {
|
| 465 |
+
if (Array.isArray(o.material)) o.material.forEach(m => m.dispose());
|
| 466 |
+
else o.material.dispose();
|
| 467 |
+
}
|
| 468 |
+
});
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function build() {
|
| 473 |
+
clearGroup(tileGroup); clearGroup(objGroup); clearGroup(agentGroup);
|
| 474 |
+
if (!state) return;
|
| 475 |
+
const size = state.grid_size;
|
| 476 |
+
const half = (size - 1) / 2;
|
| 477 |
+
const tiles = state.tiles || {};
|
| 478 |
+
const agents = state.agents || [];
|
| 479 |
+
|
| 480 |
+
for (let x = 0; x < size; x++) {
|
| 481 |
+
for (let y = 0; y < size; y++) {
|
| 482 |
+
const k = x + ',' + y;
|
| 483 |
+
const tile = tiles[k] || {};
|
| 484 |
+
const wx = (x - half) * TILE;
|
| 485 |
+
const wz = (y - half) * TILE;
|
| 486 |
+
|
| 487 |
+
const tileMat = new THREE.MeshStandardMaterial({
|
| 488 |
+
color: 0x3a5a3a, roughness: 0.9, metalness: 0.05,
|
| 489 |
+
});
|
| 490 |
+
const tileMesh = new THREE.Mesh(new THREE.BoxGeometry(TILE*0.96, 0.25, TILE*0.96), tileMat);
|
| 491 |
+
tileMesh.position.set(wx, -0.125, wz);
|
| 492 |
+
tileMesh.receiveShadow = true;
|
| 493 |
+
tileGroup.add(tileMesh);
|
| 494 |
+
|
| 495 |
+
const topMat = new THREE.MeshStandardMaterial({
|
| 496 |
+
color: 0x5a8a4a, roughness: 0.8, metalness: 0.05,
|
| 497 |
+
});
|
| 498 |
+
const top = new THREE.Mesh(new THREE.BoxGeometry(TILE*0.94, 0.04, TILE*0.94), topMat);
|
| 499 |
+
top.position.set(wx, 0.02, wz);
|
| 500 |
+
tileGroup.add(top);
|
| 501 |
+
|
| 502 |
+
if (tile.loot) {
|
| 503 |
+
const chest = new THREE.Mesh(
|
| 504 |
+
new THREE.BoxGeometry(0.28, 0.22, 0.28),
|
| 505 |
+
new THREE.MeshStandardMaterial({ color: 0xf9ca24, emissive: 0xf9ca24, emissiveIntensity: 0.6, roughness: 0.3, metalness: 0.5 })
|
| 506 |
+
);
|
| 507 |
+
chest.position.set(wx, 0.22, wz);
|
| 508 |
+
chest.userData.baseY = 0.22;
|
| 509 |
+
chest.userData.phase = Math.random() * Math.PI * 2;
|
| 510 |
+
objGroup.add(chest);
|
| 511 |
+
const glow = new THREE.PointLight(0xf9ca24, 0.3, 2, 2);
|
| 512 |
+
glow.position.set(wx, 0.2, wz);
|
| 513 |
+
objGroup.add(glow);
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
agents.forEach(a => {
|
| 519 |
+
if (!a.alive) return;
|
| 520 |
+
const ax = (a.x - half) * TILE;
|
| 521 |
+
const az = (a.y - half) * TILE;
|
| 522 |
+
const col = parseInt(AGENT_COLORS[a.id]?.slice(1) || 'ffffff', 16);
|
| 523 |
+
const shielded = a.shielded;
|
| 524 |
+
|
| 525 |
+
const pl = new THREE.PointLight(col, 0.5, 3, 2);
|
| 526 |
+
pl.position.set(ax, 0.8, az);
|
| 527 |
+
agentGroup.add(pl);
|
| 528 |
+
|
| 529 |
+
const ring = new THREE.Mesh(
|
| 530 |
+
new THREE.RingGeometry(0.3, 0.36, 24),
|
| 531 |
+
new THREE.MeshBasicMaterial({ color: shielded ? 0x60c8ff : col, side: THREE.DoubleSide, transparent: true, opacity: shielded ? 0.8 : 0.4 })
|
| 532 |
+
);
|
| 533 |
+
ring.rotation.x = -Math.PI/2;
|
| 534 |
+
ring.position.set(ax, 0.04, az);
|
| 535 |
+
agentGroup.add(ring);
|
| 536 |
+
|
| 537 |
+
const figMat = new THREE.MeshStandardMaterial({
|
| 538 |
+
color: col, emissive: shielded ? 0x60c8ff : col,
|
| 539 |
+
emissiveIntensity: shielded ? 0.8 : 0.4,
|
| 540 |
+
roughness: 0.3, metalness: 0.3,
|
| 541 |
+
});
|
| 542 |
+
const fig = new THREE.Mesh(new THREE.CapsuleGeometry(0.16, 0.5, 6, 10), figMat);
|
| 543 |
+
fig.position.set(ax, 0.42, az);
|
| 544 |
+
fig.castShadow = true;
|
| 545 |
+
fig.userData.aid = a.id;
|
| 546 |
+
fig.userData.baseY = 0.42;
|
| 547 |
+
fig.userData.phase = Math.random() * Math.PI * 2;
|
| 548 |
+
agentGroup.add(fig);
|
| 549 |
+
|
| 550 |
+
if (shielded) {
|
| 551 |
+
const sh = new THREE.Mesh(
|
| 552 |
+
new THREE.SphereGeometry(0.35, 12, 12),
|
| 553 |
+
new THREE.MeshStandardMaterial({ color: 0x60c8ff, transparent: true, opacity: 0.15, emissive: 0x60c8ff, emissiveIntensity: 0.3, side: THREE.BackSide })
|
| 554 |
+
);
|
| 555 |
+
sh.position.set(ax, 0.5, az);
|
| 556 |
+
sh.userData.baseY = 0.5;
|
| 557 |
+
sh.userData.phase = Math.random() * Math.PI * 2;
|
| 558 |
+
agentGroup.add(sh);
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
const canvas = document.createElement('canvas');
|
| 562 |
+
canvas.width = 64; canvas.height = 64;
|
| 563 |
+
const ctx = canvas.getContext('2d');
|
| 564 |
+
ctx.fillStyle = '#' + col.toString(16).padStart(6, '0');
|
| 565 |
+
ctx.beginPath(); ctx.arc(32, 32, 28, 0, Math.PI * 2); ctx.fill();
|
| 566 |
+
ctx.fillStyle = '#000';
|
| 567 |
+
ctx.font = 'bold 28px monospace';
|
| 568 |
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
| 569 |
+
ctx.fillText(a.name.charAt(0).toUpperCase(), 32, 34);
|
| 570 |
+
const tex = new THREE.CanvasTexture(canvas);
|
| 571 |
+
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
|
| 572 |
+
sprite.position.set(ax, 1.1, az);
|
| 573 |
+
sprite.scale.set(0.5, 0.5, 1);
|
| 574 |
+
sprite.userData.aid = a.id;
|
| 575 |
+
agentGroup.add(sprite);
|
| 576 |
+
});
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
app = { scene, camera, renderer, controls, build, resize };
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
function renderScene() {
|
| 583 |
+
if (app) app.build();
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
/* ── UI updates ── */
|
| 587 |
+
function buildSidebar() {
|
| 588 |
+
if (!state) return;
|
| 589 |
+
const agents = state.agents || [];
|
| 590 |
+
const sidebar = document.getElementById('sidebar');
|
| 591 |
+
|
| 592 |
+
const selected = agents.find(a => a.id === selectedId);
|
| 593 |
+
let detailHtml = '';
|
| 594 |
+
let traceHtml = '';
|
| 595 |
+
if (selected) {
|
| 596 |
+
const abilities = selected.abilities || [];
|
| 597 |
+
const abHtml = abilities.length
|
| 598 |
+
? abilities.map(ab => `<span class="skill-badge">${ab.name}${ab.uses_remaining === null ? ' ∞' : ' ' + ab.uses_remaining}</span>`).join('')
|
| 599 |
+
: '<span class="skill-badge none">none</span>';
|
| 600 |
+
const msgs = selected.messages || [];
|
| 601 |
+
const msgHtml = msgs.length
|
| 602 |
+
? msgs.map(m => `<div class="msg-row ${m.role}" style="border-left:2px solid ${m.role === 'tool' ? 'var(--faint)' : m.role === 'user' ? 'var(--accent)' : '#5a8a4a'};padding:3px 6px;margin:2px 0;font-size:9.5px;overflow:hidden">
|
| 603 |
+
<span style="color:${m.role === 'system' ? 'var(--dim)' : m.role === 'user' ? 'var(--accent)' : m.role === 'tool' ? 'var(--faint)' : 'var(--cream)'};font-weight:500;text-transform:uppercase;font-size:8.5px">${m.role}</span>
|
| 604 |
+
<div style="color:${m.role === 'system' ? '#665' : m.role === 'user' ? '#ddd' : '#998'};word-break:break-word;white-space:pre-wrap;max-height:60px;overflow-y:auto">${esc(m.content || '').slice(0, 300)}</div>
|
| 605 |
+
</div>`).join('')
|
| 606 |
+
: '<div class="ev-row muted">No messages yet.</div>';
|
| 607 |
+
detailHtml = `
|
| 608 |
+
<div class="detail-panel" style="--ac:${AGENT_COLORS[selected.id] || '#888'}">
|
| 609 |
+
<div class="dp-header" style="cursor:pointer" onclick="selectAgent(null)">
|
| 610 |
+
<span class="dp-avatar">${selected.name.charAt(0).toUpperCase()}</span>
|
| 611 |
+
<div style="flex:1">
|
| 612 |
+
<div class="dp-name">${selected.name}</div>
|
| 613 |
+
<div class="dp-loc">📍 (${selected.x}, ${selected.y}) · Kills: ${selected.kills}</div>
|
| 614 |
+
</div>
|
| 615 |
+
<span style="color:var(--faint);font-size:11px">✕</span>
|
| 616 |
+
</div>
|
| 617 |
+
<div class="dp-body">
|
| 618 |
+
<div class="dp-section"><label>HP</label><span class="dp-hp">${selected.hp}/100</span></div>
|
| 619 |
+
<div class="dp-section"><label>Score</label><span class="dp-score">${selected.score}</span></div>
|
| 620 |
+
<div class="dp-section"><label>Status</label><span>${selected.shielded ? '🛡️ Shielded' : '⚔️ Active'}</span></div>
|
| 621 |
+
<div class="dp-section"><label>Abilities</label><div class="skills-list">${abHtml}</div></div>
|
| 622 |
+
</div>
|
| 623 |
+
</div>
|
| 624 |
+
<div class="panel" style="flex:1;min-height:0;display:flex;flex-direction:column">
|
| 625 |
+
<div style="display:flex;gap:3px;margin-bottom:4px">
|
| 626 |
+
<span class="tab-btn active" id="tabSystem" style="flex:1;text-align:center;padding:4px;border-radius:4px;background:var(--border2);cursor:pointer;font-size:9.5px;text-transform:uppercase;letter-spacing:0.08em;color:var(--cream)" onclick="switchAgentTab('system')">System</span>
|
| 627 |
+
<span class="tab-btn" id="tabHistory" style="flex:1;text-align:center;padding:4px;border-radius:4px;background:transparent;cursor:pointer;font-size:9.5px;text-transform:uppercase;letter-spacing:0.08em;color:var(--dim)" onclick="switchAgentTab('history')">History</span>
|
| 628 |
+
<span class="tab-btn" id="tabTools" style="flex:1;text-align:center;padding:4px;border-radius:4px;background:transparent;cursor:pointer;font-size:9.5px;text-transform:uppercase;letter-spacing:0.08em;color:var(--dim)" onclick="switchAgentTab('tools')">Tools</span>
|
| 629 |
+
</div>
|
| 630 |
+
<div class="ev-feed" id="agentTabContent" style="flex:1;min-height:0;overflow-y:auto">
|
| 631 |
+
${buildAgentTab(selected.id, 'system')}
|
| 632 |
+
</div>
|
| 633 |
+
</div>`;
|
| 634 |
+
} else {
|
| 635 |
+
const sorted = [...agents].sort((a, b) => b.score - a.score);
|
| 636 |
+
const lbHtml = sorted.map(a => `
|
| 637 |
+
<div class="lb-row${a.alive ? '' : ' dead'}" data-aid="${a.id}">
|
| 638 |
+
<span class="lb-token" style="background:${AGENT_COLORS[a.id] || '#888'}">${a.name.charAt(0).toUpperCase()}</span>
|
| 639 |
+
<span class="lb-name">${a.name}</span>
|
| 640 |
+
<span class="lb-score">${a.score}</span>
|
| 641 |
+
<span class="lb-status">${a.alive ? '✦' : '✗'}</span>
|
| 642 |
+
</div>
|
| 643 |
+
`).join('');
|
| 644 |
+
detailHtml = '<div class="detail-panel empty">Click an agent to inspect</div>';
|
| 645 |
+
traceHtml = `
|
| 646 |
+
<div class="panel grow">
|
| 647 |
+
<h3>Agent Trace</h3>
|
| 648 |
+
<div class="ev-feed" id="traceFeed">${buildAllTrace()}</div>
|
| 649 |
+
</div>`;
|
| 650 |
+
detailHtml = `
|
| 651 |
+
${detailHtml}
|
| 652 |
+
<div class="panel" style="flex-shrink:0">
|
| 653 |
+
<h3>Leaderboard</h3>
|
| 654 |
+
${lbHtml}
|
| 655 |
+
</div>
|
| 656 |
+
${traceHtml}`;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
sidebar.innerHTML = detailHtml;
|
| 660 |
+
|
| 661 |
+
document.querySelectorAll('.lb-row[data-aid]').forEach(el => {
|
| 662 |
+
el.addEventListener('click', () => selectAgent(el.dataset.aid));
|
| 663 |
+
});
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
function switchAgentTab(tab) {
|
| 667 |
+
const aid = selectedId;
|
| 668 |
+
if (!aid) return;
|
| 669 |
+
['System','History','Tools'].forEach(t => {
|
| 670 |
+
const id = 'tab' + t;
|
| 671 |
+
const el = document.getElementById(id);
|
| 672 |
+
if (!el) return;
|
| 673 |
+
const active = t.toLowerCase() === tab;
|
| 674 |
+
el.className = 'tab-btn' + (active ? ' active' : '');
|
| 675 |
+
el.style.background = active ? 'var(--border2)' : 'transparent';
|
| 676 |
+
el.style.color = active ? 'var(--cream)' : 'var(--dim)';
|
| 677 |
+
});
|
| 678 |
+
document.getElementById('agentTabContent').innerHTML = buildAgentTab(aid, tab);
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
function buildAgentTab(aid, tab) {
|
| 682 |
+
if (tab === 'system') return buildAgentSystem(aid);
|
| 683 |
+
if (tab === 'history') return buildAgentHistory(aid);
|
| 684 |
+
if (tab === 'tools') return buildAgentTools();
|
| 685 |
+
return '';
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
function buildAgentSystem(aid) {
|
| 689 |
+
const agent = (state.agents || []).find(a => a.id === aid);
|
| 690 |
+
if (!agent) return '<div class="ev-row muted">No data.</div>';
|
| 691 |
+
const msgs = agent.messages || [];
|
| 692 |
+
const sys = msgs.filter(m => m.role === 'system');
|
| 693 |
+
return sys.length
|
| 694 |
+
? sys.map(m => `<div class="ev-row" style="border-left:2px solid #5a8a4a;padding:4px 6px;margin:2px 0;background:rgba(90,138,74,0.06)">
|
| 695 |
+
<span style="color:#5a8a4a;font-weight:500;text-transform:uppercase;font-size:8.5px;letter-spacing:0.1em">system</span>
|
| 696 |
+
<div style="color:#887;font-family:'Geist Mono',monospace;font-size:8.5px;white-space:pre-wrap;word-break:break-word;max-height:80px;overflow-y:auto;margin-top:2px">${esc(m.content || '').slice(0, 600)}</div>
|
| 697 |
+
</div>`).join('')
|
| 698 |
+
: '<div class="ev-row muted">No system prompt.</div>';
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
function buildAgentHistory(aid) {
|
| 702 |
+
const agent = (state.agents || []).find(a => a.id === aid);
|
| 703 |
+
if (!agent) return '<div class="ev-row muted">No data.</div>';
|
| 704 |
+
const msgs = (agent.messages || []).filter(m => m.role !== 'system');
|
| 705 |
+
if (!msgs.length) return '<div class="ev-row muted">No messages yet.</div>';
|
| 706 |
+
return msgs.map(m => {
|
| 707 |
+
const roleColors = {user: 'var(--accent)', assistant: 'var(--cream)', tool: 'var(--faint)'};
|
| 708 |
+
const bgColors = {user: 'rgba(231,76,60,0.04)', assistant: 'rgba(244,236,222,0.03)', tool: 'rgba(90,80,72,0.04)'};
|
| 709 |
+
if (m.role === 'assistant' && m.tool_calls) {
|
| 710 |
+
const calls = (m.tool_calls || []).map(tc =>
|
| 711 |
+
`${tc.function.name}(${esc(JSON.stringify(tc.function.arguments || {}))})`
|
| 712 |
+
).join(', ');
|
| 713 |
+
return `<div class="ev-row" style="border-left:2px solid var(--cream);padding:3px 6px;margin:2px 0;background:rgba(244,236,222,0.03)">
|
| 714 |
+
<span style="color:var(--cream);font-weight:500;text-transform:uppercase;font-size:8.5px;letter-spacing:0.1em">tool_calls</span>
|
| 715 |
+
<div style="color:#bb9;font-family:'Geist Mono',monospace;font-size:8.5px;white-space:pre-wrap;word-break:break-word;margin-top:2px">${calls}</div>
|
| 716 |
+
</div>`;
|
| 717 |
+
}
|
| 718 |
+
const label = m.role === 'tool' ? 'tool_result' : m.role;
|
| 719 |
+
const content = m.role === 'assistant' ? (m.content || '(tool calls)') : (m.content || '');
|
| 720 |
+
return `<div class="ev-row" style="border-left:2px solid ${roleColors[m.role] || '#555'};padding:3px 6px;margin:2px 0;background:${bgColors[m.role] || 'transparent'}">
|
| 721 |
+
<span style="color:${roleColors[m.role] || '#555'};font-weight:500;text-transform:uppercase;font-size:8.5px;letter-spacing:0.1em">${label}</span>
|
| 722 |
+
<div style="color:#aa9;font-family:'Geist Mono',monospace;font-size:8.5px;white-space:pre-wrap;word-break:break-word;max-height:80px;overflow-y:auto;margin-top:2px">${esc(content).slice(0, 500)}</div>
|
| 723 |
+
</div>`;
|
| 724 |
+
}).join('');
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
function buildAgentTools() {
|
| 728 |
+
const schemas = state.tool_schemas;
|
| 729 |
+
if (!schemas || !schemas.length) return '<div class="ev-row muted">No tools defined.</div>';
|
| 730 |
+
return schemas.map(t => {
|
| 731 |
+
const name = t.function?.name || t.name || '?';
|
| 732 |
+
const desc = t.function?.description || t.description || '';
|
| 733 |
+
const params = t.function?.parameters || t.parameters || {};
|
| 734 |
+
const props = params.properties || {};
|
| 735 |
+
const req = params.required || [];
|
| 736 |
+
const propHtml = Object.keys(props).map(k => {
|
| 737 |
+
const p = props[k];
|
| 738 |
+
const type = p.type || 'any';
|
| 739 |
+
const desc2 = p.description ? ` — ${esc(p.description)}` : '';
|
| 740 |
+
const required = req.includes(k) ? ' <span style="color:var(--accent)">*required</span>' : '';
|
| 741 |
+
return `<div style="padding:2px 0;font-size:8.5px;color:#998">
|
| 742 |
+
<span style="color:var(--cream)">${esc(k)}</span><span style="color:var(--dim)">: ${type}${required}</span>
|
| 743 |
+
<span style="color:#665">${desc2}</span>
|
| 744 |
+
</div>`;
|
| 745 |
+
}).join('');
|
| 746 |
+
return `<div class="ev-row" style="border-left:2px solid var(--accent);padding:4px 6px;margin:3px 0">
|
| 747 |
+
<div style="color:var(--cream);font-weight:600;font-size:10px">${esc(name)}</div>
|
| 748 |
+
<div style="color:#887;font-size:9px;margin:2px 0">${esc(desc)}</div>
|
| 749 |
+
${propHtml ? `<div style="margin-top:3px;border-top:1px solid var(--border2);padding-top:2px">${propHtml}</div>` : ''}
|
| 750 |
+
</div>`;
|
| 751 |
+
}).join('');
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
function buildAllTrace() {
|
| 755 |
+
const log = state.turn_log;
|
| 756 |
+
if (!log || !log.agents) return '<div class="ev-row muted">No turn data yet.</div>';
|
| 757 |
+
const agents = state.agents || [];
|
| 758 |
+
const rows = [];
|
| 759 |
+
for (const aid of Object.keys(log.agents)) {
|
| 760 |
+
const agent = agents.find(a => a.id === aid);
|
| 761 |
+
const name = agent ? agent.name : aid;
|
| 762 |
+
const color = AGENT_COLORS[aid] || '#888';
|
| 763 |
+
const entries = log.agents[aid] || [];
|
| 764 |
+
for (const entry of entries) {
|
| 765 |
+
if (entry.phase === 'llm') {
|
| 766 |
+
const usage = entry.usage ? ` · ${entry.usage.prompt_tokens || '?'}→${entry.usage.completion_tokens || '?'}t` : '';
|
| 767 |
+
rows.push(`<div class="ev-row" style="border-left:2px solid ${color};padding-left:6px;margin:2px 0">
|
| 768 |
+
<span style="color:${color};font-weight:600">${name}</span>
|
| 769 |
+
<span style="color:var(--faint)"> LLM </span>
|
| 770 |
+
<span style="color:var(--dim)">${entry.time_ms}ms${usage}</span>
|
| 771 |
+
</div>`);
|
| 772 |
+
} else if (entry.phase === 'exec') {
|
| 773 |
+
const args = JSON.stringify(entry.args).slice(0, 80);
|
| 774 |
+
rows.push(`<div class="ev-row" style="border-left:2px solid ${color};padding-left:10px;margin:1px 0;font-size:9.5px">
|
| 775 |
+
<span style="color:var(--muted)">r${entry.round} </span>
|
| 776 |
+
<span style="color:var(--cream)">${entry.tool}</span>
|
| 777 |
+
<span style="color:var(--faint)"> ${esc(args)}</span>
|
| 778 |
+
<span style="color:var(--dim);float:right">${entry.time_ms}ms</span>
|
| 779 |
+
<div style="color:#887;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">→ ${esc((entry.result || '').slice(0, 80))}</div>
|
| 780 |
+
</div>`);
|
| 781 |
+
}
|
| 782 |
+
}
|
| 783 |
+
}
|
| 784 |
+
const timing = log.time_ms ? `<div class="ev-row muted" style="border-top:1px solid var(--border);margin-top:4px;padding-top:4px">⏱ Step total: ${log.time_ms}ms</div>` : '';
|
| 785 |
+
return rows.length ? rows.join('') + timing : '<div class="ev-row muted">No turn data yet.</div>';
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
function esc(s) {
|
| 789 |
+
const d = document.createElement('div');
|
| 790 |
+
d.textContent = s;
|
| 791 |
+
return d.innerHTML;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
function selectAgent(id) {
|
| 795 |
+
selectedId = id;
|
| 796 |
+
buildSidebar();
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
/* ── API calls ── */
|
| 800 |
+
async function api(method, path, body) {
|
| 801 |
+
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
| 802 |
+
if (body) opts.body = JSON.stringify(body);
|
| 803 |
+
const r = await fetch(API + path, opts);
|
| 804 |
+
return r.json();
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
async function startGame() {
|
| 808 |
+
const gridSize = parseInt(document.getElementById('gridSize').value);
|
| 809 |
+
const numAgents = parseInt(document.getElementById('numAgents').value);
|
| 810 |
+
const numChests = parseInt(document.getElementById('numChests').value);
|
| 811 |
+
|
| 812 |
+
document.getElementById('startBtn').disabled = true;
|
| 813 |
+
document.getElementById('startBtn').textContent = 'Starting...';
|
| 814 |
+
|
| 815 |
+
state = await api('POST', '/api/game/start', {
|
| 816 |
+
grid_size: gridSize,
|
| 817 |
+
max_agents: numAgents,
|
| 818 |
+
num_chests: numChests,
|
| 819 |
+
});
|
| 820 |
+
|
| 821 |
+
state = await api('GET', '/api/game/state');
|
| 822 |
+
gameStarted = true;
|
| 823 |
+
|
| 824 |
+
document.getElementById('lobby').classList.add('hidden');
|
| 825 |
+
const game = document.getElementById('game');
|
| 826 |
+
game.classList.add('visible');
|
| 827 |
+
game.style.display = 'flex';
|
| 828 |
+
|
| 829 |
+
showLoading('Loading scene...');
|
| 830 |
+
try {
|
| 831 |
+
await loadThree();
|
| 832 |
+
if (!app) initScene();
|
| 833 |
+
requestAnimationFrame(() => {
|
| 834 |
+
if (app) app.resize();
|
| 835 |
+
renderScene();
|
| 836 |
+
});
|
| 837 |
+
updateUI();
|
| 838 |
+
} finally {
|
| 839 |
+
hideLoading();
|
| 840 |
+
}
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
function showLoading(label) {
|
| 844 |
+
const el = document.getElementById('loadingOverlay');
|
| 845 |
+
document.getElementById('loadingText').textContent = label;
|
| 846 |
+
el.classList.add('active');
|
| 847 |
+
document.getElementById('stepBtn').disabled = true;
|
| 848 |
+
document.getElementById('autoBtn').disabled = true;
|
| 849 |
+
}
|
| 850 |
+
function hideLoading() {
|
| 851 |
+
document.getElementById('loadingOverlay').classList.remove('active');
|
| 852 |
+
if (state && !state.game_over) {
|
| 853 |
+
const alive = (state.agents || []).filter(a => a.alive).length;
|
| 854 |
+
if (alive > 1) {
|
| 855 |
+
document.getElementById('stepBtn').disabled = false;
|
| 856 |
+
document.getElementById('autoBtn').disabled = false;
|
| 857 |
+
}
|
| 858 |
+
}
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
async function step() {
|
| 862 |
+
showLoading('Executing turn...');
|
| 863 |
+
try {
|
| 864 |
+
state = await api('POST', '/api/game/step');
|
| 865 |
+
renderScene();
|
| 866 |
+
updateUI();
|
| 867 |
+
} finally {
|
| 868 |
+
hideLoading();
|
| 869 |
+
}
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
async function auto() {
|
| 873 |
+
if (autoRunning) {
|
| 874 |
+
autoRunning = false;
|
| 875 |
+
return;
|
| 876 |
+
}
|
| 877 |
+
autoRunning = true;
|
| 878 |
+
document.getElementById('autoBtn').textContent = '⏹ Stop';
|
| 879 |
+
document.getElementById('stepBtn').disabled = true;
|
| 880 |
+
try {
|
| 881 |
+
while (autoRunning) {
|
| 882 |
+
document.getElementById('statusLeft').textContent =
|
| 883 |
+
`⏱ Turn ${(state && state.turn) || 0}/... · Auto-running...`;
|
| 884 |
+
let data;
|
| 885 |
+
try {
|
| 886 |
+
data = await api('POST', '/api/game/step');
|
| 887 |
+
} catch (e) {
|
| 888 |
+
break;
|
| 889 |
+
}
|
| 890 |
+
state = data;
|
| 891 |
+
renderScene();
|
| 892 |
+
updateUI();
|
| 893 |
+
if (state.game_over) break;
|
| 894 |
+
await new Promise(r => setTimeout(r, 100));
|
| 895 |
+
}
|
| 896 |
+
} finally {
|
| 897 |
+
autoRunning = false;
|
| 898 |
+
document.getElementById('autoBtn').textContent = '⏩ Auto';
|
| 899 |
+
const alive = state ? (state.agents || []).filter(a => a.alive).length : 0;
|
| 900 |
+
document.getElementById('stepBtn').disabled = !state || state.game_over || alive <= 1;
|
| 901 |
+
updateUI();
|
| 902 |
+
}
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
function updateUI() {
|
| 906 |
+
if (!state) return;
|
| 907 |
+
const agents = state.agents || [];
|
| 908 |
+
const alive = agents.filter(a => a.alive).length;
|
| 909 |
+
const winner = state.winner || (alive === 1 ? agents.find(a => a.alive)?.name : null);
|
| 910 |
+
const gameOver = state.game_over || alive <= 1;
|
| 911 |
+
|
| 912 |
+
const stepTiming = state.turn_log ? ` · ${state.turn_log.time_ms}ms` : '';
|
| 913 |
+
document.getElementById('statusLeft').textContent =
|
| 914 |
+
`⏱ Turn ${state.turn}/${state.max_turns || 50} · Alive ${alive}/${agents.length}${stepTiming}` +
|
| 915 |
+
(winner ? ` · 🏆 ${winner} Wins!` : gameOver ? ' · Game Over' : '');
|
| 916 |
+
|
| 917 |
+
document.getElementById('stepBtn').disabled = gameOver;
|
| 918 |
+
document.getElementById('autoBtn').disabled = gameOver;
|
| 919 |
+
|
| 920 |
+
buildSidebar();
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
/* ── Event binding ── */
|
| 924 |
+
document.getElementById('startBtn').addEventListener('click', startGame);
|
| 925 |
+
document.getElementById('stepBtn').addEventListener('click', step);
|
| 926 |
+
document.getElementById('autoBtn').addEventListener('click', auto);
|
| 927 |
+
|
| 928 |
+
document.addEventListener('keydown', e => {
|
| 929 |
+
if (e.key === 'Enter' && !gameStarted) startGame();
|
| 930 |
+
});
|
| 931 |
+
|
| 932 |
+
/* ── Resize handler for Three.js ── */
|
| 933 |
+
window.addEventListener('resize', () => { if (app) app.resize(); });
|
| 934 |
+
</script>
|
| 935 |
+
</body>
|
| 936 |
+
</html>
|
main.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
from fastapi.staticfiles import StaticFiles
|
| 3 |
+
from api import app
|
| 4 |
+
|
| 5 |
+
app.mount("/", StaticFiles(directory="frontend/static", html=True), name="frontend")
|
| 6 |
+
|
| 7 |
+
if __name__ == "__main__":
|
| 8 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
modal_vllm.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---
|
| 2 |
+
# pytest: false
|
| 3 |
+
# ---
|
| 4 |
+
|
| 5 |
+
# # Run OpenAI-compatible LLM inference with Gemma and vLLM
|
| 6 |
+
|
| 7 |
+
# In this example, we show how to run a vLLM server in OpenAI-compatible mode on Modal.
|
| 8 |
+
|
| 9 |
+
# LLMs do more than just model language: they chat, they produce JSON and XML, they run code, and more.
|
| 10 |
+
# This has complicated their interface far beyond "text-in, text-out".
|
| 11 |
+
# OpenAI's API has emerged as a standard for that interface,
|
| 12 |
+
# and it is supported by open source LLM serving frameworks like [vLLM](https://docs.vllm.ai/en/latest/).
|
| 13 |
+
|
| 14 |
+
# This example is intended to demonstrate the basics of deploying LLM inference on Modal.
|
| 15 |
+
# For more on how to optimize performance, see
|
| 16 |
+
# [this guide](https://modal.com/docs/guide/high-performance-llm-inference)
|
| 17 |
+
# and check out our
|
| 18 |
+
# [LLM Engineer's Almanac](https://modal.com/llm-almanac).
|
| 19 |
+
|
| 20 |
+
# Our examples repository also includes scripts for running clients and load-testing for OpenAI-compatible APIs
|
| 21 |
+
# [here](https://github.com/modal-labs/modal-examples/tree/main/06_gpu_and_ml/llm-serving/openai_compatible).
|
| 22 |
+
|
| 23 |
+
# ## Set up the container image
|
| 24 |
+
|
| 25 |
+
# Our first order of business is to define the environment our server will run in:
|
| 26 |
+
# the [container `Image`](https://modal.com/docs/guide/custom-container).
|
| 27 |
+
# vLLM can be installed with `uv pip`, since Modal [provides the CUDA drivers](https://modal.com/docs/guide/cuda).
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
from typing import Any
|
| 31 |
+
|
| 32 |
+
import aiohttp
|
| 33 |
+
import modal
|
| 34 |
+
|
| 35 |
+
vllm_image = (
|
| 36 |
+
modal.Image.from_registry("nvidia/cuda:12.9.0-devel-ubuntu22.04", add_python="3.12")
|
| 37 |
+
.entrypoint([])
|
| 38 |
+
.uv_pip_install("vllm==0.21.0")
|
| 39 |
+
.env(
|
| 40 |
+
{
|
| 41 |
+
"HF_XET_HIGH_PERFORMANCE": "1", # faster model transfers
|
| 42 |
+
"VLLM_LOG_STATS_INTERVAL": "1", # more frequent metrics logging
|
| 43 |
+
}
|
| 44 |
+
)
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# ## Download the model weights
|
| 48 |
+
|
| 49 |
+
# We'll be running a pretrained foundation model --
|
| 50 |
+
# [Google's Gemma 4](https://blog.google/innovation-and-ai/technology/developers-tools/gemma-4/).
|
| 51 |
+
# It can also take images, video, and audio as inputs,
|
| 52 |
+
# though we won't use that here.
|
| 53 |
+
|
| 54 |
+
# We'll use the 26BA4B variant, [`google/gemma-4-26B-A4B-it`](https://huggingface.co/google/gemma-4-26B-A4B-it).
|
| 55 |
+
# This variant is trained with reasoning capabilities, which allow it to
|
| 56 |
+
# enhance the quality of its generated responses.
|
| 57 |
+
# It has `26B`illion parameters, of which `4B`illion are `A`ctive
|
| 58 |
+
# in processing of each token.
|
| 59 |
+
|
| 60 |
+
# You can swap this model out for another by changing the strings below,
|
| 61 |
+
# though you might also need to adjust some of the server configuration as well.
|
| 62 |
+
# A single H200 GPU has enough VRAM to store this 26,000,000,000 parameter model
|
| 63 |
+
# along with a large KV cache.
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
MODEL_NAME = "google/gemma-4-26B-A4B-it"
|
| 67 |
+
MODEL_REVISION = "47b6801b24d15ff9bcd8c96dfaea0be9ed3a0301" # avoid nasty surprises when repos update!
|
| 68 |
+
|
| 69 |
+
# Although vLLM will download weights from Hugging Face on-demand,
|
| 70 |
+
# we want to cache them so we don't do it every time our server starts.
|
| 71 |
+
# We'll use [Modal Volumes](https://modal.com/docs/guide/volumes) for our cache.
|
| 72 |
+
# Modal Volumes are essentially a "shared disk" that all Modal Functions can access like it's a regular disk.
|
| 73 |
+
# For more on storing model weights on Modal, see
|
| 74 |
+
# [this guide](https://modal.com/docs/guide/model-weights).
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
|
| 78 |
+
|
| 79 |
+
# We'll also cache some of vLLM's JIT compilation artifacts in a Modal Volume.
|
| 80 |
+
|
| 81 |
+
vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
|
| 82 |
+
|
| 83 |
+
# ## Configuring vLLM
|
| 84 |
+
|
| 85 |
+
# ### Trading off fast boots and token generation performance
|
| 86 |
+
|
| 87 |
+
# vLLM has embraced dynamic and just-in-time compilation to eke out additional performance without having to write too many custom kernels,
|
| 88 |
+
# e.g. via the Torch compiler and CUDA graph capture.
|
| 89 |
+
# These compilation features incur latency in exchange for lowered latency and higher throughput during generation.
|
| 90 |
+
# This latency is typically tens of seconds to a few minutes, reduced to about ten seconds when loaded from the cache.
|
| 91 |
+
# We make this trade-off controllable with the `FAST_BOOT` variable below.
|
| 92 |
+
|
| 93 |
+
FAST_BOOT = False
|
| 94 |
+
|
| 95 |
+
# If you're running an LLM service that frequently scales from 0 (frequent ["cold starts"](https://modal.com/docs/guide/cold-start))
|
| 96 |
+
# you might want to set this to `True`, or consider [GPU memory snapshots](https://modal.com/docs/guide/memory-snapshots).
|
| 97 |
+
# It's also useful to set this when you're iterating on the server configuration.
|
| 98 |
+
|
| 99 |
+
# If you're running an LLM service that usually has multiple replicas running, then set this to `False` for improved performance.
|
| 100 |
+
|
| 101 |
+
# See the code below for details on the parameters that `FAST_BOOT` controls.
|
| 102 |
+
|
| 103 |
+
# ### Model-specific configuration
|
| 104 |
+
|
| 105 |
+
# Almost all models require some amount of configuration via command-line flags,
|
| 106 |
+
# especially to achieve optimal performance.
|
| 107 |
+
|
| 108 |
+
# We set these flags in the code below, roughly following the
|
| 109 |
+
# [usage guide from the vLLM docs](https://docs.vllm.ai/projects/recipes/en/latest/Google/Gemma4.html).
|
| 110 |
+
|
| 111 |
+
# For instance, we turn off multimodal features to save on [GPU RAM](https://modal.com/gpu-glossary/device-hardware/gpu-ram),
|
| 112 |
+
# and we activate the [built-in multi-token prediction (MTP)](https://blog.google/innovation-and-ai/technology/developers-tools/multi-token-prediction-gemma-4/)
|
| 113 |
+
# speculative decoding for improved throughput at lower concurrencies.
|
| 114 |
+
|
| 115 |
+
SPECULATIVE_MODEL_NAME = "google/gemma-4-26B-A4B-it-assistant"
|
| 116 |
+
SPECULATIVE_MODEL_REVISION = "f188f476dc11dd5bb3014dc861529d316bce49d3"
|
| 117 |
+
|
| 118 |
+
# For more on the performance you can expect when serving your own LLMs, see
|
| 119 |
+
# [our LLM engine performance benchmarks](https://modal.com/llm-almanac).
|
| 120 |
+
|
| 121 |
+
# ## Build a vLLM engine and serve it
|
| 122 |
+
|
| 123 |
+
# The function below spawns a vLLM instance listening at port 8000, serving requests to our model.
|
| 124 |
+
# We wrap it in the [`@modal.web_server` decorator](https://modal.com/docs/guide/webhooks#non-asgi-web-servers)
|
| 125 |
+
# to connect it to the Internet.
|
| 126 |
+
|
| 127 |
+
# The server runs in an independent process, via `subprocess.Popen`, and only starts accepting requests
|
| 128 |
+
# once the model is spun up and the `serve` function returns.
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
app = modal.App("example-vllm-inference")
|
| 132 |
+
|
| 133 |
+
N_GPU = 1
|
| 134 |
+
MINUTES = 60 # seconds
|
| 135 |
+
VLLM_PORT = 8000
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.function(
|
| 139 |
+
image=vllm_image,
|
| 140 |
+
gpu=f"H200:{N_GPU}",
|
| 141 |
+
scaledown_window=15 * MINUTES, # how long should we stay up with no requests?
|
| 142 |
+
timeout=10 * MINUTES, # how long should we wait for container start?
|
| 143 |
+
volumes={
|
| 144 |
+
"/root/.cache/huggingface": hf_cache_vol,
|
| 145 |
+
"/root/.cache/vllm": vllm_cache_vol,
|
| 146 |
+
},
|
| 147 |
+
)
|
| 148 |
+
@modal.concurrent( # how many requests can one replica handle? tune carefully!
|
| 149 |
+
max_inputs=100,
|
| 150 |
+
)
|
| 151 |
+
@modal.web_server(port=VLLM_PORT, startup_timeout=10 * MINUTES)
|
| 152 |
+
def serve():
|
| 153 |
+
import json
|
| 154 |
+
import subprocess
|
| 155 |
+
|
| 156 |
+
cmd = [
|
| 157 |
+
"vllm",
|
| 158 |
+
"serve",
|
| 159 |
+
MODEL_NAME,
|
| 160 |
+
"--revision",
|
| 161 |
+
MODEL_REVISION,
|
| 162 |
+
"--served-model-name",
|
| 163 |
+
MODEL_NAME,
|
| 164 |
+
"llm",
|
| 165 |
+
"--host",
|
| 166 |
+
"0.0.0.0",
|
| 167 |
+
"--port",
|
| 168 |
+
str(VLLM_PORT),
|
| 169 |
+
"--uvicorn-log-level=info",
|
| 170 |
+
"--async-scheduling",
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
# enforce-eager disables both Torch compilation and CUDA graph capture
|
| 174 |
+
# default is no-enforce-eager. see the --compilation-config flag for tighter control
|
| 175 |
+
cmd += ["--enforce-eager" if FAST_BOOT else "--no-enforce-eager"]
|
| 176 |
+
|
| 177 |
+
# assume multiple GPUs are for splitting up large matrix multiplications
|
| 178 |
+
cmd += ["--tensor-parallel-size", str(N_GPU)]
|
| 179 |
+
|
| 180 |
+
# add model-specific configuration
|
| 181 |
+
cmd += [
|
| 182 |
+
# skip multimedia support, just language
|
| 183 |
+
"--limit-mm-per-prompt",
|
| 184 |
+
f"'{json.dumps({'image': 0, 'video': 0, 'audio': 0})}'",
|
| 185 |
+
# enable reasoning and tool use
|
| 186 |
+
"--enable-auto-tool-choice",
|
| 187 |
+
"--reasoning-parser gemma4",
|
| 188 |
+
"--tool-call-parser gemma4",
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
# add speculative decoding
|
| 192 |
+
cmd += [
|
| 193 |
+
"--speculative-config",
|
| 194 |
+
f"'{json.dumps({'model': SPECULATIVE_MODEL_NAME, 'revision': SPECULATIVE_MODEL_REVISION, 'num_speculative_tokens': 4})}'",
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
print(*cmd)
|
| 198 |
+
|
| 199 |
+
subprocess.Popen(" ".join(cmd), shell=True)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ## Deploy the server
|
| 203 |
+
|
| 204 |
+
# To deploy the API on Modal, just run
|
| 205 |
+
# ```bash
|
| 206 |
+
# modal deploy vllm_inference.py
|
| 207 |
+
# ```
|
| 208 |
+
|
| 209 |
+
# This will create a new app on Modal, build the container image for it if it hasn't been built yet,
|
| 210 |
+
# and deploy the app.
|
| 211 |
+
|
| 212 |
+
# ## Interact with the server
|
| 213 |
+
|
| 214 |
+
# Once it is deployed, you'll see a URL appear in the command line,
|
| 215 |
+
# something like `https://your-workspace-name--example-vllm-inference-serve.modal.run`.
|
| 216 |
+
|
| 217 |
+
# You can find [interactive Swagger UI docs](https://swagger.io/tools/swagger-ui/)
|
| 218 |
+
# at the `/docs` route of that URL, i.e. `https://your-workspace-name--example-vllm-inference-serve.modal.run/docs`.
|
| 219 |
+
# These docs describe each route and indicate the expected input and output
|
| 220 |
+
# and translate requests into `curl` commands.
|
| 221 |
+
|
| 222 |
+
# For simple routes like `/health`, which checks whether the server is responding,
|
| 223 |
+
# you can even send a request directly from the docs.
|
| 224 |
+
|
| 225 |
+
# To interact with the API programmatically in Python, we recommend the `openai` library.
|
| 226 |
+
|
| 227 |
+
# See the `client.py` script in the examples repository
|
| 228 |
+
# [here](https://github.com/modal-labs/modal-examples/tree/main/06_gpu_and_ml/llm-serving/openai_compatible)
|
| 229 |
+
# to take it for a spin:
|
| 230 |
+
|
| 231 |
+
# ```bash
|
| 232 |
+
# # pip install openai==1.76.0
|
| 233 |
+
# python openai_compatible/client.py
|
| 234 |
+
# ```
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ## Testing the server
|
| 238 |
+
|
| 239 |
+
# To make it easier to test the server setup, we also include a `local_entrypoint`
|
| 240 |
+
# that does a healthcheck and then hits the server.
|
| 241 |
+
|
| 242 |
+
# If you execute the command
|
| 243 |
+
|
| 244 |
+
# ```bash
|
| 245 |
+
# modal run vllm_inference.py
|
| 246 |
+
# ```
|
| 247 |
+
|
| 248 |
+
# a fresh replica of the server will be spun up on Modal while
|
| 249 |
+
# the code below executes on your local machine.
|
| 250 |
+
|
| 251 |
+
# Think of this like writing simple tests inside of the `if __name__ == "__main__"`
|
| 252 |
+
# block of a Python script, but for cloud deployments!
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@app.local_entrypoint()
|
| 256 |
+
async def test(test_timeout=15 * MINUTES, content=None, twice=True):
|
| 257 |
+
url = await serve.get_web_url.aio()
|
| 258 |
+
|
| 259 |
+
system_prompt = {
|
| 260 |
+
"role": "system",
|
| 261 |
+
"content": "You are a pirate who can't help but drop sly reminders that he went to Harvard.",
|
| 262 |
+
}
|
| 263 |
+
if content is None:
|
| 264 |
+
content = "Explain the singular value decomposition."
|
| 265 |
+
|
| 266 |
+
messages = [ # OpenAI chat format
|
| 267 |
+
system_prompt,
|
| 268 |
+
{"role": "user", "content": content},
|
| 269 |
+
]
|
| 270 |
+
|
| 271 |
+
async with aiohttp.ClientSession(base_url=url) as session:
|
| 272 |
+
print(f"Running health check for server at {url}")
|
| 273 |
+
async with session.get("/health", timeout=test_timeout - 1 * MINUTES) as resp:
|
| 274 |
+
up = resp.status == 200
|
| 275 |
+
assert up, f"Failed health check for server at {url}"
|
| 276 |
+
print(f"Successful health check for server at {url}")
|
| 277 |
+
|
| 278 |
+
print(f"Sending messages to {url}:", *messages, sep="\n\t")
|
| 279 |
+
await _send_request(session, "llm", messages)
|
| 280 |
+
if twice:
|
| 281 |
+
messages[0]["content"] = "You are Jar Jar Binks."
|
| 282 |
+
print(f"Sending messages to {url}:", *messages, sep="\n\t")
|
| 283 |
+
await _send_request(session, "llm", messages)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
async def _send_request(
|
| 287 |
+
session: aiohttp.ClientSession, model: str, messages: list
|
| 288 |
+
) -> None:
|
| 289 |
+
# `stream=True` tells an OpenAI-compatible backend to stream chunks
|
| 290 |
+
payload: dict[str, Any] = {"messages": messages, "model": model, "stream": True}
|
| 291 |
+
# explicitly enable thinking for this model
|
| 292 |
+
payload["chat_template_kwargs"] = {"enable_thinking": True}
|
| 293 |
+
|
| 294 |
+
headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
|
| 295 |
+
|
| 296 |
+
async with session.post(
|
| 297 |
+
"/v1/chat/completions", json=payload, headers=headers
|
| 298 |
+
) as resp:
|
| 299 |
+
async for raw in resp.content:
|
| 300 |
+
resp.raise_for_status()
|
| 301 |
+
# extract new content and stream it
|
| 302 |
+
line = raw.decode().strip()
|
| 303 |
+
if not line or line == "data: [DONE]":
|
| 304 |
+
continue
|
| 305 |
+
if line.startswith("data: "): # SSE prefix
|
| 306 |
+
line = line[len("data: ") :]
|
| 307 |
+
|
| 308 |
+
chunk = json.loads(line)
|
| 309 |
+
assert (
|
| 310 |
+
chunk["object"] == "chat.completion.chunk"
|
| 311 |
+
) # or something went horribly wrong
|
| 312 |
+
delta = chunk["choices"][0]["delta"]
|
| 313 |
+
content = (
|
| 314 |
+
delta.get("content")
|
| 315 |
+
or delta.get("reasoning")
|
| 316 |
+
or delta.get("reasoning_content")
|
| 317 |
+
)
|
| 318 |
+
if content:
|
| 319 |
+
print(content, end="")
|
| 320 |
+
else:
|
| 321 |
+
print("\n", chunk)
|
| 322 |
+
print()
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# We also include a basic example of a load-testing setup using
|
| 326 |
+
# `locust` in the `load_test.py` script [here](https://github.com/modal-labs/modal-examples/tree/main/06_gpu_and_ml/llm-serving/openai_compatible):
|
| 327 |
+
|
| 328 |
+
# ```bash
|
| 329 |
+
# modal run openai_compatible/load_test.py
|
| 330 |
+
# ```
|