"""engine.py — model inference + code-owned game rules. No Gradio in here. The model proposes (stat + difficulty band) and narrates; this module owns all randomness and arithmetic (DECISIONS §2): the dice, HP damage, XP, leveling, and the day clock. It also owns the llama.cpp singleton and the lenient JSON parser that degrades malformed output to a `say` (§13). """ import json import os import random import re import threading import roster from assemble_context import assemble_context try: import spaces # no-op decorator off Hugging Face Spaces except ImportError: spaces = None # On ZeroGPU the H200 exists only inside @spaces.GPU calls, so the model must # load there (all layers offloaded); anywhere else we run llama.cpp on CPU. ZEROGPU = spaces is not None and bool(os.environ.get("SPACES_ZERO_GPU")) APP_DIR = os.path.dirname(os.path.abspath(__file__)) # Frozen serve system prompt — the same string baked into every training row. with open(os.path.join(APP_DIR, "system-prompt.md"), encoding="utf-8") as _f: SYSTEM_PROMPT = _f.read().strip() # --- Tunables --------------------------------------------------------------- # Calibration-prone; revisit after playtesting. TURNS_PER_DAY = 24 # How many recent log entries ride on the card (window size, DECISIONS §15 open). LOG_WINDOW = 12 # 2d6 + stat >= DC. BAND_DC = {"easy": 6, "medium": 8, "hard": 10} # HP lost on a failed roll. MUST match explode_transcript.BAND_DAMAGE — # the training data applied these exact values to the cards the model saw. BAND_DAMAGE = {"easy": 0, "medium": 2, "hard": 3} # XP gained on a successful roll. BAND_XP = {"easy": 1, "medium": 2, "hard": 3} # Cumulative XP required to *reach* each level. Level 7 is the cap. XP_LADDER = {2: 4, 3: 10, 4: 18, 5: 28, 6: 40, 7: 54} MAX_LEVEL = 7 DEATH_MESSAGE = ( "Your strength gives out, and the forest gently reclaims you. " "Moss will grow where you fell. You were a good bug." ) TORPOR_MESSAGE = ( "Torpor takes hold, and your day ends. Bugs have short memories, and the " "forest is ever-shifting... Continue Turn to start a new day." ) # --- World seeds & loading strings ------------------------------------------ with open(os.path.join(APP_DIR, "world_states.json"), encoding="utf-8") as _f: WORLD_STATES = json.load(_f) with open(os.path.join(APP_DIR, "loading-strings.txt"), encoding="utf-8") as _f: LOADING_STRINGS = [line.strip() for line in _f if line.strip()] def shuffled_world_deck(): deck = list(WORLD_STATES) random.shuffle(deck) return deck def shuffled_loading_strings(): deck = list(LOADING_STRINGS) random.shuffle(deck) return deck # --- Model loading ------------------------------------------------------------ # Two trained branches live in parallel. Flip MODEL_FAMILY to "llama" to fall # back to the 3B-f16-v4 model (works on the HF Space CPU); "qwen3" loads the # 8B-Q8_0 (heavier, persona-grounded, needs GPU to be tolerable). MODEL_FAMILY = os.environ.get("BUG_MODEL_FAMILY", "qwen3") _FAMILIES = { "llama": { "repo": "jnalv/you-are-a-bug-llama-3.2-3B", "file": "you-are-a-bug-3B-f16-v4.gguf", }, "qwen3": { "repo": "jnalv/you-are-a-bug-qwen3-8B", "file": "you-are-a-bug-qwen3-8B-Q8_0.gguf", }, } MODEL_REPO = _FAMILIES[MODEL_FAMILY]["repo"] MODEL_FILE = _FAMILIES[MODEL_FAMILY]["file"] N_CTX = 4096 # HF Spaces CPU Basic has 2 vCPUs, but the container reports the host's core # count, so llama.cpp would otherwise oversubscribe and thrash. Override # locally via BUG_N_THREADS. N_THREADS = int(os.environ.get("BUG_N_THREADS", "2")) _llm = None # Spaces serve many sessions off one process; the single CPU model must # answer one card at a time. _llm_lock = threading.Lock() def _model_path(): override = os.environ.get("BUG_MODEL_PATH") if override: return override from huggingface_hub import hf_hub_download return hf_hub_download(MODEL_REPO, MODEL_FILE) def _preload_cuda(): """Preload the pip-packaged CUDA runtime (RTLD_GLOBAL) so the dynamic linker can resolve libllama.so's libcudart/libcublas deps — the ZeroGPU image does not put them on the default search path.""" import ctypes import glob for pkg_glob in ( "nvidia/cuda_runtime/lib/libcudart.so.*", "nvidia/cublas/lib/libcublasLt.so.*", "nvidia/cublas/lib/libcublas.so.*", ): for site_dir in __import__("site").getsitepackages(): for lib in sorted(glob.glob(os.path.join(site_dir, pkg_glob))): try: ctypes.CDLL(lib, mode=ctypes.RTLD_GLOBAL) except OSError: pass def get_llm(): global _llm with _llm_lock: if _llm is None: # The CUDA-built wheel needs libcudart just to dlopen, even when # running CPU-only, so always preload (no-op if libs are absent). _preload_cuda() from llama_cpp import Llama _llm = Llama( model_path=_model_path(), n_ctx=N_CTX, n_threads=N_THREADS, n_threads_batch=N_THREADS, n_gpu_layers=-1 if ZEROGPU else 0, verbose=False, ) return _llm def warmup(): """Startup warm: on ZeroGPU only prefetch the GGUF (CUDA must not be touched outside @spaces.GPU calls); on CPU, load the model outright.""" if ZEROGPU: _model_path() else: get_llm() # --- Inference ---------------------------------------------------------------- _ROLL_KEYS = {"stat", "difficulty", "on_success", "on_fail"} _STATS = {"might", "speed", "smarts", "mystique"} # Salvage patterns for the model's known shapes when narration contains # unescaped double-quotes that break json.loads (the §13 no-quotes convention, # violated). Greedy (.*) so interior quotes stay inside the field. _SAY_SALVAGE = re.compile( r'^\s*\{\s*"action"\s*:\s*"say"\s*,\s*"text"\s*:\s*"(.*)"\s*\}\s*$', re.S ) _ROLL_SALVAGE = re.compile( r'^\s*\{\s*"action"\s*:\s*"roll"\s*,\s*"stat"\s*:\s*"(\w+)"\s*,' r'\s*"difficulty"\s*:\s*"(\w+)"\s*,\s*"on_success"\s*:\s*"(.*)"\s*,' r'\s*"on_fail"\s*:\s*"(.*)"\s*\}\s*$', re.S, ) # Lines the model sometimes parrots from the card (or bare action words) # when it drops the JSON wrapper around otherwise-good narration. _ECHO_LINES = {"PLAYER", "THE WORLD", "WHAT JUST HAPPENED", "PLAYER'S TURN", "say", "roll"} def _unescape(value: str) -> str: return value.replace('\\"', '"').replace("\\n", "\n") def _log_degraded(raw, path): # Surfaces in Spaces container logs so fallback turns stay diagnosable. print(f"[parse-degraded:{path}] {raw!r}", flush=True) def _salvage_roll_fields(text): """Field-by-field salvage for roll JSON that is truncated (max_tokens) or quote-broken. Returns a roll dict, a say dict (when only on_success narration survives), or None.""" stat = re.search(r'"stat"\s*:\s*"(\w+)"', text) band = re.search(r'"difficulty"\s*:\s*"(\w+)"', text) if not (stat and band) or stat.group(1) not in _STATS or band.group(1) not in BAND_DC: return None succ = re.search(r'"on_success"\s*:\s*"(.*?)(?:"\s*,\s*"on_fail"|"?\s*\}?\s*$)', text, re.S) fail = re.search(r'"on_fail"\s*:\s*"(.*?)"?\s*\}?\s*$', text, re.S) succ_text = _unescape(succ.group(1)).strip() if succ else "" fail_text = _unescape(fail.group(1)).strip() if fail else "" if succ_text and fail_text: return { "action": "roll", "stat": stat.group(1), "difficulty": band.group(1), "on_success": succ_text, "on_fail": fail_text, } if succ_text: # on_fail lost to truncation: a roll can't resolve, but the narration # is still better than a stock line. return {"action": "say", "text": succ_text} return None def _strip_log_tag(line): """'[dm · Might check, Easy] The log gives.' -> 'The log gives.'""" return re.sub(r"^\s*\[[^\]]*\]\s*", "", line) def parse_output(raw): """Lenient parse of the model's one-JSON-object turn (DECISIONS §13). Anything that isn't a well-formed roll/say degrades to a say so the game never stalls on a malformed turn. """ text = (raw or "").strip() obj = None try: obj = json.loads(text) except (json.JSONDecodeError, ValueError): match = re.search(r"\{.*\}", text, re.S) if match: try: obj = json.loads(match.group(0)) except (json.JSONDecodeError, ValueError): obj = None if not isinstance(obj, dict): say = _SAY_SALVAGE.match(text) if say: _log_degraded(raw, "say-salvage") return {"action": "say", "text": _unescape(say.group(1))} roll = _ROLL_SALVAGE.match(text) if roll and roll.group(1) in _STATS and roll.group(2) in BAND_DC: _log_degraded(raw, "roll-salvage") return { "action": "roll", "stat": roll.group(1), "difficulty": roll.group(2), "on_success": _unescape(roll.group(3)), "on_fail": _unescape(roll.group(4)), } salvaged = _salvage_roll_fields(text) if salvaged: _log_degraded(raw, "roll-fields") return salvaged # A card echo (model parroting its prompt) is worse than a stock line. if not text or text.startswith("PLAYER\n") or "\nTHE WORLD\n" in text: _log_degraded(raw, "card-echo") return {"action": "say", "text": "Something went wrong in the forest. Nature is like that."} # Headers-then-prose shape ('PLAYER'S TURN\nsay\n'): drop the # echoed labels; strip '[dm · ...]' log tags but keep their narration. kept = [ _strip_log_tag(line) for line in text.splitlines() if _strip_log_tag(line).strip() not in _ECHO_LINES ] kept = [line for line in kept if line.strip()] _log_degraded(raw, "prose") text = "\n".join(kept).strip() or "Something went wrong in the forest. Nature is like that." return {"action": "say", "text": text} action = obj.get("action") if ( action == "roll" and _ROLL_KEYS <= obj.keys() and obj.get("stat") in _STATS and obj.get("difficulty") in BAND_DC ): return {k: obj[k] for k in ("action", *_ROLL_KEYS)} if action == "say" and isinstance(obj.get("text"), str): return {"action": "say", "text": obj["text"]} # Wrong/missing keys: salvage any narration we can find. _log_degraded(raw, "wrong-keys") fallback = obj.get("text") or obj.get("on_success") or text return {"action": "say", "text": str(fallback)} def _qwen3_prompt(card): # Qwen3's GGUF chat template defaults to thinking-on, which would wrap the # JSON response in . We trained with enable_thinking=False, # so build the prompt by hand and stop at <|im_end|>. Empty # blocks still sometimes leak in; parse_output handles them downstream. return ( f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n" f"<|im_start|>user\n{card}<|im_end|>\n" f"<|im_start|>assistant\n" ) def _generate(card, max_tokens, temperature, top_p): llm = get_llm() with _llm_lock: if MODEL_FAMILY == "qwen3": out = llm( _qwen3_prompt(card), max_tokens=max_tokens, temperature=temperature, top_p=top_p, stop=["<|im_end|>"], ) return out["choices"][0]["text"] out = llm.create_chat_completion( messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": card}, ], max_tokens=max_tokens, temperature=temperature, top_p=top_p, ) return out["choices"][0]["message"]["content"] if spaces is not None: # Runs in a forked worker with the H200 attached; budget covers a cold # model load (GGUF -> VRAM) plus generation. _generate = spaces.GPU(duration=120)(_generate) def infer(card, max_tokens=384, temperature=0.7, top_p=0.95): """One inference: [system, user-card] -> parsed action dict.""" return parse_output(_generate(card, max_tokens, temperature, top_p)) # --- Cards ---------------------------------------------------------------------- def opening_card(sheet, daily_context): """Turn-0 card. system_prompt='' matches the training split: the frozen prompt rides as the system message, the card keeps its leading blank block.""" return assemble_context( sheet=sheet, daily_context=daily_context, system_prompt="", opening=True, ) def turn_card(sheet, daily_context, log, message, activations): return assemble_context( sheet=sheet, daily_context=daily_context, system_prompt="", log=log[-LOG_WINDOW:], user_input={"message": message, "activations": activations}, ) # --- Dice, damage, XP ------------------------------------------------------------ def resolve_roll(stat_value, band): """2d6 + stat vs the band's DC -> 'success' | 'fail'.""" total = random.randint(1, 6) + random.randint(1, 6) + stat_value return "success" if total >= BAND_DC[band] else "fail" def apply_damage(sheet, band): hp = sheet["hp"] new_cur = max(0, hp["cur"] - BAND_DAMAGE[band]) return {**sheet, "hp": {**hp, "cur": new_cur}} def level_for_xp(xp): level = 1 for lvl, threshold in sorted(XP_LADDER.items()): if xp >= threshold: level = lvl return level def maybe_level_up(sheet, xp): """Return (sheet, announcements). Carries damage: the level-up grants the +1 max HP/Moxie but does not heal (runs stay lethal, §7).""" new_level = min(level_for_xp(xp), MAX_LEVEL) if new_level <= sheet["level"]: return sheet, [] old = sheet new = roster.load_at_level(sheet["bug"], new_level) for pool in ("hp", "moxie"): gained = new[pool]["max"] - old[pool]["max"] new[pool]["cur"] = min(new[pool]["max"], old[pool]["cur"] + gained) announcements = [f"You have grown! You are now level {new_level}."] old_abilities = {a["name"] for a in old.get("abilities", [])} for ability in new.get("abilities", []): if ability["name"] not in old_abilities: announcements.append(f"New ability unlocked: {ability['name']}!") return new, announcements