Quazim0t0 commited on
Commit
1b6ac52
·
verified ·
1 Parent(s): 3c4d5f8

Upload 127 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +25 -0
  2. HuggingWizards-upload/.gitignore +11 -0
  3. HuggingWizards-upload/README.md +107 -0
  4. HuggingWizards-upload/app.py +357 -0
  5. HuggingWizards-upload/game/__init__.py +0 -0
  6. HuggingWizards-upload/game/__pycache__/__init__.cpython-313.pyc +0 -0
  7. HuggingWizards-upload/game/__pycache__/engine.cpython-313.pyc +0 -0
  8. HuggingWizards-upload/game/__pycache__/gamemaster.cpython-313.pyc +0 -0
  9. HuggingWizards-upload/game/__pycache__/skills.cpython-313.pyc +0 -0
  10. HuggingWizards-upload/game/engine.py +1537 -0
  11. HuggingWizards-upload/game/gamemaster.py +480 -0
  12. HuggingWizards-upload/game/skills.py +123 -0
  13. HuggingWizards-upload/requirements.txt +9 -0
  14. HuggingWizards-upload/static/assets/bosses/aegis.png +0 -0
  15. HuggingWizards-upload/static/assets/bosses/toaster_attack.png +0 -0
  16. HuggingWizards-upload/static/assets/bosses/toaster_boss.png +0 -0
  17. HuggingWizards-upload/static/assets/bosses/toaster_damaged.png +0 -0
  18. HuggingWizards-upload/static/assets/bosses/toaster_death.png +0 -0
  19. HuggingWizards-upload/static/assets/bosses/toaster_idle.png +0 -0
  20. HuggingWizards-upload/static/assets/bosses/toaster_run.png +0 -0
  21. HuggingWizards-upload/static/assets/buildings/black_castle.png +0 -0
  22. HuggingWizards-upload/static/assets/buildings/black_house1.png +0 -0
  23. HuggingWizards-upload/static/assets/buildings/black_tower.png +0 -0
  24. HuggingWizards-upload/static/assets/buildings/blue_archery.png +0 -0
  25. HuggingWizards-upload/static/assets/buildings/blue_castle.png +0 -0
  26. HuggingWizards-upload/static/assets/buildings/blue_monastery.png +0 -0
  27. HuggingWizards-upload/static/assets/buildings/blue_tower.png +0 -0
  28. HuggingWizards-upload/static/assets/buildings/red_barracks.png +0 -0
  29. HuggingWizards-upload/static/assets/buildings/red_castle.png +0 -0
  30. HuggingWizards-upload/static/assets/buildings/red_tower.png +0 -0
  31. HuggingWizards-upload/static/assets/characters/orc/Orc-Attack01.png +0 -0
  32. HuggingWizards-upload/static/assets/characters/orc/Orc-Attack02.png +0 -0
  33. HuggingWizards-upload/static/assets/characters/orc/Orc-Death.png +0 -0
  34. HuggingWizards-upload/static/assets/characters/orc/Orc-Hurt.png +0 -0
  35. HuggingWizards-upload/static/assets/characters/orc/Orc-Idle.png +0 -0
  36. HuggingWizards-upload/static/assets/characters/orc/Orc-Walk.png +0 -0
  37. HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack01.png +0 -0
  38. HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack02.png +0 -0
  39. HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack03.png +0 -0
  40. HuggingWizards-upload/static/assets/characters/soldier/Soldier-Death.png +0 -0
  41. HuggingWizards-upload/static/assets/characters/soldier/Soldier-Hurt.png +0 -0
  42. HuggingWizards-upload/static/assets/characters/soldier/Soldier-Idle.png +0 -0
  43. HuggingWizards-upload/static/assets/characters/soldier/Soldier-Walk.png +0 -0
  44. HuggingWizards-upload/static/assets/chars/archer_idle.png +0 -0
  45. HuggingWizards-upload/static/assets/chars/archer_run.png +0 -0
  46. HuggingWizards-upload/static/assets/chars/lancer_idle.png +0 -0
  47. HuggingWizards-upload/static/assets/chars/pawn_idle.png +0 -0
  48. HuggingWizards-upload/static/assets/chars/pawn_run.png +0 -0
  49. HuggingWizards-upload/static/assets/chars/warrior_idle.png +0 -0
  50. HuggingWizards-upload/static/assets/chars/warrior_run.png +0 -0
.gitattributes CHANGED
@@ -33,3 +33,28 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ HuggingWizards-upload/static/assets/effects/12_nebula_spritesheet.png filter=lfs diff=lfs merge=lfs -text
37
+ HuggingWizards-upload/static/assets/effects/13_vortex_spritesheet.png filter=lfs diff=lfs merge=lfs -text
38
+ HuggingWizards-upload/static/assets/effects/17_felspell_spritesheet.png filter=lfs diff=lfs merge=lfs -text
39
+ HuggingWizards-upload/static/assets/effects/18_midnight_spritesheet.png filter=lfs diff=lfs merge=lfs -text
40
+ HuggingWizards-upload/static/assets/effects/19_freezing_spritesheet.png filter=lfs diff=lfs merge=lfs -text
41
+ HuggingWizards-upload/static/assets/effects/2_magic8_spritesheet.png filter=lfs diff=lfs merge=lfs -text
42
+ HuggingWizards-upload/static/assets/glitch/portal.png filter=lfs diff=lfs merge=lfs -text
43
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]1.ogg filter=lfs diff=lfs merge=lfs -text
44
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]10.ogg filter=lfs diff=lfs merge=lfs -text
45
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]11.ogg filter=lfs diff=lfs merge=lfs -text
46
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]12.ogg filter=lfs diff=lfs merge=lfs -text
47
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]2.ogg filter=lfs diff=lfs merge=lfs -text
48
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]3.ogg filter=lfs diff=lfs merge=lfs -text
49
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]4.ogg filter=lfs diff=lfs merge=lfs -text
50
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]5.ogg filter=lfs diff=lfs merge=lfs -text
51
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]6.ogg filter=lfs diff=lfs merge=lfs -text
52
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]7.ogg filter=lfs diff=lfs merge=lfs -text
53
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]8.ogg filter=lfs diff=lfs merge=lfs -text
54
+ HuggingWizards-upload/static/assets/music/Pixel[[:space:]]9.ogg filter=lfs diff=lfs merge=lfs -text
55
+ HuggingWizards-upload/static/assets/retro/retro_a.png filter=lfs diff=lfs merge=lfs -text
56
+ HuggingWizards-upload/static/assets/retro/retro_b.png filter=lfs diff=lfs merge=lfs -text
57
+ HuggingWizards-upload/static/assets/retro/retro_c.png filter=lfs diff=lfs merge=lfs -text
58
+ HuggingWizards-upload/static/assets/retro/retro_d.png filter=lfs diff=lfs merge=lfs -text
59
+ HuggingWizards-upload/static/assets/retro/retro_e.png filter=lfs diff=lfs merge=lfs -text
60
+ HuggingWizards-upload/static/assets/retro/retro_f.png filter=lfs diff=lfs merge=lfs -text
HuggingWizards-upload/.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
File without changes
HuggingWizards-upload/game/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (157 Bytes). View file
 
HuggingWizards-upload/game/__pycache__/engine.cpython-313.pyc ADDED
Binary file (99.9 kB). View file
 
HuggingWizards-upload/game/__pycache__/gamemaster.cpython-313.pyc ADDED
Binary file (26.6 kB). View file
 
HuggingWizards-upload/game/__pycache__/skills.cpython-313.pyc ADDED
Binary file (6.45 kB). View file
 
HuggingWizards-upload/game/engine.py ADDED
@@ -0,0 +1,1537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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 ADDED
HuggingWizards-upload/static/assets/bosses/toaster_attack.png ADDED
HuggingWizards-upload/static/assets/bosses/toaster_boss.png ADDED
HuggingWizards-upload/static/assets/bosses/toaster_damaged.png ADDED
HuggingWizards-upload/static/assets/bosses/toaster_death.png ADDED
HuggingWizards-upload/static/assets/bosses/toaster_idle.png ADDED
HuggingWizards-upload/static/assets/bosses/toaster_run.png ADDED
HuggingWizards-upload/static/assets/buildings/black_castle.png ADDED
HuggingWizards-upload/static/assets/buildings/black_house1.png ADDED
HuggingWizards-upload/static/assets/buildings/black_tower.png ADDED
HuggingWizards-upload/static/assets/buildings/blue_archery.png ADDED
HuggingWizards-upload/static/assets/buildings/blue_castle.png ADDED
HuggingWizards-upload/static/assets/buildings/blue_monastery.png ADDED
HuggingWizards-upload/static/assets/buildings/blue_tower.png ADDED
HuggingWizards-upload/static/assets/buildings/red_barracks.png ADDED
HuggingWizards-upload/static/assets/buildings/red_castle.png ADDED
HuggingWizards-upload/static/assets/buildings/red_tower.png ADDED
HuggingWizards-upload/static/assets/characters/orc/Orc-Attack01.png ADDED
HuggingWizards-upload/static/assets/characters/orc/Orc-Attack02.png ADDED
HuggingWizards-upload/static/assets/characters/orc/Orc-Death.png ADDED
HuggingWizards-upload/static/assets/characters/orc/Orc-Hurt.png ADDED
HuggingWizards-upload/static/assets/characters/orc/Orc-Idle.png ADDED
HuggingWizards-upload/static/assets/characters/orc/Orc-Walk.png ADDED
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack01.png ADDED
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack02.png ADDED
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack03.png ADDED
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Death.png ADDED
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Hurt.png ADDED
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Idle.png ADDED
HuggingWizards-upload/static/assets/characters/soldier/Soldier-Walk.png ADDED
HuggingWizards-upload/static/assets/chars/archer_idle.png ADDED
HuggingWizards-upload/static/assets/chars/archer_run.png ADDED
HuggingWizards-upload/static/assets/chars/lancer_idle.png ADDED
HuggingWizards-upload/static/assets/chars/pawn_idle.png ADDED
HuggingWizards-upload/static/assets/chars/pawn_run.png ADDED
HuggingWizards-upload/static/assets/chars/warrior_idle.png ADDED
HuggingWizards-upload/static/assets/chars/warrior_run.png ADDED