Sathvik0101 commited on
Commit
8ee4863
·
verified ·
1 Parent(s): 460851a

Deploy exact mirror from Sathvik0101/cyberpunk-duel-ai (Gemma 3 4B)

Browse files
3d_scene.html ADDED
The diff for this file is too large to render. See raw diff
 
Dockerfile CHANGED
@@ -2,10 +2,14 @@ FROM pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime
2
 
3
  WORKDIR /app
4
 
 
5
  COPY requirements.txt .
6
  RUN pip install --no-cache-dir -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cu121
7
 
 
8
  COPY app.py .
 
 
9
 
10
  ENV PORT=7860
11
  EXPOSE 7860
 
2
 
3
  WORKDIR /app
4
 
5
+ # Install dependencies
6
  COPY requirements.txt .
7
  RUN pip install --no-cache-dir -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cu121
8
 
9
+ # Copy app files
10
  COPY app.py .
11
+ COPY 3d_scene.html .
12
+ COPY three.min.js .
13
 
14
  ENV PORT=7860
15
  EXPOSE 7860
README.md CHANGED
@@ -1,85 +1,16 @@
1
  ---
2
- title: Cyber Duel Tiny
3
- emoji:
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: docker
7
- hardware: a10g-small
8
- app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- # Cyber Duel Tiny
13
 
14
- A 270M-parameter combat advisor that replaces the Gemma 3 4B base model
15
- in [Duel of Albion](https://huggingface.co/spaces/Sathvik0101/cyberpunk-duel-ai).
16
 
17
- ## What it does
18
-
19
- Given the player's last 5 moves, the model recommends a counter-move from
20
- the 9 legal options (jab, cross, low_kick, roundhouse, uppercut, parry,
21
- backstep, clinch, throw), conditioned on both fighters' stats
22
- (speed, power, range, weight, stance), stamina, distance, and round.
23
-
24
- ## API
25
-
26
- `POST /predict` — counter-move recommendation.
27
-
28
- ```http
29
- POST /predict
30
- Content-Type: application/json
31
-
32
- {
33
- "sequence": "jab,cross,low_kick,jab,cross",
34
- "player": {"name": "monk", "speed": 5, "power": 2, "range": 3, "weight": 0.8, "stance": "low", "stamina": 100, "hp": 100},
35
- "npc": {"name": "brute","speed": 1, "power": 5, "range": 2, "weight": 1.4, "stance": "hunched", "stamina": 100, "hp": 100},
36
- "round": 3,
37
- "distance": "close",
38
- "playerId": "ab12-...", // optional, enables online RL
39
- "playerPrevMove": "jab" // optional, back-fills the previous log row
40
- }
41
- ```
42
-
43
- ```json
44
- {
45
- "reasoning": "The player is alternating jab and cross before finishing low...",
46
- "counterMove": "throw",
47
- "sequence": "jab,cross,low_kick,jab,cross",
48
- "adapterScope": "user" // "user" once you have a personalised adapter
49
- }
50
- ```
51
-
52
- `GET /health` — `{ready, has_token, online_rl_enabled, user_adapters_cached, buffered_users}`
53
- `GET /me?playerId=...` — `{rounds_logged, next_retrain_in, cooldown_left_sec, adapter_scope, online_rl_enabled}`
54
- `POST /forget` body `{"playerId": "..."}` — deletes the user's adapter + log (privacy / GDPR).
55
-
56
- ### Online RL
57
-
58
- If the request includes a `playerId` and the Space was started with the
59
- `MODAL_WEBHOOK_URL` and `MODAL_WEBHOOK_SECRET` env vars set, the Space
60
- will:
61
-
62
- 1. Log `(state, model_move, player_next_move)` to the
63
- `cyber-duel-tiny-logs` Hugging Face dataset (private).
64
- 2. After 25 fresh rows have been flushed, POST to the Modal webhook to
65
- trigger a per-user DPO retrain.
66
- 3. The new LoRA delta is uploaded to `cyber-duel-tiny-users/<uid>/` and
67
- loaded on the next `/predict` for that player.
68
-
69
- The default base adapter (`Sathvik0101/cyber-duel-tiny-adapter`) is used
70
- as the starting point for every per-user delta, and the global base
71
- gets refreshed weekly by Modal's `retrain_global_base` job.
72
-
73
- ## Training
74
-
75
- Trained on Modal with LoRA + DPO (verifiable rewards from the in-game
76
- combat resolver). See `modal/app.py` in the [training repo](https://huggingface.co/Sathvik0101/cyber-duel-tiny-adapter).
77
-
78
- ## How to redeploy
79
-
80
- 1. Add `HF_TOKEN` as a Space Secret so the gated `gemma-3-270m-it` weights can be downloaded.
81
- 2. (Optional) Add `MODAL_WEBHOOK_URL` and `MODAL_WEBHOOK_SECRET` to enable
82
- online per-user RL.
83
- 3. Update `ADAPTER_MODEL` env var to point to the latest adapter release.
84
- 4. The Space will hot-reload on push (you may need a manual restart to
85
- pick up the new code if the env vars change).
 
1
  ---
2
+ title: Duel of Albion
3
+ emoji: ⚔️
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: docker
 
 
7
  pinned: false
8
  ---
9
 
10
+ # Duel of Albion
11
 
12
+ A 3D AI-powered cyberpunk fighting game built with FastAPI.
13
+ The AI opponent uses a fine-tuned Gemma 3 4B model to counter your moves in real time.
14
 
15
+ ## Setup
16
+ Add your HF_TOKEN as a Secret in Space settings so the gated base model (google/gemma-3-4b-it) can be downloaded. The fine-tuned adapter is public at Sathvik0101/gemma-3-combat-npc-adapter.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,696 +1,392 @@
1
- """Cyber Duel Tiny -- FastAPI service for the HF Space.
2
-
3
- Drop-in replacement for the 4B Gemma advisor, with optional per-user
4
- online RL: each /predict can carry a `playerId` (UUID minted by the
5
- client), and the Space will lazily load that user's LoRA delta adapter
6
- from the `cyber-duel-tiny-users` Hub repo, log the (state, model_move,
7
- player_next_move) triple to the `cyber-duel-tiny-logs` dataset repo, and
8
- trigger a Modal retrain job when the user has accumulated enough new
9
- clean pairs. Clients without `playerId` keep using the frozen global
10
- adapter exactly as before.
11
-
12
- API
13
- ---
14
- POST /predict
15
- Legacy form (still supported, no RL):
16
- {"sequence": "jab,cross,low_kick,roundhouse,uppercut"}
17
- Full state form (recommended):
18
- {
19
- "sequence": "jab,cross,low_kick,roundhouse,uppercut",
20
- "player": {...}, "npc": {...},
21
- "round": 3, "distance": "close",
22
- "playerId": "ab12-...", # optional
23
- "playerPrevMove": "jab", # optional, required for online RL
24
- }
25
-
26
- GET /health # {ready, has_token, online_rl_enabled}
27
- GET /me?playerId=... # {rounds_logged, retrains_done, ...}
28
- POST /forget {"playerId": "..."} # delete user's adapter + log
29
- """
30
- import hashlib
31
- import hmac
32
- import json
33
- import logging
34
- import os
35
- import re
36
- import secrets
37
- import threading
38
- import time
39
- from collections import OrderedDict, defaultdict
40
- from pathlib import Path
41
- from typing import Any, Dict, List, Optional, Tuple
42
-
43
- import torch
44
- from transformers import AutoTokenizer, AutoModelForCausalLM, AutoConfig
45
- from peft import PeftModel
46
- from huggingface_hub import (
47
- HfApi, snapshot_download, hf_hub_download, create_repo,
48
- )
49
-
50
- from fastapi import FastAPI, Request
51
- from fastapi.middleware.cors import CORSMiddleware
52
- from fastapi.responses import JSONResponse
53
- import gradio as gr
54
-
55
- logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"),
56
- format="%(asctime)s | %(levelname)s | %(message)s")
57
- log = logging.getLogger("cyber-duel-tiny")
58
-
59
- BASE_MODEL = os.environ.get("BASE_MODEL", "google/gemma-3-270m-it")
60
- ADAPTER_MODEL = os.environ.get("ADAPTER_MODEL", "Sathvik0101/cyber-duel-tiny-adapter")
61
- USERS_REPO = os.environ.get("USERS_REPO", "Sathvik0101/cyber-duel-tiny-users")
62
- LOGS_REPO = os.environ.get("LOGS_REPO", "Sathvik0101/cyber-duel-tiny-logs")
63
- MODAL_WEBHOOK_URL = os.environ.get("MODAL_WEBHOOK_URL", "").strip()
64
- MODAL_WEBHOOK_SECRET = os.environ.get("MODAL_WEBHOOK_SECRET", "").strip()
65
-
66
- SKIP_MODEL_LOAD = os.environ.get("SKIP_MODEL_LOAD", "0") == "1"
67
- ONLINE_RL_ENABLED = MODAL_WEBHOOK_URL != "" and MODAL_WEBHOOK_SECRET != ""
68
-
69
- # Online-RL tunables
70
- RETRAIN_THRESHOLD = int(os.environ.get("RETRAIN_THRESHOLD", "25"))
71
- LOG_BUFFER_FLUSH_SEC = int(os.environ.get("LOG_BUFFER_FLUSH_SEC", "15"))
72
- LOG_BUFFER_FLUSH_ROWS = int(os.environ.get("LOG_BUFFER_FLUSH_ROWS", "10"))
73
- USER_ADAPTER_CACHE_SIZE = int(os.environ.get("USER_ADAPTER_CACHE_SIZE", "32"))
74
- RETRAIN_COOLDOWN_SEC = int(os.environ.get("RETRAIN_COOLDOWN_SEC", "600")) # 10 min
75
- # Track retrain-request timestamps per uid in RAM to avoid spamming Modal
76
- RETRAIN_INFLIGHT: Dict[str, float] = {}
77
- # Per-uid last flush time (RAM-side; not a Space secret)
78
- LAST_FLUSH_AT: Dict[str, float] = {}
79
-
80
- # ---- Global model state ---------------------------------------------------
81
- HAS_MODEL = False
82
- base_model = None # the underlying base, shared by all PEFT deltas
83
- global_adapter = None # PEFT model wrapping base_model with the global DPO adapter
84
- tokenizer = None
85
-
86
- # ---- Per-user state -------------------------------------------------------
87
- # LRU cache of PeftModel objects, keyed by playerId
88
- USER_ADAPTER_CACHE: "OrderedDict[str, PeftModel]" = OrderedDict()
89
- # Per-uid pending log rows (not yet flushed to Hub)
90
- LOG_BUFFER: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
91
- # Last row for this uid (so the *next* /predict can fill in player_next_move)
92
- LAST_ROW: Dict[str, Dict[str, Any]] = {}
93
- # uid -> total flushed rows (for /me + retrain threshold)
94
- FLUSHED_COUNT: Dict[str, int] = defaultdict(int)
95
- # uid -> last time we POSTed /retrain to Modal (RAM-side cooldown)
96
- LAST_RETRAIN_REQUEST: Dict[str, float] = {}
97
-
98
- # Single lock so concurrent /predict calls don't double-flush
99
- state_lock = threading.Lock()
100
-
101
- LEGAL_MOVES = ("jab", "cross", "low_kick", "roundhouse", "uppercut",
102
- "parry", "backstep", "clinch", "throw")
103
-
104
- # ---- Prompt schema (mirrors train/common.py + generate_data_v2.py) -------
105
- DEFAULT_PLAYER = {"name": "fighter", "speed": 3, "power": 3, "range": 3,
106
- "weight": 1.0, "stance": "neutral", "stamina": 100, "hp": 100}
107
- DEFAULT_NPC = {"name": "fighter", "speed": 3, "power": 3, "range": 3,
108
- "weight": 1.0, "stance": "neutral", "stamina": 100, "hp": 100}
109
-
110
- SYSTEM_PROMPT = (
111
- "You are an expert NPC AI for Duel of Albion, a 3D fighting game.\n"
112
- "Read the round, distance, both fighters' stats and stances, and the "
113
- "player's last 5 moves. Choose the single best counter-move from the 9 "
114
- "legal moves. Always end your reply with `counter_move: <move>` on its "
115
- "own line."
116
- )
117
-
118
-
119
- def get_hf_token() -> Optional[str]:
120
- tok = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
121
- if tok:
122
- return tok
123
- cache = Path.home() / ".cache" / "huggingface" / "token"
124
- if cache.exists():
125
- return cache.read_text(encoding="utf-8").strip() or None
126
- return None
127
-
128
-
129
- def load_global_model():
130
- """Load the base + global DPO adapter once, share base_model across
131
- all per-user PEFT deltas."""
132
- global HAS_MODEL, base_model, global_adapter, tokenizer
133
- if SKIP_MODEL_LOAD:
134
- log.info("SKIP_MODEL_LOAD=1 -- model is not loaded")
135
- return
136
- try:
137
- hf_token = get_hf_token()
138
- log.info(f"Loading base {BASE_MODEL} + global adapter {ADAPTER_MODEL}...")
139
-
140
- tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, token=hf_token)
141
- if tokenizer.pad_token is None:
142
- tokenizer.pad_token = tokenizer.eos_token
143
- tokenizer.padding_side = "left"
144
-
145
- device_arg = "auto" if torch.cuda.is_available() else None
146
- dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
147
-
148
- config = AutoConfig.from_pretrained(BASE_MODEL, token=hf_token)
149
- if hasattr(config, "vision_config") and config.vision_config is not None:
150
- config.vision_config = None
151
-
152
- base_model = AutoModelForCausalLM.from_pretrained(
153
- BASE_MODEL, config=config, token=hf_token,
154
- torch_dtype=dtype, device_map=device_arg,
155
- )
156
- adapter_path = snapshot_download(repo_id=ADAPTER_MODEL, token=hf_token)
157
- global_adapter = PeftModel.from_pretrained(base_model, adapter_path)
158
- global_adapter.eval()
159
-
160
- # Warmup
161
- warmup_state = {
162
- "player": DEFAULT_PLAYER, "npc": DEFAULT_NPC,
163
- "round": 1, "distance": "close",
164
- "sequence": "jab,cross,low_kick,roundhouse,uppercut",
165
- }
166
- warmup_prompt = build_prompt(warmup_state)
167
- warmup_inputs = tokenizer(warmup_prompt, return_tensors="pt").to(base_model.device)
168
- with torch.no_grad():
169
- _ = global_adapter.generate(
170
- **warmup_inputs, max_new_tokens=20, do_sample=False,
171
- pad_token_id=tokenizer.eos_token_id,
172
- )
173
- HAS_MODEL = True
174
- log.info("Global model loaded and warmed up on %s", base_model.device)
175
- except Exception as e:
176
- log.exception("Global model load failed: %s", e)
177
- base_model = None
178
- global_adapter = None
179
- tokenizer = None
180
- HAS_MODEL = False
181
-
182
-
183
- # ---- Prompt + parser ------------------------------------------------------
184
- def _format_fighter(f: dict) -> str:
185
- return (
186
- f"{f.get('name', 'fighter')}"
187
- f" (speed={f.get('speed', 3)}, power={f.get('power', 3)}, "
188
- f"range={f.get('range', 3)}, weight={f.get('weight', 1.0)}, "
189
- f"stance={f.get('stance', 'neutral')}, "
190
- f"stamina={f.get('stamina', 100)}, hp={f.get('hp', 100)})"
191
- )
192
-
193
-
194
- def build_prompt(state: dict) -> str:
195
- player = state.get("player") or DEFAULT_PLAYER
196
- npc = state.get("npc") or DEFAULT_NPC
197
- round_ = state.get("round", 1)
198
- dist = state.get("distance", "close")
199
- sequence = state.get("sequence", "jab,cross,low_kick,roundhouse,uppercut")
200
- user_msg = (
201
- f"Round {round_} | Distance: {dist}\n"
202
- f"Player: {_format_fighter(player)}\n"
203
- f"NPC : {_format_fighter(npc)}\n"
204
- f"Player last 5 moves: {sequence}\n"
205
- f"Decide the best counter-move from: "
206
- f"{', '.join(LEGAL_MOVES)}."
207
- )
208
- return (
209
- f"<start_of_turn>user\n{SYSTEM_PROMPT}\n\n{user_msg}<end_of_turn>\n"
210
- f"<start_of_turn>model\n"
211
- )
212
-
213
-
214
- def parse_counter(text: str) -> str:
215
- text_low = text.lower()
216
- if "counter_move:" in text_low:
217
- tail = text_low.split("counter_move:", 1)[1].strip()
218
- first = tail.split()[0].strip(".,!?;:'\"")
219
- first = first.rstrip(",.;:?!")
220
- if first in LEGAL_MOVES:
221
- return first
222
- for m in LEGAL_MOVES:
223
- if m in text_low:
224
- return m
225
- return "jab"
226
-
227
-
228
- # ---- Inference ------------------------------------------------------------
229
- def _generate_with_model(model, state: dict) -> Tuple[str, str]:
230
- """Returns (full_text, counter_move)."""
231
- prompt = build_prompt(state)
232
- inputs = tokenizer(prompt, return_tensors="pt").to(base_model.device)
233
- with torch.no_grad():
234
- out = model.generate(
235
- **inputs, max_new_tokens=200, do_sample=False,
236
- pad_token_id=tokenizer.eos_token_id,
237
- )
238
- text = tokenizer.decode(
239
- out[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True,
240
- )
241
- return text, parse_counter(text)
242
-
243
-
244
- def get_model_for(uid: Optional[str]):
245
- """Return the PeftModel to use for this request.
246
-
247
- LRU-cache per-user deltas; lazy-download from Hub on first request for
248
- a new uid. Falls back to the global adapter if the user has none yet.
249
- """
250
- if not uid or not ONLINE_RL_ENABLED:
251
- return global_adapter, "global"
252
- with state_lock:
253
- if uid in USER_ADAPTER_CACHE:
254
- USER_ADAPTER_CACHE.move_to_end(uid)
255
- return USER_ADAPTER_CACHE[uid], "user"
256
- # Lazy download outside the lock. We use snapshot_download to grab
257
- # the entire <uid>/ folder (adapter weights + tokenizer files).
258
- try:
259
- adapter_dir = snapshot_download(
260
- repo_id=USERS_REPO, allow_patterns=[f"{uid}/*"],
261
- token=get_hf_token(),
262
- )
263
- # snapshot_download returns the local root that contains
264
- # `<uid>/adapter_model.safetensors`. PEFT's from_pretrained
265
- # expects the folder containing adapter_config.json.
266
- per_user_dir = str(Path(adapter_dir) / uid)
267
- if not (Path(per_user_dir) / "adapter_config.json").exists():
268
- raise FileNotFoundError(f"adapter_config.json not in {per_user_dir}")
269
- delta = PeftModel.from_pretrained(base_model, per_user_dir)
270
- delta.eval()
271
- with state_lock:
272
- USER_ADAPTER_CACHE[uid] = delta
273
- USER_ADAPTER_CACHE.move_to_end(uid)
274
- while len(USER_ADAPTER_CACHE) > USER_ADAPTER_CACHE_SIZE:
275
- USER_ADAPTER_CACHE.popitem(last=False)
276
- return delta, "user"
277
- except Exception as e:
278
- log.info("No per-user adapter for %s (%s) -- using global", uid, e)
279
- return global_adapter, "global"
280
-
281
-
282
- def evict_user_cache(uid: str):
283
- with state_lock:
284
- USER_ADAPTER_CACHE.pop(uid, None)
285
-
286
-
287
- # ---- Online-RL logging ----------------------------------------------------
288
- def _state_to_log(state: dict, model_move: str) -> Dict[str, Any]:
289
- """Build a log row from the /predict state and the model's response."""
290
- player = state.get("player") or DEFAULT_PLAYER
291
- npc = state.get("npc") or DEFAULT_NPC
292
- last5 = (state.get("sequence", "").split(",") + ["jab"] * 5)[:5]
293
- dist = state.get("distance", "close")
294
- distance_m = {"close": 1.5, "mid": 3.0, "far": 4.5}.get(dist, 3.0)
295
- return {
296
- "ts": int(time.time()),
297
- "uid": state.get("playerId", ""),
298
- "state": {
299
- "player_char_id": player.get("name", "fighter"),
300
- "npc_char_id": npc.get("name", "fighter"),
301
- "player_speed": int(player.get("speed", 3)),
302
- "player_power": int(player.get("power", 3)),
303
- "player_range": int(player.get("range", 3)),
304
- "player_weight": float(player.get("weight", 1.0)),
305
- "player_stance": player.get("stance", "neutral"),
306
- "npc_speed": int(npc.get("speed", 3)),
307
- "npc_power": int(npc.get("power", 3)),
308
- "npc_range": int(npc.get("range", 3)),
309
- "npc_weight": float(npc.get("weight", 1.0)),
310
- "npc_stance": npc.get("stance", "neutral"),
311
- "distance_bucket": dist,
312
- "distance": distance_m,
313
- "player_stamina": int(player.get("stamina", 100)),
314
- "npc_stamina": int(npc.get("stamina", 100)),
315
- "round": int(state.get("round", 1)),
316
- "last5": last5,
317
- },
318
- "model_move": model_move,
319
- "model_adapter_scope": "user" if state.get("playerId") else "global",
320
- "player_next_move": None, # filled in by next /predict or on flush
321
- }
322
-
323
-
324
- def _flush_user_log(uid: str) -> int:
325
- """Atomically upload the buffered log rows for `uid` to the logs repo."""
326
- with state_lock:
327
- rows = LOG_BUFFER.pop(uid, [])
328
- if not rows:
329
- return 0
330
- n = len(rows)
331
- try:
332
- api = HfApi()
333
- # Ensure repo exists
334
- create_repo(LOGS_REPO, repo_type="dataset", private=True, exist_ok=True)
335
- # Download existing, append, re-upload (simple & correct)
336
- existing_lines: List[str] = []
337
- try:
338
- existing = hf_hub_download(
339
- repo_id=LOGS_REPO, repo_type="dataset",
340
- filename=f"users/{uid}.jsonl", token=get_hf_token(),
341
- )
342
- with open(existing, "r", encoding="utf-8") as f:
343
- existing_lines = f.readlines()
344
- except Exception:
345
- pass
346
- new_lines = [json.dumps(r, ensure_ascii=False) + "\n" for r in rows]
347
- with state_lock:
348
- FLUSHED_COUNT[uid] += n
349
- api.upload_file(
350
- path_or_fileobj="".join(existing_lines + new_lines).encode("utf-8"),
351
- path_in_repo=f"users/{uid}.jsonl",
352
- repo_id=LOGS_REPO, repo_type="dataset",
353
- commit_message=f"Append {n} rows for {uid}",
354
- token=get_hf_token(),
355
- )
356
- log.info("Flushed %d rows for %s (total %d)",
357
- n, uid, FLUSHED_COUNT[uid])
358
- except Exception as e:
359
- log.warning("Log flush failed for %s: %s", uid, e)
360
- # Put them back so we don't lose data
361
- with state_lock:
362
- LOG_BUFFER[uid] = rows + LOG_BUFFER.get(uid, [])
363
- return n
364
-
365
-
366
- def _post_webhook(path: str, payload: Dict[str, Any]) -> bool:
367
- if not (MODAL_WEBHOOK_URL and MODAL_WEBHOOK_SECRET):
368
- return False
369
- body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
370
- sig = hmac.new(MODAL_WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
371
- try:
372
- import urllib.request
373
- req = urllib.request.Request(
374
- MODAL_WEBHOOK_URL.rstrip("/") + path,
375
- data=body, method="POST",
376
- headers={
377
- "Content-Type": "application/json",
378
- "X-Signature": sig,
379
- "X-Timestamp": str(int(time.time())),
380
- },
381
- )
382
- with urllib.request.urlopen(req, timeout=5) as resp:
383
- log.info("Webhook %s -> %s", path, resp.status)
384
- return 200 <= resp.status < 300
385
- except Exception as e:
386
- log.warning("Webhook %s failed: %s", path, e)
387
- return False
388
-
389
-
390
- def _maybe_trigger_retrain(uid: str):
391
- if not ONLINE_RL_ENABLED:
392
- return
393
- now = time.time()
394
- with state_lock:
395
- last_ts = LAST_RETRAIN_REQUEST.get(uid, 0)
396
- flushed = FLUSHED_COUNT.get(uid, 0)
397
- if now - last_ts < RETRAIN_COOLDOWN_SEC:
398
- return
399
- if flushed < RETRAIN_THRESHOLD:
400
- return
401
- with state_lock:
402
- LAST_RETRAIN_REQUEST[uid] = now
403
- log.info("Triggering retrain for %s (flushed=%d)", uid, flushed)
404
- _post_webhook("/retrain", {"uid": uid})
405
-
406
-
407
- # ---- FastAPI app ----------------------------------------------------------
408
- app = FastAPI(title="cyber-duel-tiny")
409
- app.add_middleware(
410
- CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
411
- )
412
-
413
-
414
- @app.on_event("startup")
415
- def _startup():
416
- load_global_model()
417
- if ONLINE_RL_ENABLED:
418
- log.info("Online RL enabled (webhook=%s, logs_repo=%s, users_repo=%s)",
419
- MODAL_WEBHOOK_URL, LOGS_REPO, USERS_REPO)
420
- else:
421
- log.info("Online RL disabled (no MODAL_WEBHOOK_URL/SECRET set)")
422
-
423
-
424
- @app.get("/health")
425
- def health():
426
- return {
427
- "ready": HAS_MODEL,
428
- "has_token": get_hf_token() is not None,
429
- "online_rl_enabled": ONLINE_RL_ENABLED,
430
- "user_adapters_cached": len(USER_ADAPTER_CACHE),
431
- "buffered_users": len(LOG_BUFFER),
432
- }
433
-
434
-
435
- @app.get("/me")
436
- def me(playerId: str = ""):
437
- if not ONLINE_RL_ENABLED:
438
- return {"online_rl_enabled": False}
439
- if not playerId:
440
- return {"error": "missing playerId"}
441
- with state_lock:
442
- flushed = FLUSHED_COUNT.get(playerId, 0)
443
- last_ts = LAST_RETRAIN_REQUEST.get(playerId, 0)
444
- adapter_scope = "user" if playerId in USER_ADAPTER_CACHE else "global"
445
- next_at = max(0, RETRAIN_THRESHOLD - flushed)
446
- cooldown_left = max(0, int(RETRAIN_COOLDOWN_SEC - (time.time() - last_ts)))
447
- return {
448
- "playerId": playerId,
449
- "rounds_logged": flushed,
450
- "next_retrain_in": next_at,
451
- "cooldown_left_sec": cooldown_left if last_ts else 0,
452
- "adapter_scope": adapter_scope,
453
- "online_rl_enabled": True,
454
- }
455
-
456
-
457
- @app.post("/forget")
458
- async def forget(request: Request):
459
- if not ONLINE_RL_ENABLED:
460
- return JSONResponse(content={"ok": False, "reason": "online_rl_disabled"},
461
- status_code=400)
462
- try:
463
- data = await request.json()
464
- except Exception:
465
- data = {}
466
- uid = (data or {}).get("playerId", "")
467
- if not uid or len(uid) < 4 or len(uid) > 64:
468
- return JSONResponse(content={"ok": False, "reason": "bad playerId"},
469
- status_code=400)
470
- evict_user_cache(uid)
471
- with state_lock:
472
- LOG_BUFFER.pop(uid, None)
473
- LAST_ROW.pop(uid, None)
474
- FLUSHED_COUNT.pop(uid, None)
475
- LAST_RETRAIN_REQUEST.pop(uid, None)
476
- LAST_FLUSH_AT.pop(uid, None)
477
- _post_webhook("/forget", {"uid": uid})
478
- return {"ok": True, "uid": uid}
479
-
480
-
481
- @app.post("/predict")
482
- async def predict(request: Request):
483
- sequence = ""
484
- try:
485
- try:
486
- data = await request.json()
487
- except Exception:
488
- data = {}
489
- sequence = (data or {}).get("sequence", "")
490
- state = {
491
- "sequence": sequence,
492
- "player": (data or {}).get("player"),
493
- "npc": (data or {}).get("npc"),
494
- "round": (data or {}).get("round", 1),
495
- "distance": (data or {}).get("distance", "close"),
496
- "playerId": (data or {}).get("playerId", "") or "",
497
- }
498
- player_prev_move = (data or {}).get("playerPrevMove", "") or ""
499
-
500
- if not HAS_MODEL:
501
- return JSONResponse(
502
- content={"reasoning": "(model not loaded)", "counterMove": "jab", "sequence": sequence},
503
- status_code=503,
504
- )
505
-
506
- model, scope = get_model_for(state["playerId"] or None)
507
- text, counter_move = _generate_with_model(model, state)
508
- reasoning = text.split("counter_move:")[0].strip() if "counter_move:" in text else text.strip()
509
-
510
- # ---- Online RL bookkeeping (only if a playerId was sent) ----
511
- if ONLINE_RL_ENABLED and state["playerId"]:
512
- uid = state["playerId"]
513
- with state_lock:
514
- # Backfill the previous row's player_next_move
515
- if uid in LAST_ROW and player_prev_move in LEGAL_MOVES:
516
- LAST_ROW[uid]["player_next_move"] = player_prev_move
517
- # Save THIS row for the next call to backfill
518
- LAST_ROW[uid] = _state_to_log(state, counter_move)
519
- # Add a placeholder row carrying the player's own next move (will be
520
- # overwritten when the next /predict arrives) so we still log even
521
- # if the user never comes back.
522
- LOG_BUFFER[uid].append(LAST_ROW[uid])
523
- buf_size = len(LOG_BUFFER[uid])
524
- flushed = FLUSHED_COUNT[uid]
525
-
526
- # Flush if buffer is large or stale
527
- now = time.time()
528
- with state_lock:
529
- last_flush = LAST_FLUSH_AT.get(uid, 0)
530
- if buf_size >= LOG_BUFFER_FLUSH_ROWS or (
531
- buf_size > 0 and now - last_flush > LOG_BUFFER_FLUSH_SEC
532
- ):
533
- with state_lock:
534
- LAST_FLUSH_AT[uid] = now
535
- # Flush in a background thread so /predict isn't blocked
536
- threading.Thread(target=_flush_user_log,
537
- args=(uid,), daemon=True).start()
538
- _maybe_trigger_retrain(uid)
539
-
540
- return JSONResponse(content={
541
- "reasoning": reasoning,
542
- "counterMove": counter_move,
543
- "sequence": sequence,
544
- "adapterScope": scope,
545
- })
546
- except Exception as e:
547
- log.exception("predict failed: %s", e)
548
- return JSONResponse(
549
- content={"reasoning": f"(error: {type(e).__name__}: {e})",
550
- "counterMove": "jab", "sequence": sequence},
551
- status_code=500,
552
- )
553
-
554
-
555
- # ---- Gradio UI (mounted on top of FastAPI for the Build-with-Gradio hackathon)
556
- def _gradio_predict(sequence: str, round_n: float, distance: str):
557
- """Gradio-friendly wrapper that reuses the exact same inference path as
558
- /predict — no double HTTP hop, single model instance, single LRU cache."""
559
- if not HAS_MODEL:
560
- return (
561
- "⚠️ **Model not loaded yet.** Hit *Counter* again in a few seconds — "
562
- "the 270M Gemma + LoRA adapter is warming up on first call.",
563
- "—",
564
- "model-not-loaded",
565
- )
566
- sequence = (sequence or "").strip()
567
- if not sequence:
568
- return "_No sequence provided._", "—", "no-input"
569
- state = {
570
- "sequence": sequence,
571
- "player": dict(DEFAULT_PLAYER),
572
- "npc": dict(DEFAULT_NPC),
573
- "round": int(round_n) if round_n else 1,
574
- "distance": distance or "close",
575
- "playerId": "",
576
- }
577
- try:
578
- model, scope = get_model_for(None)
579
- text, counter = _generate_with_model(model, state)
580
- reasoning = (
581
- text.split("counter_move:")[0].strip()
582
- if "counter_move:" in text
583
- else text.strip()
584
- )
585
- if not reasoning:
586
- reasoning = "_(no reasoning emitted)_"
587
- return reasoning, counter, scope
588
- except Exception as e:
589
- log.exception("gradio predict failed: %s", e)
590
- return (
591
- f"⚠️ Inference error: `{type(e).__name__}: {e}`",
592
- "jab",
593
- "error",
594
- )
595
-
596
-
597
- def _service_status():
598
- return {
599
- "ready": HAS_MODEL,
600
- "base": BASE_MODEL,
601
- "adapter": ADAPTER_MODEL,
602
- "online_rl": ONLINE_RL_ENABLED,
603
- "legal_moves": list(LEGAL_MOVES),
604
- }
605
-
606
-
607
- with gr.Blocks(
608
- title="Cyber Duel Tiny — Combat Advisor",
609
- theme=gr.themes.Soft(primary_hue="purple", secondary_hue="blue"),
610
- css="""
611
- .counter-badge {font-size:1.6em;font-weight:800;letter-spacing:.04em;
612
- color:#fff;
613
- background:linear-gradient(135deg,#7c3aed,#2563eb);
614
- padding:14px 18px;border-radius:12px;text-align:center;
615
- text-transform:uppercase;}
616
- .reasoning-box{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
617
- font-size:0.95em;}
618
- .legal-chip{display:inline-block;margin:2px 4px;padding:4px 10px;
619
- background:#1f2937;color:#c4b5fd;border-radius:999px;
620
- font-size:0.85em;font-family:ui-monospace,monospace;}
621
- """,
622
- ) as demo:
623
- gr.Markdown(
624
- f"""
625
- # ⚔️ Cyber Duel Tiny — Combat Advisor
626
- Fine-tuned **Gemma 3 270M + LoRA** (`{ADAPTER_MODEL}`) trained on procedural
627
- rollouts from the in-game combat resolver. Given the player's last 5 moves,
628
- the model recommends one of the 9 legal counter-moves.
629
- """
630
- )
631
- gr.Markdown(
632
- "<sub>Legal moves: "
633
- + " ".join(f'<span class="legal-chip">{m}</span>' for m in LEGAL_MOVES)
634
- + "</sub>"
635
- )
636
-
637
- with gr.Row():
638
- status_box = gr.JSON(label="Service status", scale=2)
639
-
640
- with gr.Row():
641
- with gr.Column(scale=1):
642
- sequence_in = gr.Textbox(
643
- label="Player's last 5 moves (comma-separated)",
644
- value="jab,cross,low_kick,roundhouse,uppercut",
645
- placeholder="e.g. jab,cross,jab,cross,jab",
646
- )
647
- with gr.Row():
648
- round_in = gr.Slider(1, 5, value=1, step=1, label="Round")
649
- distance_in = gr.Radio(
650
- ["close", "mid", "far"], value="close", label="Distance"
651
- )
652
- run_btn = gr.Button("Counter ⚡", variant="primary", size="lg")
653
- gr.Examples(
654
- examples=[
655
- ["jab,jab,jab,jab,jab", 1, "close"],
656
- ["uppercut,uppercut,uppercut,uppercut,uppercut", 3, "close"],
657
- ["low_kick,low_kick,roundhouse,roundhouse,uppercut", 2, "mid"],
658
- ["parry,parry,backstep,parry,parry", 4, "mid"],
659
- ["clinch,clinch,clinch,clinch,clinch", 5, "close"],
660
- ],
661
- inputs=[sequence_in, round_in, distance_in],
662
- label="Try these patterns",
663
- )
664
- with gr.Column(scale=1):
665
- move_out = gr.Textbox(
666
- label="Counter move",
667
- value="—",
668
- interactive=False,
669
- elem_classes=["counter-badge"],
670
- )
671
- scope_out = gr.Textbox(
672
- label="Adapter scope", value="—", interactive=False
673
- )
674
- reasoning_out = gr.Markdown(
675
- value="_Press *Counter ⚡* to see the model's reasoning._",
676
- label="Reasoning",
677
- elem_classes=["reasoning-box"],
678
- )
679
-
680
- run_btn.click(
681
- _gradio_predict,
682
- inputs=[sequence_in, round_in, distance_in],
683
- outputs=[reasoning_out, move_out, scope_out],
684
- ).then(_service_status, outputs=status_box)
685
- demo.load(_service_status, outputs=status_box)
686
-
687
-
688
- # Mount Gradio at / on top of the existing FastAPI app.
689
- # All FastAPI routes (/predict, /health, /me, /forget) keep their original paths
690
- # — the 3D-game client in the parent project doesn't have to change anything.
691
- app = gr.mount_gradio_app(app, demo, path="/")
692
-
693
-
694
- if __name__ == "__main__":
695
- import uvicorn
696
- uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
 
1
+ import os
2
+ import sys
3
+ import json
4
+ import logging
5
+
6
+ import gradio as gr
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.responses import JSONResponse, HTMLResponse, FileResponse, RedirectResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+
12
+ # ------------------------------------------------------------------
13
+ # ML stack (optional — UI works fine in mock mode without it)
14
+ # ------------------------------------------------------------------
15
+ HAS_ML = False
16
+ try:
17
+ import torch # noqa: F401
18
+ from transformers import AutoTokenizer, AutoModelForCausalLM, AutoConfig # noqa: F401
19
+ from peft import PeftModel # noqa: F401
20
+ HAS_ML = True
21
+ except Exception:
22
+ HAS_ML = False
23
+
24
+ BASE_MODEL = "google/gemma-3-4b-it"
25
+ ADAPTER_MODEL = "Sathvik0101/gemma-3-combat-npc-adapter"
26
+
27
+ # ------------------------------------------------------------------
28
+ # Configuration
29
+ # ------------------------------------------------------------------
30
+ HOST = os.environ.get("HOST", "0.0.0.0")
31
+ PORT = int(os.environ.get("PORT", "7860"))
32
+ SKIP_MODEL_LOAD = os.environ.get("SKIP_MODEL_LOAD", "0") == "1"
33
+
34
+ logging.basicConfig(
35
+ level=os.environ.get("LOG_LEVEL", "INFO"),
36
+ format="%(asctime)s | %(levelname)s | %(message)s",
37
+ )
38
+ log = logging.getLogger("duel-of-albion")
39
+
40
+
41
+ # ------------------------------------------------------------------
42
+ # Token / model state
43
+ # ------------------------------------------------------------------
44
+ def get_hf_token():
45
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
46
+ if token:
47
+ return token
48
+ token_path = os.path.expanduser("~/.cache/huggingface/token")
49
+ if os.path.exists(token_path):
50
+ try:
51
+ with open(token_path, "r") as f:
52
+ return f.read().strip()
53
+ except Exception:
54
+ pass
55
+ return None
56
+
57
+
58
+ hf_token = get_hf_token()
59
+ HAS_MODEL = False
60
+ MODEL_ERROR = ""
61
+ model = None
62
+ tokenizer = None
63
+
64
+ # ------------------------------------------------------------------
65
+ # Model loading (skipped when SKIP_MODEL_LOAD=1, when transformers/peft
66
+ # is missing, or when there is no HF token — UI testing never needs it)
67
+ # ------------------------------------------------------------------
68
+ if not HAS_ML:
69
+ log.info("ML stack not installed — running in MOCK MODE (UI only).")
70
+ elif SKIP_MODEL_LOAD:
71
+ log.info("SKIP_MODEL_LOAD=1 — skipping model load (UI only).")
72
+ elif not hf_token:
73
+ log.info("No HF_TOKEN found — running in MOCK MODE. Set HF_TOKEN to enable the AI model.")
74
+ else:
75
+ log.info("Loading tokenizer and base model...")
76
+ try:
77
+ from huggingface_hub import snapshot_download
78
+ import torch
79
+
80
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, token=hf_token)
81
+
82
+ device_arg = "auto" if torch.cuda.is_available() else None
83
+ dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
84
+
85
+ config = AutoConfig.from_pretrained(BASE_MODEL, token=hf_token)
86
+ if hasattr(config, "vision_config") and config.vision_config is not None:
87
+ config.vision_config = None
88
+ log.info("Stripped vision_config to force text-only load path.")
89
+
90
+ base_model = AutoModelForCausalLM.from_pretrained(
91
+ BASE_MODEL,
92
+ config=config,
93
+ token=hf_token,
94
+ torch_dtype=dtype,
95
+ device_map=device_arg,
96
+ )
97
+ adapter_path = snapshot_download(repo_id=ADAPTER_MODEL, token=hf_token, force_download=True)
98
+ model = PeftModel.from_pretrained(base_model, adapter_path)
99
+ model.eval()
100
+
101
+ warmup_prompt = (
102
+ "<start_of_turn>user\nYou are an expert fighting game NPC AI. "
103
+ "The user has performed this sequence of 5 moves: jab,cross,low_kick,roundhouse,uppercut.\n"
104
+ "Decide on the best counter-move from: jab, cross, low_kick, roundhouse, uppercut, parry, backstep, clinch, throw.\n"
105
+ "Respond in this format:\n[reasoning]\ncounter_move: [move]"
106
+ "<end_of_turn>\n<start_of_turn>model\n"
107
+ )
108
+ warmup_inputs = tokenizer(warmup_prompt, return_tensors="pt").to(model.device)
109
+ with torch.no_grad():
110
+ _ = model.generate(
111
+ **warmup_inputs,
112
+ max_new_tokens=20,
113
+ do_sample=False,
114
+ pad_token_id=tokenizer.eos_token_id,
115
+ )
116
+
117
+ HAS_MODEL = True
118
+ log.info("Model loaded and warmed up.")
119
+ except Exception as e:
120
+ import traceback
121
+ MODEL_ERROR = f"{type(e).__name__}: {e}"
122
+ log.warning("Model load failed: %s", MODEL_ERROR)
123
+ log.debug(traceback.format_exc())
124
+ model = None
125
+ tokenizer = None
126
+
127
+
128
+ # ------------------------------------------------------------------
129
+ # Inference helper
130
+ # ------------------------------------------------------------------
131
+ MOCK_COUNTERS = ["jab", "cross", "low_kick", "roundhouse", "uppercut", "parry", "backstep", "clinch", "throw"]
132
+
133
+ def run_gemma(moves_sequence: str) -> str:
134
+ if not HAS_MODEL:
135
+ import time, random
136
+ time.sleep(0.25)
137
+ # Offline mode: return a clean scripted counter without leaking any
138
+ # raw model-loader diagnostics to the player UI.
139
+ reasoning = (
140
+ f"Mock Analysis: player performed {moves_sequence}. "
141
+ "AI opponent is offline — using scripted counters."
142
+ )
143
+ return json.dumps({
144
+ "reasoning": reasoning,
145
+ "counterMove": random.choice(MOCK_COUNTERS),
146
+ "sequence": moves_sequence,
147
+ })
148
+
149
+ prompt = (
150
+ f"<start_of_turn>user\n"
151
+ f"You are an expert fighting game NPC AI. "
152
+ f"The user has performed this sequence of 5 moves: {moves_sequence}.\n"
153
+ f"Observe the pattern and decide on the best counter-move from: "
154
+ f"jab, cross, low_kick, roundhouse, uppercut, parry, backstep, clinch, throw.\n"
155
+ f"Respond in this format:\n"
156
+ f"[Your reasoning about the player's pattern and tendencies]\n"
157
+ f"counter_move: [your chosen counter move]"
158
+ f"<end_of_turn>\n<start_of_turn>model\n"
159
+ )
160
+ import torch
161
+ inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
162
+ with torch.no_grad():
163
+ outputs = model.generate(
164
+ **inputs,
165
+ max_new_tokens=80,
166
+ temperature=0.2,
167
+ do_sample=True,
168
+ pad_token_id=tokenizer.eos_token_id,
169
+ )
170
+ text = tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True)
171
+
172
+ reasoning = "Unable to process reasoning."
173
+ counter_move = "jab"
174
+ if "counter_move:" in text:
175
+ parts = text.split("counter_move:")
176
+ reasoning = parts[0].strip()
177
+ counter_move = parts[1].strip()
178
+ else:
179
+ reasoning = text.strip()
180
+
181
+ return json.dumps({"reasoning": reasoning, "counterMove": counter_move, "sequence": moves_sequence})
182
+
183
+
184
+ # ------------------------------------------------------------------
185
+ # FastAPI app setup
186
+ # ------------------------------------------------------------------
187
+ app = FastAPI(title="Duel of Albion - Gemma AI Fighter")
188
+
189
+ app.add_middleware(
190
+ CORSMiddleware,
191
+ allow_origins=["*"],
192
+ allow_credentials=True,
193
+ allow_methods=["*"],
194
+ allow_headers=["*"],
195
+ )
196
+
197
+ # ------------------------------------------------------------------
198
+ # Static files: the built React game
199
+ # ------------------------------------------------------------------
200
+ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
201
+ STATIC_DIR = os.path.join(PROJECT_ROOT, "3d-game", "dist")
202
+ STATIC_DIR_EXISTS = os.path.isdir(STATIC_DIR)
203
+
204
+ if STATIC_DIR_EXISTS:
205
+ # IMPORTANT: do NOT mount anything at /assets.
206
+ # Gradio serves its own JS/CSS bundles at /assets/* (e.g.
207
+ # /assets/index-DputZZxm.js). Mounting at /assets would shadow
208
+ # Gradio's handler and return 404 for those, leaving the Gradio
209
+ # shell blank. The React build's own assets are served via the
210
+ # /game/{path:path} catch-all below.
211
+
212
+ @app.get("/favicon.ico", include_in_schema=False)
213
+ async def favicon_ico():
214
+ return FileResponse(
215
+ os.path.join(STATIC_DIR, "favicon.svg"),
216
+ media_type="image/svg+xml",
217
+ )
218
+
219
+ @app.get("/favicon.svg", include_in_schema=False)
220
+ async def favicon_svg():
221
+ return FileResponse(os.path.join(STATIC_DIR, "favicon.svg"))
222
+
223
+ @app.get("/manifest.json", include_in_schema=False)
224
+ async def manifest():
225
+ return JSONResponse(content={
226
+ "name": "Duel of Albion",
227
+ "short_name": "Duel of Albion",
228
+ "start_url": "/game/",
229
+ "display": "fullscreen",
230
+ "background_color": "#05040a",
231
+ "theme_color": "#05040a",
232
+ "icons": [],
233
+ })
234
+
235
+ NO_CACHE = {
236
+ "Cache-Control": "no-cache, no-store, must-revalidate",
237
+ "Pragma": "no-cache",
238
+ "Expires": "0",
239
+ }
240
+
241
+ @app.get("/models/{path:path}", include_in_schema=False)
242
+ async def game_models(path: str):
243
+ static_root = os.path.normpath(STATIC_DIR)
244
+ candidate = os.path.normpath(os.path.join(STATIC_DIR, "models", path))
245
+ if not candidate.startswith(static_root):
246
+ return HTMLResponse("Forbidden", status_code=403)
247
+ if os.path.isfile(candidate):
248
+ return FileResponse(candidate)
249
+ return HTMLResponse("Not Found", status_code=404)
250
+
251
+ @app.get("/game", include_in_schema=False)
252
+ @app.get("/game/", include_in_schema=False)
253
+ async def game_index():
254
+ return FileResponse(
255
+ os.path.join(STATIC_DIR, "index.html"),
256
+ headers=NO_CACHE,
257
+ )
258
+
259
+ @app.get("/game/{path:path}", include_in_schema=False)
260
+ async def game_spa(path: str):
261
+ # Path-traversal guard
262
+ static_root = os.path.normpath(STATIC_DIR)
263
+ candidate = os.path.normpath(os.path.join(STATIC_DIR, path))
264
+ if not candidate.startswith(static_root):
265
+ return HTMLResponse("Forbidden", status_code=403)
266
+ if os.path.isfile(candidate):
267
+ return FileResponse(candidate)
268
+ # SPA fallback — unknown path -> index.html
269
+ return FileResponse(
270
+ os.path.join(STATIC_DIR, "index.html"),
271
+ headers=NO_CACHE,
272
+ )
273
+ else:
274
+ @app.get("/game", include_in_schema=False)
275
+ @app.get("/game/", include_in_schema=False)
276
+ async def game_not_built():
277
+ return HTMLResponse(
278
+ "<h1 style='font-family:sans-serif;color:#f0e6d2;background:#05040a;"
279
+ "padding:40px;'>React game not built</h1>"
280
+ "<p style='font-family:sans-serif;color:#b8a88a;background:#05040a;"
281
+ "padding:0 40px 40px;'>Run <code>npm run build</code> in "
282
+ "<code>3d-game/</code> to produce the dist directory.</p>",
283
+ status_code=404,
284
+ )
285
+
286
+
287
+ # ------------------------------------------------------------------
288
+ # API endpoints used by the React game
289
+ # ------------------------------------------------------------------
290
+ @app.get("/health")
291
+ async def health():
292
+ return JSONResponse(content={
293
+ "ready": HAS_MODEL,
294
+ "has_ml": HAS_ML,
295
+ "skip_model_load": SKIP_MODEL_LOAD,
296
+ "has_token": bool(hf_token),
297
+ })
298
+
299
+
300
+ @app.post("/predict")
301
+ async def predict(request: Request):
302
+ try:
303
+ data = await request.json()
304
+ except Exception:
305
+ data = {}
306
+ sequence = data.get("sequence", "")
307
+ result_str = run_gemma(sequence)
308
+ try:
309
+ result = json.loads(result_str)
310
+ except Exception:
311
+ result = {"reasoning": result_str, "counterMove": "jab", "sequence": sequence}
312
+ return JSONResponse(content=result)
313
+
314
+
315
+ # ------------------------------------------------------------------
316
+ # Gradio UI — full-screen game shell
317
+ # ------------------------------------------------------------------
318
+ # The game is always served through a Gradio container. The React app is
319
+ # embedded full-screen in an iframe so it keeps its own rendering / input
320
+ # logic, while Gradio provides the hosting layer (HF Spaces, local, etc.).
321
+ css = """
322
+ /* Kill every Gradio container from html down — nothing should add
323
+ padding, margin, gaps, borders, or constrained height. */
324
+ html,body,#root,.app,.gradio-container,
325
+ .gradio-container>.main,.gradio-container>.main>.wrap,
326
+ .gradio-container .column,.gradio-container .column>.form,
327
+ .gradio-container [class*="container"],
328
+ .gradio-container [class*="panel"],
329
+ .gradio-container [class*="gap"] {
330
+ background:#05040a!important;
331
+ padding:0!important;margin:0!important;
332
+ max-width:none!important;width:100%!important;height:100%!important;
333
+ min-height:100vh!important;
334
+ border:none!important;box-shadow:none!important;gap:0!important;
335
+ overflow:hidden!important;
336
+ }
337
+ /* Hide all Gradio chrome: splash, footer, loader, status bar */
338
+ #app_splash,.splash,.loading,.loader,
339
+ .progress,.progress-bar,.meta-loader,
340
+ footer,.footer,.gradio-footer,.built-with,
341
+ #component-status,.meta,[class*="built-with"],
342
+ [class*="splash"],[class*="loader"],
343
+ .svelte-1ipelgc {
344
+ display:none!important;visibility:hidden!important;opacity:0!important;
345
+ height:0!important;width:0!important;overflow:hidden!important;
346
+ }
347
+ /* The Column with class game-wrap fills the viewport */
348
+ .game-wrap,
349
+ .game-wrap>.form {
350
+ position:fixed!important;inset:0!important;
351
+ width:100vw!important;height:100vh!important;
352
+ padding:0!important;margin:0!important;
353
+ overflow:hidden!important;background:#05040a!important;
354
+ z-index:2147483647;
355
+ }
356
+ /* The iframe itself is also fixed so it ignores any intermediate wrappers
357
+ Gradio may insert around gr.HTML */
358
+ #game-iframe,.game-wrap iframe {
359
+ position:fixed!important;top:0!important;left:0!important;
360
+ width:100vw!important;height:100vh!important;
361
+ border:none!important;display:block!important;
362
+ background:#05040a!important;z-index:2147483647;
363
+ }
364
+ """
365
+ with gr.Blocks(title="Duel of Albion", css=css, theme=gr.themes.Soft()) as demo:
366
+ if STATIC_DIR_EXISTS:
367
+ game_url = f"/game/?v={os.environ.get('BUILD_ID', int.from_bytes(os.urandom(2), 'big'))}"
368
+ else:
369
+ game_url = "/game"
370
+ with gr.Column(elem_classes="game-wrap"):
371
+ gr.HTML(
372
+ f'<iframe id="game-iframe" src="{game_url}" allowfullscreen '
373
+ 'allow="autoplay; fullscreen; gamepad; xr-spatial-tracking" '
374
+ 'sandbox="allow-scripts allow-same-origin allow-forms allow-popups" '
375
+ 'style="background:#05040a;"></iframe>'
376
+ )
377
+ app = gr.mount_gradio_app(app, demo, path="/")
378
+ log.info("Mounted Gradio shell at /.")
379
+
380
+
381
+ if __name__ == "__main__":
382
+ log.info("=" * 60)
383
+ log.info("Duel of Albion — Gradio server")
384
+ log.info(" Project root : %s", PROJECT_ROOT)
385
+ log.info(" Static dir : %s (exists=%s)", STATIC_DIR, STATIC_DIR_EXISTS)
386
+ log.info(" Has ML stack: %s", HAS_ML)
387
+ log.info(" Has model : %s", HAS_MODEL)
388
+ log.info(" HF token : %s", "yes" if hf_token else "no")
389
+ log.info(" URL : http://%s:%s/", "localhost" if HOST == "0.0.0.0" else HOST, PORT)
390
+ log.info("=" * 60)
391
+ import uvicorn
392
+ uvicorn.run(app, host=HOST, port=PORT, log_level="info")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/assets/index-DUDqKLMt.css ADDED
@@ -0,0 +1 @@
 
 
1
+ body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#050510;width:100vw;height:100vh;margin:0;font-family:Segoe UI,system-ui,-apple-system,sans-serif;overflow:hidden}#root{width:100%;height:100%}*{box-sizing:border-box;margin:0;padding:0}html,body,#root{color:#fff;background:#050510;width:100%;height:100%;font-family:Segoe UI,system-ui,-apple-system,sans-serif;overflow:hidden}.game-container{width:100vw;height:100vh;position:relative}.game-container canvas{display:block;width:100%!important;height:100%!important}.hud{pointer-events:none;z-index:10;justify-content:space-between;align-items:flex-start;gap:16px;width:100%;padding:16px 24px;display:flex;position:absolute;top:0;left:0}.hp-section{width:35%}.hp-label{text-transform:uppercase;letter-spacing:2px;margin-bottom:4px;font-size:11px;font-weight:700}.hp-label.player{color:#00d4ff;text-shadow:0 0 10px #00d4ff66}.hp-label.npc{color:#f34;text-align:right;text-shadow:0 0 10px #f346}.hp-bar-bg{background:#1a1a2e;border:1px solid #333;border-radius:3px;width:100%;height:14px;overflow:hidden}.hp-bar{border-radius:2px;height:100%;transition:width .3s}.hp-bar.player{background:linear-gradient(90deg,#00d4ff,#08f);box-shadow:0 0 10px #00d4ff44}.hp-bar.npc{float:right;background:linear-gradient(90deg,#f34,#f64);box-shadow:0 0 10px #f344}.center-info{text-align:center;pointer-events:none;z-index:10;position:absolute;top:12px;left:50%;transform:translate(-50%)}.round-info{color:#555;letter-spacing:2px;text-transform:uppercase;font-size:10px}.score{letter-spacing:4px;margin-top:4px;font-size:22px;font-weight:900}.score .player{color:#00d4ff}.score .npc{color:#f34}.score .dash{color:#444}.hit-splash{pointer-events:none;z-index:20;text-shadow:0 0 20px #ffffff80;font-size:30px;font-weight:900;animation:.5s ease-out forwards hitFade;position:absolute;top:38%;left:50%;transform:translate(-50%,-50%)}@keyframes hitFade{0%{opacity:1;transform:translate(-50%,-50%)scale(1.3)}to{opacity:0;transform:translate(-50%,-70%)scale(1)}}.controls-hint{color:#444;pointer-events:none;z-index:10;font-size:10px;line-height:1.8;position:absolute;bottom:14px;left:16px}.controls-hint kbd{color:#666;background:#1a1a2e;border:1px solid #333;border-radius:3px;margin:0 1px;padding:2px 5px;font-family:inherit;font-size:9px;display:inline-block}.combo-info{color:#444;pointer-events:none;z-index:10;text-align:right;font-size:10px;line-height:1.6;position:absolute;bottom:14px;right:16px}.overlay{z-index:100;cursor:pointer;background:#050510eb;flex-direction:column;justify-content:center;align-items:center;width:100%;height:100%;display:flex;position:absolute;top:0;left:0}.overlay h1{letter-spacing:6px;text-transform:uppercase;background:linear-gradient(135deg,#00d4ff,#a4f,#f34);-webkit-text-fill-color:transparent;text-align:center;-webkit-background-clip:text;background-clip:text;margin-bottom:20px;font-size:42px;font-weight:900}.overlay .subtitle{color:#666;text-align:center;max-width:420px;margin-bottom:24px;font-size:14px;line-height:1.7}.overlay .start-hint{color:#555;font-size:12px;animation:2s ease-in-out infinite pulse}@keyframes pulse{0%,to{opacity:.5}50%{opacity:1}}.game-over{z-index:50;background:#050510f0;flex-direction:column;justify-content:center;align-items:center;width:100%;height:100%;display:flex;position:absolute;top:0;left:0}.game-over h1{letter-spacing:4px;margin-bottom:12px;font-size:48px;font-weight:900}.game-over .subtitle{color:#888;margin-bottom:32px;font-size:15px}.game-over button{text-transform:uppercase;letter-spacing:2px;cursor:pointer;color:#fff;pointer-events:all;background:linear-gradient(135deg,#00d4ff,#06f);border:none;border-radius:4px;padding:14px 44px;font-size:14px;font-weight:700;transition:filter .2s,transform .2s}.game-over button:hover{filter:brightness(1.2);transform:scale(1.05)}.round-overlay{z-index:40;pointer-events:none;background:#000000bf;flex-direction:column;justify-content:center;align-items:center;width:100%;height:100%;animation:.3s ease-out roundFadeIn;display:flex;position:absolute;top:0;left:0}@keyframes roundFadeIn{0%{opacity:0}to{opacity:1}}.round-overlay h2{letter-spacing:3px;font-size:40px;font-weight:900}.round-overlay p{color:#888;margin-top:8px;font-size:16px}
static/assets/index-Dp4pBtge.js ADDED
The diff for this file is too large to render. See raw diff
 
static/favicon.svg ADDED
static/icons.svg ADDED
static/index.html ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Shadow Duel AI</title>
8
+ <script type="module" crossorigin src="/assets/index-Dp4pBtge.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DUDqKLMt.css">
10
+ <script>
11
+ // AI Bridge: receives AI_RESPONSE from Gradio parent and forwards to game
12
+ window.addEventListener("message", function(e) {
13
+ if (e.data && e.data.type === "AI_RESPONSE") {
14
+ window.dispatchEvent(new CustomEvent("ai-response", { detail: e.data }));
15
+ }
16
+ });
17
+ window.sendAIRequest = function(sequence) {
18
+ try {
19
+ window.parent.postMessage({
20
+ type: 'AI_REQUEST',
21
+ sequence: sequence
22
+ }, '*');
23
+ } catch(e) {}
24
+ };
25
+ </script>
26
+ </head>
27
+ <body>
28
+ <div id="root"></div>
29
+ </body>
30
+ </html>
three.min.js ADDED
The diff for this file is too large to render. See raw diff