diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..08f491b70fb60fce81b36db735a092cc04ca667a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,28 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/effects/12_nebula_spritesheet.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/effects/13_vortex_spritesheet.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/effects/17_felspell_spritesheet.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/effects/18_midnight_spritesheet.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/effects/19_freezing_spritesheet.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/effects/2_magic8_spritesheet.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/glitch/portal.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]1.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]10.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]11.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]12.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]2.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]3.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]4.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]5.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]6.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]7.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]8.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/music/Pixel[[:space:]]9.ogg filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/retro/retro_a.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/retro/retro_b.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/retro/retro_c.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/retro/retro_d.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/retro/retro_e.png filter=lfs diff=lfs merge=lfs -text +HuggingWizards-upload/static/assets/retro/retro_f.png filter=lfs diff=lfs merge=lfs -text diff --git a/HuggingWizards-upload/.gitignore b/HuggingWizards-upload/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..19750ee54cc3d4e5b659965d1063be5544133d5a --- /dev/null +++ b/HuggingWizards-upload/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +.DS_Store + +# Generated agent traces (keep the directory, ignore the contents) +traces/*.json + +# Don't commit the raw asset archives +*.zip +*.rar +New Resources/ diff --git a/HuggingWizards-upload/README.md b/HuggingWizards-upload/README.md new file mode 100644 index 0000000000000000000000000000000000000000..53587d9c73ad927e2f353084a2f35a4a22b66735 --- /dev/null +++ b/HuggingWizards-upload/README.md @@ -0,0 +1,107 @@ +--- +title: HuggingWizards +emoji: πŸ§™ +colorFrom: purple +colorTo: indigo +sdk: gradio +sdk_version: 5.9.1 +app_file: app.py +pinned: false +short_description: Co-op pixel wizard arena where Nemotron-4B is the Game Master +--- + +# πŸ§™ HuggingWizards + +A small 2D pixel-wizard **co-op arena** that runs as a Gradio Space on **ZeroGPU**. +Up to **8 players** join one shared arena, fight a central **boss** and the +**minions** it spawns, and upgrade their magic between rounds. Everyone else can +**spectate** live. Controls: **WASD** to move, **Space** to attack. + +The hackathon twist: **NVIDIA Nemotron-Mini-4B-Instruct** is the **Game Master**. +At the end of every round it decides: + +- **Rewards** β€” how much gold each wizard earns (based on damage / survival). +- **Blessings** β€” individual boons for wizards who earned them: any timed aura, + a `full_heal`, or even an `extra_life` for a struggling player. +- **Boss attack pattern** β€” `balanced`, `sniper` (fast aimed bolts), `artillery` + (slow heavy orbs + dense shockwaves), `swarm` (minion floods), or `berserker` + (relentless charges). The active pattern shows next to the boss's name. +- **Boss attack speed (with decaying mercy)** β€” the GM reads every wizard's + HP% and lives and tunes `boss_attack_speed` (0.5–2.0): slower for a wounded + party, faster for a healthy one. Guardrail: the allowed floor rises each + wave (no mercy left by ~wave 13), out-of-range or malformed values are + clamped, and every trace records `requested` vs `applied` plus the floor. +- **Boss & minion reset** β€” next-round boss HP, minion count/HP, spawn rate, + boss damage, aggression, and the enemy archetype mix. +- **Card pool** β€” which level-up cards may be offered next wave. + +Every round produces a structured **agent trace** (prompt β†’ raw model output β†’ +parsed decision β†’ applied effect), saved under `traces/` and shown live in the +Gradio dashboard. + +## Architecture + +- **Client** (`static/`): HTML5 canvas game loop in the browser (rendering + input). +- **Server** (`app.py`, `game/`): FastAPI WebSocket + a ~20 Hz authoritative game + tick on CPU. Gradio is mounted for the trace/leaderboard dashboard. +- **Game Master** (`game/gamemaster.py`): `@spaces.GPU` burst inference at round + boundaries only β€” the pattern ZeroGPU is built for. Falls back to deterministic + logic if the model/GPU is unavailable (so it runs locally too). + +## Run locally + +```bash +pip install -r requirements.txt +python app.py +# open http://localhost:7860 +``` + +The Game Master uses Nemotron only when a GPU + the `spaces`/`transformers` stack +is present; otherwise it transparently uses the deterministic fallback so local +dev and CI still work. + +## Game Master model + +On a Space the model is loaded **at startup** (the ZeroGPU pattern: weights are +downloaded and staged before any `@spaces.GPU` call, so the 60 s GPU window is +spent purely on inference). Locally it is lazy-loaded on the first round. + +Environment variables: + +| Variable | Values | Effect | +|---|---|---| +| `HUGGINGWIZARDS_NO_LLM` | `1` | Skip the model entirely; deterministic Game Master (CI / quick dev). | +| `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. | + +Quantization notes: 4-bit NF4 shrinks the 4B model from ~8.5 GB to ~3 GB of +VRAM and speeds up cold loads β€” ideal for small local GPUs. On ZeroGPU's H200 +plain bf16 is both comfortable and faster per token, so `auto` keeps bf16 there. + +## Assets + +Pixel art / audio from free packs (Tiny RPG Character Pack, Tiny Swords, +Free Pixel Effects, Retro Impact Effect Pack, Pixel UI pack 3, Pixel RPG Music +Pack). See each pack's license. + +- **Maps**: each boss faction has its own territory β€” the Tiny Swords grass + tileset plus a per-theme ground tint, landmark buildings + (`static/assets/buildings/`: orc camp huts, the Iron Legion's blue castle, + the Lancer Host's red keep, the Archer Coven's monastery & ranges) and a + seeded scenery mix (bushland / rockfields / forest). Scenery is purely + cosmetic β€” the server simulation is unchanged. +- **Effects**: spell-cast muzzle flashes, impact bursts (on boss/minion/player + hits) and death poofs are drawn client-side from the Free Pixel Effects + spritesheets, triggered by snapshot flags (`hurt`, attack, minion id removal). + Skill activations and GM blessings are server-driven events rendered with + more of the pack: freezing novas, felspell blasts, protection circles, + vortex summons, sunburn blessings, magic-bubble heals. +- **Characters**: pick a champion (Warrior / Archer / Lancer / Pawn) on the + name screen β€” Tiny Swords blue units (`static/assets/chars/`), all size-normalized + and growing with level. +- **Special bosses**: **Aegis** (red wave, holy-spell attacks) on waves 3/9/15… and + **Toaster Bot** (green wave, glitch-portal attacks) on waves 5/11/17… Beat one and + every survivor gains its signature attack as a permanent power. +- **Timed auras**: rare floor-only power-ups (30–60 s) visualized with the Retro + Impact Effect Pack (`static/assets/retro/`) wreathing the wizard. +- **Music**: a looping track from the Pixel RPG Music Pack starts on the first + join/spectate click (browser autoplay policy); a πŸ”Š button toggles it. diff --git a/HuggingWizards-upload/app.py b/HuggingWizards-upload/app.py new file mode 100644 index 0000000000000000000000000000000000000000..32aa0dd0d01292ba0c80ee7656bf145929a4ffab --- /dev/null +++ b/HuggingWizards-upload/app.py @@ -0,0 +1,357 @@ +"""HuggingWizards β€” co-op pixel wizard arena with a Nemotron-4B Game Master. + +Single process: + * FastAPI serves the canvas client (`/`) and a WebSocket (`/ws`). + * A ~20 Hz asyncio task runs the authoritative simulation on CPU and + broadcasts snapshots to every connected client (players + spectators). + * At each round boundary the Game Master (Nemotron, burst GPU) decides rewards + and the next round; every decision is logged as an agent trace. + * A Gradio dashboard is mounted at `/dashboard` (live traces + leaderboard). +""" +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import time +import uuid + +import gradio as gr +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from game import gamemaster +from game.engine import Engine, MAX_PLAYERS, TICK_HZ + +HERE = os.path.dirname(__file__) +engine = Engine() + +# Active websocket connections: ws -> {"pid", "role", "name", "char"} +clients: dict[WebSocket, dict] = {} +# Ordered waiting line for when the 8-slot arena is full. +queue: list[WebSocket] = [] + +# ---- persistent leaderboard ---------------------------------------------- +LEADERBOARD_FILE = os.path.join(HERE, "traces", "leaderboard.json") +_leaderboard: list[dict] = [] + + +def _load_leaderboard(): + global _leaderboard + try: + with open(LEADERBOARD_FILE, encoding="utf-8") as f: + _leaderboard = json.load(f) + except Exception: + _leaderboard = [] + + +def _save_leaderboard(): + with contextlib.suppress(Exception): + with open(LEADERBOARD_FILE, "w", encoding="utf-8") as f: + json.dump(_leaderboard[:100], f, indent=2) + + +def add_score(name: str, gold: int, level: int, wave: int) -> list[dict]: + _leaderboard.append({ + "name": str(name)[:16] or "Wizard", "gold": int(gold), + "level": int(level), "wave": int(wave), + "ts": time.strftime("%Y-%m-%d %H:%M"), + }) + _leaderboard.sort(key=lambda e: (e["gold"], e["wave"], e["level"]), reverse=True) + del _leaderboard[100:] + _save_leaderboard() + return _leaderboard[:20] + + +_load_leaderboard() + + +async def broadcast(payload: dict): + if not clients: + return + msg = json.dumps(payload) + dead = [] + for ws in list(clients.keys()): + try: + await ws.send_text(msg) + except Exception: + dead.append(ws) + for ws in dead: + _drop(ws) + + +def _drop(ws: WebSocket): + info = clients.pop(ws, None) + if info and info.get("pid"): + engine.remove_player(info["pid"]) + if not engine.players and engine.status != "lobby": + engine.status = "lobby" + engine.status_msg = "All wizards left. Waiting for players..." + + +async def _send(ws: WebSocket, payload: dict): + with contextlib.suppress(Exception): + await ws.send_text(json.dumps(payload)) + + +async def handle_skill_request(ws: WebSocket, pid: str, prompt: str): + """The round's top wizard asks the Game Master for a custom power-up.""" + if engine.status != "intermission" or engine.skill_request_used \ + or engine.skill_request_pid != pid or not prompt.strip(): + await _send(ws, {"type": "skill_result", "ok": False, + "reason": "Not available right now."}) + return + engine.skill_request_used = True # one wish per intermission + ctx = {"round": engine.round, "player_level": engine.players[pid].level + if pid in engine.players else 1} + spec = await asyncio.to_thread(gamemaster.generate_skill, prompt, ctx) + granted = engine.add_runtime_skill(pid, spec) if spec else None + await _send(ws, {"type": "skill_result", "ok": bool(granted), "skill": granted, + "reason": None if granted else "The arcane energies fizzled β€” try again."}) + + +def _ws_for_pid(pid: str): + for ws, info in clients.items(): + if info.get("pid") == pid: + return ws + return None + + +async def _broadcast_queue(): + """Tell each queued connection its current place in line.""" + for i, ws in enumerate(list(queue)): + await _send(ws, {"type": "queue", "pos": i + 1, "size": len(queue)}) + + +async def promote_queue(): + """Fill open arena slots from the front of the queue.""" + while engine.can_join() and queue: + ws = queue.pop(0) + info = clients.get(ws) + if info is None: # disconnected while waiting + continue + pid = uuid.uuid4().hex[:8] + engine.add_player(pid, info.get("name", "Wizard"), info.get("char", "warrior")) + clients[ws] = {**info, "pid": pid, "role": "player"} + await _send(ws, {"type": "welcome", "pid": pid, "role": "player", "reason": "from_queue"}) + + +async def manage_slots(): + """Eliminate out-of-lives wizards, then promote the queue into free slots.""" + for pid, p in list(engine.players.items()): + if p.lives <= 0 and not p.alive: + ws = _ws_for_pid(pid) + engine.remove_player(pid) + if ws is not None: + info = clients.get(ws, {}) + clients[ws] = {**info, "pid": None, "role": "spectator"} + await _send(ws, {"type": "eliminated", + "score": {"gold": p.gold, "level": p.level, "wave": engine.round}}) + if not engine.players and engine.status != "lobby": + engine.status = "lobby" + engine.status_msg = "Waiting for wizards to join..." + await promote_queue() + await _broadcast_queue() + + +async def game_loop(): + last = time.time() + prev_status = engine.status + gm_task: asyncio.Task | None = None + gm_applied = False + interval = 1.0 / TICK_HZ + slot_accum = 0.0 + while True: + now = time.time() + dt = min(0.1, now - last) + last = now + + engine.tick(dt) + # eliminate / promote queue / broadcast positions a few times a second + slot_accum += dt + if slot_accum >= 0.4: + slot_accum = 0.0 + await manage_slots() + + # Round just ended -> ask the Game Master (off the loop thread). + if engine.status == "intermission" and prev_status == "active": + summary = engine.round_summary() + prev_cfg = dict(engine.cfg) + gm_task = asyncio.create_task( + asyncio.to_thread(gamemaster.decide, summary, prev_cfg) + ) + gm_applied = False + + # Apply the decision as soon as it's ready (enables shopping this break). + if gm_task is not None and gm_task.done() and not gm_applied: + with contextlib.suppress(Exception): + engine.apply_gm_decision(gm_task.result()) + gm_applied = True + + # Start the next round once the break is over AND the decision is in. + if engine.status == "intermission" and gm_applied and time.time() >= engine.intermission_until: + if engine.players: + engine.start_round() + else: + engine.status = "lobby" + engine.status_msg = "Waiting for wizards to join..." + gm_task = None + gm_applied = False + + prev_status = engine.status + await broadcast(engine.snapshot()) + await asyncio.sleep(max(0, interval - (time.time() - now))) + + +# -------------------------------------------------------------------------- +# FastAPI app +# -------------------------------------------------------------------------- +app = FastAPI(title="HuggingWizards") + +_loop_task: asyncio.Task | None = None + + +def ensure_loop(): + """Start the game loop once. Robust to Gradio overriding the app lifespan.""" + global _loop_task + if _loop_task is None or _loop_task.done(): + _loop_task = asyncio.create_task(game_loop()) + + +@app.get("/") +async def index(): + return FileResponse(os.path.join(HERE, "static", "index.html")) + + +@app.get("/traces") +async def traces(): + """Recent Game Master agent traces, trimmed for the in-game panel.""" + out = [] + for t in gamemaster.RECENT_TRACES[:15]: + out.append({ + "trace_id": t.get("trace_id"), "round": t.get("round"), + "ts": t.get("ts"), "model": t.get("model"), + "source": t.get("source"), "latency_sec": t.get("latency_sec"), + "kind": t.get("kind", "round_decision"), "error": t.get("error"), + "mercy": t.get("mercy"), + "raw_output": str(t.get("raw_output", ""))[:600], + "decision": t.get("decision"), + }) + return {"traces": out} + + +@app.websocket("/ws") +async def ws_endpoint(ws: WebSocket): + await ws.accept() + ensure_loop() + clients[ws] = {"pid": None, "role": "spectator", "name": "Wizard", "char": "warrior"} + try: + while True: + data = json.loads(await ws.receive_text()) + mtype = data.get("type") + + if mtype == "join": + name = str(data.get("name", "Wizard"))[:16] or "Wizard" + char = str(data.get("char", "warrior"))[:16] or "soldier" + clients[ws]["name"] = name + clients[ws]["char"] = char + if clients[ws]["pid"]: + continue + if engine.can_join(): + pid = uuid.uuid4().hex[:8] + engine.add_player(pid, name, char) + clients[ws] = {**clients[ws], "pid": pid, "role": "player"} + await _send(ws, {"type": "welcome", "pid": pid, "role": "player"}) + else: + # game full -> join the queue + if ws not in queue: + queue.append(ws) + pos = queue.index(ws) + 1 + await _send(ws, {"type": "welcome", "pid": None, "role": "spectator", + "reason": "queued", "pos": pos, "size": len(queue)}) + + elif mtype == "spectate": + clients[ws]["name"] = str(data.get("name", "Wizard"))[:16] or "Wizard" + clients[ws]["char"] = str(data.get("char", "warrior"))[:16] or "soldier" + + elif mtype == "leave_queue": + if ws in queue: + queue.remove(ws) + + elif mtype == "get_leaderboard": + await _send(ws, {"type": "leaderboard", "top": _leaderboard[:20]}) + + elif mtype == "submit_score": + top = add_score(str(data.get("name", "Wizard")), int(data.get("gold", 0)), + int(data.get("level", 1)), int(data.get("wave", 0))) + await _send(ws, {"type": "leaderboard", "top": top, "submitted": True}) + + elif mtype == "input" and clients[ws]["pid"]: + engine.set_input(clients[ws]["pid"], data) + + elif mtype == "choose_card" and clients[ws]["pid"]: + engine.choose_card(clients[ws]["pid"], data.get("key", "")) + + elif mtype == "request_skill" and clients[ws]["pid"]: + await handle_skill_request(ws, clients[ws]["pid"], str(data.get("prompt", ""))) + + elif mtype == "start" and clients[ws]["pid"]: + if engine.status == "lobby": + engine.start_round() + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + if ws in queue: + queue.remove(ws) + _drop(ws) + + +app.mount("/static", StaticFiles(directory=os.path.join(HERE, "static")), name="static") + + +# -------------------------------------------------------------------------- +# Gradio dashboard (mounted at /dashboard) +# -------------------------------------------------------------------------- +def _dashboard_state(): + players = sorted(engine.players.values(), key=lambda p: p.gold, reverse=True) + if players: + lb = "| Wizard | Level | Gold | Last-round dmg | Kills |\n|---|---|---|---|---|\n" + lb += "\n".join( + f"| {p.name} | {p.level} | {p.gold} | {round(p.dmg_dealt)} | {p.kills} |" + for p in players + ) + else: + lb = "_No wizards in the arena yet._" + header = (f"### Round {engine.round} β€” `{engine.status}`\n" + f"{engine.status_msg}\n\n" + f"Players: {len(engine.players)}/{MAX_PLAYERS} Β· " + f"GM: _{engine.gm_message or 'β€”'}_") + traces = gamemaster.RECENT_TRACES[:10] + return header, lb, traces + + +with gr.Blocks(title="HuggingWizards β€” Game Master Dashboard") as demo: + gr.Markdown("# πŸ§™ HuggingWizards β€” Game Master Dashboard\n" + "Play at the [arena](/). Below: live state, leaderboard, and the " + "per-round **agent traces** produced by Nemotron-4B.") + header_md = gr.Markdown() + with gr.Row(): + leaderboard_md = gr.Markdown() + gr.Markdown("## Agent traces (most recent first)") + traces_json = gr.JSON() + timer = gr.Timer(2.0) + timer.tick(_dashboard_state, outputs=[header_md, leaderboard_md, traces_json]) + demo.load(_dashboard_state, outputs=[header_md, leaderboard_md, traces_json]) + +app = gr.mount_gradio_app(app, demo, path="/dashboard") + + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("PORT", 7860)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/HuggingWizards-upload/game/__init__.py b/HuggingWizards-upload/game/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/HuggingWizards-upload/game/__pycache__/__init__.cpython-313.pyc b/HuggingWizards-upload/game/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24163cfd2f50fbd939837e235cb4043f374d9d48 Binary files /dev/null and b/HuggingWizards-upload/game/__pycache__/__init__.cpython-313.pyc differ diff --git a/HuggingWizards-upload/game/__pycache__/engine.cpython-313.pyc b/HuggingWizards-upload/game/__pycache__/engine.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac12dec1caa1f15682042bc71164dfd3fdbdf075 Binary files /dev/null and b/HuggingWizards-upload/game/__pycache__/engine.cpython-313.pyc differ diff --git a/HuggingWizards-upload/game/__pycache__/gamemaster.cpython-313.pyc b/HuggingWizards-upload/game/__pycache__/gamemaster.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd1657adc40170800599003694586926b2d0f627 Binary files /dev/null and b/HuggingWizards-upload/game/__pycache__/gamemaster.cpython-313.pyc differ diff --git a/HuggingWizards-upload/game/__pycache__/skills.cpython-313.pyc b/HuggingWizards-upload/game/__pycache__/skills.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12af037f144a459aecc2474a87a0645b19666dda Binary files /dev/null and b/HuggingWizards-upload/game/__pycache__/skills.cpython-313.pyc differ diff --git a/HuggingWizards-upload/game/engine.py b/HuggingWizards-upload/game/engine.py new file mode 100644 index 0000000000000000000000000000000000000000..8412f84473ee2d7766e51396ec81f77f0dc60620 --- /dev/null +++ b/HuggingWizards-upload/game/engine.py @@ -0,0 +1,1537 @@ +"""Authoritative co-op arena simulation. + +One shared arena: a boss in the center, minions that spawn, and up to +MAX_PLAYERS wizards. The engine ticks on the CPU at TICK_HZ and produces a +JSON-serialisable snapshot that is broadcast to every connected client. + +The engine knows nothing about networking or the LLM. The server drives it: +- feed inputs via `set_input` / `add_player` / `remove_player` +- call `tick(dt)` every frame +- when `status == "intermission"`, ask the Game Master for a decision and pass + it to `apply_gm_decision`, then call `start_round`. +""" +from __future__ import annotations + +import math +import random +import time +from dataclasses import dataclass, field + +from game import skills as skillmod + +ARENA_W = 1280 +ARENA_H = 720 +CENTER = (ARENA_W / 2, ARENA_H / 2) + +MAX_PLAYERS = 8 +TICK_HZ = 20 + +# Upgrade definitions: id -> (label, cost). Cost is legacy (gold shop); the +# survivors-mode progression hands these out as level-up cards instead. +UPGRADES = { + "damage": ("Spell Power", 30), + "attack_speed": ("Cast Speed", 30), + "move_speed": ("Boots", 25), + "max_hp": ("Vitality", 25), + "multishot": ("Multishot", 60), +} + +# Level-up cards. id -> (label, description, rarity). The stat ids above plus a +# few survivors-flavored extras. The Game Master picks which cards are offered. +CARDS = { + # --- originals --- + "auto_attack": ("Auto-Cast", "Attack automatically β€” no need to hold Space", "common"), + "damage": ("Spell Power", "+6 spell damage", "common"), + "attack_speed": ("Quick Cast", "Cast faster", "common"), + "move_speed": ("Swift Boots", "+22 move speed", "common"), + "max_hp": ("Vitality", "+25 max HP (and heal)", "common"), + "multishot": ("Multishot", "+1 projectile", "rare"), + "pierce": ("Piercing Bolt", "Shots pierce +1 enemy", "rare"), + "regen": ("Regeneration", "+2 HP/sec", "uncommon"), + "projectile_speed": ("Arcane Velocity", "Faster, longer shots", "uncommon"), + "aoe": ("Arcane Nova", "Periodic burst damages nearby foes", "rare"), + "summoner": ("Spirit Caller", "Summon spirits that fight for you", "rare"), + # --- 20 new base power-ups --- + "crit": ("Critical Strike", "+8% chance to deal double damage", "uncommon"), + "lifesteal": ("Vampirism", "Heal for a % of damage dealt", "uncommon"), + "armor": ("Iron Hide", "-2 flat damage taken", "common"), + "dodge": ("Evasion", "+5% chance to avoid a hit", "uncommon"), + "thorns": ("Spiked Aura", "Melee attackers take damage back", "uncommon"), + "magnet": ("Lodestone", "Pull XP gems from much farther", "common"), + "xp_boost": ("Quick Study", "+15% XP from gems", "uncommon"), + "gold_boost": ("Prosperity", "+20% gold from rewards", "common"), + "knockback": ("Force Bolt", "Shots knock enemies back", "common"), + "explosive": ("Explosive Shots", "Shots burst for splash damage", "rare"), + "burn": ("Ignite", "Shots set enemies on fire (damage over time)", "uncommon"), + "chain": ("Chain Spark", "Hits arc to a nearby enemy", "rare"), + "big_shots": ("Heavy Orbs", "Bigger, easier-to-land projectiles", "common"), + "homing": ("Seeker Bolts", "Shots curve toward enemies", "rare"), + "giant": ("Titan Growth", "+30 max HP and you grow larger", "uncommon"), + "glass_cannon": ("Glass Cannon", "+12 damage but -15 max HP", "rare"), + "berserker": ("Berserker", "Big damage boost while below half HP", "uncommon"), + "shield": ("Aegis", "Start each wave with a damage-absorbing shield", "uncommon"), + "slow_aura": ("Frost Presence", "Nearby enemies are slowed", "uncommon"), + "fortune": ("Lucky Charm", "Better gem tiers and rarer drops", "rare"), + # --- boss-exclusive (only offered during the matching boss; see THEME_CARDS) --- + "war_drums": ("War Drums", "Periodically summon a warband", "rare"), + "savage_roar": ("Savage Roar", "Each kill erupts in an AoE blast", "rare"), + "iron_nova": ("Iron Detonation", "Heavy slowing nova every few casts", "rare"), + "bulwark": ("Bulwark", "Periodically restore a chunk of HP", "rare"), + "impaling_charge": ("Impaling Charge", "Burst of piercing lances", "rare"), + "arrow_storm": ("Arrow Storm", "A constant rain of arrows", "rare"), +} +ALL_CARD_IDS = list(CARDS.keys()) +# Cards that grant a runtime skill spec rather than a stat point. +SKILL_CARDS = set(skillmod.CARD_SKILLS.keys()) +# One-time cards excluded from draws once owned. +# (auto_attack handled below in BINARY_CARDS) + +# Boss-exclusive cards: each is ONLY offered while its boss theme is active. +# theme index = (round-1)//5 -> Orc / Iron Legion / Lancer / Archer (then cycles) +THEME_CARDS = { + 0: ["war_drums", "savage_roar"], # Orc Horde + 1: ["iron_nova", "bulwark"], # Iron Legion + 2: ["impaling_charge"], # Lancer Host + 3: ["arrow_storm"], # Archer Coven +} +# Every theme-exclusive id, so they can be filtered out of normal draws. +THEME_ONLY = {cid for ids in THEME_CARDS.values() for cid in ids} + +def theme_cards_for(theme: int) -> list[str]: + return THEME_CARDS.get(theme % len(THEME_CARDS), []) +# One-time cards excluded from draws once owned: Auto-Cast and every skill card +# (re-granting a skill would just duplicate it). +BINARY_CARDS = {"auto_attack"} | SKILL_CARDS + +# XP gem tiers 1..7 -> XP value (increasing). The client colors/sizes by tier. +GEM_TIER_XP = {1: 1, 2: 2, 3: 4, 4: 7, 5: 12, 6: 20, 7: 35} + +# Timed AURAS β€” ONLY found as floor pickups (never cards). Each lasts dur seconds +# and applies temporary multipliers. "sheet" picks a Retro Impact effect sheet. +AURAS = { + "inferno": {"label": "Inferno Aura", "dur": 40, "sheet": "a", "color": "#ff7a4a", + "dmg": 1.6}, + "frost": {"label": "Frost Aura", "dur": 50, "sheet": "b", "color": "#7ee0ff", + "enemy_slow": 0.45, "move": 1.15}, + "haste": {"label": "Haste Aura", "dur": 35, "sheet": "c", "color": "#a6ff8c", + "atk": 0.6, "move": 1.4}, + "vampire": {"label": "Vampiric Aura", "dur": 40, "sheet": "d", "color": "#ff6e6e", + "lifesteal": 0.18}, + "warding": {"label": "Warding Aura", "dur": 55, "sheet": "e", "color": "#ffd45e", + "dmg_red": 0.45}, + "fury": {"label": "Fury Aura", "dur": 30, "sheet": "f", "color": "#c08cff", + "dmg": 1.35, "atk": 0.7}, +} +ALL_AURA_IDS = list(AURAS.keys()) +PENDING_AUTOPICK_SEC = 15.0 # auto-choose a card if the player dithers + +# Special boss encounters. They replace the themed boss on certain waves, tint the +# whole screen, attack with a unique projectile style, and β€” when defeated β€” grant +# every surviving wizard the boss's own attack power. +SPECIAL_BOSSES = { + "aegis": {"name": "AEGIS", "hue": "red", "hp_mult": 2.3, "dmg_mult": 1.3, + "style": "holy", "reward": "holy_judgment"}, + "toaster": {"name": "TOASTER BOT", "hue": "green", "hp_mult": 1.9, "dmg_mult": 1.15, + "style": "glitch", "reward": "glitch_rift"}, +} + + +def special_boss_for(rnd: int) -> str | None: + """Aegis on waves 3,9,15… Toaster Bot on waves 5,11,17…""" + if rnd >= 3 and (rnd - 3) % 6 == 0: + return "aegis" + if rnd >= 5 and (rnd - 5) % 6 == 0: + return "toaster" + return None + +# Enemy archetypes: id -> stat multipliers/overrides relative to the base minion. +MINION_TYPES = { + "grunt": {"hp": 1.0, "speed": 1.0, "dmg": 1.0, "scale": 1.0}, + "fast": {"hp": 0.6, "speed": 1.8, "dmg": 0.7, "scale": 0.85}, + "tank": {"hp": 2.6, "speed": 0.6, "dmg": 1.6, "scale": 1.3}, +} + +# Boss attack patterns β€” the Game Master picks one per round. Multipliers scale +# how often each attack fires (>1 = more often); flags switch firing styles. +BOSS_PATTERNS = { + "balanced": {"shoot": 1.0, "nova": 1.0, "charge": 1.0, "spawn": 1.0, + "label": ""}, + "artillery": {"shoot": 0.6, "nova": 2.0, "charge": 0.4, "spawn": 0.8, + "label": "ARTILLERY", "slow_heavy": True}, + "sniper": {"shoot": 1.5, "nova": 0.45, "charge": 0.6, "spawn": 0.7, + "label": "SNIPER", "snipe": True}, + "swarm": {"shoot": 0.55, "nova": 0.6, "charge": 0.5, "spawn": 2.4, + "label": "SWARMLORD"}, + "berserker": {"shoot": 0.7, "nova": 0.8, "charge": 2.6, "spawn": 0.7, + "label": "BERSERKER"}, +} +ALL_PATTERN_IDS = list(BOSS_PATTERNS.keys()) + +# Blessings the Game Master may bestow on individual wizards between rounds: +# any timed aura, an extra life, or a full heal. Applied at next round start. +BLESSINGS = ["extra_life", "full_heal"] + ALL_AURA_IDS +MAX_LIVES = 5 + + +def _clamp(v, lo, hi): + return lo if v < lo else hi if v > hi else v + + +@dataclass +class Projectile: + x: float + y: float + vx: float + vy: float + dmg: float + owner: str # player id, or "boss"/"minion" + hostile: bool # True = damages players, False = damages enemies + ttl: float = 2.5 + radius: float = 10.0 + pierce: int = 0 # remaining extra enemies this shot can pass through + hit_ids: set = field(default_factory=set) # minion uids already hit (for pierce) + # power-up effect payload (player shots only) + homing: bool = False + burn: float = 0.0 # burn dps applied on hit (Ignite) + explode: float = 0.0 # AoE radius on hit (Explosive) + knock: float = 0.0 # knockback impulse (Force Bolt) + chain: int = 0 # extra chain hits (Chain Spark) + crit: bool = False # was a critical hit (visual) + style: str = "" # visual style: "" | "holy" | "glitch" + + def to_dict(self): + d = {"x": round(self.x, 1), "y": round(self.y, 1), + "hostile": self.hostile, "r": self.radius} + if self.style: + d["s"] = self.style + return d + + +@dataclass +class Minion: + x: float + y: float + hp: float + max_hp: float + speed: float + dmg: float + uid: int = 0 # stable id so the client can track hit/death effects + kind: str = "grunt" # grunt | fast | tank + scale: float = 1.0 + attack_cd: float = 0.0 + hurt: float = 0.0 + burn: float = 0.0 # burn damage-per-second (Ignite) + burn_t: float = 0.0 # burn time remaining + alive: bool = True + + def to_dict(self): + return {"id": self.uid, + "x": round(self.x, 1), "y": round(self.y, 1), + "hp": self.hp, "max_hp": self.max_hp, "kind": self.kind, + "scale": self.scale, "hurt": self.hurt > 0, + "burn": self.burn_t > 0} + + +@dataclass +class Ally: + """A summoned spirit that fights for a player (from a summon_ally skill).""" + x: float + y: float + owner: str # player id (kills/damage credit the summoner) + uid: int = 0 + hp: float = 30.0 + max_hp: float = 30.0 + speed: float = 150.0 + dmg: float = 12.0 + attack_cd: float = 0.0 + ttl: float = 14.0 + color: str = "#a6ff8c" + + def to_dict(self): + return {"id": self.uid, "x": round(self.x, 1), "y": round(self.y, 1), + "hp": self.hp, "max_hp": self.max_hp, "color": self.color} + + +@dataclass +class Pickup: + """A rare floor power-up. kind 'card' grants a permanent card; kind 'aura' + grants a timed aura buff. Walk over it to claim.""" + x: float + y: float + card: str # card id (kind=card) or aura id (kind=aura) + kind: str = "card" # card | aura + uid: int = 0 + ttl: float = 22.0 + + def to_dict(self): + d = {"id": self.uid, "x": round(self.x, 1), "y": round(self.y, 1), + "card": self.card, "kind": self.kind} + if self.kind == "aura": + d["color"] = AURAS.get(self.card, {}).get("color", "#fff") + d["sheet"] = AURAS.get(self.card, {}).get("sheet", "a") + return d + + +@dataclass +class Gem: + x: float + y: float + value: int = 1 + tier: int = 1 + ttl: float = 18.0 + + def to_dict(self): + return {"x": round(self.x, 1), "y": round(self.y, 1), + "v": self.value, "t": self.tier} + + +@dataclass +class Boss: + hp: float = 1000 + max_hp: float = 1000 + dmg: float = 12 + x: float = CENTER[0] + y: float = CENTER[1] + shoot_cd: float = 0.0 + spawn_cd: float = 0.0 + spawn_interval: float = 4.0 + hurt: float = 0.0 + # attack state machine + state: str = "idle" # idle | telegraph | charge + state_t: float = 0.0 # time remaining in the current state + charge_cd: float = 6.0 # until next charge + nova_cd: float = 5.0 # until next ground shockwave + cvx: float = 0.0 + cvy: float = 0.0 + aggro: float = 1.0 # Director-tunable attack frequency multiplier + special: str = "" # "" | "aegis" | "toaster" + name: str = "" # display name (special bosses) + special_cd: float = 4.0 # until next signature attack + pattern: str = "balanced" # Director-chosen attack pattern + atk_speed: float = 1.0 # Director-chosen attack cadence multiplier + + def to_dict(self): + return {"x": round(self.x, 1), "y": round(self.y, 1), + "hp": self.hp, "max_hp": self.max_hp, + "hurt": self.hurt > 0, "state": self.state, + "special": self.special, "name": self.name, + "pattern": self.pattern, "atk_speed": self.atk_speed, + "pattern_label": BOSS_PATTERNS.get(self.pattern, {}).get("label", ""), + "phase": 1 if self.hp > self.max_hp * 0.5 else 2} + + +@dataclass +class Player: + pid: str + name: str + x: float = 200.0 + y: float = 360.0 + hp: float = 100.0 + max_hp: float = 100.0 + speed: float = 180.0 + facing: float = 0.0 # radians, last aim direction + alive: bool = True + gold: int = 0 + level: int = 1 + # input + up: bool = False + down: bool = False + left: bool = False + right: bool = False + attacking: bool = False + attack_timer: float = 0.0 + moving: bool = False + hurt: float = 0.0 + # upgrade levels (gained via level-up cards) + up_damage: int = 0 + up_attack_speed: int = 0 + up_move_speed: int = 0 + up_max_hp: int = 0 + up_multishot: int = 0 + up_pierce: int = 0 + up_regen: int = 0 + up_projectile_speed: int = 0 + up_auto_attack: int = 0 + # new base power-ups + up_crit: int = 0 + up_lifesteal: int = 0 + up_armor: int = 0 + up_dodge: int = 0 + up_thorns: int = 0 + up_magnet: int = 0 + up_xp_boost: int = 0 + up_gold_boost: int = 0 + up_knockback: int = 0 + up_explosive: int = 0 + up_burn: int = 0 + up_chain: int = 0 + up_big_shots: int = 0 + up_homing: int = 0 + up_giant: int = 0 + up_glass_cannon: int = 0 + up_berserker: int = 0 + up_shield: int = 0 + up_slow_aura: int = 0 + up_fortune: int = 0 + shield: float = 0.0 # current absorb (from Aegis) + flash: str = "" # transient pickup/announce text for this player + flash_t: float = 0.0 + # lives / character / timed auras + lives: int = 3 + char: str = "warrior" # chosen character sprite set + respawn_t: float = 0.0 # >0 while waiting to respawn after a death + auras: list = field(default_factory=list) # active timed auras [{type,t}] + pending_until: float = 0.0 # auto-pick deadline for pending_cards + # XP / character level + xp: int = 0 + xp_needed: int = 5 + pending_cards: list = field(default_factory=list) # card ids offered now + skill_cards: set = field(default_factory=set) # owned skill-card ids + # runtime skill specs (from cards or Game-Master-authored requests) + state + skills: list = field(default_factory=list) + _attack_count: int = 0 + # per-round stats + dmg_dealt: float = 0.0 + kills: int = 0 + + # ---- timed-aura helpers ---- + def _aura_prod(self, key: str, default: float = 1.0) -> float: + v = default + for a in self.auras: + spec = AURAS.get(a["type"], {}) + if key in spec: + v *= spec[key] + return v + + def _aura_max(self, key: str) -> float: + v = 0.0 + for a in self.auras: + spec = AURAS.get(a["type"], {}) + if key in spec: + v = max(v, spec[key]) + return v + + # derived stats + @property + def damage(self) -> float: + # base + Spell Power + Glass Cannon; Berserker adds more while wounded + d = 14 + self.up_damage * 6 + self.up_glass_cannon * 12 + if self.up_berserker and self.hp < self.max_hp * 0.5: + d *= 1 + 0.15 * self.up_berserker + return d * self._aura_prod("dmg") + + @property + def size_mult(self) -> float: + # Wizards start minion-sized (0.55) and grow a step every level, capped + # a touch above boss size (2.9) β€” a long run literally makes you a + # bigger, harder-to-dodge-with target. Titan Growth stacks on top. + return min(2.9, 0.55 + 0.12 * (self.level - 1)) * (1 + self.up_giant * 0.18) + + @property + def hit_radius(self) -> float: + # Server-authoritative hurtbox β€” grows with size, so dodging gets + # harder as you level (the client renders at the same scale). + return 10.0 + 12.0 * self.size_mult + + @property + def attack_interval(self) -> float: + return max(0.12, (0.55 - self.up_attack_speed * 0.05) * self._aura_prod("atk")) + + @property + def move_speed(self) -> float: + return (180 + self.up_move_speed * 22) * self._aura_prod("move") + + @property + def shots(self) -> int: + return 1 + self.up_multishot + + @property + def projectile_speed(self) -> float: + return 520 + self.up_projectile_speed * 90 + + @property + def projectile_ttl(self) -> float: + return 2.5 + self.up_projectile_speed * 0.6 + + @property + def regen(self) -> float: + return self.up_regen * 2.0 # hp per second + + def to_dict(self): + return { + "id": self.pid, "name": self.name, + "x": round(self.x, 1), "y": round(self.y, 1), + "hp": round(self.hp, 1), "max_hp": self.max_hp, + "facing": round(self.facing, 3), "alive": self.alive, + "gold": self.gold, "level": self.level, "moving": self.moving, + "attacking": self.attack_timer > 0, "hurt": self.hurt > 0, + "xp": self.xp, "xp_needed": self.xp_needed, + "pending_cards": list(self.pending_cards), + "pending_left": round(max(0.0, self.pending_until - time.time()), 1) if self.pending_cards else 0, + "lives": self.lives, "char": self.char, + "auras": [{"type": a["type"], "t": round(a["t"], 1), + "color": AURAS.get(a["type"], {}).get("color", "#fff"), + "label": AURAS.get(a["type"], {}).get("label", a["type"])} + for a in self.auras], + "size": round(self.size_mult, 2), + "shield": round(self.shield, 1), + "flash": self.flash if self.flash_t > 0 else "", + "skills": [{"name": s["name"], "effect": s["effect"], "color": s.get("color", "#b48cff")} + for s in self.skills], + # owned level per card (skill cards report 1 when owned) + "upgrades": {k: (1 if k in self.skill_cards else getattr(self, f"up_{k}", 0)) + for k in CARDS}, + "stats": {"dmg": round(self.dmg_dealt), "kills": self.kills}, + } + + +class Engine: + def __init__(self): + self.players: dict[str, Player] = {} + self.minions: list[Minion] = [] + self._minion_seq = 0 + self.projectiles: list[Projectile] = [] + self.gems: list[Gem] = [] + self.allies: list[Ally] = [] + self._ally_seq = 0 + self.pickups: list[Pickup] = [] + self._pickup_seq = 0 + self.boss = Boss() + self.round = 0 + self.status = "lobby" # lobby | active | intermission + self.status_msg = "Waiting for wizards to join..." + self.gm_message = "" + self.intermission_until = 0.0 + self.round_started_at = 0.0 + self.skill_request_pid: str | None = None + self.skill_request_used = False + # Cards the Game Master is currently offering on level-up (ids). Empty => + # the full set is available (deterministic default). + self.card_pool: list[str] = list(ALL_CARD_IDS) + # config applied each round (mutated by the Game Master) + self.cfg = { + "boss_hp": 1000, "boss_damage": 12, + "minion_hp": 40, "minion_count": 6, "spawn_interval": 4.0, + "boss_aggro": 1.0, # Director-tunable attack frequency + "boss_attack_speed": 1.0, # Director-tunable cadence (mercy-floored) + "boss_pattern": "balanced", # Director-chosen attack pattern + # wave composition: relative weights of each enemy archetype + "wave": {"grunt": 1.0, "fast": 0.0, "tank": 0.0}, + } + # one-shot blessings the GM granted for the coming round: {pid: blessing} + self.pending_blessings: dict[str, str] = {} + # transient FX events for the client (drained each snapshot) + self._events: list[dict] = [] + + def _event(self, fx: str, x: float, y: float, **kw): + """Queue a one-shot visual event for the next snapshot broadcast.""" + if len(self._events) < 40: + self._events.append({"fx": fx, "x": round(x, 1), "y": round(y, 1), **kw}) + + # ---- lobby management ------------------------------------------------- + @property + def active_players(self) -> list[Player]: + return list(self.players.values()) + + def can_join(self) -> bool: + return len(self.players) < MAX_PLAYERS + + def add_player(self, pid: str, name: str, char: str = "warrior") -> Player: + spawn_angle = random.uniform(0, math.tau) + p = Player( + pid=pid, name=name[:16] or "Wizard", char=char or "soldier", + x=CENTER[0] + math.cos(spawn_angle) * 420, + y=CENTER[1] + math.sin(spawn_angle) * 280, + ) + p.x = _clamp(p.x, 40, ARENA_W - 40) + p.y = _clamp(p.y, 40, ARENA_H - 40) + # if joining mid-wave, jump straight into the fight + if self.status == "active": + p.hp = p.max_hp + self.players[pid] = p + if self.status == "lobby" and len(self.players) >= 1: + self.status_msg = "Press START or wait for more wizards." + return p + + def remove_player(self, pid: str): + self.players.pop(pid, None) + + def set_input(self, pid: str, data: dict): + p = self.players.get(pid) + if not p: + return + p.up = bool(data.get("up")) + p.down = bool(data.get("down")) + p.left = bool(data.get("left")) + p.right = bool(data.get("right")) + p.attacking = bool(data.get("attack")) + + def _apply_card(self, p: Player, key: str): + """Apply a chosen level-up card's effect to a player.""" + if key in SKILL_CARDS: + self._add_skill(p, skillmod.CARD_SKILLS[key]) + p.skill_cards.add(key) + return + setattr(p, f"up_{key}", getattr(p, f"up_{key}") + 1) + if key in ("max_hp", "giant", "glass_cannon"): + self._recompute_max_hp(p) + if key == "max_hp": + p.hp = min(p.max_hp, p.hp + 25) + if key == "shield": + p.shield = max(p.shield, 20 * p.up_shield) + + def _recompute_max_hp(self, p: Player): + p.max_hp = max(20, 100 + p.up_max_hp * 25 + p.up_giant * 30 - p.up_glass_cannon * 15) + p.hp = min(p.hp, p.max_hp) + + def _add_skill(self, p: Player, spec: dict) -> dict | None: + """Validate and attach a skill spec to a player (caps the count).""" + safe = skillmod.validate_skill(spec) + if not safe: + return None + safe = dict(safe) + safe["_cd"] = safe.get("interval", 0.0) # periodic cooldown timer + if len(p.skills) >= skillmod.MAX_SKILLS_PER_PLAYER: + p.skills.pop(0) + p.skills.append(safe) + return safe + + def add_runtime_skill(self, pid: str, spec: dict) -> dict | None: + """Hot-plug a Game-Master-authored skill onto a player (the LLM flow).""" + p = self.players.get(pid) + if not p: + return None + return self._add_skill(p, spec) + + def top_player_id(self) -> str | None: + """Highest-damage player of the round just ended (gets the skill request).""" + if not self.players: + return None + return max(self.players.values(), key=lambda p: p.dmg_dealt).pid + + def choose_card(self, pid: str, key: str) -> bool: + """Player picks one of the cards currently offered to them.""" + p = self.players.get(pid) + if not p or key not in CARDS or key not in p.pending_cards: + return False + self._apply_card(p, key) + p.pending_cards = [] # one pick per level-up + p.pending_until = 0.0 + return True + + def buy_upgrade(self, pid: str, key: str) -> bool: + """Legacy gold shop (kept for compatibility; survivors mode uses cards).""" + p = self.players.get(pid) + if not p or key not in UPGRADES: + return False + if self.status not in ("intermission", "lobby"): + return False + _, cost = UPGRADES[key] + if p.gold < cost: + return False + p.gold -= cost + self._apply_card(p, key) + return True + + def _owns(self, p: Player, cid: str) -> bool: + """Whether a one-time card is already taken (stat binary or a skill card).""" + if cid in SKILL_CARDS: + return cid in p.skill_cards + return bool(getattr(p, f"up_{cid}", 0)) + + def _draw_cards(self, p: Player, n: int = 3) -> list[str]: + """Draw up to n distinct card ids for a player, honoring boss-exclusive gating.""" + theme = max(0, (self.round - 1) // 5) + theme_cards = theme_cards_for(theme) + pool = [c for c in self.card_pool if c in CARDS] or list(ALL_CARD_IDS) + # boss-exclusive cards: only the current boss's; drop all other themes' + pool = [c for c in pool if c not in THEME_ONLY or c in theme_cards] + # ensure this boss's exclusives are eligible even if the Director omitted them + for tc in theme_cards: + if tc not in pool: + pool.append(tc) + # drop one-time cards already owned + pool = [c for c in pool if not (c in BINARY_CARDS and self._owns(p, c))] + chosen: list[str] = [] + # guarantee Auto-Cast is offered early so it doesn't get lost in RNG + if not p.up_auto_attack and p.level <= 3: + chosen.append("auto_attack") + rest = [c for c in pool if c not in chosen] + random.shuffle(rest) + # bias one slot toward a boss-exclusive card when available (feels special) + avail_theme = [c for c in theme_cards if c in rest] + if avail_theme and len(chosen) < n: + pick = random.choice(avail_theme) + chosen.append(pick); rest.remove(pick) + for c in rest: + if len(chosen) >= n: + break + chosen.append(c) + return chosen[:n] + + def _grant_xp(self, p: Player, amount: int): + if not p.alive: + return + p.xp += amount + leveled = False + while p.xp >= p.xp_needed: + p.xp -= p.xp_needed + p.level += 1 + p.xp_needed = int(p.xp_needed * 1.45) + 3 + leveled = True + if leveled and not p.pending_cards: + p.pending_cards = self._draw_cards(p, 3) + p.pending_until = time.time() + PENDING_AUTOPICK_SEC + + # ---- round lifecycle -------------------------------------------------- + def start_round(self): + if not self.players: + self.status = "lobby" + return + self.round += 1 + self.minions.clear() + self.projectiles.clear() + self.gems.clear() + self.allies.clear() + self.pickups.clear() + self.boss = Boss( + hp=self.cfg["boss_hp"], max_hp=self.cfg["boss_hp"], + dmg=self.cfg["boss_damage"], spawn_interval=self.cfg["spawn_interval"], + aggro=self.cfg.get("boss_aggro", 1.0), + pattern=self.cfg.get("boss_pattern", "balanced"), + atk_speed=self.cfg.get("boss_attack_speed", 1.0), + ) + # special boss encounter? (Aegis / Toaster Bot) β€” buff and rename it + sp = special_boss_for(self.round) + if sp: + cfg = SPECIAL_BOSSES[sp] + self.boss.special = sp + self.boss.name = cfg["name"] + hp = int(self.cfg["boss_hp"] * cfg["hp_mult"]) + self.boss.hp = self.boss.max_hp = hp + self.boss.dmg = int(self.cfg["boss_damage"] * cfg["dmg_mult"]) + # XP / level / cards persist across the run; only revive & reset per-round + # combat stats. Pending card picks carry over so a level-up isn't lost. + for p in self.players.values(): + self._recompute_max_hp(p) + p.hp = p.max_hp + p.alive = p.lives > 0 # eliminated wizards stay down (awaiting removal) + p.respawn_t = 0.0 + p.dmg_dealt = 0.0 + p.kills = 0 + p.shield = 20 * p.up_shield # Aegis refreshes each wave + ang = random.uniform(0, math.tau) + p.x = _clamp(CENTER[0] + math.cos(ang) * 420, 40, ARENA_W - 40) + p.y = _clamp(CENTER[1] + math.sin(ang) * 280, 40, ARENA_H - 40) + self._apply_blessings() + self.status = "active" + if self.boss.special: + self.status_msg = f"Wave {self.round} β€” {self.boss.name} has arrived!" + else: + self.status_msg = f"Wave {self.round} β€” survive and slay the boss!" + self.round_started_at = time.time() + + def _grant_boss_reward(self, special: str): + """All living wizards gain the defeated special boss's signature attack.""" + cfg = SPECIAL_BOSSES.get(special, {}) + reward = cfg.get("reward") + spec = skillmod.CARD_SKILLS.get(reward) + if not spec: + return + name = spec.get("name", reward) + for p in self.players.values(): + if p.alive and reward not in p.skill_cards: + self._add_skill(p, spec) + p.skill_cards.add(reward) + p.flash = f"Gained {name} from {cfg.get('name', 'the boss')}!" + p.flash_t = 3.0 + + def _apply_blessings(self): + """Consume the Game Master's one-shot blessings at round start.""" + for pid, blessing in self.pending_blessings.items(): + p = self.players.get(pid) + if not p or blessing not in BLESSINGS: + continue + if blessing == "extra_life": + p.lives = min(MAX_LIVES, p.lives + 1) + if p.lives > 0 and not p.alive and p.respawn_t <= 0: + p.alive = True + p.hp = p.max_hp + p.flash = "πŸ™ Blessed: an extra life!" + elif blessing == "full_heal": + p.hp = p.max_hp + p.flash = "πŸ™ Blessed: fully healed!" + else: # a timed aura + spec = AURAS.get(blessing, {}) + existing = next((a for a in p.auras if a["type"] == blessing), None) + if existing: + existing["t"] = spec.get("dur", 40) + else: + p.auras.append({"type": blessing, "t": spec.get("dur", 40)}) + p.flash = f"πŸ™ Blessed: {spec.get('label', blessing)}!" + p.flash_t = 4.0 + self._event("bless", p.x, p.y, color="#ffe9a0") + self.pending_blessings = {} + + def _respawn(self, p: Player): + p.alive = True + p.respawn_t = 0.0 + p.hp = p.max_hp + p.hurt = 1.0 # brief invuln flicker + ang = random.uniform(0, math.tau) + p.x = _clamp(CENTER[0] + math.cos(ang) * 420, 40, ARENA_W - 40) + p.y = _clamp(CENTER[1] + math.sin(ang) * 280, 40, ARENA_H - 40) + + def round_summary(self) -> dict: + """Compact, model-friendly summary of the round that just ended.""" + won = self.boss.hp <= 0 + return { + "round": self.round, + "result": "victory" if won else "defeat", + "duration_sec": round(time.time() - self.round_started_at, 1), + "boss_max_hp": self.boss.max_hp, + "players": [ + { + "id": p.pid, "name": p.name, "level": p.level, + "survived": p.alive, + "damage_dealt": round(p.dmg_dealt), + "kills": p.kills, "gold": p.gold, + "lives_left": p.lives, + "hp_pct": round(100 * p.hp / p.max_hp) if p.alive else 0, + } + for p in self.players.values() + ], + } + + def apply_gm_decision(self, decision: dict): + """Apply a (validated) Game Master decision to rewards + next config.""" + rewards = decision.get("rewards", {}) or {} + for pid, amount in rewards.items(): + p = self.players.get(pid) + if p: + amt = int(_clamp(int(amount), 0, 1000)) * (1 + 0.2 * p.up_gold_boost) # Prosperity + p.gold += int(amt) + nxt = decision.get("next_round", {}) or {} + self.cfg["boss_hp"] = int(_clamp(int(nxt.get("boss_hp", self.cfg["boss_hp"])), 300, 100000)) + self.cfg["boss_damage"] = int(_clamp(int(nxt.get("boss_damage", self.cfg["boss_damage"])), 4, 80)) + self.cfg["minion_hp"] = int(_clamp(int(nxt.get("minion_hp", self.cfg["minion_hp"])), 15, 2000)) + self.cfg["minion_count"] = int(_clamp(int(nxt.get("minion_count", self.cfg["minion_count"])), 0, 40)) + self.cfg["spawn_interval"] = float(_clamp(float(nxt.get("spawn_interval", self.cfg["spawn_interval"])), 1.0, 10.0)) + self.cfg["boss_aggro"] = float(_clamp(float(nxt.get("boss_aggro", self.cfg["boss_aggro"])), 0.6, 3.0)) + self.cfg["boss_attack_speed"] = float(_clamp(float(nxt.get("boss_attack_speed", self.cfg["boss_attack_speed"])), 0.5, 2.0)) + # boss attack pattern for the coming round + pattern = nxt.get("boss_pattern", decision.get("boss_pattern")) + if pattern in BOSS_PATTERNS: + self.cfg["boss_pattern"] = pattern + # per-player blessings (validated against roster + known blessings) + blessings = decision.get("blessings") + if isinstance(blessings, dict): + self.pending_blessings = { + pid: b for pid, b in blessings.items() + if pid in self.players and b in BLESSINGS + } + # wave composition (enemy archetype weights) + wave = nxt.get("wave") + if isinstance(wave, dict): + clean = {} + for k in MINION_TYPES: + try: + clean[k] = max(0.0, float(wave.get(k, 0.0))) + except Exception: + clean[k] = 0.0 + if sum(clean.values()) > 0: + self.cfg["wave"] = clean + # offered level-up card pool + pool = decision.get("card_pool") + if isinstance(pool, list): + valid = [c for c in pool if c in CARDS] + self.card_pool = valid if valid else list(ALL_CARD_IDS) + self.gm_message = str(decision.get("message", ""))[:240] + + def _pick_minion_kind(self) -> str: + weights = self.cfg.get("wave") or {"grunt": 1.0} + kinds = [k for k in MINION_TYPES if weights.get(k, 0) > 0] or ["grunt"] + w = [weights.get(k, 0) for k in kinds] + return random.choices(kinds, weights=w, k=1)[0] + + def begin_intermission(self, seconds: float = 12.0): + self.status = "intermission" + self.intermission_until = time.time() + seconds + won = self.boss.hp <= 0 + self.status_msg = ("Victory! " if won else "Defeated... ") + \ + "The Game Master is deciding rewards & the next round." + # the round's top damage-dealer may ask the GM for a custom power-up + self.skill_request_pid = self.top_player_id() + self.skill_request_used = False + + # ---- simulation ------------------------------------------------------- + def _spawn_minion(self): + # Minions are summoned by the boss: they emerge from around it. + ang = random.uniform(0, math.tau) + dist = random.uniform(50, 95) + x = _clamp(self.boss.x + math.cos(ang) * dist, 30, ARENA_W - 30) + y = _clamp(self.boss.y + math.sin(ang) * dist, 30, ARENA_H - 30) + kind = self._pick_minion_kind() + t = MINION_TYPES[kind] + hp = self.cfg["minion_hp"] * t["hp"] + self._minion_seq += 1 + self.minions.append(Minion( + x=x, y=y, hp=hp, max_hp=hp, + speed=(70 + self.round * 4) * t["speed"], + dmg=(6 + self.round) * t["dmg"], + uid=self._minion_seq, kind=kind, scale=t["scale"], + )) + + def _nearest_player(self, x, y): + best, bd = None, 1e18 + for p in self.players.values(): + if not p.alive: + continue + d = (p.x - x) ** 2 + (p.y - y) ** 2 + if d < bd: + best, bd = p, d + return best + + def _nearest_enemy(self, x, y): + """Closest minion, else the boss.""" + best, bd = None, 1e18 + for m in self.minions: + if not m.alive: + continue + d = (m.x - x) ** 2 + (m.y - y) ** 2 + if d < bd: + best, bd = m, d + if self.boss.hp > 0: + d = (self.boss.x - x) ** 2 + (self.boss.y - y) ** 2 + if d < bd or best is None: + best, bd = self.boss, d + return best + + def tick(self, dt: float): + if self.status != "active": + return + + for p in self.players.values(): + p.hurt = max(0.0, p.hurt - dt) + p.attack_timer = max(0.0, p.attack_timer - dt) + if p.flash_t > 0: + p.flash_t -= dt + # timed auras count down and expire + if p.auras: + for a in p.auras: + a["t"] -= dt + p.auras = [a for a in p.auras if a["t"] > 0] + # auto-pick a pending card if the player dithers past the deadline + if p.pending_cards and time.time() >= p.pending_until: + self.choose_card(p.pid, p.pending_cards[0]) + if not p.alive: + # respawn after a short delay while lives remain + if p.lives > 0 and p.respawn_t > 0: + p.respawn_t -= dt + if p.respawn_t <= 0: + self._respawn(p) + continue + if p.regen and p.hp < p.max_hp: + p.hp = min(p.max_hp, p.hp + p.regen * dt) + # periodic skills + for sk in p.skills: + if sk["trigger"] == "periodic": + sk["_cd"] = sk.get("_cd", sk.get("interval", 5.0)) - dt + if sk["_cd"] <= 0: + sk["_cd"] = sk.get("interval", 5.0) + self._fire_skill(p, sk) + dx = (1 if p.right else 0) - (1 if p.left else 0) + dy = (1 if p.down else 0) - (1 if p.up else 0) + p.moving = dx != 0 or dy != 0 + if p.moving: + inv = 1.0 / math.hypot(dx, dy) + p.x = _clamp(p.x + dx * inv * p.move_speed * dt, 24, ARENA_W - 24) + p.y = _clamp(p.y + dy * inv * p.move_speed * dt, 24, ARENA_H - 24) + # aim at nearest enemy for satisfying co-op feel + tgt = self._nearest_enemy(p.x, p.y) + if tgt is not None: + p.facing = math.atan2(tgt.y - p.y, tgt.x - p.x) + # attack (Auto-Cast fires without holding the key) + if (p.attacking or p.up_auto_attack) and p.attack_timer <= 0: + self._player_shoot(p) + p.attack_timer = p.attack_interval + + self._tick_minions(dt) + self._tick_boss(dt) + self._tick_allies(dt) + self._tick_projectiles(dt) + self._tick_gems(dt) + self._tick_pickups(dt) + + self.minions = [m for m in self.minions if m.alive] + self.allies = [a for a in self.allies if a.ttl > 0 and a.hp > 0] + + # round end conditions + if self.boss.hp <= 0: + # boss drops a shower of high-tier gems + for _ in range(10): + ang = random.uniform(0, math.tau) + d = random.uniform(20, 140) + gx = _clamp(self.boss.x + math.cos(ang) * d, 30, ARENA_W - 30) + gy = _clamp(self.boss.y + math.sin(ang) * d, 30, ARENA_H - 30) + g = self._make_gem(gx, gy, "tank") + g.tier = int(_clamp(g.tier + 2, 1, 7)) + g.value = GEM_TIER_XP[g.tier] + self.gems.append(g) + # special boss defeated -> every survivor gains its attack power + if self.boss.special: + self._grant_boss_reward(self.boss.special) + self.begin_intermission() + elif self.players and all(not p.alive and p.respawn_t <= 0 for p in self.players.values()): + # every wizard is down with no respawn pending (all eliminated) + self.begin_intermission() + + def _tick_allies(self, dt: float): + """Summoned spirits chase the nearest enemy and fire bolts at it.""" + for a in self.allies: + a.ttl -= dt + a.attack_cd = max(0.0, a.attack_cd - dt) + tgt = self._nearest_enemy(a.x, a.y) + if tgt is None: + continue + dist = math.hypot(tgt.x - a.x, tgt.y - a.y) + ang = math.atan2(tgt.y - a.y, tgt.x - a.x) + if dist > 180: + a.x += math.cos(ang) * a.speed * dt + a.y += math.sin(ang) * a.speed * dt + if a.attack_cd <= 0: + self.projectiles.append(Projectile( + x=a.x, y=a.y, vx=math.cos(ang) * 480, vy=math.sin(ang) * 480, + dmg=a.dmg, owner=a.owner, hostile=False, radius=7, ttl=1.5, + )) + a.attack_cd = 0.8 + + def _tick_gems(self, dt: float): + """XP gems drift toward nearby living players and are auto-collected.""" + alive = [] + for g in self.gems: + g.ttl -= dt + if g.ttl <= 0: + continue + tgt = self._nearest_player(g.x, g.y) + collected = False + if tgt is not None: + pull = 130.0 + tgt.up_magnet * 55 # Lodestone + grab = 26.0 + tgt.up_magnet * 10 + d = math.hypot(tgt.x - g.x, tgt.y - g.y) + if d <= grab: + amount = g.value * (1 + 0.15 * tgt.up_xp_boost) # Quick Study + self._grant_xp(tgt, max(1, round(amount))) + collected = True + elif d <= pull: + ang = math.atan2(tgt.y - g.y, tgt.x - g.x) + g.x += math.cos(ang) * 240 * dt + g.y += math.sin(ang) * 240 * dt + if not collected: + alive.append(g) + self.gems = alive + + def _tick_pickups(self, dt: float): + """Floor power-ups: walk over one to claim the card instantly.""" + alive = [] + for pk in self.pickups: + pk.ttl -= dt + if pk.ttl <= 0: + continue + taken = False + for p in self.players.values(): + if p.alive and (p.x - pk.x) ** 2 + (p.y - pk.y) ** 2 < 34 ** 2: + if pk.kind == "aura": + spec = AURAS.get(pk.card, {}) + # refresh if already active, else add + existing = next((a for a in p.auras if a["type"] == pk.card), None) + if existing: + existing["t"] = spec.get("dur", 40) + else: + p.auras.append({"type": pk.card, "t": spec.get("dur", 40)}) + p.flash = f"{spec.get('label', pk.card)}!" + else: + self._apply_card(p, pk.card) + p.flash = f"Power-up: {CARDS.get(pk.card, (pk.card,))[0]}!" + p.flash_t = 2.5 + taken = True + break + if not taken: + alive.append(pk) + self.pickups = alive + + def _player_shoot(self, p: Player): + spread = 0.18 + n = p.shots + base = p.facing + radius = 9 + p.up_big_shots * 4 + for i in range(n): + off = 0 if n == 1 else (i - (n - 1) / 2) * spread + ang = base + off + dmg = p.damage + crit = p.up_crit and random.random() < min(0.6, 0.08 * p.up_crit) + if crit: + dmg *= 2 + self.projectiles.append(Projectile( + x=p.x, y=p.y, + vx=math.cos(ang) * p.projectile_speed, + vy=math.sin(ang) * p.projectile_speed, + dmg=dmg, owner=p.pid, hostile=False, radius=radius, + ttl=p.projectile_ttl, pierce=p.up_pierce, + homing=p.up_homing > 0, burn=p.up_burn * 4.0, + explode=60.0 if p.up_explosive else 0.0, + knock=p.up_knockback * 9.0, chain=p.up_chain, crit=bool(crit), + )) + # attack-triggered skills + p._attack_count += 1 + for sk in p.skills: + if sk["trigger"] == "every_n_attacks" and p._attack_count % sk.get("n", 5) == 0: + self._fire_skill(p, sk) + + # ---- skill effects ---------------------------------------------------- + def _fire_skill(self, p: Player, sk: dict): + eff = sk["effect"] + color = sk.get("color", "#b48cff") + if eff == "aoe_damage": + self._event("aoe_frost" if sk.get("slow") else "aoe", p.x, p.y, + r=sk["radius"], color=color) + r2 = sk["radius"] ** 2 + for m in self.minions: + if m.alive and (m.x - p.x) ** 2 + (m.y - p.y) ** 2 <= r2: + m.hp -= sk["damage"]; m.hurt = 0.15 + if sk.get("slow"): + m.speed *= (1 - sk["slow"]) + p.dmg_dealt += sk["damage"] + if m.hp <= 0: + m.alive = False + self._on_minion_killed(m, p) + if self.boss.hp > 0 and (self.boss.x - p.x) ** 2 + (self.boss.y - p.y) ** 2 <= r2: + self.boss.hp -= sk["damage"]; self.boss.hurt = 0.1; p.dmg_dealt += sk["damage"] + elif eff == "projectile_nova": + self._event("nova", p.x, p.y, color=color) + cnt = sk["count"] + for k in range(cnt): + ang = k * math.tau / cnt + self.projectiles.append(Projectile( + x=p.x, y=p.y, vx=math.cos(ang) * 460, vy=math.sin(ang) * 460, + dmg=sk["damage"], owner=p.pid, hostile=False, radius=8, ttl=1.8, + )) + elif eff == "heal": + self._event("heal", p.x, p.y, color=color) + p.hp = min(p.max_hp, p.hp + sk["amount"]) + elif eff == "shield": + self._event("shield", p.x, p.y, color=color) + p.shield = min(120.0, p.shield + sk["amount"]) + elif eff == "summon_ally": + self._event("summon", p.x, p.y, color=color) + for _ in range(sk["count"]): + self._summon_ally(p) + + def _summon_ally(self, p: Player): + if len(self.allies) >= 24: + return + self._ally_seq += 1 + ang = random.uniform(0, math.tau) + self.allies.append(Ally( + x=_clamp(p.x + math.cos(ang) * 40, 24, ARENA_W - 24), + y=_clamp(p.y + math.sin(ang) * 40, 24, ARENA_H - 24), + owner=p.pid, uid=self._ally_seq, + hp=30 + p.level * 3, max_hp=30 + p.level * 3, + dmg=10 + p.up_damage * 3, + )) + + def _tick_minions(self, dt: float): + for m in self.minions: + m.hurt = max(0.0, m.hurt - dt) + m.attack_cd = max(0.0, m.attack_cd - dt) + # Ignite: burn damage over time + if m.burn_t > 0: + m.burn_t -= dt + m.hp -= m.burn * dt + if m.hp <= 0: + m.alive = False + self._on_minion_killed(m, None) + continue + tgt = self._nearest_player(m.x, m.y) + if tgt is None: + continue + # Frost Presence card + Frost Aura: nearby enemies move slower + speed = m.speed + slow = 0.12 * tgt.up_slow_aura + tgt._aura_max("enemy_slow") + if slow and (tgt.x - m.x) ** 2 + (tgt.y - m.y) ** 2 < 150 ** 2: + speed *= max(0.25, 1 - slow) + ang = math.atan2(tgt.y - m.y, tgt.x - m.x) + dist = math.hypot(tgt.x - m.x, tgt.y - m.y) + reach = 20 + tgt.hit_radius # bigger wizards are easier to reach + if dist > reach: + m.x += math.cos(ang) * speed * dt + m.y += math.sin(ang) * speed * dt + elif m.attack_cd <= 0: + self._damage_player(tgt, m.dmg) + # Spiked Aura: reflect damage to the attacker + if tgt.up_thorns: + m.hp -= 5 * tgt.up_thorns + m.hurt = 0.15 + if m.hp <= 0: + m.alive = False + self._on_minion_killed(m, tgt) + m.attack_cd = 1.0 + + def _tick_boss(self, dt: float): + b = self.boss + if b.hp <= 0: + return + b.hurt = max(0.0, b.hurt - dt) + b.shoot_cd = max(0.0, b.shoot_cd - dt) + b.spawn_cd = max(0.0, b.spawn_cd - dt) + b.charge_cd = max(0.0, b.charge_cd - dt) + b.nova_cd = max(0.0, b.nova_cd - dt) + phase2 = b.hp <= b.max_hp * 0.5 + # combined attack cadence: aggro (difficulty) x atk_speed (GM mercy knob) + agg = b.aggro * (1.3 if phase2 else 1.0) * _clamp(b.atk_speed, 0.5, 2.0) + pat = BOSS_PATTERNS.get(b.pattern, BOSS_PATTERNS["balanced"]) + + # ---- charge attack: telegraph, then rush a player and melee hard ---- + if b.state == "telegraph": + b.state_t -= dt + if b.state_t <= 0: + b.state = "charge" + b.state_t = 0.55 + return # boss is winding up, no other actions + if b.state == "charge": + b.state_t -= dt + b.x = _clamp(b.x + b.cvx * dt, 40, ARENA_W - 40) + b.y = _clamp(b.y + b.cvy * dt, 40, ARENA_H - 40) + for p in self.players.values(): + if p.alive and (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < (54 + p.hit_radius) ** 2: + self._damage_player(p, b.dmg * 2.5) # massive melee + if b.state_t <= 0: + b.state = "idle" + b.charge_cd = max(2.0, 8.0 / (agg * pat["charge"])) + return + # drift back toward the centre when idle + b.x += (CENTER[0] - b.x) * min(1.0, dt * 0.6) + b.y += (CENTER[1] - b.y) * min(1.0, dt * 0.6) + + # decide whether to start a charge (hunts down players who keep distance) + if b.charge_cd <= 0: + tgt = self._nearest_player(b.x, b.y) + if tgt: + ang = math.atan2(tgt.y - b.y, tgt.x - b.x) + speed = 620 + self.round * 8 + b.cvx, b.cvy = math.cos(ang) * speed, math.sin(ang) * speed + b.state = "telegraph" + b.state_t = 0.7 + return + + # everything below creates boss projectiles; tag them with the boss's + # visual style (holy / glitch) at the end. + n0 = len(self.projectiles) + bstyle = SPECIAL_BOSSES.get(b.special, {}).get("style", "") + + # spawn minions (the SWARMLORD pattern leans hard on this) + if b.spawn_cd <= 0 and len(self.minions) < 30: + burst = max(1, round((self.cfg["minion_count"] / 3) * pat["spawn"])) + for _ in range(burst): + self._spawn_minion() + b.spawn_cd = b.spawn_interval + + # ---- special boss signature attacks ---- + if b.special: + b.special_cd = max(0.0, b.special_cd - dt) + if b.special_cd <= 0: + self._boss_signature(b) + b.special_cd = max(2.5, 5.0 / agg) + + # ---- ground shockwave: dense ring that punishes standing still ---- + if b.nova_cd <= 0: + heavy = pat.get("slow_heavy") + n = 20 if phase2 else 14 + spd, rad, dmul = (160, 18, 1.3) if heavy else (200, 13, 1.0) + for k in range(n): + ang = k * math.tau / n + self.projectiles.append(Projectile( + x=b.x, y=b.y, vx=math.cos(ang) * spd, vy=math.sin(ang) * spd, + dmg=b.dmg * dmul, owner="boss", hostile=True, ttl=4.5, radius=rad, + )) + b.nova_cd = max(1.8, 6.0 / (agg * pat["nova"])) + + # ---- aimed spread / radial bursts (style depends on the pattern) ---- + if b.shoot_cd <= 0: + tgt = self._nearest_player(b.x, b.y) + if pat.get("snipe") and tgt: + # SNIPER: one fast, painful, well-aimed bolt + ang = math.atan2(tgt.y - b.y, tgt.x - b.x) + self.projectiles.append(Projectile( + x=b.x, y=b.y, vx=math.cos(ang) * 540, vy=math.sin(ang) * 540, + dmg=b.dmg * 1.8, owner="boss", hostile=True, ttl=3.5, radius=9, + )) + b.shoot_cd = 1.3 / (agg * pat["shoot"]) + elif pat.get("slow_heavy") and tgt: + # ARTILLERY: lob big slow orbs at the target + ang = math.atan2(tgt.y - b.y, tgt.x - b.x) + random.uniform(-0.15, 0.15) + self.projectiles.append(Projectile( + x=b.x, y=b.y, vx=math.cos(ang) * 170, vy=math.sin(ang) * 170, + dmg=b.dmg * 1.4, owner="boss", hostile=True, ttl=5.5, radius=20, + )) + b.shoot_cd = 1.6 / (agg * pat["shoot"]) + elif phase2: + for k in range(12): + ang = k * math.tau / 12 + random.uniform(-0.1, 0.1) + self.projectiles.append(Projectile( + x=b.x, y=b.y, vx=math.cos(ang) * 240, vy=math.sin(ang) * 240, + dmg=b.dmg, owner="boss", hostile=True, ttl=4, radius=12, + )) + b.shoot_cd = 2.2 / (agg * pat["shoot"]) + elif tgt: + ang = math.atan2(tgt.y - b.y, tgt.x - b.x) + for off in (-0.22, -0.11, 0, 0.11, 0.22): + self.projectiles.append(Projectile( + x=b.x, y=b.y, + vx=math.cos(ang + off) * 280, vy=math.sin(ang + off) * 280, + dmg=b.dmg, owner="boss", hostile=True, ttl=4, radius=12, + )) + b.shoot_cd = 1.8 / (agg * pat["shoot"]) + + # apply the boss's projectile style to everything it just fired + if bstyle: + for pr in self.projectiles[n0:]: + pr.style = bstyle + + def _boss_signature(self, b: Boss): + """A unique telegraphed attack per special boss.""" + if b.special == "aegis": + # Holy Cross: a rotating + and x of holy bolts that punish all angles + base = random.uniform(0, math.tau) + for k in range(8): + ang = base + k * math.tau / 8 + self.projectiles.append(Projectile( + x=b.x, y=b.y, vx=math.cos(ang) * 260, vy=math.sin(ang) * 260, + dmg=b.dmg * 1.2, owner="boss", hostile=True, ttl=4.5, radius=16, + )) + # plus aimed holy spears at the nearest two players + for p in list(self.players.values())[:2]: + if p.alive: + ang = math.atan2(p.y - b.y, p.x - b.x) + self.projectiles.append(Projectile( + x=b.x, y=b.y, vx=math.cos(ang) * 360, vy=math.sin(ang) * 360, + dmg=b.dmg * 1.6, owner="boss", hostile=True, ttl=4, radius=18, + )) + elif b.special == "toaster": + # Glitch Portals: open rifts near players that spit projectiles outward + for _ in range(3): + tgt = self._nearest_player(b.x, b.y) + px = random.uniform(150, ARENA_W - 150) + py = random.uniform(120, ARENA_H - 120) + if tgt and random.random() < 0.6: + px, py = tgt.x + random.uniform(-80, 80), tgt.y + random.uniform(-80, 80) + for k in range(6): + ang = k * math.tau / 6 + self.projectiles.append(Projectile( + x=px, y=py, vx=math.cos(ang) * 210, vy=math.sin(ang) * 210, + dmg=b.dmg, owner="boss", hostile=True, ttl=3.5, radius=14, + )) + + def _tick_projectiles(self, dt: float): + alive = [] + for pr in self.projectiles: + # Seeker Bolts: steer toward the nearest enemy + if pr.homing and not pr.hostile: + tgt = self._nearest_enemy(pr.x, pr.y) + if tgt is not None: + sp = math.hypot(pr.vx, pr.vy) or 1.0 + want = math.atan2(tgt.y - pr.y, tgt.x - pr.x) + cur = math.atan2(pr.vy, pr.vx) + d = (want - cur + math.pi) % math.tau - math.pi + cur += max(-6 * dt, min(6 * dt, d)) + pr.vx, pr.vy = math.cos(cur) * sp, math.sin(cur) * sp + pr.x += pr.vx * dt + pr.y += pr.vy * dt + pr.ttl -= dt + if pr.ttl <= 0 or pr.x < -40 or pr.x > ARENA_W + 40 or pr.y < -40 or pr.y > ARENA_H + 40: + continue + hit = False + if pr.hostile: + for p in self.players.values(): + if p.alive and (p.x - pr.x) ** 2 + (p.y - pr.y) ** 2 < (pr.radius + p.hit_radius) ** 2: + self._damage_player(p, pr.dmg) + hit = True + break + else: + owner = self.players.get(pr.owner) + for m in self.minions: + if not m.alive or m.uid in pr.hit_ids: + continue + if (m.x - pr.x) ** 2 + (m.y - pr.y) ** 2 < (pr.radius + 22) ** 2: + m.hp -= pr.dmg + m.hurt = 0.15 + pr.hit_ids.add(m.uid) + if owner: + owner.dmg_dealt += pr.dmg + self._apply_hit_effects(pr, m, owner) + if m.hp <= 0: + m.alive = False + self._on_minion_killed(m, owner) + hit = True + break + if not hit and self.boss.hp > 0 and \ + (self.boss.x - pr.x) ** 2 + (self.boss.y - pr.y) ** 2 < (pr.radius + 70) ** 2: + self.boss.hp -= pr.dmg + self.boss.hurt = 0.1 + if owner: + owner.dmg_dealt += pr.dmg + if owner.up_lifesteal: + self._heal(owner, pr.dmg * 0.03 * owner.up_lifesteal) + hit = True + # pierce: a player shot can pass through extra enemies before dying + if hit and not pr.hostile and pr.pierce > 0: + pr.pierce -= 1 + hit = False + if not hit: + alive.append(pr) + self.projectiles = alive + + def _heal(self, p: Player, amt: float): + p.hp = min(p.max_hp, p.hp + amt) + + def _apply_hit_effects(self, pr: Projectile, m: Minion, owner: "Player | None"): + """Run on a player shot striking a minion: lifesteal/burn/knock/explode/chain.""" + if owner: + ls = 0.03 * owner.up_lifesteal + owner._aura_max("lifesteal") + if ls: + self._heal(owner, pr.dmg * ls) + if pr.burn: + m.burn = max(m.burn, pr.burn) + m.burn_t = 3.0 + if pr.knock: + ang = math.atan2(m.y - pr.y, m.x - pr.x) + m.x = _clamp(m.x + math.cos(ang) * pr.knock, 20, ARENA_W - 20) + m.y = _clamp(m.y + math.sin(ang) * pr.knock, 20, ARENA_H - 20) + if pr.explode: + r2 = pr.explode ** 2 + for o in self.minions: + if o is m or not o.alive or o.uid in pr.hit_ids: + continue + if (o.x - m.x) ** 2 + (o.y - m.y) ** 2 <= r2: + o.hp -= pr.dmg * 0.5 + o.hurt = 0.15 + if owner: + owner.dmg_dealt += pr.dmg * 0.5 + if o.hp <= 0: + o.alive = False + self._on_minion_killed(o, owner) + if pr.chain: + hit_extra = 0 + for o in sorted(self.minions, key=lambda q: (q.x - m.x) ** 2 + (q.y - m.y) ** 2): + if hit_extra >= pr.chain: + break + if o is m or not o.alive or o.uid in pr.hit_ids: + continue + if (o.x - m.x) ** 2 + (o.y - m.y) ** 2 <= 170 ** 2: + o.hp -= pr.dmg * 0.5 + o.hurt = 0.15 + pr.hit_ids.add(o.uid) + hit_extra += 1 + if owner: + owner.dmg_dealt += pr.dmg * 0.5 + if o.hp <= 0: + o.alive = False + self._on_minion_killed(o, owner) + + def _on_minion_killed(self, m: Minion, owner: "Player | None"): + fortune = owner.up_fortune if owner else 0 + if owner: + owner.kills += 1 + for sk in owner.skills: + if sk["trigger"] == "on_kill": + self._fire_skill(owner, sk) + self.gems.append(self._make_gem(m.x, m.y, m.kind, fortune)) + # ~1/100 chance a minion drops a power-up card (Lucky Charm raises it) + if random.random() < 0.01 * (1 + 0.5 * fortune): + self._drop_pickup(m.x, m.y) + # rarer: a timed aura drops on the floor (~1/200, Lucky Charm raises it) + if random.random() < 0.005 * (1 + 0.5 * fortune): + self._drop_aura(m.x, m.y) + + def _drop_pickup(self, x: float, y: float): + pool = [c for c in ALL_CARD_IDS + if c not in THEME_ONLY and c not in SKILL_CARDS and c != "auto_attack"] + if not pool: + return + self._pickup_seq += 1 + self.pickups.append(Pickup(x=x, y=y, card=random.choice(pool), uid=self._pickup_seq)) + + def _drop_aura(self, x: float, y: float): + self._pickup_seq += 1 + self.pickups.append(Pickup(x=x, y=y, card=random.choice(ALL_AURA_IDS), + kind="aura", uid=self._pickup_seq, ttl=18.0)) + + def _make_gem(self, x: float, y: float, kind: str, fortune: int = 0) -> Gem: + """Pick a gem tier (1..7) by enemy type + wave, with a little variance.""" + base = {"grunt": 1, "fast": 2, "tank": 4}.get(kind, 1) + tier = base + (1 if random.random() < 0.3 else 0) + self.round // 6 + fortune + tier = int(_clamp(tier, 1, 7)) + return Gem(x=x, y=y, tier=tier, value=GEM_TIER_XP[tier]) + + def _damage_player(self, p: Player, dmg: float): + if not p.alive or p.hurt > 0: + return + # Evasion: chance to avoid the hit entirely + if p.up_dodge and random.random() < min(0.5, 0.05 * p.up_dodge): + p.hurt = 0.2 + return + # Iron Hide: flat damage reduction + if p.up_armor: + dmg = max(1.0, dmg - 2 * p.up_armor) + # Warding aura: percent damage reduction + red = p._aura_max("dmg_red") + if red: + dmg *= (1 - red) + # Aegis: shield absorbs first + if p.shield > 0: + absorbed = min(p.shield, dmg) + p.shield -= absorbed + dmg -= absorbed + p.hp -= dmg + p.hurt = 0.4 + if p.hp <= 0: + p.hp = 0 + p.alive = False + p.lives -= 1 # spend a life; >0 respawns, 0 = eliminated + if p.lives > 0: + p.respawn_t = 3.0 + p.auras = [] # auras fade on death + + # ---- snapshot --------------------------------------------------------- + def snapshot(self) -> dict: + events, self._events = self._events, [] + return { + "events": events, + "t": round(time.time(), 2), + "round": self.round, + "status": self.status, + "status_msg": self.status_msg, + "gm_message": self.gm_message, + "intermission_left": max(0, round(self.intermission_until - time.time(), 1)) + if self.status == "intermission" else 0, + "boss": self.boss.to_dict() if self.boss.hp > 0 or self.status != "lobby" else None, + "theme": max(0, (self.round - 1) // 5), # enemy skin set, swaps every 5 waves + "hue": SPECIAL_BOSSES[self.boss.special]["hue"] + if self.boss.special and self.boss.hp > 0 else None, + "players": [p.to_dict() for p in self.players.values()], + "minions": [m.to_dict() for m in self.minions], + "allies": [a.to_dict() for a in self.allies], + "projectiles": [pr.to_dict() for pr in self.projectiles], + "gems": [g.to_dict() for g in self.gems], + "pickups": [pk.to_dict() for pk in self.pickups], + "arena": {"w": ARENA_W, "h": ARENA_H}, + "max_players": MAX_PLAYERS, + "upgrades": {k: {"label": v[0], "cost": v[1]} for k, v in UPGRADES.items()}, + "cards": {k: {"label": v[0], "desc": v[1], "rarity": v[2]} + for k, v in CARDS.items()}, + "card_pool": list(self.card_pool), + "skill_request": ( + {"pid": self.skill_request_pid, "used": self.skill_request_used} + if self.status == "intermission" else None + ), + } diff --git a/HuggingWizards-upload/game/gamemaster.py b/HuggingWizards-upload/game/gamemaster.py new file mode 100644 index 0000000000000000000000000000000000000000..012f9c7f1ec142344043cb0391bcaeb25ede9238 --- /dev/null +++ b/HuggingWizards-upload/game/gamemaster.py @@ -0,0 +1,480 @@ +"""Nemotron-4B Game Master. + +At each round boundary the engine hands us a `round_summary`. We ask +NVIDIA's Nemotron-Mini-4B-Instruct to return a JSON decision covering: + + - rewards: {player_id: gold} + - next_round: {boss_hp, boss_damage, minion_hp, minion_count, spawn_interval} + - message: short flavor line shown to players + - reasoning: why (kept in the trace) + +This is *burst* GPU work that fits ZeroGPU's `@spaces.GPU` model perfectly: we +grab the GPU for one short inference per round, then release it. If the model +or GPU is unavailable (e.g. local dev / CI) we fall back to deterministic logic +so the game always keeps running. + +Every decision is written to `traces/` as a self-contained agent trace. +""" +from __future__ import annotations + +import json +import os +import re +import time +import uuid + +from game.engine import ALL_CARD_IDS, ALL_PATTERN_IDS, BLESSINGS, MINION_TYPES +from game import skills as skillmod + +MODEL_ID = "nvidia/Nemotron-Mini-4B-Instruct" +TRACE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "traces") +os.makedirs(TRACE_DIR, exist_ok=True) + +# Lazily-initialised globals (loaded once, on first GPU call). +_tokenizer = None +_model = None +_load_failed = False + +# In-memory ring of recent traces for the dashboard. +RECENT_TRACES: list[dict] = [] +_MAX_RECENT = 25 + +# True when running inside a Hugging Face Space (ZeroGPU or otherwise). +_ON_SPACES = bool(os.environ.get("SPACE_ID")) + +try: # `spaces` only exists on HF infra; degrade gracefully elsewhere. + import spaces # type: ignore + + def _gpu(fn): + return spaces.GPU(duration=60)(fn) +except Exception: # pragma: no cover - local dev path + def _gpu(fn): + return fn + + +SYSTEM_PROMPT = ( + "You are the Game Master / Director AI for HuggingWizards, a co-op pixel " + "survivors-arena where wizards fight waves of enemies and a boss. After each " + "wave you direct: rewards, the next wave's difficulty, the enemy mix, and the " + "pool of level-up cards players may be offered. Reply with ONE JSON object and " + "nothing else. Schema:\n" + '{"message": str (<=120 chars, in-character narration),' + ' "reasoning": str (one sentence),' + ' "rewards": {player_id: int gold 0-300},' + ' "blessings": {player_id: blessing_id} (optional, bless 0-3 wizards),' + ' "next_round": {"boss_hp": int, "boss_damage": int, "minion_hp": int,' + ' "minion_count": int, "spawn_interval": float, "boss_aggro": float 0.7-3,' + ' "boss_attack_speed": float 0.5-2.0, "boss_pattern": pattern_id,' + ' "wave": {"grunt": float, "fast": float, "tank": float}},' + ' "card_pool": [card_id, ...]}\n' + f"Enemy archetypes (wave weights, must sum > 0): {list(MINION_TYPES)}.\n" + f"Boss attack patterns: {ALL_PATTERN_IDS}. Switch the pattern between rounds " + "to keep fights fresh β€” sniper punishes kiting, artillery punishes camping, " + "swarm floods the arena, berserker charges relentlessly.\n" + f"Blessings: {BLESSINGS}. Bless wizards who earned it β€” surviving a brutal " + "wave, clutch plays, or to help a struggling wizard back on their feet " + "(extra_life / full_heal). Auras last one wave.\n" + f"Valid card_pool ids (offer 4-8): {ALL_CARD_IDS}.\n" + "boss_attack_speed sets how fast the boss attacks next wave (1.0 = normal). " + "Check every player's hp_pct and lives_left: if the party is badly hurt, " + "slow the boss (<1.0) so they can recover; if they are healthy, speed it up " + "(>1.0). MERCY DECAYS: each round the user prompt states the minimum you may " + "set β€” in later waves you can no longer slow the boss to protect them. " + "Values outside the allowed range are clamped.\n" + "Reward players for damage dealt, kills, and surviving. Scale difficulty up " + "after a victory and ease it slightly after a defeat. Introduce tougher enemy " + "archetypes as rounds progress. Curate cards to keep the run fun and winnable " + "for the number of players." +) + + +def _build_user_prompt(summary: dict, prev_cfg: dict) -> str: + rnd = summary.get("round") or 0 + return ( + "Current next-round config (you may adjust): " + + json.dumps(prev_cfg) + + "\nRound that just ended:\n" + + json.dumps(summary, indent=2) + + f"\nMercy floor this round: boss_attack_speed must be between " + + f"{_mercy_floor(rnd)} and {MERCY_MAX}." + + "\nReturn the JSON decision now." + ) + + +def _load_model(): + """Load tokenizer + model, optionally quantized. + + HUGGINGWIZARDS_QUANT = none | 4bit | 8bit | auto (default). + `auto` picks 4-bit on small local GPUs (<12 GB VRAM) and bf16 everywhere + else β€” ZeroGPU's H200 runs the 4B model comfortably in bf16, where it is + also faster per token than bitsandbytes 4-bit. + """ + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) + has_cuda = torch.cuda.is_available() + quant = os.environ.get("HUGGINGWIZARDS_QUANT", "auto").lower() + if quant == "auto": + if has_cuda and not _ON_SPACES: + vram = torch.cuda.get_device_properties(0).total_memory + quant = "4bit" if vram < 12 * 1024**3 else "none" + else: + quant = "none" + + if quant in ("4bit", "8bit") and has_cuda: + from transformers import BitsAndBytesConfig + + qcfg = ( + BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", + bnb_4bit_compute_dtype=torch.bfloat16) + if quant == "4bit" + else BitsAndBytesConfig(load_in_8bit=True) + ) + model = AutoModelForCausalLM.from_pretrained( + MODEL_ID, quantization_config=qcfg, device_map={"": 0} + ) + else: + model = AutoModelForCausalLM.from_pretrained( + MODEL_ID, dtype=torch.bfloat16 if has_cuda else torch.float32 + ) + if has_cuda: + model = model.to("cuda") # ZeroGPU-safe (device_map="auto" is not) + model.eval() + print(f"[gamemaster] loaded {MODEL_ID} (quant={quant}, cuda={has_cuda})") + return tokenizer, model + + +def _ensure_model(): + global _tokenizer, _model, _load_failed + if _model is not None or _load_failed: + return _model is not None + if os.environ.get("HUGGINGWIZARDS_NO_LLM"): + _load_failed = True # force deterministic fallback (local dev / CI) + return False + try: + _tokenizer, _model = _load_model() + return True + except Exception as e: # pragma: no cover + print(f"[gamemaster] model load failed, using fallback: {e}") + _load_failed = True + return False + + +# On Spaces, load the model at startup (ZeroGPU replays the .to("cuda") once a +# real GPU is attached) so the @spaces.GPU window is spent on inference only β€” +# lazy-loading inside the first GPU call would blow the 60 s duration on the +# weight download. Locally we stay lazy so dev/CI never downloads weights. +if _ON_SPACES and not os.environ.get("HUGGINGWIZARDS_NO_LLM"): + _ensure_model() + + +# ---- mercy guardrail ------------------------------------------------------- +# The GM may slow the boss's attack speed to help wounded parties, but its +# willingness to help decays as waves progress: the allowed floor rises from +# 0.5 toward 1.0 (no mercy) by ~wave 13. The ceiling is always 2.0. +MERCY_MAX = 2.0 + + +def _mercy_floor(rnd: int) -> float: + return round(min(1.0, 0.5 + 0.04 * max(0, int(rnd or 0))), 2) + + +def _clamp_attack_speed(value, rnd: int) -> float: + try: + v = float(value) + except Exception: + v = 1.0 + return round(max(_mercy_floor(rnd), min(MERCY_MAX, v)), 2) + + +@_gpu +def _run_model(system: str, user: str) -> str: + """Single short generation on the GPU. Returns raw text.""" + if not _ensure_model(): + raise RuntimeError("model unavailable") + import torch + + messages = [{"role": "system", "content": system}, + {"role": "user", "content": user}] + inputs = _tokenizer.apply_chat_template( + messages, add_generation_prompt=True, return_tensors="pt" + ).to(_model.device) + with torch.no_grad(): + out = _model.generate( + inputs, max_new_tokens=400, do_sample=True, temperature=0.7, top_p=0.9, + pad_token_id=_tokenizer.eos_token_id, + ) + text = _tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True) + return text + + +def _extract_json(text: str) -> dict | None: + m = re.search(r"\{.*\}", text, re.DOTALL) + if not m: + return None + try: + return json.loads(m.group(0)) + except Exception: + return None + + +def _deterministic(summary: dict, prev_cfg: dict) -> dict: + """Fallback Game Master logic β€” also a sane validation target.""" + won = summary.get("result") == "victory" + rewards = {} + for p in summary.get("players", []): + gold = 20 + int(p.get("damage_dealt", 0) / 25) + p.get("kills", 0) * 5 + if p.get("survived"): + gold += 25 + rewards[p["id"]] = min(300, gold) + cfg = dict(prev_cfg) + n_players = max(1, len(summary.get("players", []))) + rnd = int(summary.get("round") or 1) + if won: + cfg["boss_hp"] = int(prev_cfg["boss_hp"] * 1.35) + 200 * n_players + cfg["boss_damage"] = min(80, prev_cfg["boss_damage"] + 2) + cfg["minion_count"] = min(40, prev_cfg["minion_count"] + 2) + cfg["minion_hp"] = int(prev_cfg["minion_hp"] * 1.15) + cfg["spawn_interval"] = max(1.5, prev_cfg["spawn_interval"] - 0.3) + cfg["boss_aggro"] = min(3.0, round(prev_cfg.get("boss_aggro", 1.0) + 0.2, 2)) + msg = "Impressive. The next horde will not fall so easily." + else: + cfg["boss_hp"] = max(300, int(prev_cfg["boss_hp"] * 0.85)) + cfg["boss_damage"] = max(4, prev_cfg["boss_damage"] - 1) + cfg["minion_count"] = max(0, prev_cfg["minion_count"] - 1) + cfg["spawn_interval"] = min(8.0, prev_cfg["spawn_interval"] + 0.5) + cfg["boss_aggro"] = max(0.7, round(prev_cfg.get("boss_aggro", 1.0) - 0.1, 2)) + msg = "Rest, wizards. The arena bends slightly in your favor." + # Enemy mix escalates with the round: grunts always, fast from r2, tanks r4+. + cfg["wave"] = { + "grunt": 1.0, + "fast": round(min(0.8, max(0.0, (rnd - 1) * 0.25)), 2), + "tank": round(min(0.6, max(0.0, (rnd - 3) * 0.2)), 2), + } + # Rotate the boss's attack pattern so consecutive waves feel different. + cfg["boss_pattern"] = ALL_PATTERN_IDS[rnd % len(ALL_PATTERN_IDS)] + # Health-responsive attack speed: slow the boss for a wounded party, speed + # it up for a healthy one β€” always within the wave's mercy floor. + hps = [p.get("hp_pct", 100) for p in summary.get("players", []) if p.get("survived")] + avg_hp = sum(hps) / len(hps) if hps else 0 + desired = 0.7 if avg_hp < 35 else 0.85 if avg_hp < 60 else (1.2 if won else 1.0) + cfg["boss_attack_speed"] = _clamp_attack_speed(desired, rnd) + # Blessings: after a defeat, shield the survivors; after a hard-won victory + # (someone died), give the most wounded survivor a warding aura. + blessings = {} + survivors = [p for p in summary.get("players", []) if p.get("survived")] + if not won: + for p in survivors[:3]: + blessings[p["id"]] = "warding" + elif survivors and any(not p.get("survived") for p in summary.get("players", [])): + weakest = min(survivors, key=lambda p: p.get("hp_pct", 100)) + blessings[weakest["id"]] = "full_heal" + # Offer the full card set by default (the model may narrow it). + card_pool = list(ALL_CARD_IDS) + return {"message": msg, "reasoning": "deterministic fallback policy", + "rewards": rewards, "blessings": blessings, + "next_round": cfg, "card_pool": card_pool} + + +def _validate(decision: dict, summary: dict, prev_cfg: dict) -> dict: + """Coerce a (possibly model-authored) decision into a safe shape.""" + safe = _deterministic(summary, prev_cfg) # defaults + if not isinstance(decision, dict): + return safe + valid_ids = {p["id"] for p in summary.get("players", [])} + if isinstance(decision.get("rewards"), dict): + rewards = {} + for k, v in decision["rewards"].items(): + if k in valid_ids: + try: + rewards[k] = max(0, min(300, int(v))) + except Exception: + pass + if rewards: + safe["rewards"] = rewards + nxt = decision.get("next_round") + if isinstance(nxt, dict): + merged = dict(safe["next_round"]) + for key in ("boss_hp", "boss_damage", "minion_hp", "minion_count"): + if key in nxt: + try: + merged[key] = int(nxt[key]) + except Exception: + pass + for key in ("spawn_interval", "boss_aggro"): + if key in nxt: + try: + merged[key] = float(nxt[key]) + except Exception: + pass + # mercy guardrail: attack speed is clamped into the wave's allowed band + if "boss_attack_speed" in nxt: + merged["boss_attack_speed"] = _clamp_attack_speed( + nxt.get("boss_attack_speed"), summary.get("round") or 0) + # boss attack pattern (also accepted at the top level) + pattern = nxt.get("boss_pattern", decision.get("boss_pattern")) + if pattern in ALL_PATTERN_IDS: + merged["boss_pattern"] = pattern + # enemy mix + wave = nxt.get("wave") + if isinstance(wave, dict): + clean = {} + for k in MINION_TYPES: + try: + clean[k] = max(0.0, float(wave.get(k, 0.0))) + except Exception: + clean[k] = 0.0 + if sum(clean.values()) > 0: + merged["wave"] = clean + safe["next_round"] = merged + # per-player blessings (cap at 3, roster + id checked) + if isinstance(decision.get("blessings"), dict): + blessings = {k: v for k, v in decision["blessings"].items() + if k in valid_ids and v in BLESSINGS} + safe["blessings"] = dict(list(blessings.items())[:3]) + # level-up card pool + pool = decision.get("card_pool") + if isinstance(pool, list): + valid = [c for c in pool if c in ALL_CARD_IDS] + if valid: + safe["card_pool"] = valid + if isinstance(decision.get("message"), str): + safe["message"] = decision["message"][:120] + if isinstance(decision.get("reasoning"), str): + safe["reasoning"] = decision["reasoning"][:300] + return safe + + +def decide(summary: dict, prev_cfg: dict) -> dict: + """Produce a validated decision and persist an agent trace for the round.""" + trace_id = uuid.uuid4().hex[:8] + system, user = SYSTEM_PROMPT, _build_user_prompt(summary, prev_cfg) + raw, source, error = "", "fallback", None + requested_speed = None + t0 = time.time() + try: + raw = _run_model(system, user) + parsed = _extract_json(raw) + if isinstance(parsed, dict) and isinstance(parsed.get("next_round"), dict): + requested_speed = parsed["next_round"].get("boss_attack_speed") + decision = _validate(parsed, summary, prev_cfg) if parsed else _deterministic(summary, prev_cfg) + source = "nemotron" if parsed else "fallback(parse_failed)" + except Exception as e: + error = str(e) + decision = _deterministic(summary, prev_cfg) + latency = round(time.time() - t0, 2) + + rnd = summary.get("round") or 0 + applied_speed = decision.get("next_round", {}).get("boss_attack_speed") + mercy = { + "floor": _mercy_floor(rnd), "max": MERCY_MAX, + "requested": requested_speed, "applied": applied_speed, + "clamped": requested_speed is not None and requested_speed != applied_speed, + "note": "mercy floor rises with the wave β€” by ~wave 13 the GM can no " + "longer slow the boss to protect the party", + } + + trace = { + "trace_id": trace_id, + "round": summary.get("round"), + "mercy": mercy, + "ts": time.strftime("%Y-%m-%d %H:%M:%S"), + "model": MODEL_ID, + "source": source, + "latency_sec": latency, + "error": error, + "input": {"system": system, "user": user, "round_summary": summary}, + "raw_output": raw, + "decision": decision, + } + _persist(trace) + return decision + + +SKILL_SYSTEM_PROMPT = ( + "You are the Game Master AI for HuggingWizards. A wizard asks you to grant a " + "new power-up. Invent ONE balanced skill and reply with ONE JSON object only. " + "Schema (data only β€” never code):\n" + '{"name": str, "trigger": one of ' + + str(sorted(skillmod.TRIGGERS)) + + ', "n": int (for every_n_attacks), "interval": float (for periodic),' + ' "effect": one of ' + + str(sorted(skillmod.EFFECTS)) + + ', "radius": float, "damage": float, "count": int, "amount": float,' + ' "slow": float 0-1, "color": "#rrggbb"}\n' + "Pick fields that match the chosen effect. Keep it fun but not overpowered." +) + + +def _deterministic_skill(prompt: str) -> dict: + """Keyword-based fallback skill generator (local dev / model unavailable).""" + t = (prompt or "").lower() + frost = any(w in t for w in ("frost", "ice", "freeze", "slow", "cold")) + if any(w in t for w in ("summon", "spirit", "minion", "ally", "pet", "wolf")): + spec = {"name": "Summoned Spirit", "trigger": "every_n_attacks", "n": 7, + "effect": "summon_ally", "count": 1, "color": "#a6ff8c"} + elif any(w in t for w in ("heal", "regen", "life", "restore", "vamp", "mend")): + spec = {"name": "Mending Light", "trigger": "periodic", "interval": 6, + "effect": "heal", "amount": 30, "color": "#6effd0"} + elif any(w in t for w in ("shield", "barrier", "ward", "protect", "absorb", "armor")): + spec = {"name": "Arcane Bulwark", "trigger": "periodic", "interval": 8, + "effect": "shield", "amount": 30, "color": "#9db4c9"} + elif frost or any(w in t for w in ("explos", "blast", "aoe", "area", "detonat", "fire")): + spec = {"name": "Frost Nova" if frost else "Arcane Detonation", + "trigger": "every_n_attacks", "n": 6, "effect": "aoe_damage", + "radius": 140, "damage": 45, "slow": 0.4 if frost else 0.0, + "color": "#7ee0ff" if frost else "#ff7a4a"} + elif any(w in t for w in ("nova", "spread", "shotgun", "burst", "ring", "radial")): + spec = {"name": "Star Burst", "trigger": "every_n_attacks", "n": 5, + "effect": "projectile_nova", "count": 10, "damage": 24, "color": "#ffd45e"} + else: # default: an AoE explosion + spec = {"name": "Arcane Detonation", "trigger": "every_n_attacks", "n": 6, + "effect": "aoe_damage", "radius": 140, "damage": 45, "slow": 0.0, + "color": "#ff7a4a"} + return spec + + +def generate_skill(prompt: str, context: dict | None = None) -> dict: + """Turn a player's free-text wish into a validated, safe skill spec. + + Tries Nemotron; always falls back to deterministic keywords; the result is + run through skills.validate_skill so it is bounded and code-free. + """ + trace_id = uuid.uuid4().hex[:8] + user = f"Wizard's request: {prompt!r}\nContext: {json.dumps(context or {})}\nReturn the skill JSON." + raw, source, error = "", "fallback", None + t0 = time.time() + spec = None + try: + raw = _run_model(SKILL_SYSTEM_PROMPT, user) + parsed = _extract_json(raw) + spec = skillmod.validate_skill(parsed) if parsed else None + source = "nemotron" if spec else "fallback(parse_failed)" + except Exception as e: + error = str(e) + if spec is None: + spec = skillmod.validate_skill(_deterministic_skill(prompt)) + latency = round(time.time() - t0, 2) + _persist({ + "trace_id": trace_id, "round": (context or {}).get("round"), + "ts": time.strftime("%Y-%m-%d %H:%M:%S"), "model": MODEL_ID, + "source": source, "latency_sec": latency, "error": error, + "kind": "skill_request", + "input": {"system": SKILL_SYSTEM_PROMPT, "user": user, "prompt": prompt}, + "raw_output": raw, "decision": spec, + }) + return spec + + +def _persist(trace: dict): + RECENT_TRACES.insert(0, trace) + del RECENT_TRACES[_MAX_RECENT:] + try: + rnd = trace.get("round") + prefix = f"round_{rnd:03d}" if isinstance(rnd, int) else "skill" + path = os.path.join(TRACE_DIR, f"{prefix}_{trace['trace_id']}.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(trace, f, indent=2) + except Exception as e: # pragma: no cover + print(f"[gamemaster] failed to write trace: {e}") diff --git a/HuggingWizards-upload/game/skills.py b/HuggingWizards-upload/game/skills.py new file mode 100644 index 0000000000000000000000000000000000000000..ce4af344637f1d43e0741390683dfbdb9eb60751 --- /dev/null +++ b/HuggingWizards-upload/game/skills.py @@ -0,0 +1,123 @@ +"""Sandboxed, data-only skill specs for HuggingWizards. + +A "skill" is a plain JSON object describing *what* happens and *when* β€” never +executable code. The engine interprets a whitelisted set of triggers/effects, +so a skill authored by the Game Master (Nemotron) at runtime can hot-plug onto +a player without any `exec`/`eval` or arbitrary code execution. + +Schema (all fields optional except trigger/effect; everything is clamped): +{ + "name": str, # display name + "trigger": every_n_attacks|periodic|on_kill, + "n": int, # for every_n_attacks + "interval": float, # seconds, for periodic + "effect": aoe_damage|projectile_nova|heal|summon_ally|shield, + "radius": float, # aoe reach + "damage": float, + "count": int, # nova projectiles / allies summoned + "amount": float, # heal / shield amount + "slow": float, # 0..1 slow applied by aoe + "color": str, # client tint (#rrggbb) +} +""" +from __future__ import annotations + +TRIGGERS = {"every_n_attacks", "periodic", "on_kill"} +EFFECTS = {"aoe_damage", "projectile_nova", "heal", "summon_ally", "shield"} + +# Per-field (lo, hi) clamps. Keeps any model output inside sane, balanced bounds. +_BOUNDS = { + "n": (2, 20), + "interval": (1.0, 15.0), + "radius": (40.0, 260.0), + "damage": (0.0, 120.0), + "count": (1, 12), + "amount": (0.0, 80.0), + "slow": (0.0, 0.8), +} +_DEFAULTS = { + "every_n_attacks": {"n": 5}, + "periodic": {"interval": 5.0}, + "aoe_damage": {"radius": 120.0, "damage": 35.0, "slow": 0.0}, + "projectile_nova": {"count": 8, "damage": 22.0}, + "heal": {"amount": 25.0}, + "shield": {"amount": 25.0}, + "summon_ally": {"count": 1}, +} +_HEX = set("0123456789abcdefABCDEF") +MAX_SKILLS_PER_PLAYER = 6 + + +def _num(v, lo, hi, integer=False): + try: + v = float(v) + except Exception: + v = lo + v = lo if v < lo else hi if v > hi else v + return int(round(v)) if integer else round(v, 2) + + +def _color(v): + if isinstance(v, str) and v.startswith("#") and len(v) == 7 and all(c in _HEX for c in v[1:]): + return v + return "#b48cff" + + +def validate_skill(spec: dict) -> dict | None: + """Coerce an arbitrary dict into a safe, bounded skill β€” or None if unusable.""" + if not isinstance(spec, dict): + return None + trigger = spec.get("trigger") + effect = spec.get("effect") + if trigger not in TRIGGERS or effect not in EFFECTS: + return None + out = { + "name": str(spec.get("name", "Arcane Skill"))[:32] or "Arcane Skill", + "trigger": trigger, + "effect": effect, + "color": _color(spec.get("color")), + } + src = {**_DEFAULTS.get(trigger, {}), **_DEFAULTS.get(effect, {}), **spec} + if trigger == "every_n_attacks": + out["n"] = _num(src.get("n"), *_BOUNDS["n"], integer=True) + elif trigger == "periodic": + out["interval"] = _num(src.get("interval"), *_BOUNDS["interval"]) + if effect == "aoe_damage": + out["radius"] = _num(src.get("radius"), *_BOUNDS["radius"]) + out["damage"] = _num(src.get("damage"), *_BOUNDS["damage"]) + out["slow"] = _num(src.get("slow"), *_BOUNDS["slow"]) + elif effect == "projectile_nova": + out["count"] = _num(src.get("count"), *_BOUNDS["count"], integer=True) + out["damage"] = _num(src.get("damage"), *_BOUNDS["damage"]) + elif effect in ("heal", "shield"): + out["amount"] = _num(src.get("amount"), *_BOUNDS["amount"]) + elif effect == "summon_ally": + out["count"] = _num(src.get("count"), *_BOUNDS["count"], integer=True) + return out + + +# Predefined skills granted by the AoE / Summoner level-up cards. +CARD_SKILLS = { + "aoe": {"name": "Arcane Nova", "trigger": "every_n_attacks", "n": 6, + "effect": "aoe_damage", "radius": 130, "damage": 40, "color": "#7ee0ff"}, + "summoner": {"name": "Spirit Caller", "trigger": "every_n_attacks", "n": 8, + "effect": "summon_ally", "count": 1, "color": "#a6ff8c"}, + # ---- boss-exclusive skills (only offered while that boss reigns) ---- + "war_drums": {"name": "War Drums", "trigger": "periodic", "interval": 7, + "effect": "summon_ally", "count": 2, "color": "#ff9d6e"}, + "savage_roar": {"name": "Savage Roar", "trigger": "on_kill", + "effect": "aoe_damage", "radius": 110, "damage": 30, "color": "#ff6e6e"}, + "iron_nova": {"name": "Iron Detonation", "trigger": "every_n_attacks", "n": 5, + "effect": "aoe_damage", "radius": 160, "damage": 55, "slow": 0.3, "color": "#c9d4e0"}, + "bulwark": {"name": "Bulwark", "trigger": "periodic", "interval": 5, + "effect": "heal", "amount": 35, "color": "#9db4c9"}, + "impaling_charge": {"name": "Impaling Charge", "trigger": "every_n_attacks", "n": 4, + "effect": "projectile_nova", "count": 6, "damage": 38, "color": "#ffd45e"}, + "arrow_storm": {"name": "Arrow Storm", "trigger": "periodic", "interval": 4, + "effect": "projectile_nova", "count": 12, "damage": 20, "color": "#a6ff8c"}, + # ---- rewards for defeating the special bosses (their own attack powers) ---- + "holy_judgment": {"name": "Holy Judgment", "trigger": "periodic", "interval": 4, + "effect": "aoe_damage", "radius": 180, "damage": 70, "color": "#ffe9a0"}, + "glitch_rift": {"name": "Glitch Rift", "trigger": "periodic", "interval": 3.5, + "effect": "projectile_nova", "count": 14, "damage": 30, "color": "#7CFC00"}, +} diff --git a/HuggingWizards-upload/requirements.txt b/HuggingWizards-upload/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..7f029f2083a93bf5daaf6eea7c383df9b73e56ce --- /dev/null +++ b/HuggingWizards-upload/requirements.txt @@ -0,0 +1,9 @@ +gradio>=5.0.0 +fastapi +uvicorn[standard] +transformers>=4.56.0 +torch +accelerate +sentencepiece +bitsandbytes>=0.43.2 +spaces diff --git a/HuggingWizards-upload/static/assets/bosses/aegis.png b/HuggingWizards-upload/static/assets/bosses/aegis.png new file mode 100644 index 0000000000000000000000000000000000000000..c63608d9101ab1eea729df5ae45aa3d26c270464 Binary files /dev/null and b/HuggingWizards-upload/static/assets/bosses/aegis.png differ diff --git a/HuggingWizards-upload/static/assets/bosses/toaster_attack.png b/HuggingWizards-upload/static/assets/bosses/toaster_attack.png new file mode 100644 index 0000000000000000000000000000000000000000..4a1728655cc14d91a7b8b629ed5f65a7c356406a Binary files /dev/null and b/HuggingWizards-upload/static/assets/bosses/toaster_attack.png differ diff --git a/HuggingWizards-upload/static/assets/bosses/toaster_boss.png b/HuggingWizards-upload/static/assets/bosses/toaster_boss.png new file mode 100644 index 0000000000000000000000000000000000000000..18760f3b3170582620f089c937430f5d023a9aae Binary files /dev/null and b/HuggingWizards-upload/static/assets/bosses/toaster_boss.png differ diff --git a/HuggingWizards-upload/static/assets/bosses/toaster_damaged.png b/HuggingWizards-upload/static/assets/bosses/toaster_damaged.png new file mode 100644 index 0000000000000000000000000000000000000000..3cde9436b24ac7d5da0c74dca28e7c2b3f485497 Binary files /dev/null and b/HuggingWizards-upload/static/assets/bosses/toaster_damaged.png differ diff --git a/HuggingWizards-upload/static/assets/bosses/toaster_death.png b/HuggingWizards-upload/static/assets/bosses/toaster_death.png new file mode 100644 index 0000000000000000000000000000000000000000..eb77a916e10366fd6145094da7526cb068eea64e Binary files /dev/null and b/HuggingWizards-upload/static/assets/bosses/toaster_death.png differ diff --git a/HuggingWizards-upload/static/assets/bosses/toaster_idle.png b/HuggingWizards-upload/static/assets/bosses/toaster_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..b8605b08d10a958ca70f60453be6955539b7f94d Binary files /dev/null and b/HuggingWizards-upload/static/assets/bosses/toaster_idle.png differ diff --git a/HuggingWizards-upload/static/assets/bosses/toaster_run.png b/HuggingWizards-upload/static/assets/bosses/toaster_run.png new file mode 100644 index 0000000000000000000000000000000000000000..01f32269b6ec337f23a9ed02c848452d7d853b74 Binary files /dev/null and b/HuggingWizards-upload/static/assets/bosses/toaster_run.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/black_castle.png b/HuggingWizards-upload/static/assets/buildings/black_castle.png new file mode 100644 index 0000000000000000000000000000000000000000..dcb93f59678473602ead8ec0547d4cdfb05bfa70 Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/black_castle.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/black_house1.png b/HuggingWizards-upload/static/assets/buildings/black_house1.png new file mode 100644 index 0000000000000000000000000000000000000000..c3f536453b5276a3e5a55cc166ce198723cb31ec Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/black_house1.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/black_tower.png b/HuggingWizards-upload/static/assets/buildings/black_tower.png new file mode 100644 index 0000000000000000000000000000000000000000..cf740f09f253bb49d7b36197cc3130dfa10ee1f0 Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/black_tower.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/blue_archery.png b/HuggingWizards-upload/static/assets/buildings/blue_archery.png new file mode 100644 index 0000000000000000000000000000000000000000..e4feb3c51d2fc12a43487381e8b3e03cfcaf0333 Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/blue_archery.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/blue_castle.png b/HuggingWizards-upload/static/assets/buildings/blue_castle.png new file mode 100644 index 0000000000000000000000000000000000000000..e320cfd599c4268f224665df773c37fcd2947cea Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/blue_castle.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/blue_monastery.png b/HuggingWizards-upload/static/assets/buildings/blue_monastery.png new file mode 100644 index 0000000000000000000000000000000000000000..6a06a60eb502b4e7f2c06790a80c96c3892946ca Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/blue_monastery.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/blue_tower.png b/HuggingWizards-upload/static/assets/buildings/blue_tower.png new file mode 100644 index 0000000000000000000000000000000000000000..5470b99bb4a85e19a30165589b9ba555a3997080 Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/blue_tower.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/red_barracks.png b/HuggingWizards-upload/static/assets/buildings/red_barracks.png new file mode 100644 index 0000000000000000000000000000000000000000..d57bfff1c146b8e04b79b2127b6e3f9d5c7fd3b5 Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/red_barracks.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/red_castle.png b/HuggingWizards-upload/static/assets/buildings/red_castle.png new file mode 100644 index 0000000000000000000000000000000000000000..1ab3db786ac030c6a7e7c597ed8e871dd78fa00e Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/red_castle.png differ diff --git a/HuggingWizards-upload/static/assets/buildings/red_tower.png b/HuggingWizards-upload/static/assets/buildings/red_tower.png new file mode 100644 index 0000000000000000000000000000000000000000..c5df6f7854d1af826cdabcfacd1256060ca40305 Binary files /dev/null and b/HuggingWizards-upload/static/assets/buildings/red_tower.png differ diff --git a/HuggingWizards-upload/static/assets/characters/orc/Orc-Attack01.png b/HuggingWizards-upload/static/assets/characters/orc/Orc-Attack01.png new file mode 100644 index 0000000000000000000000000000000000000000..ff9c1a1bd9063b083896a30c0f8712c3bf2d0fab Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/orc/Orc-Attack01.png differ diff --git a/HuggingWizards-upload/static/assets/characters/orc/Orc-Attack02.png b/HuggingWizards-upload/static/assets/characters/orc/Orc-Attack02.png new file mode 100644 index 0000000000000000000000000000000000000000..f6884101f2513adc7d4ce88a781d7bf09d4464f2 Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/orc/Orc-Attack02.png differ diff --git a/HuggingWizards-upload/static/assets/characters/orc/Orc-Death.png b/HuggingWizards-upload/static/assets/characters/orc/Orc-Death.png new file mode 100644 index 0000000000000000000000000000000000000000..2a08b0ee7d82c659dc99b3d60cb7aa2fba229006 Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/orc/Orc-Death.png differ diff --git a/HuggingWizards-upload/static/assets/characters/orc/Orc-Hurt.png b/HuggingWizards-upload/static/assets/characters/orc/Orc-Hurt.png new file mode 100644 index 0000000000000000000000000000000000000000..75af6ef3d017e6eb91a411fe81075bda49b5467d Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/orc/Orc-Hurt.png differ diff --git a/HuggingWizards-upload/static/assets/characters/orc/Orc-Idle.png b/HuggingWizards-upload/static/assets/characters/orc/Orc-Idle.png new file mode 100644 index 0000000000000000000000000000000000000000..4ef7b979619fb0be2d51885cce3c7ea2ca96385b Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/orc/Orc-Idle.png differ diff --git a/HuggingWizards-upload/static/assets/characters/orc/Orc-Walk.png b/HuggingWizards-upload/static/assets/characters/orc/Orc-Walk.png new file mode 100644 index 0000000000000000000000000000000000000000..d20785c208c0f78ba3f3a0edc572df8c511ae438 Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/orc/Orc-Walk.png differ diff --git a/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack01.png b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack01.png new file mode 100644 index 0000000000000000000000000000000000000000..a39e0584ae775ade04f21840a311235c750009fe Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack01.png differ diff --git a/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack02.png b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack02.png new file mode 100644 index 0000000000000000000000000000000000000000..9e49b942bef6627d2ec51d2722498408192ef271 Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack02.png differ diff --git a/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack03.png b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack03.png new file mode 100644 index 0000000000000000000000000000000000000000..b7b8721e650181cd782513ea9acdd204e4dccd59 Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Attack03.png differ diff --git a/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Death.png b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Death.png new file mode 100644 index 0000000000000000000000000000000000000000..80ae2c00b08849fec8c351df8561f587c629b408 Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Death.png differ diff --git a/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Hurt.png b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Hurt.png new file mode 100644 index 0000000000000000000000000000000000000000..0bb3f97720bfa95cd6caf1fefd382ab8a7f86d9a Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Hurt.png differ diff --git a/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Idle.png b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Idle.png new file mode 100644 index 0000000000000000000000000000000000000000..8307973f76c0803801a87c99c4f1978658d3d403 Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Idle.png differ diff --git a/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Walk.png b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Walk.png new file mode 100644 index 0000000000000000000000000000000000000000..5aa17eaad56a9c27e4aa7674f42cf5a23af8ccff Binary files /dev/null and b/HuggingWizards-upload/static/assets/characters/soldier/Soldier-Walk.png differ diff --git a/HuggingWizards-upload/static/assets/chars/archer_idle.png b/HuggingWizards-upload/static/assets/chars/archer_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..47aabd14d53e8f8cb1665dcb0a2312bcec66ce3c Binary files /dev/null and b/HuggingWizards-upload/static/assets/chars/archer_idle.png differ diff --git a/HuggingWizards-upload/static/assets/chars/archer_run.png b/HuggingWizards-upload/static/assets/chars/archer_run.png new file mode 100644 index 0000000000000000000000000000000000000000..50c1a8b6af5b332c11b37e9f1a5de37accc6cfc8 Binary files /dev/null and b/HuggingWizards-upload/static/assets/chars/archer_run.png differ diff --git a/HuggingWizards-upload/static/assets/chars/lancer_idle.png b/HuggingWizards-upload/static/assets/chars/lancer_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..8ef9ce5323c36419a0763b86bc0c8cbea87fa60b Binary files /dev/null and b/HuggingWizards-upload/static/assets/chars/lancer_idle.png differ diff --git a/HuggingWizards-upload/static/assets/chars/pawn_idle.png b/HuggingWizards-upload/static/assets/chars/pawn_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..f87203654d41ac26f4204ef0ffccd606ad8f5e08 Binary files /dev/null and b/HuggingWizards-upload/static/assets/chars/pawn_idle.png differ diff --git a/HuggingWizards-upload/static/assets/chars/pawn_run.png b/HuggingWizards-upload/static/assets/chars/pawn_run.png new file mode 100644 index 0000000000000000000000000000000000000000..53c99aadde0c22a92bc9b4a30a9eee56186beede Binary files /dev/null and b/HuggingWizards-upload/static/assets/chars/pawn_run.png differ diff --git a/HuggingWizards-upload/static/assets/chars/warrior_idle.png b/HuggingWizards-upload/static/assets/chars/warrior_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..d4227f0c6b655b6c4867ef2abe09615724a44d85 Binary files /dev/null and b/HuggingWizards-upload/static/assets/chars/warrior_idle.png differ diff --git a/HuggingWizards-upload/static/assets/chars/warrior_run.png b/HuggingWizards-upload/static/assets/chars/warrior_run.png new file mode 100644 index 0000000000000000000000000000000000000000..b12b05fb0a526987e573c0f84c284af30ff68633 Binary files /dev/null and b/HuggingWizards-upload/static/assets/chars/warrior_run.png differ diff --git a/HuggingWizards-upload/static/assets/effects/10_weaponhit_spritesheet.png b/HuggingWizards-upload/static/assets/effects/10_weaponhit_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..53f191bc375445f79eb5dd112c95567c7c5f7af4 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/10_weaponhit_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/11_fire_spritesheet.png b/HuggingWizards-upload/static/assets/effects/11_fire_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..f37088251665aa9bf78c4ba8fede333735c2b570 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/11_fire_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/12_nebula_spritesheet.png b/HuggingWizards-upload/static/assets/effects/12_nebula_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..392cc0dcf21788fb3d2c96469304c3fcf4675fe1 --- /dev/null +++ b/HuggingWizards-upload/static/assets/effects/12_nebula_spritesheet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03d76d29474c8ab282190e7a3814b4580a199e5eff19c330b111b61047e9c7e9 +size 153165 diff --git a/HuggingWizards-upload/static/assets/effects/13_vortex_spritesheet.png b/HuggingWizards-upload/static/assets/effects/13_vortex_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..83c5fd4024b4b5e219c714af65bdd1c1a6e8cd64 --- /dev/null +++ b/HuggingWizards-upload/static/assets/effects/13_vortex_spritesheet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfa9e8ed8fc24d66a29e63d6a1541e1cf5b35138078f8fcca3246134ec4f10c1 +size 104181 diff --git a/HuggingWizards-upload/static/assets/effects/14_phantom_spritesheet.png b/HuggingWizards-upload/static/assets/effects/14_phantom_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..31874123b84ff0fc05bab16935e77676ff5cef4e Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/14_phantom_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/15_loading_spritesheet.png b/HuggingWizards-upload/static/assets/effects/15_loading_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..f4fd6e06ace7d32503fa455c4316f16d514d2a98 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/15_loading_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/16_sunburn_spritesheet.png b/HuggingWizards-upload/static/assets/effects/16_sunburn_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..eab7e3fa54b6f61e5f4c13eb5f56de38f2cad20f Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/16_sunburn_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/17_felspell_spritesheet.png b/HuggingWizards-upload/static/assets/effects/17_felspell_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..9882f2b840b5e2ace06a2954e09edcd65ed0c2f3 --- /dev/null +++ b/HuggingWizards-upload/static/assets/effects/17_felspell_spritesheet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f1485ff111a0f37a934979837bbaa85a8e0dca15c8f7a5253681d06ed0dab09 +size 263943 diff --git a/HuggingWizards-upload/static/assets/effects/18_midnight_spritesheet.png b/HuggingWizards-upload/static/assets/effects/18_midnight_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..90ab02807f6acf8cdb8a6763ef6dea31d808e734 --- /dev/null +++ b/HuggingWizards-upload/static/assets/effects/18_midnight_spritesheet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c9df75ac06deb7a8170ac8161fc2535e819898274d9fae5246389ac9fb2abe5 +size 134851 diff --git a/HuggingWizards-upload/static/assets/effects/19_freezing_spritesheet.png b/HuggingWizards-upload/static/assets/effects/19_freezing_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..2eeb264d2a655095bac699ea3a75a693531c8324 --- /dev/null +++ b/HuggingWizards-upload/static/assets/effects/19_freezing_spritesheet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40739158d386a34a9435852e85e1dda76a6e7b80de9cfc2006770bb3b2903b97 +size 202938 diff --git a/HuggingWizards-upload/static/assets/effects/1_magicspell_spritesheet.png b/HuggingWizards-upload/static/assets/effects/1_magicspell_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..96f7ca46270114dcef8f6acda742d48d87916477 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/1_magicspell_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/20_magicbubbles_spritesheet.png b/HuggingWizards-upload/static/assets/effects/20_magicbubbles_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..332619e06df804967dfa6edcd071278898afe153 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/20_magicbubbles_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/2_magic8_spritesheet.png b/HuggingWizards-upload/static/assets/effects/2_magic8_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..a34e792335d37f35c7109fcd0048d32a9b62a023 --- /dev/null +++ b/HuggingWizards-upload/static/assets/effects/2_magic8_spritesheet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c5d6ce4c9df63b0d2d03008705eb52ec265474487b2a9eadf0c6d83424f48dd +size 145952 diff --git a/HuggingWizards-upload/static/assets/effects/3_bluefire_spritesheet.png b/HuggingWizards-upload/static/assets/effects/3_bluefire_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..7b2cf14475857c2e7e4899cceba844aa33e38543 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/3_bluefire_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/4_casting_spritesheet.png b/HuggingWizards-upload/static/assets/effects/4_casting_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..dbf67ce62bbc255d8385d4dcc2d03331bca63adc Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/4_casting_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/5_magickahit_spritesheet.png b/HuggingWizards-upload/static/assets/effects/5_magickahit_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..8259f7bb1fb7a36b25f822a5330c92fb1efcb329 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/5_magickahit_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/6_flamelash_spritesheet.png b/HuggingWizards-upload/static/assets/effects/6_flamelash_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..a83c14f37d47e83e0a6c88ceb93cec36cead6abf Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/6_flamelash_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/7_firespin_spritesheet.png b/HuggingWizards-upload/static/assets/effects/7_firespin_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..f9518186a6587c49bbe56a86c03e5b5cdd367d36 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/7_firespin_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/8_protectioncircle_spritesheet.png b/HuggingWizards-upload/static/assets/effects/8_protectioncircle_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..a7095a70a1f1a09f060cbbd8b1b2b05d4509e154 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/8_protectioncircle_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/9_brightfire_spritesheet.png b/HuggingWizards-upload/static/assets/effects/9_brightfire_spritesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..cc1d5a7c5ee2a1a761ff02984c2a0119b0a9a78b Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/9_brightfire_spritesheet.png differ diff --git a/HuggingWizards-upload/static/assets/effects/cover.png b/HuggingWizards-upload/static/assets/effects/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..2dff9291d67c905df9c4d469d494763f6ddcaee0 Binary files /dev/null and b/HuggingWizards-upload/static/assets/effects/cover.png differ diff --git a/HuggingWizards-upload/static/assets/enemies/archer_idle.png b/HuggingWizards-upload/static/assets/enemies/archer_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..eb88ddc7a3e0e580003ddd7398f4298d7b350c5b Binary files /dev/null and b/HuggingWizards-upload/static/assets/enemies/archer_idle.png differ diff --git a/HuggingWizards-upload/static/assets/enemies/archer_run.png b/HuggingWizards-upload/static/assets/enemies/archer_run.png new file mode 100644 index 0000000000000000000000000000000000000000..587b52ed0bd74f01de00ef710e90c64c964866b5 Binary files /dev/null and b/HuggingWizards-upload/static/assets/enemies/archer_run.png differ diff --git a/HuggingWizards-upload/static/assets/enemies/lancer_idle.png b/HuggingWizards-upload/static/assets/enemies/lancer_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..27960eb70603870aabb41363123861b7f289afdf Binary files /dev/null and b/HuggingWizards-upload/static/assets/enemies/lancer_idle.png differ diff --git a/HuggingWizards-upload/static/assets/enemies/pawn_idle.png b/HuggingWizards-upload/static/assets/enemies/pawn_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..37e767bc427837746de17e8a96c21dd4ddf836e4 Binary files /dev/null and b/HuggingWizards-upload/static/assets/enemies/pawn_idle.png differ diff --git a/HuggingWizards-upload/static/assets/enemies/pawn_run.png b/HuggingWizards-upload/static/assets/enemies/pawn_run.png new file mode 100644 index 0000000000000000000000000000000000000000..16c4177b12445905303c7d16587c3668b0205d1f Binary files /dev/null and b/HuggingWizards-upload/static/assets/enemies/pawn_run.png differ diff --git a/HuggingWizards-upload/static/assets/enemies/warrior_idle.png b/HuggingWizards-upload/static/assets/enemies/warrior_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..3ae908353fd2d31ef8f8aada91f7e6eb6e313d10 Binary files /dev/null and b/HuggingWizards-upload/static/assets/enemies/warrior_idle.png differ diff --git a/HuggingWizards-upload/static/assets/enemies/warrior_run.png b/HuggingWizards-upload/static/assets/enemies/warrior_run.png new file mode 100644 index 0000000000000000000000000000000000000000..8425dacf8d698ae538e3a8ec11ba0b8f6f612401 Binary files /dev/null and b/HuggingWizards-upload/static/assets/enemies/warrior_run.png differ diff --git a/HuggingWizards-upload/static/assets/glitch/portal.png b/HuggingWizards-upload/static/assets/glitch/portal.png new file mode 100644 index 0000000000000000000000000000000000000000..25f44467e7be87f981de4592f78cf18a8efded7d --- /dev/null +++ b/HuggingWizards-upload/static/assets/glitch/portal.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71b39bb0d65e227b348064c954d7d704267aaaf9b04970b9c6f0240a7a54704c +size 193034 diff --git a/HuggingWizards-upload/static/assets/holy/00.png b/HuggingWizards-upload/static/assets/holy/00.png new file mode 100644 index 0000000000000000000000000000000000000000..89d36ada2ac63b145a2c549129f60bcc2a9d80b6 Binary files /dev/null and b/HuggingWizards-upload/static/assets/holy/00.png differ diff --git a/HuggingWizards-upload/static/assets/holy/01.png b/HuggingWizards-upload/static/assets/holy/01.png new file mode 100644 index 0000000000000000000000000000000000000000..8377a7ec690e7e20773221cd8527158dd8219afb Binary files /dev/null and b/HuggingWizards-upload/static/assets/holy/01.png differ diff --git a/HuggingWizards-upload/static/assets/music/Pixel 1.ogg b/HuggingWizards-upload/static/assets/music/Pixel 1.ogg new file mode 100644 index 0000000000000000000000000000000000000000..491adff8c75ea3c3f520a9ebeca3022fa50c9a17 --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 1.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cabefc93a59c00ac8a8c392bca2099aff24249105bcfb271d42cd48c4044185a +size 2200123 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 10.ogg b/HuggingWizards-upload/static/assets/music/Pixel 10.ogg new file mode 100644 index 0000000000000000000000000000000000000000..5e961c2adb9dec3859c6c906d8a5baa3260d257d --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 10.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c17cc6a49be4890bafee259aebc65f594a53d6de19ad162d3ad1bda4bc2a29a9 +size 2667482 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 11.ogg b/HuggingWizards-upload/static/assets/music/Pixel 11.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8a540128f385c7fdaca5460260bf5c620f6e8efa --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 11.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b67d2d78b59f6bab62a124ff7ce22f160f6f0d935bc13257e6d86f6c97d73c9 +size 2170776 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 12.ogg b/HuggingWizards-upload/static/assets/music/Pixel 12.ogg new file mode 100644 index 0000000000000000000000000000000000000000..7367e262cec9b3e8434dff27ea8631a5fa5db538 --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 12.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:543725f2547708b11c4cc6ec3f26c36b22ecfe9839c5896ef18a255187b23dd5 +size 2206393 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 2.ogg b/HuggingWizards-upload/static/assets/music/Pixel 2.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8acfcf8e4759777cbaa93295e761ae9c83bebcb0 --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 2.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f3cb2effdceacf49ab407fbe6bfd46e3be7cae044b73c815d8ea40d2accdae1 +size 2561817 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 3.ogg b/HuggingWizards-upload/static/assets/music/Pixel 3.ogg new file mode 100644 index 0000000000000000000000000000000000000000..84234af0c0515eaf462adc9e131a90a309061ede --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 3.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:957eace5cc192034131539a3333d9f9fc93f42d9128392964fcc019503da7d6c +size 2590496 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 4.ogg b/HuggingWizards-upload/static/assets/music/Pixel 4.ogg new file mode 100644 index 0000000000000000000000000000000000000000..45ac4b6da6e86e0072de8cf2564db2edf405d986 --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 4.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a51d39ccb2f4dfd093f4372334839ece02683ab9bd310b0c8e1d2d3ac556ec7 +size 2726174 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 5.ogg b/HuggingWizards-upload/static/assets/music/Pixel 5.ogg new file mode 100644 index 0000000000000000000000000000000000000000..172dfd0e1e0ec912bc4080ab44116554d3143920 --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 5.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ba5642c816225818e33757eb69df67b2252ab03f707dee724daf07bed883e94 +size 2433375 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 6.ogg b/HuggingWizards-upload/static/assets/music/Pixel 6.ogg new file mode 100644 index 0000000000000000000000000000000000000000..101c00d78509e307fa0446e9c979b0ccfe35ae9c --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 6.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07aca7315442e33c685e48ae6f722344ecc651cdad6a726529cc0dbecbca0eae +size 2389935 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 7.ogg b/HuggingWizards-upload/static/assets/music/Pixel 7.ogg new file mode 100644 index 0000000000000000000000000000000000000000..6a05c8dcf0957afd8c94d26a2ec623d28cc1e695 --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 7.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c6438bad95ac1eea1bfd48c58e7f9dd024e50cc21cfd8ed8a7c91fe14205b98 +size 1617811 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 8.ogg b/HuggingWizards-upload/static/assets/music/Pixel 8.ogg new file mode 100644 index 0000000000000000000000000000000000000000..24bcc3f57f5021ccd8bcf6cc5cd41e6f0f4966ed --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 8.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b204a9542cc28cafe91da1814db1499dc91e0976f59470f7903f1b5ca2da566 +size 2555685 diff --git a/HuggingWizards-upload/static/assets/music/Pixel 9.ogg b/HuggingWizards-upload/static/assets/music/Pixel 9.ogg new file mode 100644 index 0000000000000000000000000000000000000000..fd36d96df2e43ad3e5a5974904715f2beed74d4e --- /dev/null +++ b/HuggingWizards-upload/static/assets/music/Pixel 9.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6decf5f31d0d9a1725d0d3a5ba71dc1c1067aa454e2fc41eaa987155a7e3a0c4 +size 2647627 diff --git a/HuggingWizards-upload/static/assets/retro/retro_a.png b/HuggingWizards-upload/static/assets/retro/retro_a.png new file mode 100644 index 0000000000000000000000000000000000000000..0ab354083f6060d81f5a2c50cd98820051d6add4 --- /dev/null +++ b/HuggingWizards-upload/static/assets/retro/retro_a.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:598e2d1224a01532fd09e694d72d69c74997117e4b19375bf638de8480d06d8b +size 239964 diff --git a/HuggingWizards-upload/static/assets/retro/retro_b.png b/HuggingWizards-upload/static/assets/retro/retro_b.png new file mode 100644 index 0000000000000000000000000000000000000000..dd2124dddb1d765a05f1fb241a9d20e2c205d959 --- /dev/null +++ b/HuggingWizards-upload/static/assets/retro/retro_b.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6cbc1bb40c397de54618cafe6e9ca559471d9e0019e692eba277776e293ebd4f +size 253488 diff --git a/HuggingWizards-upload/static/assets/retro/retro_c.png b/HuggingWizards-upload/static/assets/retro/retro_c.png new file mode 100644 index 0000000000000000000000000000000000000000..7036a72ae9b95d1e88c176809781bc9eb5719ac2 --- /dev/null +++ b/HuggingWizards-upload/static/assets/retro/retro_c.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6b0fb381424ca3f2b84d478129afe8e797329a752d3cdb0ed43d0f98d292527 +size 226044 diff --git a/HuggingWizards-upload/static/assets/retro/retro_d.png b/HuggingWizards-upload/static/assets/retro/retro_d.png new file mode 100644 index 0000000000000000000000000000000000000000..6c97b136ca91f82b4ff1f52e0f82759e8ab1007c --- /dev/null +++ b/HuggingWizards-upload/static/assets/retro/retro_d.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9d541033e683b2631226aea60986197e6dd25ca0b4503158657640f9b3f7772 +size 233206 diff --git a/HuggingWizards-upload/static/assets/retro/retro_e.png b/HuggingWizards-upload/static/assets/retro/retro_e.png new file mode 100644 index 0000000000000000000000000000000000000000..0f10172ac24865027656b056c6796982ba0a8883 --- /dev/null +++ b/HuggingWizards-upload/static/assets/retro/retro_e.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32ed52826ab23a43ee34791b3101701989dc8f3007cafc345124dc92a3f2827f +size 252406 diff --git a/HuggingWizards-upload/static/assets/retro/retro_f.png b/HuggingWizards-upload/static/assets/retro/retro_f.png new file mode 100644 index 0000000000000000000000000000000000000000..a5bcebe2d4ab3059d9801f03a8bed48ad59ee30e --- /dev/null +++ b/HuggingWizards-upload/static/assets/retro/retro_f.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66fac959e29c1357d3753d02a7801a5426e59fdcc3a921188b8942431be7e507 +size 245812 diff --git a/HuggingWizards-upload/static/assets/terrain/bush1.png b/HuggingWizards-upload/static/assets/terrain/bush1.png new file mode 100644 index 0000000000000000000000000000000000000000..590433afbcf4d50d85de7f2563a64f368d94ecb8 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/bush1.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/bush2.png b/HuggingWizards-upload/static/assets/terrain/bush2.png new file mode 100644 index 0000000000000000000000000000000000000000..755ef6d358aa57dd05c96f6105c6214377d1d7b6 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/bush2.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/bush3.png b/HuggingWizards-upload/static/assets/terrain/bush3.png new file mode 100644 index 0000000000000000000000000000000000000000..cc992b4424c1f4559d1cb25a11a47c291297d073 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/bush3.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/bush4.png b/HuggingWizards-upload/static/assets/terrain/bush4.png new file mode 100644 index 0000000000000000000000000000000000000000..2154a811e6c41e7b83450d1bd647a0510d9458c2 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/bush4.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/foam.png b/HuggingWizards-upload/static/assets/terrain/foam.png new file mode 100644 index 0000000000000000000000000000000000000000..507b5fd579ed230303a5edeaee23f0ab61f4b55d Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/foam.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/rock1.png b/HuggingWizards-upload/static/assets/terrain/rock1.png new file mode 100644 index 0000000000000000000000000000000000000000..797084582977900c1291a4274edf86da4abb015e Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/rock1.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/rock2.png b/HuggingWizards-upload/static/assets/terrain/rock2.png new file mode 100644 index 0000000000000000000000000000000000000000..6b15046516ca35cabe880c68f9df01dbe6a6d263 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/rock2.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/rock3.png b/HuggingWizards-upload/static/assets/terrain/rock3.png new file mode 100644 index 0000000000000000000000000000000000000000..39473b7cb140f4e4b299573108d46845f2f9be47 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/rock3.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/rock4.png b/HuggingWizards-upload/static/assets/terrain/rock4.png new file mode 100644 index 0000000000000000000000000000000000000000..ebda219c071d7ef7d0363be100395a2bcd403ec7 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/rock4.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/shadow.png b/HuggingWizards-upload/static/assets/terrain/shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..a6c428132c0ab3a7ab145d9481eeb6f94c6372d7 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/shadow.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/tilemap.png b/HuggingWizards-upload/static/assets/terrain/tilemap.png new file mode 100644 index 0000000000000000000000000000000000000000..9344c6df821644f6b1cc5d50882ab7fb1f616990 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/tilemap.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/tree1.png b/HuggingWizards-upload/static/assets/terrain/tree1.png new file mode 100644 index 0000000000000000000000000000000000000000..655a141adf0e9c72221c42494f66e9591034a9e0 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/tree1.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/tree2.png b/HuggingWizards-upload/static/assets/terrain/tree2.png new file mode 100644 index 0000000000000000000000000000000000000000..6d316b6058c57c6530433c08af59f57b4ca524f5 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/tree2.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/tree3.png b/HuggingWizards-upload/static/assets/terrain/tree3.png new file mode 100644 index 0000000000000000000000000000000000000000..fe6d67bafd4f2b59fe1fa14e0acaeb7522333d20 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/tree3.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/tree4.png b/HuggingWizards-upload/static/assets/terrain/tree4.png new file mode 100644 index 0000000000000000000000000000000000000000..162140b90d7429ad9d2e1da11f22012beca48097 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/tree4.png differ diff --git a/HuggingWizards-upload/static/assets/terrain/water.png b/HuggingWizards-upload/static/assets/terrain/water.png new file mode 100644 index 0000000000000000000000000000000000000000..817eda7bda57ba0ba7997b4cd0b036402e484027 Binary files /dev/null and b/HuggingWizards-upload/static/assets/terrain/water.png differ diff --git a/HuggingWizards-upload/static/assets/ui/00.png b/HuggingWizards-upload/static/assets/ui/00.png new file mode 100644 index 0000000000000000000000000000000000000000..296fe03197999d6b674450b04b614b8ffc878cbc Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/00.png differ diff --git a/HuggingWizards-upload/static/assets/ui/01.png b/HuggingWizards-upload/static/assets/ui/01.png new file mode 100644 index 0000000000000000000000000000000000000000..3d748f18c4e625e4024afed7bb18abe8f68a9f9c Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/01.png differ diff --git a/HuggingWizards-upload/static/assets/ui/02.png b/HuggingWizards-upload/static/assets/ui/02.png new file mode 100644 index 0000000000000000000000000000000000000000..4bc1898154be7e28e662a21825b03c79047727c8 Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/02.png differ diff --git a/HuggingWizards-upload/static/assets/ui/03.png b/HuggingWizards-upload/static/assets/ui/03.png new file mode 100644 index 0000000000000000000000000000000000000000..00364cd3becf1ba5272a320ee7ecebdc980615a7 Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/03.png differ diff --git a/HuggingWizards-upload/static/assets/ui/04.png b/HuggingWizards-upload/static/assets/ui/04.png new file mode 100644 index 0000000000000000000000000000000000000000..b9d8845b5f994eb076dc751a3eb001e2075372a7 Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/04.png differ diff --git a/HuggingWizards-upload/static/assets/ui/05.png b/HuggingWizards-upload/static/assets/ui/05.png new file mode 100644 index 0000000000000000000000000000000000000000..16f7f82228bee049a65f20cbf99491e59b846242 Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/05.png differ diff --git a/HuggingWizards-upload/static/assets/ui/06.png b/HuggingWizards-upload/static/assets/ui/06.png new file mode 100644 index 0000000000000000000000000000000000000000..5a76c9597ce4847744bd338512b2863c4ef06ceb Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/06.png differ diff --git a/HuggingWizards-upload/static/assets/ui/07.png b/HuggingWizards-upload/static/assets/ui/07.png new file mode 100644 index 0000000000000000000000000000000000000000..628bb2a20700d042221e47da59803ad42e838a5d Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/07.png differ diff --git a/HuggingWizards-upload/static/assets/ui/All.png b/HuggingWizards-upload/static/assets/ui/All.png new file mode 100644 index 0000000000000000000000000000000000000000..4edc6ae47742be876b5244ab25c53a46eef72f6e Binary files /dev/null and b/HuggingWizards-upload/static/assets/ui/All.png differ diff --git a/HuggingWizards-upload/static/game.js b/HuggingWizards-upload/static/game.js new file mode 100644 index 0000000000000000000000000000000000000000..749f20c1c2d26a8e94ba6ca51b14b5c69dceb7aa --- /dev/null +++ b/HuggingWizards-upload/static/game.js @@ -0,0 +1,971 @@ +// HuggingWizards client: WebSocket sync + canvas rendering + input. +// The browser renders; the server owns the simulation. +"use strict"; + +const canvas = document.getElementById("game"); +const ctx = canvas.getContext("2d"); +ctx.imageSmoothingEnabled = false; + +let ws = null; +let myPid = null; +let myRole = "spectator"; +let snap = null; // latest server snapshot +const input = { up: false, down: false, left: false, right: false, attack: false }; + +// ---- asset loading ------------------------------------------------------- +const FW = 100, FH = 100; // character source frame size +function loadStrip(src) { + const img = new Image(); + img.src = src; + return { img, get frames() { return img.complete && img.height ? Math.max(1, Math.round(img.width / img.height)) : 1; } }; +} +const SOL = "/static/assets/characters/soldier/"; +const ORC = "/static/assets/characters/orc/"; +const SPR = { + player: { + idle: loadStrip(SOL + "Soldier-Idle.png"), + walk: loadStrip(SOL + "Soldier-Walk.png"), + attack: loadStrip(SOL + "Soldier-Attack01.png"), + hurt: loadStrip(SOL + "Soldier-Hurt.png"), + death: loadStrip(SOL + "Soldier-Death.png"), + }, + minion: { + idle: loadStrip(ORC + "Orc-Idle.png"), + walk: loadStrip(ORC + "Orc-Walk.png"), + attack: loadStrip(ORC + "Orc-Attack01.png"), + death: loadStrip(ORC + "Orc-Death.png"), + }, +}; +// Enemy units (Tiny Swords) for the per-theme boss/minion skins. +const ENM = "/static/assets/enemies/"; +function uimg(src) { const i = new Image(); i.src = src; return i; } +const UNIT = { + warrior: uimg(ENM + "warrior_idle.png"), warriorRun: uimg(ENM + "warrior_run.png"), + lancer: uimg(ENM + "lancer_idle.png"), + archer: uimg(ENM + "archer_idle.png"), archerRun: uimg(ENM + "archer_run.png"), + pawn: uimg(ENM + "pawn_idle.png"), pawnRun: uimg(ENM + "pawn_run.png"), +}; +// Tiny Swords buildings β€” per-theme map landmarks. +const BLD = "/static/assets/buildings/"; +const BUILDINGS = {}; +for (const n of ["blue_castle", "blue_tower", "blue_archery", "blue_monastery", + "red_castle", "red_tower", "red_barracks", + "black_castle", "black_tower", "black_house1"]) { + BUILDINGS[n] = uimg(BLD + n + ".png"); +} + +// Theme set, swapped every 5 waves. Theme 0 keeps the original orcs. +// Each theme is also a distinct MAP: ground tint, ring color, landmark +// buildings, and a seeded scenery mix (tree/bush/rock weights). +const THEMES = [ + { name: "Orc Horde", boss: SPR.minion.idle.img, minion: SPR.minion.walk.img, + tint: "rgba(110,140,40,.10)", ring: "rgba(120,90,40,.5)", + deco: { tree: 2, bush: 5, rock: 2 }, seed: 101, + landmarks: [{ b: "black_house1", x: 150, y: 150, s: 0.55 }, + { b: "black_tower", x: 1130, y: 165, s: 0.5 }, + { b: "black_house1", x: 1100, y: 640, s: 0.5 }] }, + { name: "Iron Legion", boss: UNIT.warrior, minion: UNIT.warriorRun, + tint: "rgba(90,120,190,.10)", ring: "rgba(120,150,220,.45)", + deco: { tree: 1, bush: 1, rock: 6 }, seed: 202, + landmarks: [{ b: "blue_castle", x: 640, y: 130, s: 0.62 }, + { b: "blue_tower", x: 170, y: 620, s: 0.5 }, + { b: "blue_tower", x: 1110, y: 620, s: 0.5 }] }, + { name: "Lancer Host", boss: UNIT.lancer, minion: UNIT.pawnRun, + tint: "rgba(200,90,50,.10)", ring: "rgba(220,110,80,.45)", + deco: { tree: 1, bush: 2, rock: 4 }, seed: 303, + landmarks: [{ b: "red_castle", x: 200, y: 150, s: 0.58 }, + { b: "red_barracks", x: 1110, y: 160, s: 0.55 }, + { b: "red_tower", x: 640, y: 680, s: 0.5 }] }, + { name: "Archer Coven", boss: UNIT.archer, minion: UNIT.archerRun, + tint: "rgba(30,100,55,.13)", ring: "rgba(80,180,110,.45)", + deco: { tree: 6, bush: 3, rock: 1 }, seed: 404, + landmarks: [{ b: "blue_archery", x: 1110, y: 165, s: 0.55 }, + { b: "blue_monastery", x: 165, y: 175, s: 0.5 }, + { b: "blue_archery", x: 640, y: 690, s: 0.5 }] }, +]; +const themeOf = (s) => THEMES[(s.theme || 0) % THEMES.length]; + +// Special boss sprites + their attack-effect sheets. +const BOSS_DIR = "/static/assets/bosses/"; +const BOSS_SPRITES = { aegis: uimg(BOSS_DIR + "aegis.png"), toaster: uimg(BOSS_DIR + "toaster_boss.png") }; +const HOLY = uimg("/static/assets/holy/00.png"); // 64px frames, 3 rows of effects +const GLITCH = uimg("/static/assets/glitch/portal.png"); // 64px frames, 60-frame strip +const HUE_RGBA = { red: "rgba(255,30,30,0.20)", green: "rgba(40,255,90,0.16)" }; + +// holy effect: pick a row (0 bloom / 1 cross / 2 blade), loop its 19 frames +function drawHoly(cx, cy, size, row, fps) { + if (!HOLY.complete || !HOLY.height) return; + const cols = 19, fs = 64; + const f = Math.floor(performance.now() / (1000 / (fps || 24))) % cols; + ctx.drawImage(HOLY, f * fs + 0.5, (row || 0) * fs + 0.5, fs - 1, fs - 1, cx - size / 2, cy - size / 2, size, size); +} +function drawGlitch(cx, cy, size, fps) { + if (!GLITCH.complete || !GLITCH.height) return; + const fs = 64, frames = Math.max(1, Math.round(GLITCH.width / fs)); + const f = Math.floor(performance.now() / (1000 / (fps || 24))) % frames; + ctx.drawImage(GLITCH, f * fs + 0.5, 0.5, fs - 1, fs - 1, cx - size / 2, cy - size / 2, size, size); +} + +// Playable characters (chosen on the name screen) β€” all Tiny Swords blue units. +// `fill` = how much of the (square) frame the character occupies; `foot` = the +// content's bottom as a fraction of the frame. These let drawChar() render every +// champion at the SAME on-screen size with feet on the ground, despite different +// sheet sizes / padding. +const CH = "/static/assets/chars/"; +// `fill` = the BODY height (NOT incl. raised weapons like the lance) as a fraction +// of the native frame, so every champion's body renders the same size; `foot` = +// where the feet sit in the frame (for grounding). +const CHARACTERS = { + warrior: { label: "Warrior", idle: uimg(CH + "warrior_idle.png"), run: uimg(CH + "warrior_run.png"), fill: 0.46, foot: 0.71 }, + archer: { label: "Archer", idle: uimg(CH + "archer_idle.png"), run: uimg(CH + "archer_run.png"), fill: 0.46, foot: 0.71 }, + lancer: { label: "Lancer", idle: uimg(CH + "lancer_idle.png"), run: uimg(CH + "lancer_idle.png"), fill: 0.26, foot: 0.62 }, + pawn: { label: "Pawn", idle: uimg(CH + "pawn_idle.png"), run: uimg(CH + "pawn_run.png"), fill: 0.38, foot: 0.70 }, +}; +const CHAR_IDS = Object.keys(CHARACTERS); + +// Draw a character frame so its CONTENT is `contentH` px tall with feet at footY. +function drawChar(ch, cx, footY, contentH, moving, faceLeft) { + const img = (moving && ch.run.complete) ? ch.run : ch.idle; + if (!img.complete || !img.height) return; + const fs = img.height; + const frames = Math.max(1, Math.round(img.width / fs)); + const f = Math.floor(performance.now() / (1000 / (moving ? 10 : 7))) % frames; + const frameH = contentH / ch.fill; // on-screen height of the whole frame + const topY = footY - ch.foot * frameH; // place feet on the ground + ctx.save(); + if (faceLeft) { ctx.translate(cx, 0); ctx.scale(-1, 1); ctx.translate(-cx, 0); } + ctx.drawImage(img, f * fs + 0.5, 0.5, fs - 1, fs - 1, cx - frameH / 2, topY, frameH, frameH); + ctx.restore(); +} + +// Retro Impact Effect Pack sheets (3x10 frames of 192px) for timed auras. +const RETRO_DIR = "/static/assets/retro/"; +const RETRO = {}; +for (const s of ["a", "b", "c", "d", "e", "f"]) RETRO[s] = uimg(RETRO_DIR + "retro_" + s + ".png"); +// aura type -> retro sheet (mirrors AURAS["sheet"] in engine.py) +const RETRO_SHEET = { inferno: "a", frost: "b", haste: "c", vampire: "d", warding: "e", fury: "f" }; +// Draw a looping retro effect (row-major across the 3x10 grid) at a position. +function drawRetro(sheetId, cx, cy, size, fps) { + const img = RETRO[sheetId] || RETRO.a; + if (!img.complete || !img.height) return; + const cols = 3, rows = 10, fs = 192, total = cols * rows; + const f = Math.floor(performance.now() / (1000 / (fps || 18))) % total; + const sx = (f % cols) * fs, sy = Math.floor(f / cols) * fs; + ctx.drawImage(img, sx + 0.5, sy + 0.5, fs - 1, fs - 1, cx - size / 2, cy - size / 2, size, size); +} + +// Draw one frame of a horizontal sprite-strip at a target on-screen HEIGHT, +// deriving the (square) frame size from the image height β€” works for any sheet. +function drawUnitH(img, cx, cy, targetH, faceLeft, fps) { + if (!img.complete || !img.height) return; + const fs = img.height; + const frames = Math.max(1, Math.round(img.width / fs)); + const f = Math.floor(performance.now() / (1000 / (fps || 9))) % frames; + const dh = targetH, dw = targetH; + ctx.save(); + ctx.translate(cx, cy); + if (faceLeft) ctx.scale(-1, 1); + // inset source rect ~0.5px to avoid neighbouring-frame bleed when scaling + ctx.drawImage(img, f * fs + 0.5, 0.5, fs - 1, fs - 1, -dw / 2, -dh / 2, dw, dh); + ctx.restore(); +} + +const PALETTE = ["#7ee0ff", "#ff8cc6", "#a6ff8c", "#ffd45e", "#c08cff", "#ff9d6e", "#6effd0", "#ff6e6e"]; +// XP gem colors by tier 1..7 (dull green -> brilliant gold) +const GEM_COLORS = ["#6effd0", "#7ee0ff", "#9d8cff", "#ff8cc6", "#ff9d6e", "#ffd45e", "#fff3a0"]; +const BOSS_SCALE = 4.2; // keep in sync with boss render scale + +// ---- terrain (Tiny Swords) ---------------------------------------------- +function loadImg(src) { const img = new Image(); img.src = src; return img; } +const TER = "/static/assets/terrain/"; +const TILEMAP = loadImg(TER + "tilemap.png"); // 64px tiles; seamless grass fill at (64,64) +const TILE = 64; +// Decorations: 8-frame strips drawn as static frame 0. {fw, fh} = source frame +// size β€” trees are 192x256 (non-square!), the rest square. +const DECO = { + tree1: { img: loadImg(TER + "tree1.png"), fw: 192, fh: 256 }, + tree2: { img: loadImg(TER + "tree2.png"), fw: 192, fh: 256 }, + tree3: { img: loadImg(TER + "tree3.png"), fw: 192, fh: 192 }, + tree4: { img: loadImg(TER + "tree4.png"), fw: 192, fh: 192 }, + bush1: { img: loadImg(TER + "bush1.png"), fw: 128, fh: 128 }, + bush2: { img: loadImg(TER + "bush2.png"), fw: 128, fh: 128 }, + bush3: { img: loadImg(TER + "bush3.png"), fw: 128, fh: 128 }, + bush4: { img: loadImg(TER + "bush4.png"), fw: 128, fh: 128 }, + rock1: { img: loadImg(TER + "rock1.png"), fw: 64, fh: 64 }, + rock2: { img: loadImg(TER + "rock2.png"), fw: 64, fh: 64 }, + rock3: { img: loadImg(TER + "rock3.png"), fw: 64, fh: 64 }, + rock4: { img: loadImg(TER + "rock4.png"), fw: 64, fh: 64 }, +}; +// Per-theme perimeter decorations, generated deterministically from the theme +// seed so every client sees the same map. The deco weights make each boss's +// territory feel different (orc bushland, legion rockfields, archer forest...). +function mulberry32(a) { + return function () { + a |= 0; a = a + 0x6D2B79F5 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} +const _sceneryCache = {}; +function sceneryFor(themeIdx) { + if (_sceneryCache[themeIdx]) return _sceneryCache[themeIdx]; + const th = THEMES[themeIdx % THEMES.length]; + const rnd = mulberry32(th.seed); + const kinds = []; + for (const [k, w] of Object.entries(th.deco)) + for (let i = 0; i < w; i++) kinds.push(k); + const list = []; + let guard = 0; + while (list.length < 26 && guard++ < 400) { + const x = 60 + rnd() * 1160, y = 70 + rnd() * 600; + // keep the central fight area and landmark spots clear + if (Math.hypot(x - 640, y - 360) < 240) continue; + if (th.landmarks.some((L) => Math.hypot(x - L.x, y - L.y) < 110)) continue; + if (list.some((d) => Math.hypot(x - d.x, y - d.y) < 70)) continue; + const fam = kinds[Math.floor(rnd() * kinds.length)]; + const k = fam + (1 + Math.floor(rnd() * 4)); // tree1..4 / bush1..4 / rock1..4 + const s = fam === "tree" ? 0.34 + rnd() * 0.1 + : fam === "bush" ? 0.45 + rnd() * 0.12 : 0.55 + rnd() * 0.2; + list.push({ k, x, y, s }); + } + list.sort((a, b) => a.y - b.y); // painter's order + _sceneryCache[themeIdx] = list; + return list; +} + +// ---- effects (Free Pixel Effects Pack) ----------------------------------- +// Each sheet is a square grid of 100px frames, played row-major. +const FX_DIR = "/static/assets/effects/"; +function fxSheet(file, side) { + const img = loadImg(FX_DIR + file); + const cols = side / 100; + return { img, cols, total: cols * cols }; +} +const FX = { + cast: fxSheet("1_magicspell_spritesheet.png", 900), + hit: fxSheet("5_magickahit_spritesheet.png", 700), + smallhit: fxSheet("10_weaponhit_spritesheet.png", 600), + death: fxSheet("9_brightfire_spritesheet.png", 800), + bossdeath: fxSheet("11_fire_spritesheet.png", 800), + // skill / blessing effects (server-driven events) + aoe: fxSheet("17_felspell_spritesheet.png", 1000), + aoe_frost: fxSheet("19_freezing_spritesheet.png", 1000), + nova: fxSheet("2_magic8_spritesheet.png", 800), + heal: fxSheet("20_magicbubbles_spritesheet.png", 800), + shield: fxSheet("8_protectioncircle_spritesheet.png", 800), + summon: fxSheet("13_vortex_spritesheet.png", 800), + bless: fxSheet("16_sunburn_spritesheet.png", 800), +}; +// Server skill events -> effect sheet + sizing. AoE events carry a radius. +function playEvent(ev) { + const sheet = FX[ev.fx]; + if (!sheet) return; + const scale = ev.r ? Math.max(0.8, (ev.r * 2) / 100) : (ev.fx === "bless" ? 1.6 : 1.1); + spawnFx(sheet, ev.x, ev.y - 8, scale, 40); +} +// Live one-shot effects: {sheet, x, y, scale, fps, start} +let effects = []; +function spawnFx(sheet, x, y, scale = 0.7, fps = 45) { + effects.push({ sheet, x, y, scale, fps, start: performance.now() }); + if (effects.length > 120) effects.shift(); +} +function drawEffects() { + const now = performance.now(); + const keep = []; + for (const e of effects) { + const f = Math.floor((now - e.start) / 1000 * e.fps); + if (f >= e.sheet.total) continue; + const img = e.sheet.img; + if (img.complete && img.height) { + const c = e.sheet.cols, sx = (f % c) * 100, sy = Math.floor(f / c) * 100; + const dw = 100 * e.scale; + ctx.drawImage(img, sx, sy, 100, 100, e.x - dw / 2, e.y - dw / 2, dw, dw); + } + keep.push(e); + } + effects = keep; +} + +function drawSprite(strip, cx, cy, scale, faceLeft, frameOverride) { + const img = strip.img; + if (!img.complete || !img.height) return; + const frames = strip.frames; + const f = frameOverride != null ? Math.min(frames - 1, frameOverride) + : Math.floor(performance.now() / 110) % frames; + const dw = FW * scale, dh = FH * scale; + ctx.save(); + ctx.translate(cx, cy); + if (faceLeft) ctx.scale(-1, 1); + // inset source rect ~0.5px to avoid neighbouring-frame bleed when scaling + ctx.drawImage(img, f * FH + 0.5, 0.5, FH - 1, FH - 1, -dw / 2, -dh / 2, dw, dh); + ctx.restore(); +} + +// ---- networking ---------------------------------------------------------- +function connect() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + ws = new WebSocket(`${proto}://${location.host}/ws`); + ws.onopen = () => setConn("connected"); + ws.onclose = () => { setConn("disconnected β€” retrying…"); setTimeout(connect, 1500); }; + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.type === "welcome") { + myPid = m.pid; myRole = m.role; + el("join-screen").classList.add("hidden"); + el("roster").classList.remove("hidden"); + if (m.role === "player") { + el("hud").classList.remove("hidden"); + el("queue-banner").classList.add("hidden"); + el("gameover").classList.add("hidden"); + if (m.reason === "rotated_in" || m.reason === "from_queue") { startMusic(); flash("βš” You're in β€” take the field!"); } + } else { + el("hud").classList.add("hidden"); + el("levelup").classList.add("hidden"); + if (m.reason === "queued") { showQueue(m.pos, m.size); flash(`Game full β€” you're #${m.pos} in queue.`); } + else if (m.reason === "lobby_full") flash("Lobby full (8) β€” you're spectating."); + else if (m.reason === "rotated_out") flash("You fell β€” spectating until a slot opens."); + } + } else if (m.type === "queue") { + showQueue(m.pos, m.size); + } else if (m.type === "eliminated") { + myRole = "spectator"; myPid = null; + lastScore = m.score || lastScore; + el("hud").classList.add("hidden"); el("levelup").classList.add("hidden"); + el("go-score").textContent = `Wave ${lastScore.wave} Β· Level ${lastScore.level} Β· πŸͺ™ ${lastScore.gold}`; + el("gameover").classList.remove("hidden"); + } else if (m.type === "leaderboard") { + renderLeaderboard(m.top || []); + if (m.submitted) flash("Score submitted! πŸ†"); + } else if (m.type === "skill_result") { + const box = el("skill-msg"); + if (m.ok && m.skill) { box.textContent = `✨ Granted: ${m.skill.name} (${m.skill.effect})`; box.style.color = "#a6ff8c"; } + else { box.textContent = m.reason || "Request failed."; box.style.color = "#ff8c8c"; } + el("skill-btn").disabled = m.ok; + } else { + snap = m; + onSnapshot(); + } + }; +} +function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } +function setConn(t) { document.getElementById("conn-status").textContent = t; } +let _toastT = null; +function flash(text) { + const t = document.getElementById("toast"); + t.textContent = text; t.classList.remove("hidden"); + clearTimeout(_toastT); + _toastT = setTimeout(() => t.classList.add("hidden"), 4000); +} + +// ---- input --------------------------------------------------------------- +const KEYMAP = { KeyW: "up", KeyA: "left", KeyS: "down", KeyD: "right", Space: "attack", + ArrowUp: "up", ArrowLeft: "left", ArrowDown: "down", ArrowRight: "right" }; +function setKey(code, down) { + const k = KEYMAP[code]; + if (!k) return false; + if (input[k] !== down) { input[k] = down; sendInput(); } + return true; +} +function sendInput() { + if (myRole !== "player") return; + send({ type: "input", up: input.up, down: input.down, left: input.left, right: input.right, attack: input.attack }); +} +// Ignore movement/attack keys while typing in a text field (Wish box, name, etc.) +function typingInField(e) { + const t = e.target; + return t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable); +} +addEventListener("keydown", (e) => { + if (typingInField(e)) return; + // 1/2/3 pick the offered level-up card + if ((e.code === "Digit1" || e.code === "Digit2" || e.code === "Digit3" || + e.code === "Numpad1" || e.code === "Numpad2" || e.code === "Numpad3")) { + const p = me(); + if (p && p.pending_cards && p.pending_cards.length) { + const idx = (e.code.endsWith("1") ? 0 : e.code.endsWith("2") ? 1 : 2); + if (p.pending_cards[idx]) { send({ type: "choose_card", key: p.pending_cards[idx] }); lastCardSig = ""; } + e.preventDefault(); return; + } + } + if (setKey(e.code, true)) e.preventDefault(); +}); +addEventListener("keyup", (e) => { if (typingInField(e)) return; if (setKey(e.code, false)) e.preventDefault(); }); + +// ---- UI wiring ----------------------------------------------------------- +const el = (id) => document.getElementById(id); + +// ---- music: a shuffled playlist from the Pixel RPG Music Pack ------------- +const bgm = el("bgm"); +const TRACKS = Array.from({ length: 12 }, (_, i) => `/static/assets/music/Pixel ${i + 1}.ogg`); +const BOSS_TRACK = "/static/assets/music/Pixel 9.ogg"; // tense track for special bosses +let _playlist = [], _trackPos = 0, _bossMusic = false, _musicOn = false; +function _shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } +function playTrack(url, loop) { bgm.loop = !!loop; bgm.src = url; bgm.play().catch(() => {}); } +function nextTrack() { + if (_trackPos >= _playlist.length) { _playlist = _shuffle(TRACKS.slice()); _trackPos = 0; } + playTrack(_playlist[_trackPos++], false); +} +bgm.addEventListener("ended", () => { if (_musicOn && !_bossMusic) nextTrack(); }); +function startMusic() { + if (_musicOn) return; + _musicOn = true; bgm.volume = 0.32; + _playlist = _shuffle(TRACKS.slice()); _trackPos = 0; + nextTrack(); + el("mute-btn").classList.remove("hidden"); +} +// switch to the boss theme during special waves, resume the playlist after +function updateMusicForWave(s) { + if (!_musicOn) return; + const boss = !!s.hue; + if (boss && !_bossMusic) { _bossMusic = true; playTrack(BOSS_TRACK, true); } + else if (!boss && _bossMusic) { _bossMusic = false; nextTrack(); } +} +el("mute-btn").onclick = () => { + if (bgm.paused) { bgm.play().catch(() => {}); el("mute-btn").textContent = "πŸ”Š"; } + else { bgm.pause(); el("mute-btn").textContent = "πŸ”‡"; } +}; + +// ---- character picker ---------------------------------------------------- +let chosenChar = "warrior"; +function buildCharPicker() { + const wrap = el("char-picker"); + wrap.innerHTML = ""; + for (const id of CHAR_IDS) { + const ch = CHARACTERS[id]; + const div = document.createElement("div"); + div.className = "char-opt" + (id === chosenChar ? " sel" : ""); + div.dataset.id = id; + const c = document.createElement("canvas"); c.width = 56; c.height = 56; + const cc = c.getContext("2d"); cc.imageSmoothingEnabled = false; + div._draw = () => { + cc.clearRect(0, 0, 56, 56); + const img = ch.idle; + if (!img.complete || !img.height) return; + const fs = img.height, frames = Math.max(1, Math.round(img.width / fs)); + const f = Math.floor(performance.now() / 140) % frames; + // normalize preview so each champion shows at the same size + const draw = 48 / ch.fill; + cc.drawImage(img, f * fs + 0.5, 0.5, fs - 1, fs - 1, 28 - draw / 2, 54 - ch.foot * draw, draw, draw); + }; + div.appendChild(c); + const nm = document.createElement("div"); nm.className = "cn"; nm.textContent = ch.label; + div.appendChild(nm); + div.onclick = () => { chosenChar = id; buildCharPicker(); }; + wrap.appendChild(div); + } +} +buildCharPicker(); +// keep previews animating +setInterval(() => document.querySelectorAll(".char-opt").forEach((d) => d._draw && d._draw()), 120); + +el("join-btn").onclick = () => { + const name = el("name-input").value.trim() || "Wizard"; + send({ type: "join", name, char: chosenChar }); + el("join-screen").classList.add("hidden"); + el("hud").classList.remove("hidden"); + el("roster").classList.remove("hidden"); + startMusic(); +}; +el("spectate-btn").onclick = () => { + myRole = "spectator"; + const name = el("name-input").value.trim() || "Wizard"; + send({ type: "spectate", name, char: chosenChar }); + el("join-screen").classList.add("hidden"); + el("roster").classList.remove("hidden"); + startMusic(); + flash("Spectating β€” you'll be rotated in when a wizard falls."); +}; +el("start-btn").onclick = () => send({ type: "start" }); +el("skill-btn").onclick = () => { + const prompt = el("skill-input").value.trim(); + if (!prompt) { el("skill-msg").textContent = "Describe the power-up you want."; return; } + send({ type: "request_skill", prompt }); + el("skill-btn").disabled = true; + el("skill-msg").style.color = "#c9bdf0"; + el("skill-msg").textContent = "πŸ§™ The Game Master is conjuring…"; +}; + +// ---- queue / leaderboard / elimination ----------------------------------- +let lastScore = { gold: 0, level: 1, wave: 0 }; +function showQueue(pos, size) { + const b = el("queue-banner"); + b.classList.remove("hidden"); + b.textContent = `⏳ In queue β€” position ${pos}${size ? " of " + size : ""}. You'll join when a slot opens.`; +} +function renderLeaderboard(top) { + el("lb-list").innerHTML = top.length + ? top.map((e, i) => + `
+ #${i + 1} + ${e.name} + πŸͺ™ ${e.gold} Β· W${e.wave} Β· Lv${e.level} +
`).join("") + : "

No scores yet β€” be the first!

"; +} +el("lb-btn").onclick = () => { + send({ type: "get_leaderboard" }); + // show my current score for submission if I'm in a run + const p = me(); + const score = p ? { gold: p.gold, level: p.level, wave: snap ? snap.round : 0 } : lastScore; + lastScore = score; + if (score.wave > 0 || score.gold > 0) { + el("lb-submit").classList.remove("hidden"); + el("lb-score").textContent = `Your run: πŸͺ™ ${score.gold} Β· Wave ${score.wave} Β· Lv ${score.level}`; + } else el("lb-submit").classList.add("hidden"); + el("leaderboard").classList.remove("hidden"); +}; +el("lb-close").onclick = () => el("leaderboard").classList.add("hidden"); + +// ---- Game Master agent traces panel --------------------------------------- +function renderTraces(traces) { + el("trace-list").innerHTML = traces.length ? traces.map((t) => { + const d = t.decision || {}; + const bless = d.blessings && Object.keys(d.blessings).length + ? `πŸ™ ${Object.values(d.blessings).join(", ")}` : ""; + const pat = (d.next_round && d.next_round.boss_pattern) ? `πŸ—‘ ${d.next_round.boss_pattern}` : ""; + const m = t.mercy || {}; + const mercy = m.applied != null + ? `⏱ atk speed ${m.applied}${m.clamped ? ` (asked ${m.requested}, floor ${m.floor})` : ""}` : ""; + const srcCls = String(t.source || "").startsWith("nemotron") ? "src-llm" : "src-fb"; + return `
+
+ ${t.kind === "skill_request" ? "✨ skill wish" : "wave " + (t.round ?? "?")} + ${t.source} + ${t.latency_sec}s + ${t.ts || ""} +
+ ${d.message ? `
β€œ${d.message}”
` : ""} + ${d.reasoning ? `
${d.reasoning}
` : ""} +
${pat} ${mercy} ${bless}
+ ${t.raw_output ? `
raw model output
${
+        t.raw_output.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" }[c]))
+      }
` : ""} +
`; + }).join("") : "

No decisions yet β€” finish a wave first.

"; +} +el("traces-btn").onclick = async () => { + el("traces").classList.remove("hidden"); + el("trace-list").innerHTML = "

Loading…

"; + try { + const r = await fetch("/traces"); + renderTraces((await r.json()).traces || []); + } catch { el("trace-list").innerHTML = "

Failed to load traces.

"; } +}; +el("traces-close").onclick = () => el("traces").classList.add("hidden"); +function submitScore() { + const name = el("name-input").value.trim() || "Wizard"; + send({ type: "submit_score", name, gold: lastScore.gold, level: lastScore.level, wave: lastScore.wave }); +} +el("lb-submit-btn").onclick = submitScore; +el("go-submit").onclick = () => { submitScore(); send({ type: "get_leaderboard" }); el("gameover").classList.add("hidden"); el("leaderboard").classList.remove("hidden"); }; +el("go-rejoin").onclick = () => { + el("gameover").classList.add("hidden"); + send({ type: "join", name: el("name-input").value.trim() || "Wizard", char: chosenChar }); +}; + +// ---- effect triggers (derived from successive snapshots) ----------------- +const fxState = { mhurt: new Set(), mpos: new Map(), bossHurt: false, + bossAlive: false, phurt: {}, lastCast: {} }; +function attackInterval(p) { return Math.max(0.18, 0.55 - (p.upgrades.attack_speed || 0) * 0.05); } + +function spawnEffectsFromSnapshot(s) { + if (s.status !== "active") { + fxState.mhurt.clear(); fxState.mpos.clear(); + fxState.bossHurt = false; fxState.bossAlive = !!(s.boss && s.boss.hp > 0); + return; + } + const now = performance.now() / 1000; + // minion hits (rising edge of hurt) + deaths (id disappeared from the roster) + const pos = new Map(), hurtNow = new Set(); + for (const m of s.minions) { + pos.set(m.id, [m.x, m.y]); + if (m.hurt) { hurtNow.add(m.id); if (!fxState.mhurt.has(m.id)) spawnFx(FX.smallhit, m.x, m.y - 8, 0.6, 50); } + } + for (const [id, p] of fxState.mpos) + if (!pos.has(id)) spawnFx(FX.death, p[0], p[1] - 8, 0.7, 45); // minion died + fxState.mhurt = hurtNow; fxState.mpos = pos; + + // boss hit + death + if (s.boss) { + if (s.boss.hurt && !fxState.bossHurt) spawnFx(FX.hit, s.boss.x, s.boss.y - 20, 1.0, 50); + fxState.bossHurt = s.boss.hurt; + const aliveNow = s.boss.hp > 0; + if (fxState.bossAlive && !aliveNow) { + for (let i = 0; i < 6; i++) + spawnFx(FX.bossdeath, s.boss.x + (Math.random() - 0.5) * 120, + s.boss.y - 20 + (Math.random() - 0.5) * 120, 1.4, 40); + } + fxState.bossAlive = aliveNow; + } + + // player hurt + cast muzzle + for (const p of s.players) { + const wasHurt = fxState.phurt[p.id]; + if (p.hurt && !wasHurt) spawnFx(FX.smallhit, p.x, p.y - 10, 0.7, 50); + fxState.phurt[p.id] = p.hurt; + if (p.alive && p.attacking) { + const last = fxState.lastCast[p.id] || 0; + if (now - last >= attackInterval(p)) { + fxState.lastCast[p.id] = now; + spawnFx(FX.cast, p.x + Math.cos(p.facing) * 26, p.y + Math.sin(p.facing) * 26 - 6, 0.5, 60); + } + } + } +} + +let _prevStatus = null, _prevTheme = 0, _lastFlash = ""; +function onSnapshot() { + const s = snap; + // reset the skill-request UI each time a fresh intermission begins + if (s.status === "intermission" && _prevStatus !== "intermission") { + el("skill-msg").textContent = ""; el("skill-input").value = ""; el("skill-btn").disabled = false; + } + // announce when the enemy faction changes (every 5 waves) + const th = s.theme || 0; + if (th !== _prevTheme && s.status === "active") { + const prev = THEMES[_prevTheme % THEMES.length], next = THEMES[th % THEMES.length]; + if (th > _prevTheme) flash(`βš” The ${prev.name} is defeated β€” the ${next.name} rises!`); + _prevTheme = th; + } + _prevStatus = s.status; + // toast when I claim a floor power-up + const meP = me(); + if (meP && meP.flash && meP.flash !== _lastFlash) { flash("✨ " + meP.flash); _lastFlash = meP.flash; } + else if (meP && !meP.flash) _lastFlash = ""; + // server-driven skill / blessing effects (one-shot events) + for (const ev of (s.events || [])) playEvent(ev); + updateMusicForWave(s); // boss theme during special waves + spawnEffectsFromSnapshot(s); + updateHud(s); + updateRoster(s); + // overlays driven by game status + const lobby = el("lobby-start"), shop = el("shop"); + if (s.status === "lobby" && myRole === "player") { + lobby.classList.remove("hidden"); shop.classList.add("hidden"); + el("lobby-msg").textContent = s.status_msg; + } else if (s.status === "intermission") { + lobby.classList.add("hidden"); + shop.classList.remove("hidden"); + renderShop(s); + } else { + lobby.classList.add("hidden"); shop.classList.add("hidden"); + } + // level-up card pick takes priority (can occur mid-wave) + renderLevelUp(s); +} + +let lastCardSig = ""; +function renderLevelUp(s) { + const p = me(); + const pending = (p && p.pending_cards) || []; + const overlay = el("levelup"); + if (!pending.length) { overlay.classList.add("hidden"); lastCardSig = ""; return; } + // live auto-pick countdown + el("lu-timer").textContent = (p.pending_left != null && p.pending_left > 0) + ? `auto in ${Math.ceil(p.pending_left)}s` : ""; + const sig = p.id + ":" + pending.join(","); + if (sig === lastCardSig) return; // avoid rebuilding (preserves hover) + lastCardSig = sig; + overlay.classList.remove("hidden"); + el("card-list").innerHTML = pending.map((id, n) => { + const c = s.cards[id] || { label: id, desc: "", rarity: "common" }; + const lvl = (p.upgrades[id] || 0); + return `
+
${n + 1}
+
${c.label}
+
${c.desc}
+
${c.rarity}
+
${lvl > 0 ? "owned Γ—" + lvl : "new"}
+
`; + }).join(""); + el("card-list").querySelectorAll(".card").forEach((b) => + b.onclick = () => { send({ type: "choose_card", key: b.dataset.key }); lastCardSig = ""; }); +} + +function me() { return snap ? snap.players.find((p) => p.id === myPid) : null; } + +function updateHud(s) { + el("round-info").textContent = `Wave ${s.round} Β· ${s.status}`; + const p = me(); + const xpbar = el("xp-bar"), hearts = el("hearts"); + if (p) { + el("self-info").innerHTML = + `❀ ${Math.ceil(p.hp)}/${p.max_hp}` + + (p.shield > 0 ? ` πŸ›‘${Math.ceil(p.shield)}` : "") + + `   πŸͺ™ ${p.gold}   Lv.${p.level}` + + (p.alive ? "" : "   DOWN"); + xpbar.style.display = ""; + const frac = p.xp_needed ? p.xp / p.xp_needed : 0; + el("xp-fill").style.width = Math.min(100, frac * 100) + "%"; + el("xp-text").textContent = `XP ${p.xp}/${p.xp_needed}`; + // hearts (lives) + hearts.classList.remove("hidden"); + const lives = p.lives != null ? p.lives : 3; + hearts.innerHTML = "❀️".repeat(Math.max(0, lives)) + + "" + "πŸ–€".repeat(Math.max(0, 3 - lives)) + ""; + // aura chips + el("aura-bar").innerHTML = (p.auras || []).map((a) => + `${a.label} ${Math.ceil(a.t)}s`).join(""); + } else { + el("self-info").textContent = myRole === "spectator" ? "πŸ‘ spectating" : ""; + xpbar.style.display = "none"; + hearts.classList.add("hidden"); + el("aura-bar").innerHTML = ""; + } +} + +function updateRoster(s) { + let html = `

Arena ${s.players.length}/${s.max_players}

`; + s.players.forEach((p, i) => { + const c = PALETTE[i % PALETTE.length]; + html += `
${p.alive ? "" : "πŸ’€ "}${p.name}` + + `${p.gold}
`; + }); + if (s.gm_message) html += `
πŸ§™ ${s.gm_message}
`; + el("roster").innerHTML = html; +} + +function renderShop(s) { + el("shop-title").textContent = s.status_msg.startsWith("Victory") ? "Wave Cleared!" : "Wave Over"; + el("gm-line").textContent = s.gm_message ? `πŸ§™ ${s.gm_message}` : "πŸ§™ The Game Master is directing…"; + // skill request box β€” only for this wave's top damage dealer + const sr = s.skill_request; + const box = el("skill-box"); + if (sr && sr.pid && sr.pid === myPid) { + box.classList.remove("hidden"); + el("skill-btn").disabled = sr.used; + if (sr.used && !el("skill-msg").textContent) el("skill-msg").textContent = "Power-up already requested this wave."; + } else { + box.classList.add("hidden"); + } + el("shop-timer").textContent = s.intermission_left + ? `Next wave in ${s.intermission_left}s` : "Summoning the next wave…"; + // Show the card pool the Director has curated for the coming wave. + const pool = s.card_pool || []; + el("shop-gold").innerHTML = pool.length + ? `Level up by collecting XP gems to draw from the Director's pool:` + : ""; + el("upgrade-list").innerHTML = pool.map((id) => { + const c = (s.cards && s.cards[id]) || { label: id, rarity: "common" }; + return `
${c.label}` + + `${c.rarity}
`; + }).join(""); +} + +// ---- rendering ----------------------------------------------------------- +function drawScenery(d, x, y, s) { + const img = d.img; + if (!img.complete || !img.height) return; + const fw = d.fw, fh = d.fh, dw = fw * s, dh = fh * s; + // static frame 0; inset the source rect ~0.5px so the neighbouring frame + // on the strip can't bleed into the edges when downscaled. + ctx.drawImage(img, 0.5, 0.5, fw - 1, fh - 1, x - dw / 2, y - dh, dw, dh); // anchored at base +} + +// Draw a Tiny Swords building anchored at its base (frame 0 of any strip). +function drawBuilding(img, x, y, s) { + if (!img.complete || !img.height) return; + const dw = img.width * s, dh = img.height * s; + ctx.drawImage(img, x - dw / 2, y - dh, dw, dh); +} + +function drawBackground(arena, themeIdx) { + const th = THEMES[themeIdx % THEMES.length]; + if (TILEMAP.complete && TILEMAP.height) { + // one continuous grass landmass: the single seamless interior tile (1,1), + // tiled across the whole arena (covers the partial bottom row too). + for (let y = 0; y < arena.h; y += TILE) + for (let x = 0; x < arena.w; x += TILE) + ctx.drawImage(TILEMAP, TILE, TILE, TILE, TILE, x, y, TILE, TILE); + } else { + ctx.fillStyle = "#5a8f3a"; ctx.fillRect(0, 0, arena.w, arena.h); + } + // theme ground tint β€” each boss's territory has its own cast + ctx.fillStyle = th.tint; + ctx.fillRect(0, 0, arena.w, arena.h); + // theme landmarks (Tiny Swords buildings), then seeded scenery + // (purely cosmetic β€” no effect on the server simulation) + for (const L of th.landmarks) drawBuilding(BUILDINGS[L.b], L.x, L.y, L.s); + for (const d of sceneryFor(themeIdx)) drawScenery(DECO[d.k], d.x, d.y, d.s); + // central arena ring where the boss stands (ring color follows the theme) + ctx.save(); + ctx.strokeStyle = "rgba(40,28,60,.5)"; ctx.lineWidth = 8; + ctx.beginPath(); ctx.arc(arena.w / 2, arena.h / 2, 90, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = th.ring; ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(arena.w / 2, arena.h / 2, 90, 0, Math.PI * 2); ctx.stroke(); + ctx.restore(); +} + +function hpBar(x, y, w, frac, color) { + ctx.fillStyle = "rgba(0,0,0,.6)"; ctx.fillRect(x - w / 2, y, w, 6); + ctx.fillStyle = color; ctx.fillRect(x - w / 2, y, w * Math.max(0, frac), 6); +} + +function render() { + requestAnimationFrame(render); + if (!snap) return; + const s = snap; + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawBackground(s.arena, s.theme || 0); + + // floor power-up pickups (rare minion drops) + for (const pk of (s.pickups || [])) { + const pulse = 0.6 + 0.4 * Math.sin(performance.now() / 300); + if (pk.kind === "aura") { + // timed aura: pulsing colored orb wreathed in a retro effect + drawRetro(pk.sheet || "a", pk.x, pk.y, 64, 18); + ctx.save(); ctx.translate(pk.x, pk.y); + ctx.shadowColor = pk.color || "#fff"; ctx.shadowBlur = 18 * pulse; + ctx.fillStyle = pk.color || "#fff"; + ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); ctx.shadowBlur = 0; + continue; + } + ctx.save(); + ctx.translate(pk.x, pk.y); + ctx.shadowColor = "#ffd45e"; ctx.shadowBlur = 16 * pulse; + ctx.fillStyle = "#fff3a0"; + ctx.beginPath(); + for (let i = 0; i < 10; i++) { + const ang = -Math.PI / 2 + i * Math.PI / 5; + const rad = i % 2 === 0 ? 11 : 5; + ctx[i ? "lineTo" : "moveTo"](Math.cos(ang) * rad, Math.sin(ang) * rad); + } + ctx.closePath(); ctx.fill(); + ctx.restore(); ctx.shadowBlur = 0; + } + + // XP gems (under everything else) β€” color & size by tier (1..7) + for (const g of (s.gems || [])) { + const tier = g.t || 1; + ctx.save(); + ctx.translate(g.x, g.y); + ctx.rotate(Math.PI / 4); + ctx.fillStyle = GEM_COLORS[tier - 1] || "#6effd0"; + ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = 8 + tier; + const r = 3 + tier * 0.9; + ctx.fillRect(-r, -r, r * 2, r * 2); + ctx.restore(); + ctx.shadowBlur = 0; + } + + // projectiles + for (const pr of s.projectiles) { + if (pr.s === "holy") { drawHoly(pr.x, pr.y, pr.r * 4.5, 0, 24); continue; } + if (pr.s === "glitch") { drawGlitch(pr.x, pr.y, pr.r * 4, 24); continue; } + ctx.beginPath(); + ctx.fillStyle = pr.hostile ? "#ff5e7a" : "#7ee0ff"; + ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = 12; + ctx.arc(pr.x, pr.y, pr.r, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + } + + // minions (archetype: scale + a colored ground ring to distinguish types) + const theme = themeOf(s); + const KIND_RING = { fast: "#6effd0", tank: "#ff6e6e" }; + for (const m of s.minions) { + const ms = m.scale || 1; + const h = 116 * ms; // on-screen height + const ring = KIND_RING[m.kind]; + if (ring) { + ctx.beginPath(); ctx.strokeStyle = ring; ctx.globalAlpha = 0.6; ctx.lineWidth = 2; + ctx.ellipse(m.x, m.y + 24 * ms, 26 * ms, 10 * ms, 0, 0, Math.PI * 2); + ctx.stroke(); ctx.globalAlpha = 1; + } + const faceLeft = (m.x % 2) < 1 ? false : false; // minions face target; keep upright + drawUnitH(theme.minion, m.x, m.y - 14, h, false); + if (m.hurt) { ctx.fillStyle = "rgba(255,80,80,.3)"; ctx.beginPath(); + ctx.arc(m.x, m.y - 8, h * 0.4, 0, Math.PI * 2); ctx.fill(); } + hpBar(m.x, m.y - 62 * ms, 48 * ms, m.hp / m.max_hp, "#ff7a4a"); + } + + // allies (summoned spirits) β€” tinted soldier sprites + for (const a of (s.allies || [])) { + ctx.save(); + ctx.globalAlpha = 0.9; + drawSprite(SPR.player.idle, a.x, a.y - 4, 0.6, false); + ctx.restore(); + // colored aura ring + ctx.beginPath(); ctx.strokeStyle = a.color || "#a6ff8c"; ctx.globalAlpha = 0.7; ctx.lineWidth = 2; + ctx.ellipse(a.x, a.y + 14, 16, 6, 0, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1; + if (a.hp < a.max_hp) hpBar(a.x, a.y - 38, 26, a.hp / a.max_hp, a.color || "#a6ff8c"); + } + + // boss β€” themed unit, larger, with charge telegraph + if (s.boss) { + const b = s.boss; + const H = 360; // boss on-screen height + // telegraph: pulsing red warning ring before a charge + if (b.state === "telegraph") { + const pulse = 0.5 + 0.5 * Math.sin(performance.now() / 60); + ctx.beginPath(); ctx.strokeStyle = `rgba(255,60,60,${0.4 + pulse * 0.5})`; + ctx.lineWidth = 6; ctx.arc(b.x, b.y - 30, 90, 0, Math.PI * 2); ctx.stroke(); + } + if (b.state === "charge") { // motion streak + ctx.fillStyle = "rgba(255,80,80,.18)"; + ctx.beginPath(); ctx.arc(b.x, b.y - 30, 80, 0, Math.PI * 2); ctx.fill(); + } + // special bosses use their own sprite; otherwise the themed enemy unit + if (b.special && BOSS_SPRITES[b.special]) { + const bh = b.special === "toaster" ? 300 : 380; // toaster is chunky-upscaled + const aura = b.special === "aegis" ? "rgba(255,60,90,.5)" : "rgba(60,255,120,.5)"; + ctx.save(); ctx.shadowColor = aura; ctx.shadowBlur = 30; + drawUnitH(BOSS_SPRITES[b.special], b.x, b.y - 50, bh, false, b.special === "toaster" ? 4 : 10); + ctx.restore(); + } else { + drawUnitH(theme.boss, b.x, b.y - 60, H, false, 7); + } + if (b.hurt) { ctx.fillStyle = "rgba(255,80,80,.22)"; ctx.beginPath(); + ctx.arc(b.x, b.y - 50, H * 0.42, 0, Math.PI * 2); ctx.fill(); } + let label = (b.name || theme.name).toUpperCase(); + if (b.pattern_label) label += " Β· " + b.pattern_label; // GM-chosen attack pattern + hpBar(b.x, b.y - 150, 260, b.hp / b.max_hp, b.special === "aegis" ? "#ff3030" : b.special === "toaster" ? "#5CFC30" : "#ff4a6a"); + ctx.fillStyle = "#ffe0e8"; ctx.font = "bold 16px sans-serif"; ctx.textAlign = "center"; + ctx.fillText(`${label} ${Math.ceil(b.hp)}/${b.max_hp}` + + (b.phase === 2 ? " ⚑ENRAGED" : ""), b.x, b.y - 158); + } + + // players + s.players.forEach((p, i) => { + const color = PALETTE[i % PALETTE.length]; + const faceLeft = Math.cos(p.facing) < 0; + // size is server-authoritative (hitbox grows with it): wizards start + // minion-sized and cap out a little larger than the boss + const psc = Math.min(4.6, 1.55 * (p.size || 1)); + const top = 66 * psc; // bar/name sit just above the character's head + // active timed auras: a retro effect wreathing the wizard + for (const au of (p.auras || [])) { + ctx.globalAlpha = 0.85; + drawRetro(RETRO_SHEET[au.type] || "a", p.x, p.y - 10 * psc, 120 * psc, 16); + ctx.globalAlpha = 1; + } + if (p.id === myPid) { + ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2; + ctx.arc(p.x, p.y + 26 * psc, 26 * psc, 0, Math.PI * 2); ctx.stroke(); + } + ctx.globalAlpha = p.alive ? 1 : 0.4; + // every champion renders at the same content height (66px) * level scale + const ch = CHARACTERS[p.char] || CHARACTERS.warrior; + drawChar(ch, p.x, p.y + 6, 66 * psc, p.moving, faceLeft); + ctx.globalAlpha = 1; + if (p.alive) hpBar(p.x, p.y - top, 52, p.hp / p.max_hp, color); + ctx.fillStyle = color; ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center"; + ctx.fillText(p.name + " Lv" + p.level, p.x, p.y - top - 8); + }); + + // spell / impact / death effects on top + drawEffects(); + + // special-boss screen tint (pulsing) over everything + if (s.hue && HUE_RGBA[s.hue]) { + const pulse = 0.75 + 0.25 * Math.sin(performance.now() / 400); + ctx.save(); ctx.globalAlpha = pulse; + ctx.fillStyle = HUE_RGBA[s.hue]; + ctx.fillRect(0, 0, s.arena.w, s.arena.h); + ctx.restore(); + } +} + +connect(); +requestAnimationFrame(render); diff --git a/HuggingWizards-upload/static/index.html b/HuggingWizards-upload/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..4ed0319f00d939826674e0fbaedfe672773e4c78 --- /dev/null +++ b/HuggingWizards-upload/static/index.html @@ -0,0 +1,129 @@ + + + + + + πŸ§™ HuggingWizards + + + +
+ + + +
+ + +
+ + + + + + + + + + + + + + +
+
+

πŸ§™ HuggingWizards

+

Co-op pixel arena Β· Nemotron-4B is the Game Master

+ +

Choose your champion:

+
+
+ + +
+

Move: W A S D Β· Attack: Space (auto-aim)

+

Open Game Master dashboard β†—

+
+
+ + + + + + + + + + + + + + + + +
connecting…
+ +
+ + +
+
+ + + + diff --git a/HuggingWizards-upload/static/style.css b/HuggingWizards-upload/static/style.css new file mode 100644 index 0000000000000000000000000000000000000000..e0ff591e4aa5c2c770eeba8c3c47ff68e6054490 --- /dev/null +++ b/HuggingWizards-upload/static/style.css @@ -0,0 +1,240 @@ +:root { + --bg: #1a1230; + --panel: #2a1f4a; + --accent: #b48cff; + --accent2: #7ee0ff; + --gold: #ffd45e; +} +* { box-sizing: border-box; } +html, body { + margin: 0; height: 100%; + background: #0d0a1a; + color: #eee; + font-family: "Trebuchet MS", system-ui, sans-serif; + overflow: hidden; +} +#game-wrap { + position: relative; + width: 100vw; height: 100vh; + display: flex; align-items: center; justify-content: center; +} +#game { + width: min(100vw, 177.7vh); + height: min(56.25vw, 100vh); + image-rendering: pixelated; + background: #14102a; + box-shadow: 0 0 40px rgba(0,0,0,.6); +} +.hidden { display: none !important; } + +/* Overlays */ +.overlay { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + background: rgba(8, 5, 20, .72); + z-index: 10; +} +.panel { + background: var(--panel); + border: 3px solid var(--accent); + border-radius: 14px; + padding: 28px 34px; + text-align: center; + max-width: 90%; + box-shadow: 0 8px 40px rgba(0,0,0,.6); +} +.panel.wide { min-width: min(560px, 90vw); } +h1 { margin: 0 0 6px; color: var(--accent); letter-spacing: 1px; } +h2 { margin: 0 0 10px; color: var(--accent); } +.tag { margin: 0 0 18px; color: #c9bdf0; } +.hint { font-size: 13px; color: #9d92c0; margin: 8px 0 0; } +.hint a, a { color: var(--accent2); } +.gm { font-style: italic; color: var(--accent2); min-height: 1.2em; } + +input { + width: 100%; padding: 10px 12px; margin: 6px 0 16px; + border-radius: 8px; border: 2px solid var(--accent); + background: #1c1536; color: #fff; font-size: 16px; text-align: center; +} +.btn-row { display: flex; gap: 10px; justify-content: center; } +button { + background: var(--accent); color: #1a1230; border: none; + padding: 10px 18px; border-radius: 8px; font-weight: bold; + font-size: 15px; cursor: pointer; transition: transform .08s; +} +button:hover { transform: translateY(-2px); } +button:disabled { opacity: .45; cursor: not-allowed; transform: none; } +button.ghost { background: transparent; color: var(--accent); border: 2px solid var(--accent); } + +/* Upgrade shop β€” card pool shown as compact wrapping chips (scrolls if huge) */ +#upgrade-list { + display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; + margin: 12px 0; max-height: 34vh; overflow-y: auto; padding: 4px; +} +.upg { + display: flex; gap: 6px; align-items: center; + background: #1c1536; border: 1px solid #4a3a78; border-radius: 7px; padding: 4px 9px; + font-size: 12px; +} +.upg .lvl { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; } +#shop-gold { color: var(--gold); font-weight: bold; } + +/* Skill request (top wizard asks the LLM) */ +#skill-box { + margin: 10px 0; padding: 12px; background: #1c1536; + border: 2px solid var(--gold); border-radius: 10px; +} +.skill-prompt { color: var(--gold); margin: 0 0 8px; font-weight: bold; font-size: 14px; } +.skill-row { display: flex; gap: 8px; } +#skill-input { margin: 0; flex: 1; text-align: left; font-size: 14px; padding: 8px 10px; } +#skill-btn { padding: 8px 16px; white-space: nowrap; } +#skill-msg { margin: 8px 0 0; min-height: 1.1em; } + +/* HUD */ +#hud { + position: absolute; top: 14px; left: 18px; z-index: 8; + text-shadow: 0 2px 4px #000; font-size: 15px; pointer-events: none; +} +#round-info { color: var(--accent); font-weight: bold; font-size: 18px; } +#self-info { margin-top: 4px; } +#xp-bar { + position: relative; width: 200px; height: 12px; margin-top: 6px; + background: rgba(10,8,22,.7); border: 1px solid #4a3a78; border-radius: 6px; + overflow: hidden; +} +#xp-fill { height: 100%; width: 0%; background: linear-gradient(90deg,#7ee0ff,#b48cff); transition: width .15s; } +#xp-text { position: absolute; inset: 0; text-align: center; font-size: 10px; line-height: 12px; color: #fff; } + +/* Level-up cards β€” docked as a non-blocking bottom bar */ +#levelup { + position: absolute; left: 0; right: 0; bottom: 0; z-index: 12; + display: flex; align-items: stretch; gap: 12px; padding: 8px 14px; + background: linear-gradient(0deg, rgba(8,5,20,.94), rgba(8,5,20,.55)); + border-top: 2px solid var(--accent); +} +#levelup .lu-label { + display: flex; flex-direction: column; justify-content: center; + color: var(--accent); font-size: 15px; line-height: 1.2; white-space: nowrap; + text-shadow: 0 2px 4px #000; +} +#levelup .lu-label span { font-size: 11px; color: #9d92c0; } +#card-list { display: flex; gap: 10px; flex: 1; justify-content: center; flex-wrap: wrap; } +.card { + width: 160px; background: #1c1536; border: 2px solid #4a3a78; border-radius: 10px; + padding: 8px 12px; cursor: pointer; transition: transform .1s, border-color .1s; + text-align: center; +} +.card:hover { transform: translateY(-3px); border-color: var(--accent2); } +.card { position: relative; } +.card .ckey { + position: absolute; top: -8px; left: -8px; width: 22px; height: 22px; + background: var(--accent2); color: #1a1230; border-radius: 50%; + font-weight: bold; font-size: 13px; line-height: 22px; text-align: center; + box-shadow: 0 2px 6px rgba(0,0,0,.5); +} +.card .cname { font-weight: bold; color: var(--accent); font-size: 15px; margin-bottom: 3px; } +.card .cdesc { font-size: 12px; color: #c9bdf0; min-height: 2.2em; } +.card .crarity { margin-top: 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; } +.card .lvltag { font-size: 10px; color: #9d92c0; margin-top: 2px; } +.rarity-common { color: #b9c4d0; } +.rarity-uncommon { color: #6effd0; } +.rarity-rare { color: #ffd45e; } +#roster { + position: absolute; top: 14px; right: 18px; z-index: 8; + background: rgba(20,16,42,.7); border: 2px solid #4a3a78; border-radius: 8px; + padding: 8px 12px; font-size: 13px; min-width: 150px; text-shadow: 0 1px 2px #000; +} +#roster h4 { margin: 0 0 6px; color: var(--accent); } +#roster .row { display: flex; justify-content: space-between; gap: 12px; } +#roster .gold { color: var(--gold); } +#conn-status { + position: absolute; bottom: 10px; left: 12px; z-index: 8; + font-size: 12px; color: #8a7fb0; +} +#bottom-bar { + position: absolute; bottom: 10px; right: 12px; z-index: 9; + display: flex; gap: 8px; align-items: center; +} +#mute-btn, #traces-btn { + background: rgba(20,16,42,.7); border: 2px solid #4a3a78; color: #fff; + padding: 6px 10px; font-size: 16px; border-radius: 8px; +} +#traces-btn { font-size: 13px; color: var(--accent2); } +#mute-btn:hover, #traces-btn:hover { transform: none; border-color: var(--accent); } + +/* Agent traces panel */ +#trace-list { max-height: 56vh; overflow-y: auto; text-align: left; margin: 10px 0; } +.trace { + background: #1c1536; border: 1px solid #4a3a78; border-radius: 8px; + padding: 8px 12px; margin-bottom: 8px; font-size: 13px; +} +.t-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; } +.t-round { color: var(--accent); font-weight: bold; } +.t-src { font-size: 11px; padding: 1px 7px; border-radius: 8px; border: 1px solid; } +.src-llm { color: #a6ff8c; border-color: #a6ff8c; } +.src-fb { color: #ffd45e; border-color: #ffd45e; } +.t-lat, .t-ts { color: #9d92c0; font-size: 11px; } +.t-msg { color: var(--accent2); font-style: italic; margin-top: 4px; } +.t-why { color: #c9bdf0; font-size: 12px; margin-top: 2px; } +.t-tags { color: var(--gold); font-size: 12px; margin-top: 3px; } +.trace details { margin-top: 5px; } +.trace summary { cursor: pointer; color: #9d92c0; font-size: 11px; } +.trace pre { + white-space: pre-wrap; word-break: break-word; font-size: 11px; + background: #14102a; padding: 6px 8px; border-radius: 6px; max-height: 160px; overflow-y: auto; +} +#toast { + position: absolute; top: 70px; left: 50%; transform: translateX(-50%); z-index: 11; + background: rgba(20,16,42,.92); border: 2px solid var(--accent); color: #fff; + padding: 8px 16px; border-radius: 8px; font-size: 14px; max-width: 80%; + text-align: center; box-shadow: 0 4px 20px rgba(0,0,0,.5); +} + +/* Top bar: hearts + leaderboard button */ +#topbar { + position: absolute; top: 12px; left: 50%; transform: translateX(-50%); z-index: 9; + display: flex; align-items: center; gap: 14px; +} +#hearts { font-size: 22px; letter-spacing: 2px; text-shadow: 0 2px 4px #000; } +#lb-btn { + background: rgba(20,16,42,.8); color: var(--gold); border: 2px solid #4a3a78; + padding: 6px 12px; font-size: 13px; border-radius: 8px; +} +#lb-btn:hover { border-color: var(--gold); transform: none; } + +/* Aura HUD chips */ +#aura-bar { display: flex; gap: 6px; margin-top: 8px; } +.aura-chip { + font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: bold; + background: rgba(10,8,22,.7); border: 1px solid; text-shadow: 0 1px 2px #000; +} + +/* Queue banner */ +#queue-banner { + position: absolute; top: 56px; left: 50%; transform: translateX(-50%); z-index: 9; + background: rgba(20,16,42,.9); border: 2px solid var(--accent2); color: #fff; + padding: 8px 18px; border-radius: 10px; font-size: 15px; text-align: center; +} + +/* Character picker */ +.pick-label { margin: 4px 0 8px; color: #c9bdf0; font-size: 14px; } +#char-picker { display: flex; gap: 8px; justify-content: center; margin-bottom: 16px; flex-wrap: wrap; } +.char-opt { + width: 84px; padding: 8px 4px; background: #1c1536; border: 2px solid #4a3a78; + border-radius: 10px; cursor: pointer; text-align: center; transition: border-color .1s, transform .1s; +} +.char-opt:hover { transform: translateY(-2px); } +.char-opt.sel { border-color: var(--accent2); box-shadow: 0 0 12px rgba(126,224,255,.4); } +.char-opt canvas { image-rendering: pixelated; width: 56px; height: 56px; } +.char-opt .cn { font-size: 12px; color: #c9bdf0; margin-top: 2px; } + +/* Leaderboard */ +#lb-list { margin: 12px 0; max-height: 50vh; overflow-y: auto; } +.lb-row { + display: flex; justify-content: space-between; gap: 12px; padding: 6px 10px; + border-bottom: 1px solid #33285a; font-size: 14px; +} +.lb-row.me { background: rgba(126,224,255,.1); } +.lb-row .rank { color: var(--gold); font-weight: bold; width: 28px; } +.lb-row .nm { flex: 1; text-align: left; } +#lu-timer { color: var(--gold); font-weight: bold; } diff --git a/HuggingWizards-upload/traces/.gitkeep b/HuggingWizards-upload/traces/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..061477bc1c49a3769a045cc12648e41b1c9154cc --- /dev/null +++ b/HuggingWizards-upload/traces/.gitkeep @@ -0,0 +1 @@ +# generated agent traces land here