Spaces:
Running on Zero
Running on Zero
Delete HuggingWizards-upload
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- HuggingWizards-upload/.gitignore +0 -11
- HuggingWizards-upload/README.md +0 -107
- HuggingWizards-upload/app.py +0 -357
- HuggingWizards-upload/game/__init__.py +0 -0
- HuggingWizards-upload/game/__pycache__/__init__.cpython-313.pyc +0 -0
- HuggingWizards-upload/game/__pycache__/engine.cpython-313.pyc +0 -0
- HuggingWizards-upload/game/__pycache__/gamemaster.cpython-313.pyc +0 -0
- HuggingWizards-upload/game/__pycache__/skills.cpython-313.pyc +0 -0
- HuggingWizards-upload/game/engine.py +0 -1537
- HuggingWizards-upload/game/gamemaster.py +0 -480
- HuggingWizards-upload/game/skills.py +0 -123
- HuggingWizards-upload/requirements.txt +0 -9
- HuggingWizards-upload/static/assets/bosses/aegis.png +0 -0
- HuggingWizards-upload/static/assets/bosses/toaster_attack.png +0 -0
- HuggingWizards-upload/static/assets/bosses/toaster_boss.png +0 -0
- HuggingWizards-upload/static/assets/bosses/toaster_damaged.png +0 -0
- HuggingWizards-upload/static/assets/bosses/toaster_death.png +0 -0
- HuggingWizards-upload/static/assets/bosses/toaster_idle.png +0 -0
- HuggingWizards-upload/static/assets/bosses/toaster_run.png +0 -0
- HuggingWizards-upload/static/assets/buildings/black_castle.png +0 -0
- HuggingWizards-upload/static/assets/buildings/black_house1.png +0 -0
- HuggingWizards-upload/static/assets/buildings/black_tower.png +0 -0
- HuggingWizards-upload/static/assets/buildings/blue_archery.png +0 -0
- HuggingWizards-upload/static/assets/buildings/blue_castle.png +0 -0
- HuggingWizards-upload/static/assets/buildings/blue_monastery.png +0 -0
- HuggingWizards-upload/static/assets/buildings/blue_tower.png +0 -0
- HuggingWizards-upload/static/assets/buildings/red_barracks.png +0 -0
- HuggingWizards-upload/static/assets/buildings/red_castle.png +0 -0
- HuggingWizards-upload/static/assets/buildings/red_tower.png +0 -0
- HuggingWizards-upload/static/assets/characters/orc/Orc-Attack01.png +0 -0
- HuggingWizards-upload/static/assets/characters/orc/Orc-Attack02.png +0 -0
- HuggingWizards-upload/static/assets/characters/orc/Orc-Death.png +0 -0
- HuggingWizards-upload/static/assets/characters/orc/Orc-Hurt.png +0 -0
- HuggingWizards-upload/static/assets/characters/orc/Orc-Idle.png +0 -0
- HuggingWizards-upload/static/assets/characters/orc/Orc-Walk.png +0 -0
- HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack01.png +0 -0
- HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack02.png +0 -0
- HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack03.png +0 -0
- HuggingWizards-upload/static/assets/characters/soldier/Soldier-Death.png +0 -0
- HuggingWizards-upload/static/assets/characters/soldier/Soldier-Hurt.png +0 -0
- HuggingWizards-upload/static/assets/characters/soldier/Soldier-Idle.png +0 -0
- HuggingWizards-upload/static/assets/characters/soldier/Soldier-Walk.png +0 -0
- HuggingWizards-upload/static/assets/chars/archer_idle.png +0 -0
- HuggingWizards-upload/static/assets/chars/archer_run.png +0 -0
- HuggingWizards-upload/static/assets/chars/lancer_idle.png +0 -0
- HuggingWizards-upload/static/assets/chars/pawn_idle.png +0 -0
- HuggingWizards-upload/static/assets/chars/pawn_run.png +0 -0
- HuggingWizards-upload/static/assets/chars/warrior_idle.png +0 -0
- HuggingWizards-upload/static/assets/chars/warrior_run.png +0 -0
- HuggingWizards-upload/static/assets/effects/10_weaponhit_spritesheet.png +0 -0
HuggingWizards-upload/.gitignore
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 1 |
-
__pycache__/
|
| 2 |
-
*.pyc
|
| 3 |
-
.DS_Store
|
| 4 |
-
|
| 5 |
-
# Generated agent traces (keep the directory, ignore the contents)
|
| 6 |
-
traces/*.json
|
| 7 |
-
|
| 8 |
-
# Don't commit the raw asset archives
|
| 9 |
-
*.zip
|
| 10 |
-
*.rar
|
| 11 |
-
New Resources/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HuggingWizards-upload/README.md
DELETED
|
@@ -1,107 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: HuggingWizards
|
| 3 |
-
emoji: 🧙
|
| 4 |
-
colorFrom: purple
|
| 5 |
-
colorTo: indigo
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 5.9.1
|
| 8 |
-
app_file: app.py
|
| 9 |
-
pinned: false
|
| 10 |
-
short_description: Co-op pixel wizard arena where Nemotron-4B is the Game Master
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
# 🧙 HuggingWizards
|
| 14 |
-
|
| 15 |
-
A small 2D pixel-wizard **co-op arena** that runs as a Gradio Space on **ZeroGPU**.
|
| 16 |
-
Up to **8 players** join one shared arena, fight a central **boss** and the
|
| 17 |
-
**minions** it spawns, and upgrade their magic between rounds. Everyone else can
|
| 18 |
-
**spectate** live. Controls: **WASD** to move, **Space** to attack.
|
| 19 |
-
|
| 20 |
-
The hackathon twist: **NVIDIA Nemotron-Mini-4B-Instruct** is the **Game Master**.
|
| 21 |
-
At the end of every round it decides:
|
| 22 |
-
|
| 23 |
-
- **Rewards** — how much gold each wizard earns (based on damage / survival).
|
| 24 |
-
- **Blessings** — individual boons for wizards who earned them: any timed aura,
|
| 25 |
-
a `full_heal`, or even an `extra_life` for a struggling player.
|
| 26 |
-
- **Boss attack pattern** — `balanced`, `sniper` (fast aimed bolts), `artillery`
|
| 27 |
-
(slow heavy orbs + dense shockwaves), `swarm` (minion floods), or `berserker`
|
| 28 |
-
(relentless charges). The active pattern shows next to the boss's name.
|
| 29 |
-
- **Boss attack speed (with decaying mercy)** — the GM reads every wizard's
|
| 30 |
-
HP% and lives and tunes `boss_attack_speed` (0.5–2.0): slower for a wounded
|
| 31 |
-
party, faster for a healthy one. Guardrail: the allowed floor rises each
|
| 32 |
-
wave (no mercy left by ~wave 13), out-of-range or malformed values are
|
| 33 |
-
clamped, and every trace records `requested` vs `applied` plus the floor.
|
| 34 |
-
- **Boss & minion reset** — next-round boss HP, minion count/HP, spawn rate,
|
| 35 |
-
boss damage, aggression, and the enemy archetype mix.
|
| 36 |
-
- **Card pool** — which level-up cards may be offered next wave.
|
| 37 |
-
|
| 38 |
-
Every round produces a structured **agent trace** (prompt → raw model output →
|
| 39 |
-
parsed decision → applied effect), saved under `traces/` and shown live in the
|
| 40 |
-
Gradio dashboard.
|
| 41 |
-
|
| 42 |
-
## Architecture
|
| 43 |
-
|
| 44 |
-
- **Client** (`static/`): HTML5 canvas game loop in the browser (rendering + input).
|
| 45 |
-
- **Server** (`app.py`, `game/`): FastAPI WebSocket + a ~20 Hz authoritative game
|
| 46 |
-
tick on CPU. Gradio is mounted for the trace/leaderboard dashboard.
|
| 47 |
-
- **Game Master** (`game/gamemaster.py`): `@spaces.GPU` burst inference at round
|
| 48 |
-
boundaries only — the pattern ZeroGPU is built for. Falls back to deterministic
|
| 49 |
-
logic if the model/GPU is unavailable (so it runs locally too).
|
| 50 |
-
|
| 51 |
-
## Run locally
|
| 52 |
-
|
| 53 |
-
```bash
|
| 54 |
-
pip install -r requirements.txt
|
| 55 |
-
python app.py
|
| 56 |
-
# open http://localhost:7860
|
| 57 |
-
```
|
| 58 |
-
|
| 59 |
-
The Game Master uses Nemotron only when a GPU + the `spaces`/`transformers` stack
|
| 60 |
-
is present; otherwise it transparently uses the deterministic fallback so local
|
| 61 |
-
dev and CI still work.
|
| 62 |
-
|
| 63 |
-
## Game Master model
|
| 64 |
-
|
| 65 |
-
On a Space the model is loaded **at startup** (the ZeroGPU pattern: weights are
|
| 66 |
-
downloaded and staged before any `@spaces.GPU` call, so the 60 s GPU window is
|
| 67 |
-
spent purely on inference). Locally it is lazy-loaded on the first round.
|
| 68 |
-
|
| 69 |
-
Environment variables:
|
| 70 |
-
|
| 71 |
-
| Variable | Values | Effect |
|
| 72 |
-
|---|---|---|
|
| 73 |
-
| `HUGGINGWIZARDS_NO_LLM` | `1` | Skip the model entirely; deterministic Game Master (CI / quick dev). |
|
| 74 |
-
| `HUGGINGWIZARDS_QUANT` | `auto` (default), `none`, `4bit`, `8bit` | Quantization via bitsandbytes. `auto` = bf16 on Spaces/big GPUs, NF4 4-bit on local GPUs with <12 GB VRAM. |
|
| 75 |
-
|
| 76 |
-
Quantization notes: 4-bit NF4 shrinks the 4B model from ~8.5 GB to ~3 GB of
|
| 77 |
-
VRAM and speeds up cold loads — ideal for small local GPUs. On ZeroGPU's H200
|
| 78 |
-
plain bf16 is both comfortable and faster per token, so `auto` keeps bf16 there.
|
| 79 |
-
|
| 80 |
-
## Assets
|
| 81 |
-
|
| 82 |
-
Pixel art / audio from free packs (Tiny RPG Character Pack, Tiny Swords,
|
| 83 |
-
Free Pixel Effects, Retro Impact Effect Pack, Pixel UI pack 3, Pixel RPG Music
|
| 84 |
-
Pack). See each pack's license.
|
| 85 |
-
|
| 86 |
-
- **Maps**: each boss faction has its own territory — the Tiny Swords grass
|
| 87 |
-
tileset plus a per-theme ground tint, landmark buildings
|
| 88 |
-
(`static/assets/buildings/`: orc camp huts, the Iron Legion's blue castle,
|
| 89 |
-
the Lancer Host's red keep, the Archer Coven's monastery & ranges) and a
|
| 90 |
-
seeded scenery mix (bushland / rockfields / forest). Scenery is purely
|
| 91 |
-
cosmetic — the server simulation is unchanged.
|
| 92 |
-
- **Effects**: spell-cast muzzle flashes, impact bursts (on boss/minion/player
|
| 93 |
-
hits) and death poofs are drawn client-side from the Free Pixel Effects
|
| 94 |
-
spritesheets, triggered by snapshot flags (`hurt`, attack, minion id removal).
|
| 95 |
-
Skill activations and GM blessings are server-driven events rendered with
|
| 96 |
-
more of the pack: freezing novas, felspell blasts, protection circles,
|
| 97 |
-
vortex summons, sunburn blessings, magic-bubble heals.
|
| 98 |
-
- **Characters**: pick a champion (Warrior / Archer / Lancer / Pawn) on the
|
| 99 |
-
name screen — Tiny Swords blue units (`static/assets/chars/`), all size-normalized
|
| 100 |
-
and growing with level.
|
| 101 |
-
- **Special bosses**: **Aegis** (red wave, holy-spell attacks) on waves 3/9/15… and
|
| 102 |
-
**Toaster Bot** (green wave, glitch-portal attacks) on waves 5/11/17… Beat one and
|
| 103 |
-
every survivor gains its signature attack as a permanent power.
|
| 104 |
-
- **Timed auras**: rare floor-only power-ups (30–60 s) visualized with the Retro
|
| 105 |
-
Impact Effect Pack (`static/assets/retro/`) wreathing the wizard.
|
| 106 |
-
- **Music**: a looping track from the Pixel RPG Music Pack starts on the first
|
| 107 |
-
join/spectate click (browser autoplay policy); a 🔊 button toggles it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HuggingWizards-upload/app.py
DELETED
|
@@ -1,357 +0,0 @@
|
|
| 1 |
-
"""HuggingWizards — co-op pixel wizard arena with a Nemotron-4B Game Master.
|
| 2 |
-
|
| 3 |
-
Single process:
|
| 4 |
-
* FastAPI serves the canvas client (`/`) and a WebSocket (`/ws`).
|
| 5 |
-
* A ~20 Hz asyncio task runs the authoritative simulation on CPU and
|
| 6 |
-
broadcasts snapshots to every connected client (players + spectators).
|
| 7 |
-
* At each round boundary the Game Master (Nemotron, burst GPU) decides rewards
|
| 8 |
-
and the next round; every decision is logged as an agent trace.
|
| 9 |
-
* A Gradio dashboard is mounted at `/dashboard` (live traces + leaderboard).
|
| 10 |
-
"""
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import asyncio
|
| 14 |
-
import contextlib
|
| 15 |
-
import json
|
| 16 |
-
import os
|
| 17 |
-
import time
|
| 18 |
-
import uuid
|
| 19 |
-
|
| 20 |
-
import gradio as gr
|
| 21 |
-
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
| 22 |
-
from fastapi.responses import FileResponse
|
| 23 |
-
from fastapi.staticfiles import StaticFiles
|
| 24 |
-
|
| 25 |
-
from game import gamemaster
|
| 26 |
-
from game.engine import Engine, MAX_PLAYERS, TICK_HZ
|
| 27 |
-
|
| 28 |
-
HERE = os.path.dirname(__file__)
|
| 29 |
-
engine = Engine()
|
| 30 |
-
|
| 31 |
-
# Active websocket connections: ws -> {"pid", "role", "name", "char"}
|
| 32 |
-
clients: dict[WebSocket, dict] = {}
|
| 33 |
-
# Ordered waiting line for when the 8-slot arena is full.
|
| 34 |
-
queue: list[WebSocket] = []
|
| 35 |
-
|
| 36 |
-
# ---- persistent leaderboard ----------------------------------------------
|
| 37 |
-
LEADERBOARD_FILE = os.path.join(HERE, "traces", "leaderboard.json")
|
| 38 |
-
_leaderboard: list[dict] = []
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def _load_leaderboard():
|
| 42 |
-
global _leaderboard
|
| 43 |
-
try:
|
| 44 |
-
with open(LEADERBOARD_FILE, encoding="utf-8") as f:
|
| 45 |
-
_leaderboard = json.load(f)
|
| 46 |
-
except Exception:
|
| 47 |
-
_leaderboard = []
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def _save_leaderboard():
|
| 51 |
-
with contextlib.suppress(Exception):
|
| 52 |
-
with open(LEADERBOARD_FILE, "w", encoding="utf-8") as f:
|
| 53 |
-
json.dump(_leaderboard[:100], f, indent=2)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def add_score(name: str, gold: int, level: int, wave: int) -> list[dict]:
|
| 57 |
-
_leaderboard.append({
|
| 58 |
-
"name": str(name)[:16] or "Wizard", "gold": int(gold),
|
| 59 |
-
"level": int(level), "wave": int(wave),
|
| 60 |
-
"ts": time.strftime("%Y-%m-%d %H:%M"),
|
| 61 |
-
})
|
| 62 |
-
_leaderboard.sort(key=lambda e: (e["gold"], e["wave"], e["level"]), reverse=True)
|
| 63 |
-
del _leaderboard[100:]
|
| 64 |
-
_save_leaderboard()
|
| 65 |
-
return _leaderboard[:20]
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
_load_leaderboard()
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
async def broadcast(payload: dict):
|
| 72 |
-
if not clients:
|
| 73 |
-
return
|
| 74 |
-
msg = json.dumps(payload)
|
| 75 |
-
dead = []
|
| 76 |
-
for ws in list(clients.keys()):
|
| 77 |
-
try:
|
| 78 |
-
await ws.send_text(msg)
|
| 79 |
-
except Exception:
|
| 80 |
-
dead.append(ws)
|
| 81 |
-
for ws in dead:
|
| 82 |
-
_drop(ws)
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def _drop(ws: WebSocket):
|
| 86 |
-
info = clients.pop(ws, None)
|
| 87 |
-
if info and info.get("pid"):
|
| 88 |
-
engine.remove_player(info["pid"])
|
| 89 |
-
if not engine.players and engine.status != "lobby":
|
| 90 |
-
engine.status = "lobby"
|
| 91 |
-
engine.status_msg = "All wizards left. Waiting for players..."
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
async def _send(ws: WebSocket, payload: dict):
|
| 95 |
-
with contextlib.suppress(Exception):
|
| 96 |
-
await ws.send_text(json.dumps(payload))
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
async def handle_skill_request(ws: WebSocket, pid: str, prompt: str):
|
| 100 |
-
"""The round's top wizard asks the Game Master for a custom power-up."""
|
| 101 |
-
if engine.status != "intermission" or engine.skill_request_used \
|
| 102 |
-
or engine.skill_request_pid != pid or not prompt.strip():
|
| 103 |
-
await _send(ws, {"type": "skill_result", "ok": False,
|
| 104 |
-
"reason": "Not available right now."})
|
| 105 |
-
return
|
| 106 |
-
engine.skill_request_used = True # one wish per intermission
|
| 107 |
-
ctx = {"round": engine.round, "player_level": engine.players[pid].level
|
| 108 |
-
if pid in engine.players else 1}
|
| 109 |
-
spec = await asyncio.to_thread(gamemaster.generate_skill, prompt, ctx)
|
| 110 |
-
granted = engine.add_runtime_skill(pid, spec) if spec else None
|
| 111 |
-
await _send(ws, {"type": "skill_result", "ok": bool(granted), "skill": granted,
|
| 112 |
-
"reason": None if granted else "The arcane energies fizzled — try again."})
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _ws_for_pid(pid: str):
|
| 116 |
-
for ws, info in clients.items():
|
| 117 |
-
if info.get("pid") == pid:
|
| 118 |
-
return ws
|
| 119 |
-
return None
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
async def _broadcast_queue():
|
| 123 |
-
"""Tell each queued connection its current place in line."""
|
| 124 |
-
for i, ws in enumerate(list(queue)):
|
| 125 |
-
await _send(ws, {"type": "queue", "pos": i + 1, "size": len(queue)})
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
async def promote_queue():
|
| 129 |
-
"""Fill open arena slots from the front of the queue."""
|
| 130 |
-
while engine.can_join() and queue:
|
| 131 |
-
ws = queue.pop(0)
|
| 132 |
-
info = clients.get(ws)
|
| 133 |
-
if info is None: # disconnected while waiting
|
| 134 |
-
continue
|
| 135 |
-
pid = uuid.uuid4().hex[:8]
|
| 136 |
-
engine.add_player(pid, info.get("name", "Wizard"), info.get("char", "warrior"))
|
| 137 |
-
clients[ws] = {**info, "pid": pid, "role": "player"}
|
| 138 |
-
await _send(ws, {"type": "welcome", "pid": pid, "role": "player", "reason": "from_queue"})
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
async def manage_slots():
|
| 142 |
-
"""Eliminate out-of-lives wizards, then promote the queue into free slots."""
|
| 143 |
-
for pid, p in list(engine.players.items()):
|
| 144 |
-
if p.lives <= 0 and not p.alive:
|
| 145 |
-
ws = _ws_for_pid(pid)
|
| 146 |
-
engine.remove_player(pid)
|
| 147 |
-
if ws is not None:
|
| 148 |
-
info = clients.get(ws, {})
|
| 149 |
-
clients[ws] = {**info, "pid": None, "role": "spectator"}
|
| 150 |
-
await _send(ws, {"type": "eliminated",
|
| 151 |
-
"score": {"gold": p.gold, "level": p.level, "wave": engine.round}})
|
| 152 |
-
if not engine.players and engine.status != "lobby":
|
| 153 |
-
engine.status = "lobby"
|
| 154 |
-
engine.status_msg = "Waiting for wizards to join..."
|
| 155 |
-
await promote_queue()
|
| 156 |
-
await _broadcast_queue()
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
async def game_loop():
|
| 160 |
-
last = time.time()
|
| 161 |
-
prev_status = engine.status
|
| 162 |
-
gm_task: asyncio.Task | None = None
|
| 163 |
-
gm_applied = False
|
| 164 |
-
interval = 1.0 / TICK_HZ
|
| 165 |
-
slot_accum = 0.0
|
| 166 |
-
while True:
|
| 167 |
-
now = time.time()
|
| 168 |
-
dt = min(0.1, now - last)
|
| 169 |
-
last = now
|
| 170 |
-
|
| 171 |
-
engine.tick(dt)
|
| 172 |
-
# eliminate / promote queue / broadcast positions a few times a second
|
| 173 |
-
slot_accum += dt
|
| 174 |
-
if slot_accum >= 0.4:
|
| 175 |
-
slot_accum = 0.0
|
| 176 |
-
await manage_slots()
|
| 177 |
-
|
| 178 |
-
# Round just ended -> ask the Game Master (off the loop thread).
|
| 179 |
-
if engine.status == "intermission" and prev_status == "active":
|
| 180 |
-
summary = engine.round_summary()
|
| 181 |
-
prev_cfg = dict(engine.cfg)
|
| 182 |
-
gm_task = asyncio.create_task(
|
| 183 |
-
asyncio.to_thread(gamemaster.decide, summary, prev_cfg)
|
| 184 |
-
)
|
| 185 |
-
gm_applied = False
|
| 186 |
-
|
| 187 |
-
# Apply the decision as soon as it's ready (enables shopping this break).
|
| 188 |
-
if gm_task is not None and gm_task.done() and not gm_applied:
|
| 189 |
-
with contextlib.suppress(Exception):
|
| 190 |
-
engine.apply_gm_decision(gm_task.result())
|
| 191 |
-
gm_applied = True
|
| 192 |
-
|
| 193 |
-
# Start the next round once the break is over AND the decision is in.
|
| 194 |
-
if engine.status == "intermission" and gm_applied and time.time() >= engine.intermission_until:
|
| 195 |
-
if engine.players:
|
| 196 |
-
engine.start_round()
|
| 197 |
-
else:
|
| 198 |
-
engine.status = "lobby"
|
| 199 |
-
engine.status_msg = "Waiting for wizards to join..."
|
| 200 |
-
gm_task = None
|
| 201 |
-
gm_applied = False
|
| 202 |
-
|
| 203 |
-
prev_status = engine.status
|
| 204 |
-
await broadcast(engine.snapshot())
|
| 205 |
-
await asyncio.sleep(max(0, interval - (time.time() - now)))
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
# --------------------------------------------------------------------------
|
| 209 |
-
# FastAPI app
|
| 210 |
-
# --------------------------------------------------------------------------
|
| 211 |
-
app = FastAPI(title="HuggingWizards")
|
| 212 |
-
|
| 213 |
-
_loop_task: asyncio.Task | None = None
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
def ensure_loop():
|
| 217 |
-
"""Start the game loop once. Robust to Gradio overriding the app lifespan."""
|
| 218 |
-
global _loop_task
|
| 219 |
-
if _loop_task is None or _loop_task.done():
|
| 220 |
-
_loop_task = asyncio.create_task(game_loop())
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
@app.get("/")
|
| 224 |
-
async def index():
|
| 225 |
-
return FileResponse(os.path.join(HERE, "static", "index.html"))
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
@app.get("/traces")
|
| 229 |
-
async def traces():
|
| 230 |
-
"""Recent Game Master agent traces, trimmed for the in-game panel."""
|
| 231 |
-
out = []
|
| 232 |
-
for t in gamemaster.RECENT_TRACES[:15]:
|
| 233 |
-
out.append({
|
| 234 |
-
"trace_id": t.get("trace_id"), "round": t.get("round"),
|
| 235 |
-
"ts": t.get("ts"), "model": t.get("model"),
|
| 236 |
-
"source": t.get("source"), "latency_sec": t.get("latency_sec"),
|
| 237 |
-
"kind": t.get("kind", "round_decision"), "error": t.get("error"),
|
| 238 |
-
"mercy": t.get("mercy"),
|
| 239 |
-
"raw_output": str(t.get("raw_output", ""))[:600],
|
| 240 |
-
"decision": t.get("decision"),
|
| 241 |
-
})
|
| 242 |
-
return {"traces": out}
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
@app.websocket("/ws")
|
| 246 |
-
async def ws_endpoint(ws: WebSocket):
|
| 247 |
-
await ws.accept()
|
| 248 |
-
ensure_loop()
|
| 249 |
-
clients[ws] = {"pid": None, "role": "spectator", "name": "Wizard", "char": "warrior"}
|
| 250 |
-
try:
|
| 251 |
-
while True:
|
| 252 |
-
data = json.loads(await ws.receive_text())
|
| 253 |
-
mtype = data.get("type")
|
| 254 |
-
|
| 255 |
-
if mtype == "join":
|
| 256 |
-
name = str(data.get("name", "Wizard"))[:16] or "Wizard"
|
| 257 |
-
char = str(data.get("char", "warrior"))[:16] or "soldier"
|
| 258 |
-
clients[ws]["name"] = name
|
| 259 |
-
clients[ws]["char"] = char
|
| 260 |
-
if clients[ws]["pid"]:
|
| 261 |
-
continue
|
| 262 |
-
if engine.can_join():
|
| 263 |
-
pid = uuid.uuid4().hex[:8]
|
| 264 |
-
engine.add_player(pid, name, char)
|
| 265 |
-
clients[ws] = {**clients[ws], "pid": pid, "role": "player"}
|
| 266 |
-
await _send(ws, {"type": "welcome", "pid": pid, "role": "player"})
|
| 267 |
-
else:
|
| 268 |
-
# game full -> join the queue
|
| 269 |
-
if ws not in queue:
|
| 270 |
-
queue.append(ws)
|
| 271 |
-
pos = queue.index(ws) + 1
|
| 272 |
-
await _send(ws, {"type": "welcome", "pid": None, "role": "spectator",
|
| 273 |
-
"reason": "queued", "pos": pos, "size": len(queue)})
|
| 274 |
-
|
| 275 |
-
elif mtype == "spectate":
|
| 276 |
-
clients[ws]["name"] = str(data.get("name", "Wizard"))[:16] or "Wizard"
|
| 277 |
-
clients[ws]["char"] = str(data.get("char", "warrior"))[:16] or "soldier"
|
| 278 |
-
|
| 279 |
-
elif mtype == "leave_queue":
|
| 280 |
-
if ws in queue:
|
| 281 |
-
queue.remove(ws)
|
| 282 |
-
|
| 283 |
-
elif mtype == "get_leaderboard":
|
| 284 |
-
await _send(ws, {"type": "leaderboard", "top": _leaderboard[:20]})
|
| 285 |
-
|
| 286 |
-
elif mtype == "submit_score":
|
| 287 |
-
top = add_score(str(data.get("name", "Wizard")), int(data.get("gold", 0)),
|
| 288 |
-
int(data.get("level", 1)), int(data.get("wave", 0)))
|
| 289 |
-
await _send(ws, {"type": "leaderboard", "top": top, "submitted": True})
|
| 290 |
-
|
| 291 |
-
elif mtype == "input" and clients[ws]["pid"]:
|
| 292 |
-
engine.set_input(clients[ws]["pid"], data)
|
| 293 |
-
|
| 294 |
-
elif mtype == "choose_card" and clients[ws]["pid"]:
|
| 295 |
-
engine.choose_card(clients[ws]["pid"], data.get("key", ""))
|
| 296 |
-
|
| 297 |
-
elif mtype == "request_skill" and clients[ws]["pid"]:
|
| 298 |
-
await handle_skill_request(ws, clients[ws]["pid"], str(data.get("prompt", "")))
|
| 299 |
-
|
| 300 |
-
elif mtype == "start" and clients[ws]["pid"]:
|
| 301 |
-
if engine.status == "lobby":
|
| 302 |
-
engine.start_round()
|
| 303 |
-
except WebSocketDisconnect:
|
| 304 |
-
pass
|
| 305 |
-
except Exception:
|
| 306 |
-
pass
|
| 307 |
-
finally:
|
| 308 |
-
if ws in queue:
|
| 309 |
-
queue.remove(ws)
|
| 310 |
-
_drop(ws)
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
app.mount("/static", StaticFiles(directory=os.path.join(HERE, "static")), name="static")
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
# --------------------------------------------------------------------------
|
| 317 |
-
# Gradio dashboard (mounted at /dashboard)
|
| 318 |
-
# --------------------------------------------------------------------------
|
| 319 |
-
def _dashboard_state():
|
| 320 |
-
players = sorted(engine.players.values(), key=lambda p: p.gold, reverse=True)
|
| 321 |
-
if players:
|
| 322 |
-
lb = "| Wizard | Level | Gold | Last-round dmg | Kills |\n|---|---|---|---|---|\n"
|
| 323 |
-
lb += "\n".join(
|
| 324 |
-
f"| {p.name} | {p.level} | {p.gold} | {round(p.dmg_dealt)} | {p.kills} |"
|
| 325 |
-
for p in players
|
| 326 |
-
)
|
| 327 |
-
else:
|
| 328 |
-
lb = "_No wizards in the arena yet._"
|
| 329 |
-
header = (f"### Round {engine.round} — `{engine.status}`\n"
|
| 330 |
-
f"{engine.status_msg}\n\n"
|
| 331 |
-
f"Players: {len(engine.players)}/{MAX_PLAYERS} · "
|
| 332 |
-
f"GM: _{engine.gm_message or '—'}_")
|
| 333 |
-
traces = gamemaster.RECENT_TRACES[:10]
|
| 334 |
-
return header, lb, traces
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
with gr.Blocks(title="HuggingWizards — Game Master Dashboard") as demo:
|
| 338 |
-
gr.Markdown("# 🧙 HuggingWizards — Game Master Dashboard\n"
|
| 339 |
-
"Play at the [arena](/). Below: live state, leaderboard, and the "
|
| 340 |
-
"per-round **agent traces** produced by Nemotron-4B.")
|
| 341 |
-
header_md = gr.Markdown()
|
| 342 |
-
with gr.Row():
|
| 343 |
-
leaderboard_md = gr.Markdown()
|
| 344 |
-
gr.Markdown("## Agent traces (most recent first)")
|
| 345 |
-
traces_json = gr.JSON()
|
| 346 |
-
timer = gr.Timer(2.0)
|
| 347 |
-
timer.tick(_dashboard_state, outputs=[header_md, leaderboard_md, traces_json])
|
| 348 |
-
demo.load(_dashboard_state, outputs=[header_md, leaderboard_md, traces_json])
|
| 349 |
-
|
| 350 |
-
app = gr.mount_gradio_app(app, demo, path="/dashboard")
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
if __name__ == "__main__":
|
| 354 |
-
import uvicorn
|
| 355 |
-
|
| 356 |
-
port = int(os.environ.get("PORT", 7860))
|
| 357 |
-
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HuggingWizards-upload/game/__init__.py
DELETED
|
File without changes
|
HuggingWizards-upload/game/__pycache__/__init__.cpython-313.pyc
DELETED
|
Binary file (157 Bytes)
|
|
|
HuggingWizards-upload/game/__pycache__/engine.cpython-313.pyc
DELETED
|
Binary file (99.9 kB)
|
|
|
HuggingWizards-upload/game/__pycache__/gamemaster.cpython-313.pyc
DELETED
|
Binary file (26.6 kB)
|
|
|
HuggingWizards-upload/game/__pycache__/skills.cpython-313.pyc
DELETED
|
Binary file (6.45 kB)
|
|
|
HuggingWizards-upload/game/engine.py
DELETED
|
@@ -1,1537 +0,0 @@
|
|
| 1 |
-
"""Authoritative co-op arena simulation.
|
| 2 |
-
|
| 3 |
-
One shared arena: a boss in the center, minions that spawn, and up to
|
| 4 |
-
MAX_PLAYERS wizards. The engine ticks on the CPU at TICK_HZ and produces a
|
| 5 |
-
JSON-serialisable snapshot that is broadcast to every connected client.
|
| 6 |
-
|
| 7 |
-
The engine knows nothing about networking or the LLM. The server drives it:
|
| 8 |
-
- feed inputs via `set_input` / `add_player` / `remove_player`
|
| 9 |
-
- call `tick(dt)` every frame
|
| 10 |
-
- when `status == "intermission"`, ask the Game Master for a decision and pass
|
| 11 |
-
it to `apply_gm_decision`, then call `start_round`.
|
| 12 |
-
"""
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
|
| 15 |
-
import math
|
| 16 |
-
import random
|
| 17 |
-
import time
|
| 18 |
-
from dataclasses import dataclass, field
|
| 19 |
-
|
| 20 |
-
from game import skills as skillmod
|
| 21 |
-
|
| 22 |
-
ARENA_W = 1280
|
| 23 |
-
ARENA_H = 720
|
| 24 |
-
CENTER = (ARENA_W / 2, ARENA_H / 2)
|
| 25 |
-
|
| 26 |
-
MAX_PLAYERS = 8
|
| 27 |
-
TICK_HZ = 20
|
| 28 |
-
|
| 29 |
-
# Upgrade definitions: id -> (label, cost). Cost is legacy (gold shop); the
|
| 30 |
-
# survivors-mode progression hands these out as level-up cards instead.
|
| 31 |
-
UPGRADES = {
|
| 32 |
-
"damage": ("Spell Power", 30),
|
| 33 |
-
"attack_speed": ("Cast Speed", 30),
|
| 34 |
-
"move_speed": ("Boots", 25),
|
| 35 |
-
"max_hp": ("Vitality", 25),
|
| 36 |
-
"multishot": ("Multishot", 60),
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
# Level-up cards. id -> (label, description, rarity). The stat ids above plus a
|
| 40 |
-
# few survivors-flavored extras. The Game Master picks which cards are offered.
|
| 41 |
-
CARDS = {
|
| 42 |
-
# --- originals ---
|
| 43 |
-
"auto_attack": ("Auto-Cast", "Attack automatically — no need to hold Space", "common"),
|
| 44 |
-
"damage": ("Spell Power", "+6 spell damage", "common"),
|
| 45 |
-
"attack_speed": ("Quick Cast", "Cast faster", "common"),
|
| 46 |
-
"move_speed": ("Swift Boots", "+22 move speed", "common"),
|
| 47 |
-
"max_hp": ("Vitality", "+25 max HP (and heal)", "common"),
|
| 48 |
-
"multishot": ("Multishot", "+1 projectile", "rare"),
|
| 49 |
-
"pierce": ("Piercing Bolt", "Shots pierce +1 enemy", "rare"),
|
| 50 |
-
"regen": ("Regeneration", "+2 HP/sec", "uncommon"),
|
| 51 |
-
"projectile_speed": ("Arcane Velocity", "Faster, longer shots", "uncommon"),
|
| 52 |
-
"aoe": ("Arcane Nova", "Periodic burst damages nearby foes", "rare"),
|
| 53 |
-
"summoner": ("Spirit Caller", "Summon spirits that fight for you", "rare"),
|
| 54 |
-
# --- 20 new base power-ups ---
|
| 55 |
-
"crit": ("Critical Strike", "+8% chance to deal double damage", "uncommon"),
|
| 56 |
-
"lifesteal": ("Vampirism", "Heal for a % of damage dealt", "uncommon"),
|
| 57 |
-
"armor": ("Iron Hide", "-2 flat damage taken", "common"),
|
| 58 |
-
"dodge": ("Evasion", "+5% chance to avoid a hit", "uncommon"),
|
| 59 |
-
"thorns": ("Spiked Aura", "Melee attackers take damage back", "uncommon"),
|
| 60 |
-
"magnet": ("Lodestone", "Pull XP gems from much farther", "common"),
|
| 61 |
-
"xp_boost": ("Quick Study", "+15% XP from gems", "uncommon"),
|
| 62 |
-
"gold_boost": ("Prosperity", "+20% gold from rewards", "common"),
|
| 63 |
-
"knockback": ("Force Bolt", "Shots knock enemies back", "common"),
|
| 64 |
-
"explosive": ("Explosive Shots", "Shots burst for splash damage", "rare"),
|
| 65 |
-
"burn": ("Ignite", "Shots set enemies on fire (damage over time)", "uncommon"),
|
| 66 |
-
"chain": ("Chain Spark", "Hits arc to a nearby enemy", "rare"),
|
| 67 |
-
"big_shots": ("Heavy Orbs", "Bigger, easier-to-land projectiles", "common"),
|
| 68 |
-
"homing": ("Seeker Bolts", "Shots curve toward enemies", "rare"),
|
| 69 |
-
"giant": ("Titan Growth", "+30 max HP and you grow larger", "uncommon"),
|
| 70 |
-
"glass_cannon": ("Glass Cannon", "+12 damage but -15 max HP", "rare"),
|
| 71 |
-
"berserker": ("Berserker", "Big damage boost while below half HP", "uncommon"),
|
| 72 |
-
"shield": ("Aegis", "Start each wave with a damage-absorbing shield", "uncommon"),
|
| 73 |
-
"slow_aura": ("Frost Presence", "Nearby enemies are slowed", "uncommon"),
|
| 74 |
-
"fortune": ("Lucky Charm", "Better gem tiers and rarer drops", "rare"),
|
| 75 |
-
# --- boss-exclusive (only offered during the matching boss; see THEME_CARDS) ---
|
| 76 |
-
"war_drums": ("War Drums", "Periodically summon a warband", "rare"),
|
| 77 |
-
"savage_roar": ("Savage Roar", "Each kill erupts in an AoE blast", "rare"),
|
| 78 |
-
"iron_nova": ("Iron Detonation", "Heavy slowing nova every few casts", "rare"),
|
| 79 |
-
"bulwark": ("Bulwark", "Periodically restore a chunk of HP", "rare"),
|
| 80 |
-
"impaling_charge": ("Impaling Charge", "Burst of piercing lances", "rare"),
|
| 81 |
-
"arrow_storm": ("Arrow Storm", "A constant rain of arrows", "rare"),
|
| 82 |
-
}
|
| 83 |
-
ALL_CARD_IDS = list(CARDS.keys())
|
| 84 |
-
# Cards that grant a runtime skill spec rather than a stat point.
|
| 85 |
-
SKILL_CARDS = set(skillmod.CARD_SKILLS.keys())
|
| 86 |
-
# One-time cards excluded from draws once owned.
|
| 87 |
-
# (auto_attack handled below in BINARY_CARDS)
|
| 88 |
-
|
| 89 |
-
# Boss-exclusive cards: each is ONLY offered while its boss theme is active.
|
| 90 |
-
# theme index = (round-1)//5 -> Orc / Iron Legion / Lancer / Archer (then cycles)
|
| 91 |
-
THEME_CARDS = {
|
| 92 |
-
0: ["war_drums", "savage_roar"], # Orc Horde
|
| 93 |
-
1: ["iron_nova", "bulwark"], # Iron Legion
|
| 94 |
-
2: ["impaling_charge"], # Lancer Host
|
| 95 |
-
3: ["arrow_storm"], # Archer Coven
|
| 96 |
-
}
|
| 97 |
-
# Every theme-exclusive id, so they can be filtered out of normal draws.
|
| 98 |
-
THEME_ONLY = {cid for ids in THEME_CARDS.values() for cid in ids}
|
| 99 |
-
|
| 100 |
-
def theme_cards_for(theme: int) -> list[str]:
|
| 101 |
-
return THEME_CARDS.get(theme % len(THEME_CARDS), [])
|
| 102 |
-
# One-time cards excluded from draws once owned: Auto-Cast and every skill card
|
| 103 |
-
# (re-granting a skill would just duplicate it).
|
| 104 |
-
BINARY_CARDS = {"auto_attack"} | SKILL_CARDS
|
| 105 |
-
|
| 106 |
-
# XP gem tiers 1..7 -> XP value (increasing). The client colors/sizes by tier.
|
| 107 |
-
GEM_TIER_XP = {1: 1, 2: 2, 3: 4, 4: 7, 5: 12, 6: 20, 7: 35}
|
| 108 |
-
|
| 109 |
-
# Timed AURAS — ONLY found as floor pickups (never cards). Each lasts dur seconds
|
| 110 |
-
# and applies temporary multipliers. "sheet" picks a Retro Impact effect sheet.
|
| 111 |
-
AURAS = {
|
| 112 |
-
"inferno": {"label": "Inferno Aura", "dur": 40, "sheet": "a", "color": "#ff7a4a",
|
| 113 |
-
"dmg": 1.6},
|
| 114 |
-
"frost": {"label": "Frost Aura", "dur": 50, "sheet": "b", "color": "#7ee0ff",
|
| 115 |
-
"enemy_slow": 0.45, "move": 1.15},
|
| 116 |
-
"haste": {"label": "Haste Aura", "dur": 35, "sheet": "c", "color": "#a6ff8c",
|
| 117 |
-
"atk": 0.6, "move": 1.4},
|
| 118 |
-
"vampire": {"label": "Vampiric Aura", "dur": 40, "sheet": "d", "color": "#ff6e6e",
|
| 119 |
-
"lifesteal": 0.18},
|
| 120 |
-
"warding": {"label": "Warding Aura", "dur": 55, "sheet": "e", "color": "#ffd45e",
|
| 121 |
-
"dmg_red": 0.45},
|
| 122 |
-
"fury": {"label": "Fury Aura", "dur": 30, "sheet": "f", "color": "#c08cff",
|
| 123 |
-
"dmg": 1.35, "atk": 0.7},
|
| 124 |
-
}
|
| 125 |
-
ALL_AURA_IDS = list(AURAS.keys())
|
| 126 |
-
PENDING_AUTOPICK_SEC = 15.0 # auto-choose a card if the player dithers
|
| 127 |
-
|
| 128 |
-
# Special boss encounters. They replace the themed boss on certain waves, tint the
|
| 129 |
-
# whole screen, attack with a unique projectile style, and — when defeated — grant
|
| 130 |
-
# every surviving wizard the boss's own attack power.
|
| 131 |
-
SPECIAL_BOSSES = {
|
| 132 |
-
"aegis": {"name": "AEGIS", "hue": "red", "hp_mult": 2.3, "dmg_mult": 1.3,
|
| 133 |
-
"style": "holy", "reward": "holy_judgment"},
|
| 134 |
-
"toaster": {"name": "TOASTER BOT", "hue": "green", "hp_mult": 1.9, "dmg_mult": 1.15,
|
| 135 |
-
"style": "glitch", "reward": "glitch_rift"},
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def special_boss_for(rnd: int) -> str | None:
|
| 140 |
-
"""Aegis on waves 3,9,15… Toaster Bot on waves 5,11,17…"""
|
| 141 |
-
if rnd >= 3 and (rnd - 3) % 6 == 0:
|
| 142 |
-
return "aegis"
|
| 143 |
-
if rnd >= 5 and (rnd - 5) % 6 == 0:
|
| 144 |
-
return "toaster"
|
| 145 |
-
return None
|
| 146 |
-
|
| 147 |
-
# Enemy archetypes: id -> stat multipliers/overrides relative to the base minion.
|
| 148 |
-
MINION_TYPES = {
|
| 149 |
-
"grunt": {"hp": 1.0, "speed": 1.0, "dmg": 1.0, "scale": 1.0},
|
| 150 |
-
"fast": {"hp": 0.6, "speed": 1.8, "dmg": 0.7, "scale": 0.85},
|
| 151 |
-
"tank": {"hp": 2.6, "speed": 0.6, "dmg": 1.6, "scale": 1.3},
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
# Boss attack patterns — the Game Master picks one per round. Multipliers scale
|
| 155 |
-
# how often each attack fires (>1 = more often); flags switch firing styles.
|
| 156 |
-
BOSS_PATTERNS = {
|
| 157 |
-
"balanced": {"shoot": 1.0, "nova": 1.0, "charge": 1.0, "spawn": 1.0,
|
| 158 |
-
"label": ""},
|
| 159 |
-
"artillery": {"shoot": 0.6, "nova": 2.0, "charge": 0.4, "spawn": 0.8,
|
| 160 |
-
"label": "ARTILLERY", "slow_heavy": True},
|
| 161 |
-
"sniper": {"shoot": 1.5, "nova": 0.45, "charge": 0.6, "spawn": 0.7,
|
| 162 |
-
"label": "SNIPER", "snipe": True},
|
| 163 |
-
"swarm": {"shoot": 0.55, "nova": 0.6, "charge": 0.5, "spawn": 2.4,
|
| 164 |
-
"label": "SWARMLORD"},
|
| 165 |
-
"berserker": {"shoot": 0.7, "nova": 0.8, "charge": 2.6, "spawn": 0.7,
|
| 166 |
-
"label": "BERSERKER"},
|
| 167 |
-
}
|
| 168 |
-
ALL_PATTERN_IDS = list(BOSS_PATTERNS.keys())
|
| 169 |
-
|
| 170 |
-
# Blessings the Game Master may bestow on individual wizards between rounds:
|
| 171 |
-
# any timed aura, an extra life, or a full heal. Applied at next round start.
|
| 172 |
-
BLESSINGS = ["extra_life", "full_heal"] + ALL_AURA_IDS
|
| 173 |
-
MAX_LIVES = 5
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def _clamp(v, lo, hi):
|
| 177 |
-
return lo if v < lo else hi if v > hi else v
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
@dataclass
|
| 181 |
-
class Projectile:
|
| 182 |
-
x: float
|
| 183 |
-
y: float
|
| 184 |
-
vx: float
|
| 185 |
-
vy: float
|
| 186 |
-
dmg: float
|
| 187 |
-
owner: str # player id, or "boss"/"minion"
|
| 188 |
-
hostile: bool # True = damages players, False = damages enemies
|
| 189 |
-
ttl: float = 2.5
|
| 190 |
-
radius: float = 10.0
|
| 191 |
-
pierce: int = 0 # remaining extra enemies this shot can pass through
|
| 192 |
-
hit_ids: set = field(default_factory=set) # minion uids already hit (for pierce)
|
| 193 |
-
# power-up effect payload (player shots only)
|
| 194 |
-
homing: bool = False
|
| 195 |
-
burn: float = 0.0 # burn dps applied on hit (Ignite)
|
| 196 |
-
explode: float = 0.0 # AoE radius on hit (Explosive)
|
| 197 |
-
knock: float = 0.0 # knockback impulse (Force Bolt)
|
| 198 |
-
chain: int = 0 # extra chain hits (Chain Spark)
|
| 199 |
-
crit: bool = False # was a critical hit (visual)
|
| 200 |
-
style: str = "" # visual style: "" | "holy" | "glitch"
|
| 201 |
-
|
| 202 |
-
def to_dict(self):
|
| 203 |
-
d = {"x": round(self.x, 1), "y": round(self.y, 1),
|
| 204 |
-
"hostile": self.hostile, "r": self.radius}
|
| 205 |
-
if self.style:
|
| 206 |
-
d["s"] = self.style
|
| 207 |
-
return d
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
@dataclass
|
| 211 |
-
class Minion:
|
| 212 |
-
x: float
|
| 213 |
-
y: float
|
| 214 |
-
hp: float
|
| 215 |
-
max_hp: float
|
| 216 |
-
speed: float
|
| 217 |
-
dmg: float
|
| 218 |
-
uid: int = 0 # stable id so the client can track hit/death effects
|
| 219 |
-
kind: str = "grunt" # grunt | fast | tank
|
| 220 |
-
scale: float = 1.0
|
| 221 |
-
attack_cd: float = 0.0
|
| 222 |
-
hurt: float = 0.0
|
| 223 |
-
burn: float = 0.0 # burn damage-per-second (Ignite)
|
| 224 |
-
burn_t: float = 0.0 # burn time remaining
|
| 225 |
-
alive: bool = True
|
| 226 |
-
|
| 227 |
-
def to_dict(self):
|
| 228 |
-
return {"id": self.uid,
|
| 229 |
-
"x": round(self.x, 1), "y": round(self.y, 1),
|
| 230 |
-
"hp": self.hp, "max_hp": self.max_hp, "kind": self.kind,
|
| 231 |
-
"scale": self.scale, "hurt": self.hurt > 0,
|
| 232 |
-
"burn": self.burn_t > 0}
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
@dataclass
|
| 236 |
-
class Ally:
|
| 237 |
-
"""A summoned spirit that fights for a player (from a summon_ally skill)."""
|
| 238 |
-
x: float
|
| 239 |
-
y: float
|
| 240 |
-
owner: str # player id (kills/damage credit the summoner)
|
| 241 |
-
uid: int = 0
|
| 242 |
-
hp: float = 30.0
|
| 243 |
-
max_hp: float = 30.0
|
| 244 |
-
speed: float = 150.0
|
| 245 |
-
dmg: float = 12.0
|
| 246 |
-
attack_cd: float = 0.0
|
| 247 |
-
ttl: float = 14.0
|
| 248 |
-
color: str = "#a6ff8c"
|
| 249 |
-
|
| 250 |
-
def to_dict(self):
|
| 251 |
-
return {"id": self.uid, "x": round(self.x, 1), "y": round(self.y, 1),
|
| 252 |
-
"hp": self.hp, "max_hp": self.max_hp, "color": self.color}
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
@dataclass
|
| 256 |
-
class Pickup:
|
| 257 |
-
"""A rare floor power-up. kind 'card' grants a permanent card; kind 'aura'
|
| 258 |
-
grants a timed aura buff. Walk over it to claim."""
|
| 259 |
-
x: float
|
| 260 |
-
y: float
|
| 261 |
-
card: str # card id (kind=card) or aura id (kind=aura)
|
| 262 |
-
kind: str = "card" # card | aura
|
| 263 |
-
uid: int = 0
|
| 264 |
-
ttl: float = 22.0
|
| 265 |
-
|
| 266 |
-
def to_dict(self):
|
| 267 |
-
d = {"id": self.uid, "x": round(self.x, 1), "y": round(self.y, 1),
|
| 268 |
-
"card": self.card, "kind": self.kind}
|
| 269 |
-
if self.kind == "aura":
|
| 270 |
-
d["color"] = AURAS.get(self.card, {}).get("color", "#fff")
|
| 271 |
-
d["sheet"] = AURAS.get(self.card, {}).get("sheet", "a")
|
| 272 |
-
return d
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
@dataclass
|
| 276 |
-
class Gem:
|
| 277 |
-
x: float
|
| 278 |
-
y: float
|
| 279 |
-
value: int = 1
|
| 280 |
-
tier: int = 1
|
| 281 |
-
ttl: float = 18.0
|
| 282 |
-
|
| 283 |
-
def to_dict(self):
|
| 284 |
-
return {"x": round(self.x, 1), "y": round(self.y, 1),
|
| 285 |
-
"v": self.value, "t": self.tier}
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
@dataclass
|
| 289 |
-
class Boss:
|
| 290 |
-
hp: float = 1000
|
| 291 |
-
max_hp: float = 1000
|
| 292 |
-
dmg: float = 12
|
| 293 |
-
x: float = CENTER[0]
|
| 294 |
-
y: float = CENTER[1]
|
| 295 |
-
shoot_cd: float = 0.0
|
| 296 |
-
spawn_cd: float = 0.0
|
| 297 |
-
spawn_interval: float = 4.0
|
| 298 |
-
hurt: float = 0.0
|
| 299 |
-
# attack state machine
|
| 300 |
-
state: str = "idle" # idle | telegraph | charge
|
| 301 |
-
state_t: float = 0.0 # time remaining in the current state
|
| 302 |
-
charge_cd: float = 6.0 # until next charge
|
| 303 |
-
nova_cd: float = 5.0 # until next ground shockwave
|
| 304 |
-
cvx: float = 0.0
|
| 305 |
-
cvy: float = 0.0
|
| 306 |
-
aggro: float = 1.0 # Director-tunable attack frequency multiplier
|
| 307 |
-
special: str = "" # "" | "aegis" | "toaster"
|
| 308 |
-
name: str = "" # display name (special bosses)
|
| 309 |
-
special_cd: float = 4.0 # until next signature attack
|
| 310 |
-
pattern: str = "balanced" # Director-chosen attack pattern
|
| 311 |
-
atk_speed: float = 1.0 # Director-chosen attack cadence multiplier
|
| 312 |
-
|
| 313 |
-
def to_dict(self):
|
| 314 |
-
return {"x": round(self.x, 1), "y": round(self.y, 1),
|
| 315 |
-
"hp": self.hp, "max_hp": self.max_hp,
|
| 316 |
-
"hurt": self.hurt > 0, "state": self.state,
|
| 317 |
-
"special": self.special, "name": self.name,
|
| 318 |
-
"pattern": self.pattern, "atk_speed": self.atk_speed,
|
| 319 |
-
"pattern_label": BOSS_PATTERNS.get(self.pattern, {}).get("label", ""),
|
| 320 |
-
"phase": 1 if self.hp > self.max_hp * 0.5 else 2}
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
@dataclass
|
| 324 |
-
class Player:
|
| 325 |
-
pid: str
|
| 326 |
-
name: str
|
| 327 |
-
x: float = 200.0
|
| 328 |
-
y: float = 360.0
|
| 329 |
-
hp: float = 100.0
|
| 330 |
-
max_hp: float = 100.0
|
| 331 |
-
speed: float = 180.0
|
| 332 |
-
facing: float = 0.0 # radians, last aim direction
|
| 333 |
-
alive: bool = True
|
| 334 |
-
gold: int = 0
|
| 335 |
-
level: int = 1
|
| 336 |
-
# input
|
| 337 |
-
up: bool = False
|
| 338 |
-
down: bool = False
|
| 339 |
-
left: bool = False
|
| 340 |
-
right: bool = False
|
| 341 |
-
attacking: bool = False
|
| 342 |
-
attack_timer: float = 0.0
|
| 343 |
-
moving: bool = False
|
| 344 |
-
hurt: float = 0.0
|
| 345 |
-
# upgrade levels (gained via level-up cards)
|
| 346 |
-
up_damage: int = 0
|
| 347 |
-
up_attack_speed: int = 0
|
| 348 |
-
up_move_speed: int = 0
|
| 349 |
-
up_max_hp: int = 0
|
| 350 |
-
up_multishot: int = 0
|
| 351 |
-
up_pierce: int = 0
|
| 352 |
-
up_regen: int = 0
|
| 353 |
-
up_projectile_speed: int = 0
|
| 354 |
-
up_auto_attack: int = 0
|
| 355 |
-
# new base power-ups
|
| 356 |
-
up_crit: int = 0
|
| 357 |
-
up_lifesteal: int = 0
|
| 358 |
-
up_armor: int = 0
|
| 359 |
-
up_dodge: int = 0
|
| 360 |
-
up_thorns: int = 0
|
| 361 |
-
up_magnet: int = 0
|
| 362 |
-
up_xp_boost: int = 0
|
| 363 |
-
up_gold_boost: int = 0
|
| 364 |
-
up_knockback: int = 0
|
| 365 |
-
up_explosive: int = 0
|
| 366 |
-
up_burn: int = 0
|
| 367 |
-
up_chain: int = 0
|
| 368 |
-
up_big_shots: int = 0
|
| 369 |
-
up_homing: int = 0
|
| 370 |
-
up_giant: int = 0
|
| 371 |
-
up_glass_cannon: int = 0
|
| 372 |
-
up_berserker: int = 0
|
| 373 |
-
up_shield: int = 0
|
| 374 |
-
up_slow_aura: int = 0
|
| 375 |
-
up_fortune: int = 0
|
| 376 |
-
shield: float = 0.0 # current absorb (from Aegis)
|
| 377 |
-
flash: str = "" # transient pickup/announce text for this player
|
| 378 |
-
flash_t: float = 0.0
|
| 379 |
-
# lives / character / timed auras
|
| 380 |
-
lives: int = 3
|
| 381 |
-
char: str = "warrior" # chosen character sprite set
|
| 382 |
-
respawn_t: float = 0.0 # >0 while waiting to respawn after a death
|
| 383 |
-
auras: list = field(default_factory=list) # active timed auras [{type,t}]
|
| 384 |
-
pending_until: float = 0.0 # auto-pick deadline for pending_cards
|
| 385 |
-
# XP / character level
|
| 386 |
-
xp: int = 0
|
| 387 |
-
xp_needed: int = 5
|
| 388 |
-
pending_cards: list = field(default_factory=list) # card ids offered now
|
| 389 |
-
skill_cards: set = field(default_factory=set) # owned skill-card ids
|
| 390 |
-
# runtime skill specs (from cards or Game-Master-authored requests) + state
|
| 391 |
-
skills: list = field(default_factory=list)
|
| 392 |
-
_attack_count: int = 0
|
| 393 |
-
# per-round stats
|
| 394 |
-
dmg_dealt: float = 0.0
|
| 395 |
-
kills: int = 0
|
| 396 |
-
|
| 397 |
-
# ---- timed-aura helpers ----
|
| 398 |
-
def _aura_prod(self, key: str, default: float = 1.0) -> float:
|
| 399 |
-
v = default
|
| 400 |
-
for a in self.auras:
|
| 401 |
-
spec = AURAS.get(a["type"], {})
|
| 402 |
-
if key in spec:
|
| 403 |
-
v *= spec[key]
|
| 404 |
-
return v
|
| 405 |
-
|
| 406 |
-
def _aura_max(self, key: str) -> float:
|
| 407 |
-
v = 0.0
|
| 408 |
-
for a in self.auras:
|
| 409 |
-
spec = AURAS.get(a["type"], {})
|
| 410 |
-
if key in spec:
|
| 411 |
-
v = max(v, spec[key])
|
| 412 |
-
return v
|
| 413 |
-
|
| 414 |
-
# derived stats
|
| 415 |
-
@property
|
| 416 |
-
def damage(self) -> float:
|
| 417 |
-
# base + Spell Power + Glass Cannon; Berserker adds more while wounded
|
| 418 |
-
d = 14 + self.up_damage * 6 + self.up_glass_cannon * 12
|
| 419 |
-
if self.up_berserker and self.hp < self.max_hp * 0.5:
|
| 420 |
-
d *= 1 + 0.15 * self.up_berserker
|
| 421 |
-
return d * self._aura_prod("dmg")
|
| 422 |
-
|
| 423 |
-
@property
|
| 424 |
-
def size_mult(self) -> float:
|
| 425 |
-
# Wizards start minion-sized (0.55) and grow a step every level, capped
|
| 426 |
-
# a touch above boss size (2.9) — a long run literally makes you a
|
| 427 |
-
# bigger, harder-to-dodge-with target. Titan Growth stacks on top.
|
| 428 |
-
return min(2.9, 0.55 + 0.12 * (self.level - 1)) * (1 + self.up_giant * 0.18)
|
| 429 |
-
|
| 430 |
-
@property
|
| 431 |
-
def hit_radius(self) -> float:
|
| 432 |
-
# Server-authoritative hurtbox — grows with size, so dodging gets
|
| 433 |
-
# harder as you level (the client renders at the same scale).
|
| 434 |
-
return 10.0 + 12.0 * self.size_mult
|
| 435 |
-
|
| 436 |
-
@property
|
| 437 |
-
def attack_interval(self) -> float:
|
| 438 |
-
return max(0.12, (0.55 - self.up_attack_speed * 0.05) * self._aura_prod("atk"))
|
| 439 |
-
|
| 440 |
-
@property
|
| 441 |
-
def move_speed(self) -> float:
|
| 442 |
-
return (180 + self.up_move_speed * 22) * self._aura_prod("move")
|
| 443 |
-
|
| 444 |
-
@property
|
| 445 |
-
def shots(self) -> int:
|
| 446 |
-
return 1 + self.up_multishot
|
| 447 |
-
|
| 448 |
-
@property
|
| 449 |
-
def projectile_speed(self) -> float:
|
| 450 |
-
return 520 + self.up_projectile_speed * 90
|
| 451 |
-
|
| 452 |
-
@property
|
| 453 |
-
def projectile_ttl(self) -> float:
|
| 454 |
-
return 2.5 + self.up_projectile_speed * 0.6
|
| 455 |
-
|
| 456 |
-
@property
|
| 457 |
-
def regen(self) -> float:
|
| 458 |
-
return self.up_regen * 2.0 # hp per second
|
| 459 |
-
|
| 460 |
-
def to_dict(self):
|
| 461 |
-
return {
|
| 462 |
-
"id": self.pid, "name": self.name,
|
| 463 |
-
"x": round(self.x, 1), "y": round(self.y, 1),
|
| 464 |
-
"hp": round(self.hp, 1), "max_hp": self.max_hp,
|
| 465 |
-
"facing": round(self.facing, 3), "alive": self.alive,
|
| 466 |
-
"gold": self.gold, "level": self.level, "moving": self.moving,
|
| 467 |
-
"attacking": self.attack_timer > 0, "hurt": self.hurt > 0,
|
| 468 |
-
"xp": self.xp, "xp_needed": self.xp_needed,
|
| 469 |
-
"pending_cards": list(self.pending_cards),
|
| 470 |
-
"pending_left": round(max(0.0, self.pending_until - time.time()), 1) if self.pending_cards else 0,
|
| 471 |
-
"lives": self.lives, "char": self.char,
|
| 472 |
-
"auras": [{"type": a["type"], "t": round(a["t"], 1),
|
| 473 |
-
"color": AURAS.get(a["type"], {}).get("color", "#fff"),
|
| 474 |
-
"label": AURAS.get(a["type"], {}).get("label", a["type"])}
|
| 475 |
-
for a in self.auras],
|
| 476 |
-
"size": round(self.size_mult, 2),
|
| 477 |
-
"shield": round(self.shield, 1),
|
| 478 |
-
"flash": self.flash if self.flash_t > 0 else "",
|
| 479 |
-
"skills": [{"name": s["name"], "effect": s["effect"], "color": s.get("color", "#b48cff")}
|
| 480 |
-
for s in self.skills],
|
| 481 |
-
# owned level per card (skill cards report 1 when owned)
|
| 482 |
-
"upgrades": {k: (1 if k in self.skill_cards else getattr(self, f"up_{k}", 0))
|
| 483 |
-
for k in CARDS},
|
| 484 |
-
"stats": {"dmg": round(self.dmg_dealt), "kills": self.kills},
|
| 485 |
-
}
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
class Engine:
|
| 489 |
-
def __init__(self):
|
| 490 |
-
self.players: dict[str, Player] = {}
|
| 491 |
-
self.minions: list[Minion] = []
|
| 492 |
-
self._minion_seq = 0
|
| 493 |
-
self.projectiles: list[Projectile] = []
|
| 494 |
-
self.gems: list[Gem] = []
|
| 495 |
-
self.allies: list[Ally] = []
|
| 496 |
-
self._ally_seq = 0
|
| 497 |
-
self.pickups: list[Pickup] = []
|
| 498 |
-
self._pickup_seq = 0
|
| 499 |
-
self.boss = Boss()
|
| 500 |
-
self.round = 0
|
| 501 |
-
self.status = "lobby" # lobby | active | intermission
|
| 502 |
-
self.status_msg = "Waiting for wizards to join..."
|
| 503 |
-
self.gm_message = ""
|
| 504 |
-
self.intermission_until = 0.0
|
| 505 |
-
self.round_started_at = 0.0
|
| 506 |
-
self.skill_request_pid: str | None = None
|
| 507 |
-
self.skill_request_used = False
|
| 508 |
-
# Cards the Game Master is currently offering on level-up (ids). Empty =>
|
| 509 |
-
# the full set is available (deterministic default).
|
| 510 |
-
self.card_pool: list[str] = list(ALL_CARD_IDS)
|
| 511 |
-
# config applied each round (mutated by the Game Master)
|
| 512 |
-
self.cfg = {
|
| 513 |
-
"boss_hp": 1000, "boss_damage": 12,
|
| 514 |
-
"minion_hp": 40, "minion_count": 6, "spawn_interval": 4.0,
|
| 515 |
-
"boss_aggro": 1.0, # Director-tunable attack frequency
|
| 516 |
-
"boss_attack_speed": 1.0, # Director-tunable cadence (mercy-floored)
|
| 517 |
-
"boss_pattern": "balanced", # Director-chosen attack pattern
|
| 518 |
-
# wave composition: relative weights of each enemy archetype
|
| 519 |
-
"wave": {"grunt": 1.0, "fast": 0.0, "tank": 0.0},
|
| 520 |
-
}
|
| 521 |
-
# one-shot blessings the GM granted for the coming round: {pid: blessing}
|
| 522 |
-
self.pending_blessings: dict[str, str] = {}
|
| 523 |
-
# transient FX events for the client (drained each snapshot)
|
| 524 |
-
self._events: list[dict] = []
|
| 525 |
-
|
| 526 |
-
def _event(self, fx: str, x: float, y: float, **kw):
|
| 527 |
-
"""Queue a one-shot visual event for the next snapshot broadcast."""
|
| 528 |
-
if len(self._events) < 40:
|
| 529 |
-
self._events.append({"fx": fx, "x": round(x, 1), "y": round(y, 1), **kw})
|
| 530 |
-
|
| 531 |
-
# ---- lobby management -------------------------------------------------
|
| 532 |
-
@property
|
| 533 |
-
def active_players(self) -> list[Player]:
|
| 534 |
-
return list(self.players.values())
|
| 535 |
-
|
| 536 |
-
def can_join(self) -> bool:
|
| 537 |
-
return len(self.players) < MAX_PLAYERS
|
| 538 |
-
|
| 539 |
-
def add_player(self, pid: str, name: str, char: str = "warrior") -> Player:
|
| 540 |
-
spawn_angle = random.uniform(0, math.tau)
|
| 541 |
-
p = Player(
|
| 542 |
-
pid=pid, name=name[:16] or "Wizard", char=char or "soldier",
|
| 543 |
-
x=CENTER[0] + math.cos(spawn_angle) * 420,
|
| 544 |
-
y=CENTER[1] + math.sin(spawn_angle) * 280,
|
| 545 |
-
)
|
| 546 |
-
p.x = _clamp(p.x, 40, ARENA_W - 40)
|
| 547 |
-
p.y = _clamp(p.y, 40, ARENA_H - 40)
|
| 548 |
-
# if joining mid-wave, jump straight into the fight
|
| 549 |
-
if self.status == "active":
|
| 550 |
-
p.hp = p.max_hp
|
| 551 |
-
self.players[pid] = p
|
| 552 |
-
if self.status == "lobby" and len(self.players) >= 1:
|
| 553 |
-
self.status_msg = "Press START or wait for more wizards."
|
| 554 |
-
return p
|
| 555 |
-
|
| 556 |
-
def remove_player(self, pid: str):
|
| 557 |
-
self.players.pop(pid, None)
|
| 558 |
-
|
| 559 |
-
def set_input(self, pid: str, data: dict):
|
| 560 |
-
p = self.players.get(pid)
|
| 561 |
-
if not p:
|
| 562 |
-
return
|
| 563 |
-
p.up = bool(data.get("up"))
|
| 564 |
-
p.down = bool(data.get("down"))
|
| 565 |
-
p.left = bool(data.get("left"))
|
| 566 |
-
p.right = bool(data.get("right"))
|
| 567 |
-
p.attacking = bool(data.get("attack"))
|
| 568 |
-
|
| 569 |
-
def _apply_card(self, p: Player, key: str):
|
| 570 |
-
"""Apply a chosen level-up card's effect to a player."""
|
| 571 |
-
if key in SKILL_CARDS:
|
| 572 |
-
self._add_skill(p, skillmod.CARD_SKILLS[key])
|
| 573 |
-
p.skill_cards.add(key)
|
| 574 |
-
return
|
| 575 |
-
setattr(p, f"up_{key}", getattr(p, f"up_{key}") + 1)
|
| 576 |
-
if key in ("max_hp", "giant", "glass_cannon"):
|
| 577 |
-
self._recompute_max_hp(p)
|
| 578 |
-
if key == "max_hp":
|
| 579 |
-
p.hp = min(p.max_hp, p.hp + 25)
|
| 580 |
-
if key == "shield":
|
| 581 |
-
p.shield = max(p.shield, 20 * p.up_shield)
|
| 582 |
-
|
| 583 |
-
def _recompute_max_hp(self, p: Player):
|
| 584 |
-
p.max_hp = max(20, 100 + p.up_max_hp * 25 + p.up_giant * 30 - p.up_glass_cannon * 15)
|
| 585 |
-
p.hp = min(p.hp, p.max_hp)
|
| 586 |
-
|
| 587 |
-
def _add_skill(self, p: Player, spec: dict) -> dict | None:
|
| 588 |
-
"""Validate and attach a skill spec to a player (caps the count)."""
|
| 589 |
-
safe = skillmod.validate_skill(spec)
|
| 590 |
-
if not safe:
|
| 591 |
-
return None
|
| 592 |
-
safe = dict(safe)
|
| 593 |
-
safe["_cd"] = safe.get("interval", 0.0) # periodic cooldown timer
|
| 594 |
-
if len(p.skills) >= skillmod.MAX_SKILLS_PER_PLAYER:
|
| 595 |
-
p.skills.pop(0)
|
| 596 |
-
p.skills.append(safe)
|
| 597 |
-
return safe
|
| 598 |
-
|
| 599 |
-
def add_runtime_skill(self, pid: str, spec: dict) -> dict | None:
|
| 600 |
-
"""Hot-plug a Game-Master-authored skill onto a player (the LLM flow)."""
|
| 601 |
-
p = self.players.get(pid)
|
| 602 |
-
if not p:
|
| 603 |
-
return None
|
| 604 |
-
return self._add_skill(p, spec)
|
| 605 |
-
|
| 606 |
-
def top_player_id(self) -> str | None:
|
| 607 |
-
"""Highest-damage player of the round just ended (gets the skill request)."""
|
| 608 |
-
if not self.players:
|
| 609 |
-
return None
|
| 610 |
-
return max(self.players.values(), key=lambda p: p.dmg_dealt).pid
|
| 611 |
-
|
| 612 |
-
def choose_card(self, pid: str, key: str) -> bool:
|
| 613 |
-
"""Player picks one of the cards currently offered to them."""
|
| 614 |
-
p = self.players.get(pid)
|
| 615 |
-
if not p or key not in CARDS or key not in p.pending_cards:
|
| 616 |
-
return False
|
| 617 |
-
self._apply_card(p, key)
|
| 618 |
-
p.pending_cards = [] # one pick per level-up
|
| 619 |
-
p.pending_until = 0.0
|
| 620 |
-
return True
|
| 621 |
-
|
| 622 |
-
def buy_upgrade(self, pid: str, key: str) -> bool:
|
| 623 |
-
"""Legacy gold shop (kept for compatibility; survivors mode uses cards)."""
|
| 624 |
-
p = self.players.get(pid)
|
| 625 |
-
if not p or key not in UPGRADES:
|
| 626 |
-
return False
|
| 627 |
-
if self.status not in ("intermission", "lobby"):
|
| 628 |
-
return False
|
| 629 |
-
_, cost = UPGRADES[key]
|
| 630 |
-
if p.gold < cost:
|
| 631 |
-
return False
|
| 632 |
-
p.gold -= cost
|
| 633 |
-
self._apply_card(p, key)
|
| 634 |
-
return True
|
| 635 |
-
|
| 636 |
-
def _owns(self, p: Player, cid: str) -> bool:
|
| 637 |
-
"""Whether a one-time card is already taken (stat binary or a skill card)."""
|
| 638 |
-
if cid in SKILL_CARDS:
|
| 639 |
-
return cid in p.skill_cards
|
| 640 |
-
return bool(getattr(p, f"up_{cid}", 0))
|
| 641 |
-
|
| 642 |
-
def _draw_cards(self, p: Player, n: int = 3) -> list[str]:
|
| 643 |
-
"""Draw up to n distinct card ids for a player, honoring boss-exclusive gating."""
|
| 644 |
-
theme = max(0, (self.round - 1) // 5)
|
| 645 |
-
theme_cards = theme_cards_for(theme)
|
| 646 |
-
pool = [c for c in self.card_pool if c in CARDS] or list(ALL_CARD_IDS)
|
| 647 |
-
# boss-exclusive cards: only the current boss's; drop all other themes'
|
| 648 |
-
pool = [c for c in pool if c not in THEME_ONLY or c in theme_cards]
|
| 649 |
-
# ensure this boss's exclusives are eligible even if the Director omitted them
|
| 650 |
-
for tc in theme_cards:
|
| 651 |
-
if tc not in pool:
|
| 652 |
-
pool.append(tc)
|
| 653 |
-
# drop one-time cards already owned
|
| 654 |
-
pool = [c for c in pool if not (c in BINARY_CARDS and self._owns(p, c))]
|
| 655 |
-
chosen: list[str] = []
|
| 656 |
-
# guarantee Auto-Cast is offered early so it doesn't get lost in RNG
|
| 657 |
-
if not p.up_auto_attack and p.level <= 3:
|
| 658 |
-
chosen.append("auto_attack")
|
| 659 |
-
rest = [c for c in pool if c not in chosen]
|
| 660 |
-
random.shuffle(rest)
|
| 661 |
-
# bias one slot toward a boss-exclusive card when available (feels special)
|
| 662 |
-
avail_theme = [c for c in theme_cards if c in rest]
|
| 663 |
-
if avail_theme and len(chosen) < n:
|
| 664 |
-
pick = random.choice(avail_theme)
|
| 665 |
-
chosen.append(pick); rest.remove(pick)
|
| 666 |
-
for c in rest:
|
| 667 |
-
if len(chosen) >= n:
|
| 668 |
-
break
|
| 669 |
-
chosen.append(c)
|
| 670 |
-
return chosen[:n]
|
| 671 |
-
|
| 672 |
-
def _grant_xp(self, p: Player, amount: int):
|
| 673 |
-
if not p.alive:
|
| 674 |
-
return
|
| 675 |
-
p.xp += amount
|
| 676 |
-
leveled = False
|
| 677 |
-
while p.xp >= p.xp_needed:
|
| 678 |
-
p.xp -= p.xp_needed
|
| 679 |
-
p.level += 1
|
| 680 |
-
p.xp_needed = int(p.xp_needed * 1.45) + 3
|
| 681 |
-
leveled = True
|
| 682 |
-
if leveled and not p.pending_cards:
|
| 683 |
-
p.pending_cards = self._draw_cards(p, 3)
|
| 684 |
-
p.pending_until = time.time() + PENDING_AUTOPICK_SEC
|
| 685 |
-
|
| 686 |
-
# ---- round lifecycle --------------------------------------------------
|
| 687 |
-
def start_round(self):
|
| 688 |
-
if not self.players:
|
| 689 |
-
self.status = "lobby"
|
| 690 |
-
return
|
| 691 |
-
self.round += 1
|
| 692 |
-
self.minions.clear()
|
| 693 |
-
self.projectiles.clear()
|
| 694 |
-
self.gems.clear()
|
| 695 |
-
self.allies.clear()
|
| 696 |
-
self.pickups.clear()
|
| 697 |
-
self.boss = Boss(
|
| 698 |
-
hp=self.cfg["boss_hp"], max_hp=self.cfg["boss_hp"],
|
| 699 |
-
dmg=self.cfg["boss_damage"], spawn_interval=self.cfg["spawn_interval"],
|
| 700 |
-
aggro=self.cfg.get("boss_aggro", 1.0),
|
| 701 |
-
pattern=self.cfg.get("boss_pattern", "balanced"),
|
| 702 |
-
atk_speed=self.cfg.get("boss_attack_speed", 1.0),
|
| 703 |
-
)
|
| 704 |
-
# special boss encounter? (Aegis / Toaster Bot) — buff and rename it
|
| 705 |
-
sp = special_boss_for(self.round)
|
| 706 |
-
if sp:
|
| 707 |
-
cfg = SPECIAL_BOSSES[sp]
|
| 708 |
-
self.boss.special = sp
|
| 709 |
-
self.boss.name = cfg["name"]
|
| 710 |
-
hp = int(self.cfg["boss_hp"] * cfg["hp_mult"])
|
| 711 |
-
self.boss.hp = self.boss.max_hp = hp
|
| 712 |
-
self.boss.dmg = int(self.cfg["boss_damage"] * cfg["dmg_mult"])
|
| 713 |
-
# XP / level / cards persist across the run; only revive & reset per-round
|
| 714 |
-
# combat stats. Pending card picks carry over so a level-up isn't lost.
|
| 715 |
-
for p in self.players.values():
|
| 716 |
-
self._recompute_max_hp(p)
|
| 717 |
-
p.hp = p.max_hp
|
| 718 |
-
p.alive = p.lives > 0 # eliminated wizards stay down (awaiting removal)
|
| 719 |
-
p.respawn_t = 0.0
|
| 720 |
-
p.dmg_dealt = 0.0
|
| 721 |
-
p.kills = 0
|
| 722 |
-
p.shield = 20 * p.up_shield # Aegis refreshes each wave
|
| 723 |
-
ang = random.uniform(0, math.tau)
|
| 724 |
-
p.x = _clamp(CENTER[0] + math.cos(ang) * 420, 40, ARENA_W - 40)
|
| 725 |
-
p.y = _clamp(CENTER[1] + math.sin(ang) * 280, 40, ARENA_H - 40)
|
| 726 |
-
self._apply_blessings()
|
| 727 |
-
self.status = "active"
|
| 728 |
-
if self.boss.special:
|
| 729 |
-
self.status_msg = f"Wave {self.round} — {self.boss.name} has arrived!"
|
| 730 |
-
else:
|
| 731 |
-
self.status_msg = f"Wave {self.round} — survive and slay the boss!"
|
| 732 |
-
self.round_started_at = time.time()
|
| 733 |
-
|
| 734 |
-
def _grant_boss_reward(self, special: str):
|
| 735 |
-
"""All living wizards gain the defeated special boss's signature attack."""
|
| 736 |
-
cfg = SPECIAL_BOSSES.get(special, {})
|
| 737 |
-
reward = cfg.get("reward")
|
| 738 |
-
spec = skillmod.CARD_SKILLS.get(reward)
|
| 739 |
-
if not spec:
|
| 740 |
-
return
|
| 741 |
-
name = spec.get("name", reward)
|
| 742 |
-
for p in self.players.values():
|
| 743 |
-
if p.alive and reward not in p.skill_cards:
|
| 744 |
-
self._add_skill(p, spec)
|
| 745 |
-
p.skill_cards.add(reward)
|
| 746 |
-
p.flash = f"Gained {name} from {cfg.get('name', 'the boss')}!"
|
| 747 |
-
p.flash_t = 3.0
|
| 748 |
-
|
| 749 |
-
def _apply_blessings(self):
|
| 750 |
-
"""Consume the Game Master's one-shot blessings at round start."""
|
| 751 |
-
for pid, blessing in self.pending_blessings.items():
|
| 752 |
-
p = self.players.get(pid)
|
| 753 |
-
if not p or blessing not in BLESSINGS:
|
| 754 |
-
continue
|
| 755 |
-
if blessing == "extra_life":
|
| 756 |
-
p.lives = min(MAX_LIVES, p.lives + 1)
|
| 757 |
-
if p.lives > 0 and not p.alive and p.respawn_t <= 0:
|
| 758 |
-
p.alive = True
|
| 759 |
-
p.hp = p.max_hp
|
| 760 |
-
p.flash = "🙏 Blessed: an extra life!"
|
| 761 |
-
elif blessing == "full_heal":
|
| 762 |
-
p.hp = p.max_hp
|
| 763 |
-
p.flash = "🙏 Blessed: fully healed!"
|
| 764 |
-
else: # a timed aura
|
| 765 |
-
spec = AURAS.get(blessing, {})
|
| 766 |
-
existing = next((a for a in p.auras if a["type"] == blessing), None)
|
| 767 |
-
if existing:
|
| 768 |
-
existing["t"] = spec.get("dur", 40)
|
| 769 |
-
else:
|
| 770 |
-
p.auras.append({"type": blessing, "t": spec.get("dur", 40)})
|
| 771 |
-
p.flash = f"🙏 Blessed: {spec.get('label', blessing)}!"
|
| 772 |
-
p.flash_t = 4.0
|
| 773 |
-
self._event("bless", p.x, p.y, color="#ffe9a0")
|
| 774 |
-
self.pending_blessings = {}
|
| 775 |
-
|
| 776 |
-
def _respawn(self, p: Player):
|
| 777 |
-
p.alive = True
|
| 778 |
-
p.respawn_t = 0.0
|
| 779 |
-
p.hp = p.max_hp
|
| 780 |
-
p.hurt = 1.0 # brief invuln flicker
|
| 781 |
-
ang = random.uniform(0, math.tau)
|
| 782 |
-
p.x = _clamp(CENTER[0] + math.cos(ang) * 420, 40, ARENA_W - 40)
|
| 783 |
-
p.y = _clamp(CENTER[1] + math.sin(ang) * 280, 40, ARENA_H - 40)
|
| 784 |
-
|
| 785 |
-
def round_summary(self) -> dict:
|
| 786 |
-
"""Compact, model-friendly summary of the round that just ended."""
|
| 787 |
-
won = self.boss.hp <= 0
|
| 788 |
-
return {
|
| 789 |
-
"round": self.round,
|
| 790 |
-
"result": "victory" if won else "defeat",
|
| 791 |
-
"duration_sec": round(time.time() - self.round_started_at, 1),
|
| 792 |
-
"boss_max_hp": self.boss.max_hp,
|
| 793 |
-
"players": [
|
| 794 |
-
{
|
| 795 |
-
"id": p.pid, "name": p.name, "level": p.level,
|
| 796 |
-
"survived": p.alive,
|
| 797 |
-
"damage_dealt": round(p.dmg_dealt),
|
| 798 |
-
"kills": p.kills, "gold": p.gold,
|
| 799 |
-
"lives_left": p.lives,
|
| 800 |
-
"hp_pct": round(100 * p.hp / p.max_hp) if p.alive else 0,
|
| 801 |
-
}
|
| 802 |
-
for p in self.players.values()
|
| 803 |
-
],
|
| 804 |
-
}
|
| 805 |
-
|
| 806 |
-
def apply_gm_decision(self, decision: dict):
|
| 807 |
-
"""Apply a (validated) Game Master decision to rewards + next config."""
|
| 808 |
-
rewards = decision.get("rewards", {}) or {}
|
| 809 |
-
for pid, amount in rewards.items():
|
| 810 |
-
p = self.players.get(pid)
|
| 811 |
-
if p:
|
| 812 |
-
amt = int(_clamp(int(amount), 0, 1000)) * (1 + 0.2 * p.up_gold_boost) # Prosperity
|
| 813 |
-
p.gold += int(amt)
|
| 814 |
-
nxt = decision.get("next_round", {}) or {}
|
| 815 |
-
self.cfg["boss_hp"] = int(_clamp(int(nxt.get("boss_hp", self.cfg["boss_hp"])), 300, 100000))
|
| 816 |
-
self.cfg["boss_damage"] = int(_clamp(int(nxt.get("boss_damage", self.cfg["boss_damage"])), 4, 80))
|
| 817 |
-
self.cfg["minion_hp"] = int(_clamp(int(nxt.get("minion_hp", self.cfg["minion_hp"])), 15, 2000))
|
| 818 |
-
self.cfg["minion_count"] = int(_clamp(int(nxt.get("minion_count", self.cfg["minion_count"])), 0, 40))
|
| 819 |
-
self.cfg["spawn_interval"] = float(_clamp(float(nxt.get("spawn_interval", self.cfg["spawn_interval"])), 1.0, 10.0))
|
| 820 |
-
self.cfg["boss_aggro"] = float(_clamp(float(nxt.get("boss_aggro", self.cfg["boss_aggro"])), 0.6, 3.0))
|
| 821 |
-
self.cfg["boss_attack_speed"] = float(_clamp(float(nxt.get("boss_attack_speed", self.cfg["boss_attack_speed"])), 0.5, 2.0))
|
| 822 |
-
# boss attack pattern for the coming round
|
| 823 |
-
pattern = nxt.get("boss_pattern", decision.get("boss_pattern"))
|
| 824 |
-
if pattern in BOSS_PATTERNS:
|
| 825 |
-
self.cfg["boss_pattern"] = pattern
|
| 826 |
-
# per-player blessings (validated against roster + known blessings)
|
| 827 |
-
blessings = decision.get("blessings")
|
| 828 |
-
if isinstance(blessings, dict):
|
| 829 |
-
self.pending_blessings = {
|
| 830 |
-
pid: b for pid, b in blessings.items()
|
| 831 |
-
if pid in self.players and b in BLESSINGS
|
| 832 |
-
}
|
| 833 |
-
# wave composition (enemy archetype weights)
|
| 834 |
-
wave = nxt.get("wave")
|
| 835 |
-
if isinstance(wave, dict):
|
| 836 |
-
clean = {}
|
| 837 |
-
for k in MINION_TYPES:
|
| 838 |
-
try:
|
| 839 |
-
clean[k] = max(0.0, float(wave.get(k, 0.0)))
|
| 840 |
-
except Exception:
|
| 841 |
-
clean[k] = 0.0
|
| 842 |
-
if sum(clean.values()) > 0:
|
| 843 |
-
self.cfg["wave"] = clean
|
| 844 |
-
# offered level-up card pool
|
| 845 |
-
pool = decision.get("card_pool")
|
| 846 |
-
if isinstance(pool, list):
|
| 847 |
-
valid = [c for c in pool if c in CARDS]
|
| 848 |
-
self.card_pool = valid if valid else list(ALL_CARD_IDS)
|
| 849 |
-
self.gm_message = str(decision.get("message", ""))[:240]
|
| 850 |
-
|
| 851 |
-
def _pick_minion_kind(self) -> str:
|
| 852 |
-
weights = self.cfg.get("wave") or {"grunt": 1.0}
|
| 853 |
-
kinds = [k for k in MINION_TYPES if weights.get(k, 0) > 0] or ["grunt"]
|
| 854 |
-
w = [weights.get(k, 0) for k in kinds]
|
| 855 |
-
return random.choices(kinds, weights=w, k=1)[0]
|
| 856 |
-
|
| 857 |
-
def begin_intermission(self, seconds: float = 12.0):
|
| 858 |
-
self.status = "intermission"
|
| 859 |
-
self.intermission_until = time.time() + seconds
|
| 860 |
-
won = self.boss.hp <= 0
|
| 861 |
-
self.status_msg = ("Victory! " if won else "Defeated... ") + \
|
| 862 |
-
"The Game Master is deciding rewards & the next round."
|
| 863 |
-
# the round's top damage-dealer may ask the GM for a custom power-up
|
| 864 |
-
self.skill_request_pid = self.top_player_id()
|
| 865 |
-
self.skill_request_used = False
|
| 866 |
-
|
| 867 |
-
# ---- simulation -------------------------------------------------------
|
| 868 |
-
def _spawn_minion(self):
|
| 869 |
-
# Minions are summoned by the boss: they emerge from around it.
|
| 870 |
-
ang = random.uniform(0, math.tau)
|
| 871 |
-
dist = random.uniform(50, 95)
|
| 872 |
-
x = _clamp(self.boss.x + math.cos(ang) * dist, 30, ARENA_W - 30)
|
| 873 |
-
y = _clamp(self.boss.y + math.sin(ang) * dist, 30, ARENA_H - 30)
|
| 874 |
-
kind = self._pick_minion_kind()
|
| 875 |
-
t = MINION_TYPES[kind]
|
| 876 |
-
hp = self.cfg["minion_hp"] * t["hp"]
|
| 877 |
-
self._minion_seq += 1
|
| 878 |
-
self.minions.append(Minion(
|
| 879 |
-
x=x, y=y, hp=hp, max_hp=hp,
|
| 880 |
-
speed=(70 + self.round * 4) * t["speed"],
|
| 881 |
-
dmg=(6 + self.round) * t["dmg"],
|
| 882 |
-
uid=self._minion_seq, kind=kind, scale=t["scale"],
|
| 883 |
-
))
|
| 884 |
-
|
| 885 |
-
def _nearest_player(self, x, y):
|
| 886 |
-
best, bd = None, 1e18
|
| 887 |
-
for p in self.players.values():
|
| 888 |
-
if not p.alive:
|
| 889 |
-
continue
|
| 890 |
-
d = (p.x - x) ** 2 + (p.y - y) ** 2
|
| 891 |
-
if d < bd:
|
| 892 |
-
best, bd = p, d
|
| 893 |
-
return best
|
| 894 |
-
|
| 895 |
-
def _nearest_enemy(self, x, y):
|
| 896 |
-
"""Closest minion, else the boss."""
|
| 897 |
-
best, bd = None, 1e18
|
| 898 |
-
for m in self.minions:
|
| 899 |
-
if not m.alive:
|
| 900 |
-
continue
|
| 901 |
-
d = (m.x - x) ** 2 + (m.y - y) ** 2
|
| 902 |
-
if d < bd:
|
| 903 |
-
best, bd = m, d
|
| 904 |
-
if self.boss.hp > 0:
|
| 905 |
-
d = (self.boss.x - x) ** 2 + (self.boss.y - y) ** 2
|
| 906 |
-
if d < bd or best is None:
|
| 907 |
-
best, bd = self.boss, d
|
| 908 |
-
return best
|
| 909 |
-
|
| 910 |
-
def tick(self, dt: float):
|
| 911 |
-
if self.status != "active":
|
| 912 |
-
return
|
| 913 |
-
|
| 914 |
-
for p in self.players.values():
|
| 915 |
-
p.hurt = max(0.0, p.hurt - dt)
|
| 916 |
-
p.attack_timer = max(0.0, p.attack_timer - dt)
|
| 917 |
-
if p.flash_t > 0:
|
| 918 |
-
p.flash_t -= dt
|
| 919 |
-
# timed auras count down and expire
|
| 920 |
-
if p.auras:
|
| 921 |
-
for a in p.auras:
|
| 922 |
-
a["t"] -= dt
|
| 923 |
-
p.auras = [a for a in p.auras if a["t"] > 0]
|
| 924 |
-
# auto-pick a pending card if the player dithers past the deadline
|
| 925 |
-
if p.pending_cards and time.time() >= p.pending_until:
|
| 926 |
-
self.choose_card(p.pid, p.pending_cards[0])
|
| 927 |
-
if not p.alive:
|
| 928 |
-
# respawn after a short delay while lives remain
|
| 929 |
-
if p.lives > 0 and p.respawn_t > 0:
|
| 930 |
-
p.respawn_t -= dt
|
| 931 |
-
if p.respawn_t <= 0:
|
| 932 |
-
self._respawn(p)
|
| 933 |
-
continue
|
| 934 |
-
if p.regen and p.hp < p.max_hp:
|
| 935 |
-
p.hp = min(p.max_hp, p.hp + p.regen * dt)
|
| 936 |
-
# periodic skills
|
| 937 |
-
for sk in p.skills:
|
| 938 |
-
if sk["trigger"] == "periodic":
|
| 939 |
-
sk["_cd"] = sk.get("_cd", sk.get("interval", 5.0)) - dt
|
| 940 |
-
if sk["_cd"] <= 0:
|
| 941 |
-
sk["_cd"] = sk.get("interval", 5.0)
|
| 942 |
-
self._fire_skill(p, sk)
|
| 943 |
-
dx = (1 if p.right else 0) - (1 if p.left else 0)
|
| 944 |
-
dy = (1 if p.down else 0) - (1 if p.up else 0)
|
| 945 |
-
p.moving = dx != 0 or dy != 0
|
| 946 |
-
if p.moving:
|
| 947 |
-
inv = 1.0 / math.hypot(dx, dy)
|
| 948 |
-
p.x = _clamp(p.x + dx * inv * p.move_speed * dt, 24, ARENA_W - 24)
|
| 949 |
-
p.y = _clamp(p.y + dy * inv * p.move_speed * dt, 24, ARENA_H - 24)
|
| 950 |
-
# aim at nearest enemy for satisfying co-op feel
|
| 951 |
-
tgt = self._nearest_enemy(p.x, p.y)
|
| 952 |
-
if tgt is not None:
|
| 953 |
-
p.facing = math.atan2(tgt.y - p.y, tgt.x - p.x)
|
| 954 |
-
# attack (Auto-Cast fires without holding the key)
|
| 955 |
-
if (p.attacking or p.up_auto_attack) and p.attack_timer <= 0:
|
| 956 |
-
self._player_shoot(p)
|
| 957 |
-
p.attack_timer = p.attack_interval
|
| 958 |
-
|
| 959 |
-
self._tick_minions(dt)
|
| 960 |
-
self._tick_boss(dt)
|
| 961 |
-
self._tick_allies(dt)
|
| 962 |
-
self._tick_projectiles(dt)
|
| 963 |
-
self._tick_gems(dt)
|
| 964 |
-
self._tick_pickups(dt)
|
| 965 |
-
|
| 966 |
-
self.minions = [m for m in self.minions if m.alive]
|
| 967 |
-
self.allies = [a for a in self.allies if a.ttl > 0 and a.hp > 0]
|
| 968 |
-
|
| 969 |
-
# round end conditions
|
| 970 |
-
if self.boss.hp <= 0:
|
| 971 |
-
# boss drops a shower of high-tier gems
|
| 972 |
-
for _ in range(10):
|
| 973 |
-
ang = random.uniform(0, math.tau)
|
| 974 |
-
d = random.uniform(20, 140)
|
| 975 |
-
gx = _clamp(self.boss.x + math.cos(ang) * d, 30, ARENA_W - 30)
|
| 976 |
-
gy = _clamp(self.boss.y + math.sin(ang) * d, 30, ARENA_H - 30)
|
| 977 |
-
g = self._make_gem(gx, gy, "tank")
|
| 978 |
-
g.tier = int(_clamp(g.tier + 2, 1, 7))
|
| 979 |
-
g.value = GEM_TIER_XP[g.tier]
|
| 980 |
-
self.gems.append(g)
|
| 981 |
-
# special boss defeated -> every survivor gains its attack power
|
| 982 |
-
if self.boss.special:
|
| 983 |
-
self._grant_boss_reward(self.boss.special)
|
| 984 |
-
self.begin_intermission()
|
| 985 |
-
elif self.players and all(not p.alive and p.respawn_t <= 0 for p in self.players.values()):
|
| 986 |
-
# every wizard is down with no respawn pending (all eliminated)
|
| 987 |
-
self.begin_intermission()
|
| 988 |
-
|
| 989 |
-
def _tick_allies(self, dt: float):
|
| 990 |
-
"""Summoned spirits chase the nearest enemy and fire bolts at it."""
|
| 991 |
-
for a in self.allies:
|
| 992 |
-
a.ttl -= dt
|
| 993 |
-
a.attack_cd = max(0.0, a.attack_cd - dt)
|
| 994 |
-
tgt = self._nearest_enemy(a.x, a.y)
|
| 995 |
-
if tgt is None:
|
| 996 |
-
continue
|
| 997 |
-
dist = math.hypot(tgt.x - a.x, tgt.y - a.y)
|
| 998 |
-
ang = math.atan2(tgt.y - a.y, tgt.x - a.x)
|
| 999 |
-
if dist > 180:
|
| 1000 |
-
a.x += math.cos(ang) * a.speed * dt
|
| 1001 |
-
a.y += math.sin(ang) * a.speed * dt
|
| 1002 |
-
if a.attack_cd <= 0:
|
| 1003 |
-
self.projectiles.append(Projectile(
|
| 1004 |
-
x=a.x, y=a.y, vx=math.cos(ang) * 480, vy=math.sin(ang) * 480,
|
| 1005 |
-
dmg=a.dmg, owner=a.owner, hostile=False, radius=7, ttl=1.5,
|
| 1006 |
-
))
|
| 1007 |
-
a.attack_cd = 0.8
|
| 1008 |
-
|
| 1009 |
-
def _tick_gems(self, dt: float):
|
| 1010 |
-
"""XP gems drift toward nearby living players and are auto-collected."""
|
| 1011 |
-
alive = []
|
| 1012 |
-
for g in self.gems:
|
| 1013 |
-
g.ttl -= dt
|
| 1014 |
-
if g.ttl <= 0:
|
| 1015 |
-
continue
|
| 1016 |
-
tgt = self._nearest_player(g.x, g.y)
|
| 1017 |
-
collected = False
|
| 1018 |
-
if tgt is not None:
|
| 1019 |
-
pull = 130.0 + tgt.up_magnet * 55 # Lodestone
|
| 1020 |
-
grab = 26.0 + tgt.up_magnet * 10
|
| 1021 |
-
d = math.hypot(tgt.x - g.x, tgt.y - g.y)
|
| 1022 |
-
if d <= grab:
|
| 1023 |
-
amount = g.value * (1 + 0.15 * tgt.up_xp_boost) # Quick Study
|
| 1024 |
-
self._grant_xp(tgt, max(1, round(amount)))
|
| 1025 |
-
collected = True
|
| 1026 |
-
elif d <= pull:
|
| 1027 |
-
ang = math.atan2(tgt.y - g.y, tgt.x - g.x)
|
| 1028 |
-
g.x += math.cos(ang) * 240 * dt
|
| 1029 |
-
g.y += math.sin(ang) * 240 * dt
|
| 1030 |
-
if not collected:
|
| 1031 |
-
alive.append(g)
|
| 1032 |
-
self.gems = alive
|
| 1033 |
-
|
| 1034 |
-
def _tick_pickups(self, dt: float):
|
| 1035 |
-
"""Floor power-ups: walk over one to claim the card instantly."""
|
| 1036 |
-
alive = []
|
| 1037 |
-
for pk in self.pickups:
|
| 1038 |
-
pk.ttl -= dt
|
| 1039 |
-
if pk.ttl <= 0:
|
| 1040 |
-
continue
|
| 1041 |
-
taken = False
|
| 1042 |
-
for p in self.players.values():
|
| 1043 |
-
if p.alive and (p.x - pk.x) ** 2 + (p.y - pk.y) ** 2 < 34 ** 2:
|
| 1044 |
-
if pk.kind == "aura":
|
| 1045 |
-
spec = AURAS.get(pk.card, {})
|
| 1046 |
-
# refresh if already active, else add
|
| 1047 |
-
existing = next((a for a in p.auras if a["type"] == pk.card), None)
|
| 1048 |
-
if existing:
|
| 1049 |
-
existing["t"] = spec.get("dur", 40)
|
| 1050 |
-
else:
|
| 1051 |
-
p.auras.append({"type": pk.card, "t": spec.get("dur", 40)})
|
| 1052 |
-
p.flash = f"{spec.get('label', pk.card)}!"
|
| 1053 |
-
else:
|
| 1054 |
-
self._apply_card(p, pk.card)
|
| 1055 |
-
p.flash = f"Power-up: {CARDS.get(pk.card, (pk.card,))[0]}!"
|
| 1056 |
-
p.flash_t = 2.5
|
| 1057 |
-
taken = True
|
| 1058 |
-
break
|
| 1059 |
-
if not taken:
|
| 1060 |
-
alive.append(pk)
|
| 1061 |
-
self.pickups = alive
|
| 1062 |
-
|
| 1063 |
-
def _player_shoot(self, p: Player):
|
| 1064 |
-
spread = 0.18
|
| 1065 |
-
n = p.shots
|
| 1066 |
-
base = p.facing
|
| 1067 |
-
radius = 9 + p.up_big_shots * 4
|
| 1068 |
-
for i in range(n):
|
| 1069 |
-
off = 0 if n == 1 else (i - (n - 1) / 2) * spread
|
| 1070 |
-
ang = base + off
|
| 1071 |
-
dmg = p.damage
|
| 1072 |
-
crit = p.up_crit and random.random() < min(0.6, 0.08 * p.up_crit)
|
| 1073 |
-
if crit:
|
| 1074 |
-
dmg *= 2
|
| 1075 |
-
self.projectiles.append(Projectile(
|
| 1076 |
-
x=p.x, y=p.y,
|
| 1077 |
-
vx=math.cos(ang) * p.projectile_speed,
|
| 1078 |
-
vy=math.sin(ang) * p.projectile_speed,
|
| 1079 |
-
dmg=dmg, owner=p.pid, hostile=False, radius=radius,
|
| 1080 |
-
ttl=p.projectile_ttl, pierce=p.up_pierce,
|
| 1081 |
-
homing=p.up_homing > 0, burn=p.up_burn * 4.0,
|
| 1082 |
-
explode=60.0 if p.up_explosive else 0.0,
|
| 1083 |
-
knock=p.up_knockback * 9.0, chain=p.up_chain, crit=bool(crit),
|
| 1084 |
-
))
|
| 1085 |
-
# attack-triggered skills
|
| 1086 |
-
p._attack_count += 1
|
| 1087 |
-
for sk in p.skills:
|
| 1088 |
-
if sk["trigger"] == "every_n_attacks" and p._attack_count % sk.get("n", 5) == 0:
|
| 1089 |
-
self._fire_skill(p, sk)
|
| 1090 |
-
|
| 1091 |
-
# ---- skill effects ----------------------------------------------------
|
| 1092 |
-
def _fire_skill(self, p: Player, sk: dict):
|
| 1093 |
-
eff = sk["effect"]
|
| 1094 |
-
color = sk.get("color", "#b48cff")
|
| 1095 |
-
if eff == "aoe_damage":
|
| 1096 |
-
self._event("aoe_frost" if sk.get("slow") else "aoe", p.x, p.y,
|
| 1097 |
-
r=sk["radius"], color=color)
|
| 1098 |
-
r2 = sk["radius"] ** 2
|
| 1099 |
-
for m in self.minions:
|
| 1100 |
-
if m.alive and (m.x - p.x) ** 2 + (m.y - p.y) ** 2 <= r2:
|
| 1101 |
-
m.hp -= sk["damage"]; m.hurt = 0.15
|
| 1102 |
-
if sk.get("slow"):
|
| 1103 |
-
m.speed *= (1 - sk["slow"])
|
| 1104 |
-
p.dmg_dealt += sk["damage"]
|
| 1105 |
-
if m.hp <= 0:
|
| 1106 |
-
m.alive = False
|
| 1107 |
-
self._on_minion_killed(m, p)
|
| 1108 |
-
if self.boss.hp > 0 and (self.boss.x - p.x) ** 2 + (self.boss.y - p.y) ** 2 <= r2:
|
| 1109 |
-
self.boss.hp -= sk["damage"]; self.boss.hurt = 0.1; p.dmg_dealt += sk["damage"]
|
| 1110 |
-
elif eff == "projectile_nova":
|
| 1111 |
-
self._event("nova", p.x, p.y, color=color)
|
| 1112 |
-
cnt = sk["count"]
|
| 1113 |
-
for k in range(cnt):
|
| 1114 |
-
ang = k * math.tau / cnt
|
| 1115 |
-
self.projectiles.append(Projectile(
|
| 1116 |
-
x=p.x, y=p.y, vx=math.cos(ang) * 460, vy=math.sin(ang) * 460,
|
| 1117 |
-
dmg=sk["damage"], owner=p.pid, hostile=False, radius=8, ttl=1.8,
|
| 1118 |
-
))
|
| 1119 |
-
elif eff == "heal":
|
| 1120 |
-
self._event("heal", p.x, p.y, color=color)
|
| 1121 |
-
p.hp = min(p.max_hp, p.hp + sk["amount"])
|
| 1122 |
-
elif eff == "shield":
|
| 1123 |
-
self._event("shield", p.x, p.y, color=color)
|
| 1124 |
-
p.shield = min(120.0, p.shield + sk["amount"])
|
| 1125 |
-
elif eff == "summon_ally":
|
| 1126 |
-
self._event("summon", p.x, p.y, color=color)
|
| 1127 |
-
for _ in range(sk["count"]):
|
| 1128 |
-
self._summon_ally(p)
|
| 1129 |
-
|
| 1130 |
-
def _summon_ally(self, p: Player):
|
| 1131 |
-
if len(self.allies) >= 24:
|
| 1132 |
-
return
|
| 1133 |
-
self._ally_seq += 1
|
| 1134 |
-
ang = random.uniform(0, math.tau)
|
| 1135 |
-
self.allies.append(Ally(
|
| 1136 |
-
x=_clamp(p.x + math.cos(ang) * 40, 24, ARENA_W - 24),
|
| 1137 |
-
y=_clamp(p.y + math.sin(ang) * 40, 24, ARENA_H - 24),
|
| 1138 |
-
owner=p.pid, uid=self._ally_seq,
|
| 1139 |
-
hp=30 + p.level * 3, max_hp=30 + p.level * 3,
|
| 1140 |
-
dmg=10 + p.up_damage * 3,
|
| 1141 |
-
))
|
| 1142 |
-
|
| 1143 |
-
def _tick_minions(self, dt: float):
|
| 1144 |
-
for m in self.minions:
|
| 1145 |
-
m.hurt = max(0.0, m.hurt - dt)
|
| 1146 |
-
m.attack_cd = max(0.0, m.attack_cd - dt)
|
| 1147 |
-
# Ignite: burn damage over time
|
| 1148 |
-
if m.burn_t > 0:
|
| 1149 |
-
m.burn_t -= dt
|
| 1150 |
-
m.hp -= m.burn * dt
|
| 1151 |
-
if m.hp <= 0:
|
| 1152 |
-
m.alive = False
|
| 1153 |
-
self._on_minion_killed(m, None)
|
| 1154 |
-
continue
|
| 1155 |
-
tgt = self._nearest_player(m.x, m.y)
|
| 1156 |
-
if tgt is None:
|
| 1157 |
-
continue
|
| 1158 |
-
# Frost Presence card + Frost Aura: nearby enemies move slower
|
| 1159 |
-
speed = m.speed
|
| 1160 |
-
slow = 0.12 * tgt.up_slow_aura + tgt._aura_max("enemy_slow")
|
| 1161 |
-
if slow and (tgt.x - m.x) ** 2 + (tgt.y - m.y) ** 2 < 150 ** 2:
|
| 1162 |
-
speed *= max(0.25, 1 - slow)
|
| 1163 |
-
ang = math.atan2(tgt.y - m.y, tgt.x - m.x)
|
| 1164 |
-
dist = math.hypot(tgt.x - m.x, tgt.y - m.y)
|
| 1165 |
-
reach = 20 + tgt.hit_radius # bigger wizards are easier to reach
|
| 1166 |
-
if dist > reach:
|
| 1167 |
-
m.x += math.cos(ang) * speed * dt
|
| 1168 |
-
m.y += math.sin(ang) * speed * dt
|
| 1169 |
-
elif m.attack_cd <= 0:
|
| 1170 |
-
self._damage_player(tgt, m.dmg)
|
| 1171 |
-
# Spiked Aura: reflect damage to the attacker
|
| 1172 |
-
if tgt.up_thorns:
|
| 1173 |
-
m.hp -= 5 * tgt.up_thorns
|
| 1174 |
-
m.hurt = 0.15
|
| 1175 |
-
if m.hp <= 0:
|
| 1176 |
-
m.alive = False
|
| 1177 |
-
self._on_minion_killed(m, tgt)
|
| 1178 |
-
m.attack_cd = 1.0
|
| 1179 |
-
|
| 1180 |
-
def _tick_boss(self, dt: float):
|
| 1181 |
-
b = self.boss
|
| 1182 |
-
if b.hp <= 0:
|
| 1183 |
-
return
|
| 1184 |
-
b.hurt = max(0.0, b.hurt - dt)
|
| 1185 |
-
b.shoot_cd = max(0.0, b.shoot_cd - dt)
|
| 1186 |
-
b.spawn_cd = max(0.0, b.spawn_cd - dt)
|
| 1187 |
-
b.charge_cd = max(0.0, b.charge_cd - dt)
|
| 1188 |
-
b.nova_cd = max(0.0, b.nova_cd - dt)
|
| 1189 |
-
phase2 = b.hp <= b.max_hp * 0.5
|
| 1190 |
-
# combined attack cadence: aggro (difficulty) x atk_speed (GM mercy knob)
|
| 1191 |
-
agg = b.aggro * (1.3 if phase2 else 1.0) * _clamp(b.atk_speed, 0.5, 2.0)
|
| 1192 |
-
pat = BOSS_PATTERNS.get(b.pattern, BOSS_PATTERNS["balanced"])
|
| 1193 |
-
|
| 1194 |
-
# ---- charge attack: telegraph, then rush a player and melee hard ----
|
| 1195 |
-
if b.state == "telegraph":
|
| 1196 |
-
b.state_t -= dt
|
| 1197 |
-
if b.state_t <= 0:
|
| 1198 |
-
b.state = "charge"
|
| 1199 |
-
b.state_t = 0.55
|
| 1200 |
-
return # boss is winding up, no other actions
|
| 1201 |
-
if b.state == "charge":
|
| 1202 |
-
b.state_t -= dt
|
| 1203 |
-
b.x = _clamp(b.x + b.cvx * dt, 40, ARENA_W - 40)
|
| 1204 |
-
b.y = _clamp(b.y + b.cvy * dt, 40, ARENA_H - 40)
|
| 1205 |
-
for p in self.players.values():
|
| 1206 |
-
if p.alive and (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < (54 + p.hit_radius) ** 2:
|
| 1207 |
-
self._damage_player(p, b.dmg * 2.5) # massive melee
|
| 1208 |
-
if b.state_t <= 0:
|
| 1209 |
-
b.state = "idle"
|
| 1210 |
-
b.charge_cd = max(2.0, 8.0 / (agg * pat["charge"]))
|
| 1211 |
-
return
|
| 1212 |
-
# drift back toward the centre when idle
|
| 1213 |
-
b.x += (CENTER[0] - b.x) * min(1.0, dt * 0.6)
|
| 1214 |
-
b.y += (CENTER[1] - b.y) * min(1.0, dt * 0.6)
|
| 1215 |
-
|
| 1216 |
-
# decide whether to start a charge (hunts down players who keep distance)
|
| 1217 |
-
if b.charge_cd <= 0:
|
| 1218 |
-
tgt = self._nearest_player(b.x, b.y)
|
| 1219 |
-
if tgt:
|
| 1220 |
-
ang = math.atan2(tgt.y - b.y, tgt.x - b.x)
|
| 1221 |
-
speed = 620 + self.round * 8
|
| 1222 |
-
b.cvx, b.cvy = math.cos(ang) * speed, math.sin(ang) * speed
|
| 1223 |
-
b.state = "telegraph"
|
| 1224 |
-
b.state_t = 0.7
|
| 1225 |
-
return
|
| 1226 |
-
|
| 1227 |
-
# everything below creates boss projectiles; tag them with the boss's
|
| 1228 |
-
# visual style (holy / glitch) at the end.
|
| 1229 |
-
n0 = len(self.projectiles)
|
| 1230 |
-
bstyle = SPECIAL_BOSSES.get(b.special, {}).get("style", "")
|
| 1231 |
-
|
| 1232 |
-
# spawn minions (the SWARMLORD pattern leans hard on this)
|
| 1233 |
-
if b.spawn_cd <= 0 and len(self.minions) < 30:
|
| 1234 |
-
burst = max(1, round((self.cfg["minion_count"] / 3) * pat["spawn"]))
|
| 1235 |
-
for _ in range(burst):
|
| 1236 |
-
self._spawn_minion()
|
| 1237 |
-
b.spawn_cd = b.spawn_interval
|
| 1238 |
-
|
| 1239 |
-
# ---- special boss signature attacks ----
|
| 1240 |
-
if b.special:
|
| 1241 |
-
b.special_cd = max(0.0, b.special_cd - dt)
|
| 1242 |
-
if b.special_cd <= 0:
|
| 1243 |
-
self._boss_signature(b)
|
| 1244 |
-
b.special_cd = max(2.5, 5.0 / agg)
|
| 1245 |
-
|
| 1246 |
-
# ---- ground shockwave: dense ring that punishes standing still ----
|
| 1247 |
-
if b.nova_cd <= 0:
|
| 1248 |
-
heavy = pat.get("slow_heavy")
|
| 1249 |
-
n = 20 if phase2 else 14
|
| 1250 |
-
spd, rad, dmul = (160, 18, 1.3) if heavy else (200, 13, 1.0)
|
| 1251 |
-
for k in range(n):
|
| 1252 |
-
ang = k * math.tau / n
|
| 1253 |
-
self.projectiles.append(Projectile(
|
| 1254 |
-
x=b.x, y=b.y, vx=math.cos(ang) * spd, vy=math.sin(ang) * spd,
|
| 1255 |
-
dmg=b.dmg * dmul, owner="boss", hostile=True, ttl=4.5, radius=rad,
|
| 1256 |
-
))
|
| 1257 |
-
b.nova_cd = max(1.8, 6.0 / (agg * pat["nova"]))
|
| 1258 |
-
|
| 1259 |
-
# ---- aimed spread / radial bursts (style depends on the pattern) ----
|
| 1260 |
-
if b.shoot_cd <= 0:
|
| 1261 |
-
tgt = self._nearest_player(b.x, b.y)
|
| 1262 |
-
if pat.get("snipe") and tgt:
|
| 1263 |
-
# SNIPER: one fast, painful, well-aimed bolt
|
| 1264 |
-
ang = math.atan2(tgt.y - b.y, tgt.x - b.x)
|
| 1265 |
-
self.projectiles.append(Projectile(
|
| 1266 |
-
x=b.x, y=b.y, vx=math.cos(ang) * 540, vy=math.sin(ang) * 540,
|
| 1267 |
-
dmg=b.dmg * 1.8, owner="boss", hostile=True, ttl=3.5, radius=9,
|
| 1268 |
-
))
|
| 1269 |
-
b.shoot_cd = 1.3 / (agg * pat["shoot"])
|
| 1270 |
-
elif pat.get("slow_heavy") and tgt:
|
| 1271 |
-
# ARTILLERY: lob big slow orbs at the target
|
| 1272 |
-
ang = math.atan2(tgt.y - b.y, tgt.x - b.x) + random.uniform(-0.15, 0.15)
|
| 1273 |
-
self.projectiles.append(Projectile(
|
| 1274 |
-
x=b.x, y=b.y, vx=math.cos(ang) * 170, vy=math.sin(ang) * 170,
|
| 1275 |
-
dmg=b.dmg * 1.4, owner="boss", hostile=True, ttl=5.5, radius=20,
|
| 1276 |
-
))
|
| 1277 |
-
b.shoot_cd = 1.6 / (agg * pat["shoot"])
|
| 1278 |
-
elif phase2:
|
| 1279 |
-
for k in range(12):
|
| 1280 |
-
ang = k * math.tau / 12 + random.uniform(-0.1, 0.1)
|
| 1281 |
-
self.projectiles.append(Projectile(
|
| 1282 |
-
x=b.x, y=b.y, vx=math.cos(ang) * 240, vy=math.sin(ang) * 240,
|
| 1283 |
-
dmg=b.dmg, owner="boss", hostile=True, ttl=4, radius=12,
|
| 1284 |
-
))
|
| 1285 |
-
b.shoot_cd = 2.2 / (agg * pat["shoot"])
|
| 1286 |
-
elif tgt:
|
| 1287 |
-
ang = math.atan2(tgt.y - b.y, tgt.x - b.x)
|
| 1288 |
-
for off in (-0.22, -0.11, 0, 0.11, 0.22):
|
| 1289 |
-
self.projectiles.append(Projectile(
|
| 1290 |
-
x=b.x, y=b.y,
|
| 1291 |
-
vx=math.cos(ang + off) * 280, vy=math.sin(ang + off) * 280,
|
| 1292 |
-
dmg=b.dmg, owner="boss", hostile=True, ttl=4, radius=12,
|
| 1293 |
-
))
|
| 1294 |
-
b.shoot_cd = 1.8 / (agg * pat["shoot"])
|
| 1295 |
-
|
| 1296 |
-
# apply the boss's projectile style to everything it just fired
|
| 1297 |
-
if bstyle:
|
| 1298 |
-
for pr in self.projectiles[n0:]:
|
| 1299 |
-
pr.style = bstyle
|
| 1300 |
-
|
| 1301 |
-
def _boss_signature(self, b: Boss):
|
| 1302 |
-
"""A unique telegraphed attack per special boss."""
|
| 1303 |
-
if b.special == "aegis":
|
| 1304 |
-
# Holy Cross: a rotating + and x of holy bolts that punish all angles
|
| 1305 |
-
base = random.uniform(0, math.tau)
|
| 1306 |
-
for k in range(8):
|
| 1307 |
-
ang = base + k * math.tau / 8
|
| 1308 |
-
self.projectiles.append(Projectile(
|
| 1309 |
-
x=b.x, y=b.y, vx=math.cos(ang) * 260, vy=math.sin(ang) * 260,
|
| 1310 |
-
dmg=b.dmg * 1.2, owner="boss", hostile=True, ttl=4.5, radius=16,
|
| 1311 |
-
))
|
| 1312 |
-
# plus aimed holy spears at the nearest two players
|
| 1313 |
-
for p in list(self.players.values())[:2]:
|
| 1314 |
-
if p.alive:
|
| 1315 |
-
ang = math.atan2(p.y - b.y, p.x - b.x)
|
| 1316 |
-
self.projectiles.append(Projectile(
|
| 1317 |
-
x=b.x, y=b.y, vx=math.cos(ang) * 360, vy=math.sin(ang) * 360,
|
| 1318 |
-
dmg=b.dmg * 1.6, owner="boss", hostile=True, ttl=4, radius=18,
|
| 1319 |
-
))
|
| 1320 |
-
elif b.special == "toaster":
|
| 1321 |
-
# Glitch Portals: open rifts near players that spit projectiles outward
|
| 1322 |
-
for _ in range(3):
|
| 1323 |
-
tgt = self._nearest_player(b.x, b.y)
|
| 1324 |
-
px = random.uniform(150, ARENA_W - 150)
|
| 1325 |
-
py = random.uniform(120, ARENA_H - 120)
|
| 1326 |
-
if tgt and random.random() < 0.6:
|
| 1327 |
-
px, py = tgt.x + random.uniform(-80, 80), tgt.y + random.uniform(-80, 80)
|
| 1328 |
-
for k in range(6):
|
| 1329 |
-
ang = k * math.tau / 6
|
| 1330 |
-
self.projectiles.append(Projectile(
|
| 1331 |
-
x=px, y=py, vx=math.cos(ang) * 210, vy=math.sin(ang) * 210,
|
| 1332 |
-
dmg=b.dmg, owner="boss", hostile=True, ttl=3.5, radius=14,
|
| 1333 |
-
))
|
| 1334 |
-
|
| 1335 |
-
def _tick_projectiles(self, dt: float):
|
| 1336 |
-
alive = []
|
| 1337 |
-
for pr in self.projectiles:
|
| 1338 |
-
# Seeker Bolts: steer toward the nearest enemy
|
| 1339 |
-
if pr.homing and not pr.hostile:
|
| 1340 |
-
tgt = self._nearest_enemy(pr.x, pr.y)
|
| 1341 |
-
if tgt is not None:
|
| 1342 |
-
sp = math.hypot(pr.vx, pr.vy) or 1.0
|
| 1343 |
-
want = math.atan2(tgt.y - pr.y, tgt.x - pr.x)
|
| 1344 |
-
cur = math.atan2(pr.vy, pr.vx)
|
| 1345 |
-
d = (want - cur + math.pi) % math.tau - math.pi
|
| 1346 |
-
cur += max(-6 * dt, min(6 * dt, d))
|
| 1347 |
-
pr.vx, pr.vy = math.cos(cur) * sp, math.sin(cur) * sp
|
| 1348 |
-
pr.x += pr.vx * dt
|
| 1349 |
-
pr.y += pr.vy * dt
|
| 1350 |
-
pr.ttl -= dt
|
| 1351 |
-
if pr.ttl <= 0 or pr.x < -40 or pr.x > ARENA_W + 40 or pr.y < -40 or pr.y > ARENA_H + 40:
|
| 1352 |
-
continue
|
| 1353 |
-
hit = False
|
| 1354 |
-
if pr.hostile:
|
| 1355 |
-
for p in self.players.values():
|
| 1356 |
-
if p.alive and (p.x - pr.x) ** 2 + (p.y - pr.y) ** 2 < (pr.radius + p.hit_radius) ** 2:
|
| 1357 |
-
self._damage_player(p, pr.dmg)
|
| 1358 |
-
hit = True
|
| 1359 |
-
break
|
| 1360 |
-
else:
|
| 1361 |
-
owner = self.players.get(pr.owner)
|
| 1362 |
-
for m in self.minions:
|
| 1363 |
-
if not m.alive or m.uid in pr.hit_ids:
|
| 1364 |
-
continue
|
| 1365 |
-
if (m.x - pr.x) ** 2 + (m.y - pr.y) ** 2 < (pr.radius + 22) ** 2:
|
| 1366 |
-
m.hp -= pr.dmg
|
| 1367 |
-
m.hurt = 0.15
|
| 1368 |
-
pr.hit_ids.add(m.uid)
|
| 1369 |
-
if owner:
|
| 1370 |
-
owner.dmg_dealt += pr.dmg
|
| 1371 |
-
self._apply_hit_effects(pr, m, owner)
|
| 1372 |
-
if m.hp <= 0:
|
| 1373 |
-
m.alive = False
|
| 1374 |
-
self._on_minion_killed(m, owner)
|
| 1375 |
-
hit = True
|
| 1376 |
-
break
|
| 1377 |
-
if not hit and self.boss.hp > 0 and \
|
| 1378 |
-
(self.boss.x - pr.x) ** 2 + (self.boss.y - pr.y) ** 2 < (pr.radius + 70) ** 2:
|
| 1379 |
-
self.boss.hp -= pr.dmg
|
| 1380 |
-
self.boss.hurt = 0.1
|
| 1381 |
-
if owner:
|
| 1382 |
-
owner.dmg_dealt += pr.dmg
|
| 1383 |
-
if owner.up_lifesteal:
|
| 1384 |
-
self._heal(owner, pr.dmg * 0.03 * owner.up_lifesteal)
|
| 1385 |
-
hit = True
|
| 1386 |
-
# pierce: a player shot can pass through extra enemies before dying
|
| 1387 |
-
if hit and not pr.hostile and pr.pierce > 0:
|
| 1388 |
-
pr.pierce -= 1
|
| 1389 |
-
hit = False
|
| 1390 |
-
if not hit:
|
| 1391 |
-
alive.append(pr)
|
| 1392 |
-
self.projectiles = alive
|
| 1393 |
-
|
| 1394 |
-
def _heal(self, p: Player, amt: float):
|
| 1395 |
-
p.hp = min(p.max_hp, p.hp + amt)
|
| 1396 |
-
|
| 1397 |
-
def _apply_hit_effects(self, pr: Projectile, m: Minion, owner: "Player | None"):
|
| 1398 |
-
"""Run on a player shot striking a minion: lifesteal/burn/knock/explode/chain."""
|
| 1399 |
-
if owner:
|
| 1400 |
-
ls = 0.03 * owner.up_lifesteal + owner._aura_max("lifesteal")
|
| 1401 |
-
if ls:
|
| 1402 |
-
self._heal(owner, pr.dmg * ls)
|
| 1403 |
-
if pr.burn:
|
| 1404 |
-
m.burn = max(m.burn, pr.burn)
|
| 1405 |
-
m.burn_t = 3.0
|
| 1406 |
-
if pr.knock:
|
| 1407 |
-
ang = math.atan2(m.y - pr.y, m.x - pr.x)
|
| 1408 |
-
m.x = _clamp(m.x + math.cos(ang) * pr.knock, 20, ARENA_W - 20)
|
| 1409 |
-
m.y = _clamp(m.y + math.sin(ang) * pr.knock, 20, ARENA_H - 20)
|
| 1410 |
-
if pr.explode:
|
| 1411 |
-
r2 = pr.explode ** 2
|
| 1412 |
-
for o in self.minions:
|
| 1413 |
-
if o is m or not o.alive or o.uid in pr.hit_ids:
|
| 1414 |
-
continue
|
| 1415 |
-
if (o.x - m.x) ** 2 + (o.y - m.y) ** 2 <= r2:
|
| 1416 |
-
o.hp -= pr.dmg * 0.5
|
| 1417 |
-
o.hurt = 0.15
|
| 1418 |
-
if owner:
|
| 1419 |
-
owner.dmg_dealt += pr.dmg * 0.5
|
| 1420 |
-
if o.hp <= 0:
|
| 1421 |
-
o.alive = False
|
| 1422 |
-
self._on_minion_killed(o, owner)
|
| 1423 |
-
if pr.chain:
|
| 1424 |
-
hit_extra = 0
|
| 1425 |
-
for o in sorted(self.minions, key=lambda q: (q.x - m.x) ** 2 + (q.y - m.y) ** 2):
|
| 1426 |
-
if hit_extra >= pr.chain:
|
| 1427 |
-
break
|
| 1428 |
-
if o is m or not o.alive or o.uid in pr.hit_ids:
|
| 1429 |
-
continue
|
| 1430 |
-
if (o.x - m.x) ** 2 + (o.y - m.y) ** 2 <= 170 ** 2:
|
| 1431 |
-
o.hp -= pr.dmg * 0.5
|
| 1432 |
-
o.hurt = 0.15
|
| 1433 |
-
pr.hit_ids.add(o.uid)
|
| 1434 |
-
hit_extra += 1
|
| 1435 |
-
if owner:
|
| 1436 |
-
owner.dmg_dealt += pr.dmg * 0.5
|
| 1437 |
-
if o.hp <= 0:
|
| 1438 |
-
o.alive = False
|
| 1439 |
-
self._on_minion_killed(o, owner)
|
| 1440 |
-
|
| 1441 |
-
def _on_minion_killed(self, m: Minion, owner: "Player | None"):
|
| 1442 |
-
fortune = owner.up_fortune if owner else 0
|
| 1443 |
-
if owner:
|
| 1444 |
-
owner.kills += 1
|
| 1445 |
-
for sk in owner.skills:
|
| 1446 |
-
if sk["trigger"] == "on_kill":
|
| 1447 |
-
self._fire_skill(owner, sk)
|
| 1448 |
-
self.gems.append(self._make_gem(m.x, m.y, m.kind, fortune))
|
| 1449 |
-
# ~1/100 chance a minion drops a power-up card (Lucky Charm raises it)
|
| 1450 |
-
if random.random() < 0.01 * (1 + 0.5 * fortune):
|
| 1451 |
-
self._drop_pickup(m.x, m.y)
|
| 1452 |
-
# rarer: a timed aura drops on the floor (~1/200, Lucky Charm raises it)
|
| 1453 |
-
if random.random() < 0.005 * (1 + 0.5 * fortune):
|
| 1454 |
-
self._drop_aura(m.x, m.y)
|
| 1455 |
-
|
| 1456 |
-
def _drop_pickup(self, x: float, y: float):
|
| 1457 |
-
pool = [c for c in ALL_CARD_IDS
|
| 1458 |
-
if c not in THEME_ONLY and c not in SKILL_CARDS and c != "auto_attack"]
|
| 1459 |
-
if not pool:
|
| 1460 |
-
return
|
| 1461 |
-
self._pickup_seq += 1
|
| 1462 |
-
self.pickups.append(Pickup(x=x, y=y, card=random.choice(pool), uid=self._pickup_seq))
|
| 1463 |
-
|
| 1464 |
-
def _drop_aura(self, x: float, y: float):
|
| 1465 |
-
self._pickup_seq += 1
|
| 1466 |
-
self.pickups.append(Pickup(x=x, y=y, card=random.choice(ALL_AURA_IDS),
|
| 1467 |
-
kind="aura", uid=self._pickup_seq, ttl=18.0))
|
| 1468 |
-
|
| 1469 |
-
def _make_gem(self, x: float, y: float, kind: str, fortune: int = 0) -> Gem:
|
| 1470 |
-
"""Pick a gem tier (1..7) by enemy type + wave, with a little variance."""
|
| 1471 |
-
base = {"grunt": 1, "fast": 2, "tank": 4}.get(kind, 1)
|
| 1472 |
-
tier = base + (1 if random.random() < 0.3 else 0) + self.round // 6 + fortune
|
| 1473 |
-
tier = int(_clamp(tier, 1, 7))
|
| 1474 |
-
return Gem(x=x, y=y, tier=tier, value=GEM_TIER_XP[tier])
|
| 1475 |
-
|
| 1476 |
-
def _damage_player(self, p: Player, dmg: float):
|
| 1477 |
-
if not p.alive or p.hurt > 0:
|
| 1478 |
-
return
|
| 1479 |
-
# Evasion: chance to avoid the hit entirely
|
| 1480 |
-
if p.up_dodge and random.random() < min(0.5, 0.05 * p.up_dodge):
|
| 1481 |
-
p.hurt = 0.2
|
| 1482 |
-
return
|
| 1483 |
-
# Iron Hide: flat damage reduction
|
| 1484 |
-
if p.up_armor:
|
| 1485 |
-
dmg = max(1.0, dmg - 2 * p.up_armor)
|
| 1486 |
-
# Warding aura: percent damage reduction
|
| 1487 |
-
red = p._aura_max("dmg_red")
|
| 1488 |
-
if red:
|
| 1489 |
-
dmg *= (1 - red)
|
| 1490 |
-
# Aegis: shield absorbs first
|
| 1491 |
-
if p.shield > 0:
|
| 1492 |
-
absorbed = min(p.shield, dmg)
|
| 1493 |
-
p.shield -= absorbed
|
| 1494 |
-
dmg -= absorbed
|
| 1495 |
-
p.hp -= dmg
|
| 1496 |
-
p.hurt = 0.4
|
| 1497 |
-
if p.hp <= 0:
|
| 1498 |
-
p.hp = 0
|
| 1499 |
-
p.alive = False
|
| 1500 |
-
p.lives -= 1 # spend a life; >0 respawns, 0 = eliminated
|
| 1501 |
-
if p.lives > 0:
|
| 1502 |
-
p.respawn_t = 3.0
|
| 1503 |
-
p.auras = [] # auras fade on death
|
| 1504 |
-
|
| 1505 |
-
# ---- snapshot ---------------------------------------------------------
|
| 1506 |
-
def snapshot(self) -> dict:
|
| 1507 |
-
events, self._events = self._events, []
|
| 1508 |
-
return {
|
| 1509 |
-
"events": events,
|
| 1510 |
-
"t": round(time.time(), 2),
|
| 1511 |
-
"round": self.round,
|
| 1512 |
-
"status": self.status,
|
| 1513 |
-
"status_msg": self.status_msg,
|
| 1514 |
-
"gm_message": self.gm_message,
|
| 1515 |
-
"intermission_left": max(0, round(self.intermission_until - time.time(), 1))
|
| 1516 |
-
if self.status == "intermission" else 0,
|
| 1517 |
-
"boss": self.boss.to_dict() if self.boss.hp > 0 or self.status != "lobby" else None,
|
| 1518 |
-
"theme": max(0, (self.round - 1) // 5), # enemy skin set, swaps every 5 waves
|
| 1519 |
-
"hue": SPECIAL_BOSSES[self.boss.special]["hue"]
|
| 1520 |
-
if self.boss.special and self.boss.hp > 0 else None,
|
| 1521 |
-
"players": [p.to_dict() for p in self.players.values()],
|
| 1522 |
-
"minions": [m.to_dict() for m in self.minions],
|
| 1523 |
-
"allies": [a.to_dict() for a in self.allies],
|
| 1524 |
-
"projectiles": [pr.to_dict() for pr in self.projectiles],
|
| 1525 |
-
"gems": [g.to_dict() for g in self.gems],
|
| 1526 |
-
"pickups": [pk.to_dict() for pk in self.pickups],
|
| 1527 |
-
"arena": {"w": ARENA_W, "h": ARENA_H},
|
| 1528 |
-
"max_players": MAX_PLAYERS,
|
| 1529 |
-
"upgrades": {k: {"label": v[0], "cost": v[1]} for k, v in UPGRADES.items()},
|
| 1530 |
-
"cards": {k: {"label": v[0], "desc": v[1], "rarity": v[2]}
|
| 1531 |
-
for k, v in CARDS.items()},
|
| 1532 |
-
"card_pool": list(self.card_pool),
|
| 1533 |
-
"skill_request": (
|
| 1534 |
-
{"pid": self.skill_request_pid, "used": self.skill_request_used}
|
| 1535 |
-
if self.status == "intermission" else None
|
| 1536 |
-
),
|
| 1537 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HuggingWizards-upload/game/gamemaster.py
DELETED
|
@@ -1,480 +0,0 @@
|
|
| 1 |
-
"""Nemotron-4B Game Master.
|
| 2 |
-
|
| 3 |
-
At each round boundary the engine hands us a `round_summary`. We ask
|
| 4 |
-
NVIDIA's Nemotron-Mini-4B-Instruct to return a JSON decision covering:
|
| 5 |
-
|
| 6 |
-
- rewards: {player_id: gold}
|
| 7 |
-
- next_round: {boss_hp, boss_damage, minion_hp, minion_count, spawn_interval}
|
| 8 |
-
- message: short flavor line shown to players
|
| 9 |
-
- reasoning: why (kept in the trace)
|
| 10 |
-
|
| 11 |
-
This is *burst* GPU work that fits ZeroGPU's `@spaces.GPU` model perfectly: we
|
| 12 |
-
grab the GPU for one short inference per round, then release it. If the model
|
| 13 |
-
or GPU is unavailable (e.g. local dev / CI) we fall back to deterministic logic
|
| 14 |
-
so the game always keeps running.
|
| 15 |
-
|
| 16 |
-
Every decision is written to `traces/` as a self-contained agent trace.
|
| 17 |
-
"""
|
| 18 |
-
from __future__ import annotations
|
| 19 |
-
|
| 20 |
-
import json
|
| 21 |
-
import os
|
| 22 |
-
import re
|
| 23 |
-
import time
|
| 24 |
-
import uuid
|
| 25 |
-
|
| 26 |
-
from game.engine import ALL_CARD_IDS, ALL_PATTERN_IDS, BLESSINGS, MINION_TYPES
|
| 27 |
-
from game import skills as skillmod
|
| 28 |
-
|
| 29 |
-
MODEL_ID = "nvidia/Nemotron-Mini-4B-Instruct"
|
| 30 |
-
TRACE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "traces")
|
| 31 |
-
os.makedirs(TRACE_DIR, exist_ok=True)
|
| 32 |
-
|
| 33 |
-
# Lazily-initialised globals (loaded once, on first GPU call).
|
| 34 |
-
_tokenizer = None
|
| 35 |
-
_model = None
|
| 36 |
-
_load_failed = False
|
| 37 |
-
|
| 38 |
-
# In-memory ring of recent traces for the dashboard.
|
| 39 |
-
RECENT_TRACES: list[dict] = []
|
| 40 |
-
_MAX_RECENT = 25
|
| 41 |
-
|
| 42 |
-
# True when running inside a Hugging Face Space (ZeroGPU or otherwise).
|
| 43 |
-
_ON_SPACES = bool(os.environ.get("SPACE_ID"))
|
| 44 |
-
|
| 45 |
-
try: # `spaces` only exists on HF infra; degrade gracefully elsewhere.
|
| 46 |
-
import spaces # type: ignore
|
| 47 |
-
|
| 48 |
-
def _gpu(fn):
|
| 49 |
-
return spaces.GPU(duration=60)(fn)
|
| 50 |
-
except Exception: # pragma: no cover - local dev path
|
| 51 |
-
def _gpu(fn):
|
| 52 |
-
return fn
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
SYSTEM_PROMPT = (
|
| 56 |
-
"You are the Game Master / Director AI for HuggingWizards, a co-op pixel "
|
| 57 |
-
"survivors-arena where wizards fight waves of enemies and a boss. After each "
|
| 58 |
-
"wave you direct: rewards, the next wave's difficulty, the enemy mix, and the "
|
| 59 |
-
"pool of level-up cards players may be offered. Reply with ONE JSON object and "
|
| 60 |
-
"nothing else. Schema:\n"
|
| 61 |
-
'{"message": str (<=120 chars, in-character narration),'
|
| 62 |
-
' "reasoning": str (one sentence),'
|
| 63 |
-
' "rewards": {player_id: int gold 0-300},'
|
| 64 |
-
' "blessings": {player_id: blessing_id} (optional, bless 0-3 wizards),'
|
| 65 |
-
' "next_round": {"boss_hp": int, "boss_damage": int, "minion_hp": int,'
|
| 66 |
-
' "minion_count": int, "spawn_interval": float, "boss_aggro": float 0.7-3,'
|
| 67 |
-
' "boss_attack_speed": float 0.5-2.0, "boss_pattern": pattern_id,'
|
| 68 |
-
' "wave": {"grunt": float, "fast": float, "tank": float}},'
|
| 69 |
-
' "card_pool": [card_id, ...]}\n'
|
| 70 |
-
f"Enemy archetypes (wave weights, must sum > 0): {list(MINION_TYPES)}.\n"
|
| 71 |
-
f"Boss attack patterns: {ALL_PATTERN_IDS}. Switch the pattern between rounds "
|
| 72 |
-
"to keep fights fresh — sniper punishes kiting, artillery punishes camping, "
|
| 73 |
-
"swarm floods the arena, berserker charges relentlessly.\n"
|
| 74 |
-
f"Blessings: {BLESSINGS}. Bless wizards who earned it — surviving a brutal "
|
| 75 |
-
"wave, clutch plays, or to help a struggling wizard back on their feet "
|
| 76 |
-
"(extra_life / full_heal). Auras last one wave.\n"
|
| 77 |
-
f"Valid card_pool ids (offer 4-8): {ALL_CARD_IDS}.\n"
|
| 78 |
-
"boss_attack_speed sets how fast the boss attacks next wave (1.0 = normal). "
|
| 79 |
-
"Check every player's hp_pct and lives_left: if the party is badly hurt, "
|
| 80 |
-
"slow the boss (<1.0) so they can recover; if they are healthy, speed it up "
|
| 81 |
-
"(>1.0). MERCY DECAYS: each round the user prompt states the minimum you may "
|
| 82 |
-
"set — in later waves you can no longer slow the boss to protect them. "
|
| 83 |
-
"Values outside the allowed range are clamped.\n"
|
| 84 |
-
"Reward players for damage dealt, kills, and surviving. Scale difficulty up "
|
| 85 |
-
"after a victory and ease it slightly after a defeat. Introduce tougher enemy "
|
| 86 |
-
"archetypes as rounds progress. Curate cards to keep the run fun and winnable "
|
| 87 |
-
"for the number of players."
|
| 88 |
-
)
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def _build_user_prompt(summary: dict, prev_cfg: dict) -> str:
|
| 92 |
-
rnd = summary.get("round") or 0
|
| 93 |
-
return (
|
| 94 |
-
"Current next-round config (you may adjust): "
|
| 95 |
-
+ json.dumps(prev_cfg)
|
| 96 |
-
+ "\nRound that just ended:\n"
|
| 97 |
-
+ json.dumps(summary, indent=2)
|
| 98 |
-
+ f"\nMercy floor this round: boss_attack_speed must be between "
|
| 99 |
-
+ f"{_mercy_floor(rnd)} and {MERCY_MAX}."
|
| 100 |
-
+ "\nReturn the JSON decision now."
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def _load_model():
|
| 105 |
-
"""Load tokenizer + model, optionally quantized.
|
| 106 |
-
|
| 107 |
-
HUGGINGWIZARDS_QUANT = none | 4bit | 8bit | auto (default).
|
| 108 |
-
`auto` picks 4-bit on small local GPUs (<12 GB VRAM) and bf16 everywhere
|
| 109 |
-
else — ZeroGPU's H200 runs the 4B model comfortably in bf16, where it is
|
| 110 |
-
also faster per token than bitsandbytes 4-bit.
|
| 111 |
-
"""
|
| 112 |
-
import torch
|
| 113 |
-
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 114 |
-
|
| 115 |
-
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
| 116 |
-
has_cuda = torch.cuda.is_available()
|
| 117 |
-
quant = os.environ.get("HUGGINGWIZARDS_QUANT", "auto").lower()
|
| 118 |
-
if quant == "auto":
|
| 119 |
-
if has_cuda and not _ON_SPACES:
|
| 120 |
-
vram = torch.cuda.get_device_properties(0).total_memory
|
| 121 |
-
quant = "4bit" if vram < 12 * 1024**3 else "none"
|
| 122 |
-
else:
|
| 123 |
-
quant = "none"
|
| 124 |
-
|
| 125 |
-
if quant in ("4bit", "8bit") and has_cuda:
|
| 126 |
-
from transformers import BitsAndBytesConfig
|
| 127 |
-
|
| 128 |
-
qcfg = (
|
| 129 |
-
BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
|
| 130 |
-
bnb_4bit_compute_dtype=torch.bfloat16)
|
| 131 |
-
if quant == "4bit"
|
| 132 |
-
else BitsAndBytesConfig(load_in_8bit=True)
|
| 133 |
-
)
|
| 134 |
-
model = AutoModelForCausalLM.from_pretrained(
|
| 135 |
-
MODEL_ID, quantization_config=qcfg, device_map={"": 0}
|
| 136 |
-
)
|
| 137 |
-
else:
|
| 138 |
-
model = AutoModelForCausalLM.from_pretrained(
|
| 139 |
-
MODEL_ID, dtype=torch.bfloat16 if has_cuda else torch.float32
|
| 140 |
-
)
|
| 141 |
-
if has_cuda:
|
| 142 |
-
model = model.to("cuda") # ZeroGPU-safe (device_map="auto" is not)
|
| 143 |
-
model.eval()
|
| 144 |
-
print(f"[gamemaster] loaded {MODEL_ID} (quant={quant}, cuda={has_cuda})")
|
| 145 |
-
return tokenizer, model
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def _ensure_model():
|
| 149 |
-
global _tokenizer, _model, _load_failed
|
| 150 |
-
if _model is not None or _load_failed:
|
| 151 |
-
return _model is not None
|
| 152 |
-
if os.environ.get("HUGGINGWIZARDS_NO_LLM"):
|
| 153 |
-
_load_failed = True # force deterministic fallback (local dev / CI)
|
| 154 |
-
return False
|
| 155 |
-
try:
|
| 156 |
-
_tokenizer, _model = _load_model()
|
| 157 |
-
return True
|
| 158 |
-
except Exception as e: # pragma: no cover
|
| 159 |
-
print(f"[gamemaster] model load failed, using fallback: {e}")
|
| 160 |
-
_load_failed = True
|
| 161 |
-
return False
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
# On Spaces, load the model at startup (ZeroGPU replays the .to("cuda") once a
|
| 165 |
-
# real GPU is attached) so the @spaces.GPU window is spent on inference only —
|
| 166 |
-
# lazy-loading inside the first GPU call would blow the 60 s duration on the
|
| 167 |
-
# weight download. Locally we stay lazy so dev/CI never downloads weights.
|
| 168 |
-
if _ON_SPACES and not os.environ.get("HUGGINGWIZARDS_NO_LLM"):
|
| 169 |
-
_ensure_model()
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
# ---- mercy guardrail -------------------------------------------------------
|
| 173 |
-
# The GM may slow the boss's attack speed to help wounded parties, but its
|
| 174 |
-
# willingness to help decays as waves progress: the allowed floor rises from
|
| 175 |
-
# 0.5 toward 1.0 (no mercy) by ~wave 13. The ceiling is always 2.0.
|
| 176 |
-
MERCY_MAX = 2.0
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
def _mercy_floor(rnd: int) -> float:
|
| 180 |
-
return round(min(1.0, 0.5 + 0.04 * max(0, int(rnd or 0))), 2)
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
def _clamp_attack_speed(value, rnd: int) -> float:
|
| 184 |
-
try:
|
| 185 |
-
v = float(value)
|
| 186 |
-
except Exception:
|
| 187 |
-
v = 1.0
|
| 188 |
-
return round(max(_mercy_floor(rnd), min(MERCY_MAX, v)), 2)
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
@_gpu
|
| 192 |
-
def _run_model(system: str, user: str) -> str:
|
| 193 |
-
"""Single short generation on the GPU. Returns raw text."""
|
| 194 |
-
if not _ensure_model():
|
| 195 |
-
raise RuntimeError("model unavailable")
|
| 196 |
-
import torch
|
| 197 |
-
|
| 198 |
-
messages = [{"role": "system", "content": system},
|
| 199 |
-
{"role": "user", "content": user}]
|
| 200 |
-
inputs = _tokenizer.apply_chat_template(
|
| 201 |
-
messages, add_generation_prompt=True, return_tensors="pt"
|
| 202 |
-
).to(_model.device)
|
| 203 |
-
with torch.no_grad():
|
| 204 |
-
out = _model.generate(
|
| 205 |
-
inputs, max_new_tokens=400, do_sample=True, temperature=0.7, top_p=0.9,
|
| 206 |
-
pad_token_id=_tokenizer.eos_token_id,
|
| 207 |
-
)
|
| 208 |
-
text = _tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
|
| 209 |
-
return text
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
def _extract_json(text: str) -> dict | None:
|
| 213 |
-
m = re.search(r"\{.*\}", text, re.DOTALL)
|
| 214 |
-
if not m:
|
| 215 |
-
return None
|
| 216 |
-
try:
|
| 217 |
-
return json.loads(m.group(0))
|
| 218 |
-
except Exception:
|
| 219 |
-
return None
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
def _deterministic(summary: dict, prev_cfg: dict) -> dict:
|
| 223 |
-
"""Fallback Game Master logic — also a sane validation target."""
|
| 224 |
-
won = summary.get("result") == "victory"
|
| 225 |
-
rewards = {}
|
| 226 |
-
for p in summary.get("players", []):
|
| 227 |
-
gold = 20 + int(p.get("damage_dealt", 0) / 25) + p.get("kills", 0) * 5
|
| 228 |
-
if p.get("survived"):
|
| 229 |
-
gold += 25
|
| 230 |
-
rewards[p["id"]] = min(300, gold)
|
| 231 |
-
cfg = dict(prev_cfg)
|
| 232 |
-
n_players = max(1, len(summary.get("players", [])))
|
| 233 |
-
rnd = int(summary.get("round") or 1)
|
| 234 |
-
if won:
|
| 235 |
-
cfg["boss_hp"] = int(prev_cfg["boss_hp"] * 1.35) + 200 * n_players
|
| 236 |
-
cfg["boss_damage"] = min(80, prev_cfg["boss_damage"] + 2)
|
| 237 |
-
cfg["minion_count"] = min(40, prev_cfg["minion_count"] + 2)
|
| 238 |
-
cfg["minion_hp"] = int(prev_cfg["minion_hp"] * 1.15)
|
| 239 |
-
cfg["spawn_interval"] = max(1.5, prev_cfg["spawn_interval"] - 0.3)
|
| 240 |
-
cfg["boss_aggro"] = min(3.0, round(prev_cfg.get("boss_aggro", 1.0) + 0.2, 2))
|
| 241 |
-
msg = "Impressive. The next horde will not fall so easily."
|
| 242 |
-
else:
|
| 243 |
-
cfg["boss_hp"] = max(300, int(prev_cfg["boss_hp"] * 0.85))
|
| 244 |
-
cfg["boss_damage"] = max(4, prev_cfg["boss_damage"] - 1)
|
| 245 |
-
cfg["minion_count"] = max(0, prev_cfg["minion_count"] - 1)
|
| 246 |
-
cfg["spawn_interval"] = min(8.0, prev_cfg["spawn_interval"] + 0.5)
|
| 247 |
-
cfg["boss_aggro"] = max(0.7, round(prev_cfg.get("boss_aggro", 1.0) - 0.1, 2))
|
| 248 |
-
msg = "Rest, wizards. The arena bends slightly in your favor."
|
| 249 |
-
# Enemy mix escalates with the round: grunts always, fast from r2, tanks r4+.
|
| 250 |
-
cfg["wave"] = {
|
| 251 |
-
"grunt": 1.0,
|
| 252 |
-
"fast": round(min(0.8, max(0.0, (rnd - 1) * 0.25)), 2),
|
| 253 |
-
"tank": round(min(0.6, max(0.0, (rnd - 3) * 0.2)), 2),
|
| 254 |
-
}
|
| 255 |
-
# Rotate the boss's attack pattern so consecutive waves feel different.
|
| 256 |
-
cfg["boss_pattern"] = ALL_PATTERN_IDS[rnd % len(ALL_PATTERN_IDS)]
|
| 257 |
-
# Health-responsive attack speed: slow the boss for a wounded party, speed
|
| 258 |
-
# it up for a healthy one — always within the wave's mercy floor.
|
| 259 |
-
hps = [p.get("hp_pct", 100) for p in summary.get("players", []) if p.get("survived")]
|
| 260 |
-
avg_hp = sum(hps) / len(hps) if hps else 0
|
| 261 |
-
desired = 0.7 if avg_hp < 35 else 0.85 if avg_hp < 60 else (1.2 if won else 1.0)
|
| 262 |
-
cfg["boss_attack_speed"] = _clamp_attack_speed(desired, rnd)
|
| 263 |
-
# Blessings: after a defeat, shield the survivors; after a hard-won victory
|
| 264 |
-
# (someone died), give the most wounded survivor a warding aura.
|
| 265 |
-
blessings = {}
|
| 266 |
-
survivors = [p for p in summary.get("players", []) if p.get("survived")]
|
| 267 |
-
if not won:
|
| 268 |
-
for p in survivors[:3]:
|
| 269 |
-
blessings[p["id"]] = "warding"
|
| 270 |
-
elif survivors and any(not p.get("survived") for p in summary.get("players", [])):
|
| 271 |
-
weakest = min(survivors, key=lambda p: p.get("hp_pct", 100))
|
| 272 |
-
blessings[weakest["id"]] = "full_heal"
|
| 273 |
-
# Offer the full card set by default (the model may narrow it).
|
| 274 |
-
card_pool = list(ALL_CARD_IDS)
|
| 275 |
-
return {"message": msg, "reasoning": "deterministic fallback policy",
|
| 276 |
-
"rewards": rewards, "blessings": blessings,
|
| 277 |
-
"next_round": cfg, "card_pool": card_pool}
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def _validate(decision: dict, summary: dict, prev_cfg: dict) -> dict:
|
| 281 |
-
"""Coerce a (possibly model-authored) decision into a safe shape."""
|
| 282 |
-
safe = _deterministic(summary, prev_cfg) # defaults
|
| 283 |
-
if not isinstance(decision, dict):
|
| 284 |
-
return safe
|
| 285 |
-
valid_ids = {p["id"] for p in summary.get("players", [])}
|
| 286 |
-
if isinstance(decision.get("rewards"), dict):
|
| 287 |
-
rewards = {}
|
| 288 |
-
for k, v in decision["rewards"].items():
|
| 289 |
-
if k in valid_ids:
|
| 290 |
-
try:
|
| 291 |
-
rewards[k] = max(0, min(300, int(v)))
|
| 292 |
-
except Exception:
|
| 293 |
-
pass
|
| 294 |
-
if rewards:
|
| 295 |
-
safe["rewards"] = rewards
|
| 296 |
-
nxt = decision.get("next_round")
|
| 297 |
-
if isinstance(nxt, dict):
|
| 298 |
-
merged = dict(safe["next_round"])
|
| 299 |
-
for key in ("boss_hp", "boss_damage", "minion_hp", "minion_count"):
|
| 300 |
-
if key in nxt:
|
| 301 |
-
try:
|
| 302 |
-
merged[key] = int(nxt[key])
|
| 303 |
-
except Exception:
|
| 304 |
-
pass
|
| 305 |
-
for key in ("spawn_interval", "boss_aggro"):
|
| 306 |
-
if key in nxt:
|
| 307 |
-
try:
|
| 308 |
-
merged[key] = float(nxt[key])
|
| 309 |
-
except Exception:
|
| 310 |
-
pass
|
| 311 |
-
# mercy guardrail: attack speed is clamped into the wave's allowed band
|
| 312 |
-
if "boss_attack_speed" in nxt:
|
| 313 |
-
merged["boss_attack_speed"] = _clamp_attack_speed(
|
| 314 |
-
nxt.get("boss_attack_speed"), summary.get("round") or 0)
|
| 315 |
-
# boss attack pattern (also accepted at the top level)
|
| 316 |
-
pattern = nxt.get("boss_pattern", decision.get("boss_pattern"))
|
| 317 |
-
if pattern in ALL_PATTERN_IDS:
|
| 318 |
-
merged["boss_pattern"] = pattern
|
| 319 |
-
# enemy mix
|
| 320 |
-
wave = nxt.get("wave")
|
| 321 |
-
if isinstance(wave, dict):
|
| 322 |
-
clean = {}
|
| 323 |
-
for k in MINION_TYPES:
|
| 324 |
-
try:
|
| 325 |
-
clean[k] = max(0.0, float(wave.get(k, 0.0)))
|
| 326 |
-
except Exception:
|
| 327 |
-
clean[k] = 0.0
|
| 328 |
-
if sum(clean.values()) > 0:
|
| 329 |
-
merged["wave"] = clean
|
| 330 |
-
safe["next_round"] = merged
|
| 331 |
-
# per-player blessings (cap at 3, roster + id checked)
|
| 332 |
-
if isinstance(decision.get("blessings"), dict):
|
| 333 |
-
blessings = {k: v for k, v in decision["blessings"].items()
|
| 334 |
-
if k in valid_ids and v in BLESSINGS}
|
| 335 |
-
safe["blessings"] = dict(list(blessings.items())[:3])
|
| 336 |
-
# level-up card pool
|
| 337 |
-
pool = decision.get("card_pool")
|
| 338 |
-
if isinstance(pool, list):
|
| 339 |
-
valid = [c for c in pool if c in ALL_CARD_IDS]
|
| 340 |
-
if valid:
|
| 341 |
-
safe["card_pool"] = valid
|
| 342 |
-
if isinstance(decision.get("message"), str):
|
| 343 |
-
safe["message"] = decision["message"][:120]
|
| 344 |
-
if isinstance(decision.get("reasoning"), str):
|
| 345 |
-
safe["reasoning"] = decision["reasoning"][:300]
|
| 346 |
-
return safe
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
def decide(summary: dict, prev_cfg: dict) -> dict:
|
| 350 |
-
"""Produce a validated decision and persist an agent trace for the round."""
|
| 351 |
-
trace_id = uuid.uuid4().hex[:8]
|
| 352 |
-
system, user = SYSTEM_PROMPT, _build_user_prompt(summary, prev_cfg)
|
| 353 |
-
raw, source, error = "", "fallback", None
|
| 354 |
-
requested_speed = None
|
| 355 |
-
t0 = time.time()
|
| 356 |
-
try:
|
| 357 |
-
raw = _run_model(system, user)
|
| 358 |
-
parsed = _extract_json(raw)
|
| 359 |
-
if isinstance(parsed, dict) and isinstance(parsed.get("next_round"), dict):
|
| 360 |
-
requested_speed = parsed["next_round"].get("boss_attack_speed")
|
| 361 |
-
decision = _validate(parsed, summary, prev_cfg) if parsed else _deterministic(summary, prev_cfg)
|
| 362 |
-
source = "nemotron" if parsed else "fallback(parse_failed)"
|
| 363 |
-
except Exception as e:
|
| 364 |
-
error = str(e)
|
| 365 |
-
decision = _deterministic(summary, prev_cfg)
|
| 366 |
-
latency = round(time.time() - t0, 2)
|
| 367 |
-
|
| 368 |
-
rnd = summary.get("round") or 0
|
| 369 |
-
applied_speed = decision.get("next_round", {}).get("boss_attack_speed")
|
| 370 |
-
mercy = {
|
| 371 |
-
"floor": _mercy_floor(rnd), "max": MERCY_MAX,
|
| 372 |
-
"requested": requested_speed, "applied": applied_speed,
|
| 373 |
-
"clamped": requested_speed is not None and requested_speed != applied_speed,
|
| 374 |
-
"note": "mercy floor rises with the wave — by ~wave 13 the GM can no "
|
| 375 |
-
"longer slow the boss to protect the party",
|
| 376 |
-
}
|
| 377 |
-
|
| 378 |
-
trace = {
|
| 379 |
-
"trace_id": trace_id,
|
| 380 |
-
"round": summary.get("round"),
|
| 381 |
-
"mercy": mercy,
|
| 382 |
-
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
| 383 |
-
"model": MODEL_ID,
|
| 384 |
-
"source": source,
|
| 385 |
-
"latency_sec": latency,
|
| 386 |
-
"error": error,
|
| 387 |
-
"input": {"system": system, "user": user, "round_summary": summary},
|
| 388 |
-
"raw_output": raw,
|
| 389 |
-
"decision": decision,
|
| 390 |
-
}
|
| 391 |
-
_persist(trace)
|
| 392 |
-
return decision
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
SKILL_SYSTEM_PROMPT = (
|
| 396 |
-
"You are the Game Master AI for HuggingWizards. A wizard asks you to grant a "
|
| 397 |
-
"new power-up. Invent ONE balanced skill and reply with ONE JSON object only. "
|
| 398 |
-
"Schema (data only — never code):\n"
|
| 399 |
-
'{"name": str, "trigger": one of '
|
| 400 |
-
+ str(sorted(skillmod.TRIGGERS))
|
| 401 |
-
+ ', "n": int (for every_n_attacks), "interval": float (for periodic),'
|
| 402 |
-
' "effect": one of '
|
| 403 |
-
+ str(sorted(skillmod.EFFECTS))
|
| 404 |
-
+ ', "radius": float, "damage": float, "count": int, "amount": float,'
|
| 405 |
-
' "slow": float 0-1, "color": "#rrggbb"}\n'
|
| 406 |
-
"Pick fields that match the chosen effect. Keep it fun but not overpowered."
|
| 407 |
-
)
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
def _deterministic_skill(prompt: str) -> dict:
|
| 411 |
-
"""Keyword-based fallback skill generator (local dev / model unavailable)."""
|
| 412 |
-
t = (prompt or "").lower()
|
| 413 |
-
frost = any(w in t for w in ("frost", "ice", "freeze", "slow", "cold"))
|
| 414 |
-
if any(w in t for w in ("summon", "spirit", "minion", "ally", "pet", "wolf")):
|
| 415 |
-
spec = {"name": "Summoned Spirit", "trigger": "every_n_attacks", "n": 7,
|
| 416 |
-
"effect": "summon_ally", "count": 1, "color": "#a6ff8c"}
|
| 417 |
-
elif any(w in t for w in ("heal", "regen", "life", "restore", "vamp", "mend")):
|
| 418 |
-
spec = {"name": "Mending Light", "trigger": "periodic", "interval": 6,
|
| 419 |
-
"effect": "heal", "amount": 30, "color": "#6effd0"}
|
| 420 |
-
elif any(w in t for w in ("shield", "barrier", "ward", "protect", "absorb", "armor")):
|
| 421 |
-
spec = {"name": "Arcane Bulwark", "trigger": "periodic", "interval": 8,
|
| 422 |
-
"effect": "shield", "amount": 30, "color": "#9db4c9"}
|
| 423 |
-
elif frost or any(w in t for w in ("explos", "blast", "aoe", "area", "detonat", "fire")):
|
| 424 |
-
spec = {"name": "Frost Nova" if frost else "Arcane Detonation",
|
| 425 |
-
"trigger": "every_n_attacks", "n": 6, "effect": "aoe_damage",
|
| 426 |
-
"radius": 140, "damage": 45, "slow": 0.4 if frost else 0.0,
|
| 427 |
-
"color": "#7ee0ff" if frost else "#ff7a4a"}
|
| 428 |
-
elif any(w in t for w in ("nova", "spread", "shotgun", "burst", "ring", "radial")):
|
| 429 |
-
spec = {"name": "Star Burst", "trigger": "every_n_attacks", "n": 5,
|
| 430 |
-
"effect": "projectile_nova", "count": 10, "damage": 24, "color": "#ffd45e"}
|
| 431 |
-
else: # default: an AoE explosion
|
| 432 |
-
spec = {"name": "Arcane Detonation", "trigger": "every_n_attacks", "n": 6,
|
| 433 |
-
"effect": "aoe_damage", "radius": 140, "damage": 45, "slow": 0.0,
|
| 434 |
-
"color": "#ff7a4a"}
|
| 435 |
-
return spec
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
def generate_skill(prompt: str, context: dict | None = None) -> dict:
|
| 439 |
-
"""Turn a player's free-text wish into a validated, safe skill spec.
|
| 440 |
-
|
| 441 |
-
Tries Nemotron; always falls back to deterministic keywords; the result is
|
| 442 |
-
run through skills.validate_skill so it is bounded and code-free.
|
| 443 |
-
"""
|
| 444 |
-
trace_id = uuid.uuid4().hex[:8]
|
| 445 |
-
user = f"Wizard's request: {prompt!r}\nContext: {json.dumps(context or {})}\nReturn the skill JSON."
|
| 446 |
-
raw, source, error = "", "fallback", None
|
| 447 |
-
t0 = time.time()
|
| 448 |
-
spec = None
|
| 449 |
-
try:
|
| 450 |
-
raw = _run_model(SKILL_SYSTEM_PROMPT, user)
|
| 451 |
-
parsed = _extract_json(raw)
|
| 452 |
-
spec = skillmod.validate_skill(parsed) if parsed else None
|
| 453 |
-
source = "nemotron" if spec else "fallback(parse_failed)"
|
| 454 |
-
except Exception as e:
|
| 455 |
-
error = str(e)
|
| 456 |
-
if spec is None:
|
| 457 |
-
spec = skillmod.validate_skill(_deterministic_skill(prompt))
|
| 458 |
-
latency = round(time.time() - t0, 2)
|
| 459 |
-
_persist({
|
| 460 |
-
"trace_id": trace_id, "round": (context or {}).get("round"),
|
| 461 |
-
"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "model": MODEL_ID,
|
| 462 |
-
"source": source, "latency_sec": latency, "error": error,
|
| 463 |
-
"kind": "skill_request",
|
| 464 |
-
"input": {"system": SKILL_SYSTEM_PROMPT, "user": user, "prompt": prompt},
|
| 465 |
-
"raw_output": raw, "decision": spec,
|
| 466 |
-
})
|
| 467 |
-
return spec
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
def _persist(trace: dict):
|
| 471 |
-
RECENT_TRACES.insert(0, trace)
|
| 472 |
-
del RECENT_TRACES[_MAX_RECENT:]
|
| 473 |
-
try:
|
| 474 |
-
rnd = trace.get("round")
|
| 475 |
-
prefix = f"round_{rnd:03d}" if isinstance(rnd, int) else "skill"
|
| 476 |
-
path = os.path.join(TRACE_DIR, f"{prefix}_{trace['trace_id']}.json")
|
| 477 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 478 |
-
json.dump(trace, f, indent=2)
|
| 479 |
-
except Exception as e: # pragma: no cover
|
| 480 |
-
print(f"[gamemaster] failed to write trace: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HuggingWizards-upload/game/skills.py
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
"""Sandboxed, data-only skill specs for HuggingWizards.
|
| 2 |
-
|
| 3 |
-
A "skill" is a plain JSON object describing *what* happens and *when* — never
|
| 4 |
-
executable code. The engine interprets a whitelisted set of triggers/effects,
|
| 5 |
-
so a skill authored by the Game Master (Nemotron) at runtime can hot-plug onto
|
| 6 |
-
a player without any `exec`/`eval` or arbitrary code execution.
|
| 7 |
-
|
| 8 |
-
Schema (all fields optional except trigger/effect; everything is clamped):
|
| 9 |
-
{
|
| 10 |
-
"name": str, # display name
|
| 11 |
-
"trigger": every_n_attacks|periodic|on_kill,
|
| 12 |
-
"n": int, # for every_n_attacks
|
| 13 |
-
"interval": float, # seconds, for periodic
|
| 14 |
-
"effect": aoe_damage|projectile_nova|heal|summon_ally|shield,
|
| 15 |
-
"radius": float, # aoe reach
|
| 16 |
-
"damage": float,
|
| 17 |
-
"count": int, # nova projectiles / allies summoned
|
| 18 |
-
"amount": float, # heal / shield amount
|
| 19 |
-
"slow": float, # 0..1 slow applied by aoe
|
| 20 |
-
"color": str, # client tint (#rrggbb)
|
| 21 |
-
}
|
| 22 |
-
"""
|
| 23 |
-
from __future__ import annotations
|
| 24 |
-
|
| 25 |
-
TRIGGERS = {"every_n_attacks", "periodic", "on_kill"}
|
| 26 |
-
EFFECTS = {"aoe_damage", "projectile_nova", "heal", "summon_ally", "shield"}
|
| 27 |
-
|
| 28 |
-
# Per-field (lo, hi) clamps. Keeps any model output inside sane, balanced bounds.
|
| 29 |
-
_BOUNDS = {
|
| 30 |
-
"n": (2, 20),
|
| 31 |
-
"interval": (1.0, 15.0),
|
| 32 |
-
"radius": (40.0, 260.0),
|
| 33 |
-
"damage": (0.0, 120.0),
|
| 34 |
-
"count": (1, 12),
|
| 35 |
-
"amount": (0.0, 80.0),
|
| 36 |
-
"slow": (0.0, 0.8),
|
| 37 |
-
}
|
| 38 |
-
_DEFAULTS = {
|
| 39 |
-
"every_n_attacks": {"n": 5},
|
| 40 |
-
"periodic": {"interval": 5.0},
|
| 41 |
-
"aoe_damage": {"radius": 120.0, "damage": 35.0, "slow": 0.0},
|
| 42 |
-
"projectile_nova": {"count": 8, "damage": 22.0},
|
| 43 |
-
"heal": {"amount": 25.0},
|
| 44 |
-
"shield": {"amount": 25.0},
|
| 45 |
-
"summon_ally": {"count": 1},
|
| 46 |
-
}
|
| 47 |
-
_HEX = set("0123456789abcdefABCDEF")
|
| 48 |
-
MAX_SKILLS_PER_PLAYER = 6
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def _num(v, lo, hi, integer=False):
|
| 52 |
-
try:
|
| 53 |
-
v = float(v)
|
| 54 |
-
except Exception:
|
| 55 |
-
v = lo
|
| 56 |
-
v = lo if v < lo else hi if v > hi else v
|
| 57 |
-
return int(round(v)) if integer else round(v, 2)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _color(v):
|
| 61 |
-
if isinstance(v, str) and v.startswith("#") and len(v) == 7 and all(c in _HEX for c in v[1:]):
|
| 62 |
-
return v
|
| 63 |
-
return "#b48cff"
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def validate_skill(spec: dict) -> dict | None:
|
| 67 |
-
"""Coerce an arbitrary dict into a safe, bounded skill — or None if unusable."""
|
| 68 |
-
if not isinstance(spec, dict):
|
| 69 |
-
return None
|
| 70 |
-
trigger = spec.get("trigger")
|
| 71 |
-
effect = spec.get("effect")
|
| 72 |
-
if trigger not in TRIGGERS or effect not in EFFECTS:
|
| 73 |
-
return None
|
| 74 |
-
out = {
|
| 75 |
-
"name": str(spec.get("name", "Arcane Skill"))[:32] or "Arcane Skill",
|
| 76 |
-
"trigger": trigger,
|
| 77 |
-
"effect": effect,
|
| 78 |
-
"color": _color(spec.get("color")),
|
| 79 |
-
}
|
| 80 |
-
src = {**_DEFAULTS.get(trigger, {}), **_DEFAULTS.get(effect, {}), **spec}
|
| 81 |
-
if trigger == "every_n_attacks":
|
| 82 |
-
out["n"] = _num(src.get("n"), *_BOUNDS["n"], integer=True)
|
| 83 |
-
elif trigger == "periodic":
|
| 84 |
-
out["interval"] = _num(src.get("interval"), *_BOUNDS["interval"])
|
| 85 |
-
if effect == "aoe_damage":
|
| 86 |
-
out["radius"] = _num(src.get("radius"), *_BOUNDS["radius"])
|
| 87 |
-
out["damage"] = _num(src.get("damage"), *_BOUNDS["damage"])
|
| 88 |
-
out["slow"] = _num(src.get("slow"), *_BOUNDS["slow"])
|
| 89 |
-
elif effect == "projectile_nova":
|
| 90 |
-
out["count"] = _num(src.get("count"), *_BOUNDS["count"], integer=True)
|
| 91 |
-
out["damage"] = _num(src.get("damage"), *_BOUNDS["damage"])
|
| 92 |
-
elif effect in ("heal", "shield"):
|
| 93 |
-
out["amount"] = _num(src.get("amount"), *_BOUNDS["amount"])
|
| 94 |
-
elif effect == "summon_ally":
|
| 95 |
-
out["count"] = _num(src.get("count"), *_BOUNDS["count"], integer=True)
|
| 96 |
-
return out
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
# Predefined skills granted by the AoE / Summoner level-up cards.
|
| 100 |
-
CARD_SKILLS = {
|
| 101 |
-
"aoe": {"name": "Arcane Nova", "trigger": "every_n_attacks", "n": 6,
|
| 102 |
-
"effect": "aoe_damage", "radius": 130, "damage": 40, "color": "#7ee0ff"},
|
| 103 |
-
"summoner": {"name": "Spirit Caller", "trigger": "every_n_attacks", "n": 8,
|
| 104 |
-
"effect": "summon_ally", "count": 1, "color": "#a6ff8c"},
|
| 105 |
-
# ---- boss-exclusive skills (only offered while that boss reigns) ----
|
| 106 |
-
"war_drums": {"name": "War Drums", "trigger": "periodic", "interval": 7,
|
| 107 |
-
"effect": "summon_ally", "count": 2, "color": "#ff9d6e"},
|
| 108 |
-
"savage_roar": {"name": "Savage Roar", "trigger": "on_kill",
|
| 109 |
-
"effect": "aoe_damage", "radius": 110, "damage": 30, "color": "#ff6e6e"},
|
| 110 |
-
"iron_nova": {"name": "Iron Detonation", "trigger": "every_n_attacks", "n": 5,
|
| 111 |
-
"effect": "aoe_damage", "radius": 160, "damage": 55, "slow": 0.3, "color": "#c9d4e0"},
|
| 112 |
-
"bulwark": {"name": "Bulwark", "trigger": "periodic", "interval": 5,
|
| 113 |
-
"effect": "heal", "amount": 35, "color": "#9db4c9"},
|
| 114 |
-
"impaling_charge": {"name": "Impaling Charge", "trigger": "every_n_attacks", "n": 4,
|
| 115 |
-
"effect": "projectile_nova", "count": 6, "damage": 38, "color": "#ffd45e"},
|
| 116 |
-
"arrow_storm": {"name": "Arrow Storm", "trigger": "periodic", "interval": 4,
|
| 117 |
-
"effect": "projectile_nova", "count": 12, "damage": 20, "color": "#a6ff8c"},
|
| 118 |
-
# ---- rewards for defeating the special bosses (their own attack powers) ----
|
| 119 |
-
"holy_judgment": {"name": "Holy Judgment", "trigger": "periodic", "interval": 4,
|
| 120 |
-
"effect": "aoe_damage", "radius": 180, "damage": 70, "color": "#ffe9a0"},
|
| 121 |
-
"glitch_rift": {"name": "Glitch Rift", "trigger": "periodic", "interval": 3.5,
|
| 122 |
-
"effect": "projectile_nova", "count": 14, "damage": 30, "color": "#7CFC00"},
|
| 123 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HuggingWizards-upload/requirements.txt
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
gradio>=5.0.0
|
| 2 |
-
fastapi
|
| 3 |
-
uvicorn[standard]
|
| 4 |
-
transformers>=4.56.0
|
| 5 |
-
torch
|
| 6 |
-
accelerate
|
| 7 |
-
sentencepiece
|
| 8 |
-
bitsandbytes>=0.43.2
|
| 9 |
-
spaces
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HuggingWizards-upload/static/assets/bosses/aegis.png
DELETED
|
Binary file (66.4 kB)
|
|
|
HuggingWizards-upload/static/assets/bosses/toaster_attack.png
DELETED
|
Binary file (3.21 kB)
|
|
|
HuggingWizards-upload/static/assets/bosses/toaster_boss.png
DELETED
|
Binary file (363 Bytes)
|
|
|
HuggingWizards-upload/static/assets/bosses/toaster_damaged.png
DELETED
|
Binary file (649 Bytes)
|
|
|
HuggingWizards-upload/static/assets/bosses/toaster_death.png
DELETED
|
Binary file (1.26 kB)
|
|
|
HuggingWizards-upload/static/assets/bosses/toaster_idle.png
DELETED
|
Binary file (1.02 kB)
|
|
|
HuggingWizards-upload/static/assets/bosses/toaster_run.png
DELETED
|
Binary file (1.81 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/black_castle.png
DELETED
|
Binary file (17.1 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/black_house1.png
DELETED
|
Binary file (5.28 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/black_tower.png
DELETED
|
Binary file (6.5 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/blue_archery.png
DELETED
|
Binary file (10.2 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/blue_castle.png
DELETED
|
Binary file (17.1 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/blue_monastery.png
DELETED
|
Binary file (11.3 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/blue_tower.png
DELETED
|
Binary file (6.54 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/red_barracks.png
DELETED
|
Binary file (9.88 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/red_castle.png
DELETED
|
Binary file (16.9 kB)
|
|
|
HuggingWizards-upload/static/assets/buildings/red_tower.png
DELETED
|
Binary file (6.46 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/orc/Orc-Attack01.png
DELETED
|
Binary file (2.33 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/orc/Orc-Attack02.png
DELETED
|
Binary file (2.28 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/orc/Orc-Death.png
DELETED
|
Binary file (1.76 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/orc/Orc-Hurt.png
DELETED
|
Binary file (2.09 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/orc/Orc-Idle.png
DELETED
|
Binary file (1.41 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/orc/Orc-Walk.png
DELETED
|
Binary file (1.87 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack01.png
DELETED
|
Binary file (1.93 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack02.png
DELETED
|
Binary file (2.2 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack03.png
DELETED
|
Binary file (2.49 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Death.png
DELETED
|
Binary file (1.53 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Hurt.png
DELETED
|
Binary file (1.92 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Idle.png
DELETED
|
Binary file (1.37 kB)
|
|
|
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Walk.png
DELETED
|
Binary file (1.81 kB)
|
|
|
HuggingWizards-upload/static/assets/chars/archer_idle.png
DELETED
|
Binary file (5.02 kB)
|
|
|
HuggingWizards-upload/static/assets/chars/archer_run.png
DELETED
|
Binary file (5.33 kB)
|
|
|
HuggingWizards-upload/static/assets/chars/lancer_idle.png
DELETED
|
Binary file (13.3 kB)
|
|
|
HuggingWizards-upload/static/assets/chars/pawn_idle.png
DELETED
|
Binary file (6.66 kB)
|
|
|
HuggingWizards-upload/static/assets/chars/pawn_run.png
DELETED
|
Binary file (6.08 kB)
|
|
|
HuggingWizards-upload/static/assets/chars/warrior_idle.png
DELETED
|
Binary file (9.95 kB)
|
|
|
HuggingWizards-upload/static/assets/chars/warrior_run.png
DELETED
|
Binary file (9.81 kB)
|
|
|
HuggingWizards-upload/static/assets/effects/10_weaponhit_spritesheet.png
DELETED
|
Binary file (6.52 kB)
|
|
|