Upload folder using huggingface_hub
Browse files- .claude/settings.local.json +7 -0
- .gitignore +11 -0
- README.md +136 -8
- app.py +166 -0
- engine/__init__.py +7 -0
- engine/engine.py +65 -0
- engine/game_state.py +170 -0
- engine/llm.py +178 -0
- engine/parser.py +201 -0
- engine/prompts.py +82 -0
- finetune/build_dataset.py +376 -0
- finetune/data/eval.jsonl +0 -0
- finetune/data/train.jsonl +0 -0
- finetune/train.py +150 -0
- requirements-train.txt +10 -0
- requirements.txt +5 -0
- style.css +100 -0
- tests/test_parser.py +103 -0
.claude/settings.local.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"permissions": {
|
| 3 |
+
"allow": [
|
| 4 |
+
"Bash(python -m tests.test_parser)"
|
| 5 |
+
]
|
| 6 |
+
}
|
| 7 |
+
}
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
*.log
|
| 7 |
+
saves/
|
| 8 |
+
.gradio/
|
| 9 |
+
flagged/
|
| 10 |
+
finetune/out/
|
| 11 |
+
finetune/data/
|
README.md
CHANGED
|
@@ -1,13 +1,141 @@
|
|
| 1 |
---
|
| 2 |
-
title: Micro
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Micro RPG Engine
|
| 3 |
+
emoji: 🍄
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.50.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
short_description: A whole RPG world generated live by a small 1B-4B model.
|
| 12 |
+
tags:
|
| 13 |
+
- small-models-hackathon
|
| 14 |
+
- thousand-token-wood
|
| 15 |
+
- rpg
|
| 16 |
+
- text-adventure
|
| 17 |
+
- qwen
|
| 18 |
+
- minicpm
|
| 19 |
---
|
| 20 |
|
| 21 |
+
<!-- SUBMISSÃO — preencher antes de submeter:
|
| 22 |
+
Demo video: <COLE O LINK DO VÍDEO AQUI>
|
| 23 |
+
Social post: <COLE O LINK DO POST AQUI>
|
| 24 |
+
Track: Thousand Token Wood (entretenimento/whimsical)
|
| 25 |
+
⚠️ Confirme o slug exato da tag de track no template da org build-small-hackathon.
|
| 26 |
+
-->
|
| 27 |
+
|
| 28 |
+
> **🎥 Demo video:** _<adicionar link>_ • **📣 Social post:** _<adicionar link>_
|
| 29 |
+
|
| 30 |
+
# 🍄 Micro RPG Engine
|
| 31 |
+
|
| 32 |
+
A text RPG where a **small language model (1B–4B)** generates *everything* in real
|
| 33 |
+
time — the world, NPCs, dialogue, combat, the shop, random events. There is no
|
| 34 |
+
pre-written content. **No AI, no game.** Every playthrough is unique.
|
| 35 |
+
|
| 36 |
+
> Hugging Face Small Models Hackathon — **Track 2**
|
| 37 |
+
|
| 38 |
+
## The technical bet
|
| 39 |
+
|
| 40 |
+
The hard part with small models isn't writing pretty prose — it's **narrative
|
| 41 |
+
consistency**: not forgetting your HP, your inventory, that you already killed the
|
| 42 |
+
goblin. A generic "RPG-themed chatbot" loses the plot in three turns.
|
| 43 |
+
|
| 44 |
+
Our approach makes the **Python engine the source of truth**, not the model:
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
┌─────────────────────────────────────────┐
|
| 48 |
+
player input │ GameEngine (turn loop) │
|
| 49 |
+
───────────────▶ │
|
| 50 |
+
│ 1. build context from GameState ───────┼──▶ System prompt
|
| 51 |
+
│ 2. call the 1B-4B model │ + authoritative
|
| 52 |
+
│ 3. parse output ◀──────────────────────┼───── state snapshot
|
| 53 |
+
│ ├─ <narrative> → shown to player │
|
| 54 |
+
│ └─ <state> tags → VALIDATED & applied │
|
| 55 |
+
│ 4. GameState mutates (HP, gold, items) │
|
| 56 |
+
└─────────────────────────────────────────┘
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
The model never *remembers* the numbers — it receives them, fresh, every turn, and
|
| 60 |
+
may only *propose* deltas (`HP: -10`, `ITEM_ADD: Rusty Sword`) through a strict tag
|
| 61 |
+
protocol. The parser clamps and validates every change against the real state. The
|
| 62 |
+
model handles imagination; Python handles bookkeeping. That's what keeps a 1.5B
|
| 63 |
+
model coherent across a long dungeon crawl.
|
| 64 |
+
|
| 65 |
+
## Run locally
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
pip install -r requirements.txt
|
| 69 |
+
python app.py
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
By default it loads the model with `transformers`. To run with no local GPU, set a
|
| 73 |
+
Hugging Face token and it falls back to the serverless Inference API:
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
# Windows PowerShell
|
| 77 |
+
$env:HF_TOKEN = "hf_..."
|
| 78 |
+
$env:MICRORPG_BACKEND = "inference_api"
|
| 79 |
+
python app.py
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## Configuration (env vars)
|
| 83 |
+
|
| 84 |
+
| Variable | Default | Meaning |
|
| 85 |
+
|----------------------|-------------------------------|------------------------------------------|
|
| 86 |
+
| `MICRORPG_MODEL` | `Qwen/Qwen3-4B-Instruct-2507` | Model repo id |
|
| 87 |
+
| `MICRORPG_BACKEND` | `transformers` | `transformers` \| `inference_api` \| `mock` |
|
| 88 |
+
| `HF_TOKEN` | — | Token for the Inference API backend |
|
| 89 |
+
| `MICRORPG_MAX_TOKENS`| `512` | Max new tokens per turn |
|
| 90 |
+
|
| 91 |
+
Set `MICRORPG_BACKEND=mock` to run the full engine with a deterministic fake model
|
| 92 |
+
(no weights, no network) — handy for testing the parser and UI.
|
| 93 |
+
|
| 94 |
+
## Fine-tuning (the "Well-Tuned" quest)
|
| 95 |
+
|
| 96 |
+
The hard skill for a small model here is emitting the strict three-block tag format
|
| 97 |
+
with valid mechanics, every turn. We teach it with a **parser-validated synthetic
|
| 98 |
+
dataset**: `build_dataset.py` generates RPG turns in the exact protocol, then runs
|
| 99 |
+
**every single one through the real engine parser** and keeps only those that parse
|
| 100 |
+
and apply cleanly. 100% of the training data is guaranteed well-formed.
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
pip install -r requirements-train.txt # GPU / Colab
|
| 104 |
+
python -m finetune.build_dataset --n 1200 # offline, no model needed
|
| 105 |
+
python -m finetune.train \
|
| 106 |
+
--model Qwen/Qwen3-4B-Instruct-2507 \
|
| 107 |
+
--out finetune/out/qwen3-4b-microrpg # LoRA, ~few MB adapter
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Play with your fine-tuned model by pointing the engine at the adapter:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
# Windows PowerShell
|
| 114 |
+
$env:MICRORPG_ADAPTER = "finetune/out/qwen3-4b-microrpg"
|
| 115 |
+
python app.py
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
The dataset is model-agnostic — swap `--model` for MiniCPM, or a Llama for the
|
| 119 |
+
**Llama Champion** quest. Add `--load-4bit` for QLoRA on a small GPU.
|
| 120 |
+
|
| 121 |
+
## Project layout
|
| 122 |
+
|
| 123 |
+
```
|
| 124 |
+
app.py Gradio UI + glue
|
| 125 |
+
style.css Custom theme (parchment / arcane)
|
| 126 |
+
engine/
|
| 127 |
+
game_state.py GameState: HP, gold, inventory, location, NPCs, quest log
|
| 128 |
+
prompts.py System prompt + the tag protocol the model must follow
|
| 129 |
+
llm.py Model backends (transformers / inference API / mock)
|
| 130 |
+
parser.py Splits narrative from mechanics, validates deltas
|
| 131 |
+
engine.py GameEngine: the turn loop
|
| 132 |
+
finetune/
|
| 133 |
+
build_dataset.py Parser-validated synthetic turns → train.jsonl / eval.jsonl
|
| 134 |
+
train.py LoRA SFT (TRL/PEFT); produces a small adapter
|
| 135 |
+
tests/
|
| 136 |
+
test_parser.py Parser/engine smoke tests (run with mock backend)
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
## License
|
| 140 |
+
|
| 141 |
+
Apache-2.0.
|
app.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Micro RPG Engine — Gradio app.
|
| 2 |
+
|
| 3 |
+
A text RPG generated live by a small (1B-4B) language model. The model imagines;
|
| 4 |
+
Python keeps the books. See README.md for the architecture.
|
| 5 |
+
|
| 6 |
+
Run: python app.py
|
| 7 |
+
Test: MICRORPG_BACKEND=mock python app.py (no weights, no network)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import gradio as gr
|
| 14 |
+
|
| 15 |
+
from engine import GameEngine, GameState, build_backend
|
| 16 |
+
from engine.parser import TurnResult
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
BACKEND_KIND = os.environ.get("MICRORPG_BACKEND", "transformers")
|
| 20 |
+
MODEL_ID = os.environ.get("MICRORPG_MODEL", "Qwen/Qwen3-4B-Instruct-2507")
|
| 21 |
+
|
| 22 |
+
# Build the backend once at startup (loading weights can be slow).
|
| 23 |
+
_backend = build_backend(BACKEND_KIND, MODEL_ID)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# --------------------------------------------------------------------------- #
|
| 27 |
+
# rendering helpers
|
| 28 |
+
# --------------------------------------------------------------------------- #
|
| 29 |
+
def render_story(history: list[TurnResult]) -> str:
|
| 30 |
+
"""Render the running story as markdown."""
|
| 31 |
+
if not history:
|
| 32 |
+
return "_Press **Begin Adventure** to enter the world..._"
|
| 33 |
+
parts = []
|
| 34 |
+
for i, turn in enumerate(history):
|
| 35 |
+
if turn.narrative:
|
| 36 |
+
parts.append(turn.narrative)
|
| 37 |
+
if turn.applied:
|
| 38 |
+
parts.append("> " + " · ".join(turn.applied))
|
| 39 |
+
return "\n\n".join(parts)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def render_stats(state: GameState) -> str:
|
| 43 |
+
hp_pct = int(100 * state.hp / max(1, state.max_hp))
|
| 44 |
+
bar = (
|
| 45 |
+
f'<div class="hpbar"><div class="hpfill" style="width:{hp_pct}%"></div></div>'
|
| 46 |
+
)
|
| 47 |
+
lines = [
|
| 48 |
+
"### ⚔️ Hero",
|
| 49 |
+
f"**HP** {state.hp}/{state.max_hp}",
|
| 50 |
+
bar,
|
| 51 |
+
f"**Level** {state.level} (XP {state.xp}/{state.level*10})",
|
| 52 |
+
f"**Gold** {state.gold} 🪙",
|
| 53 |
+
f"**Location** {state.location}",
|
| 54 |
+
"",
|
| 55 |
+
"**Inventory**",
|
| 56 |
+
]
|
| 57 |
+
lines += [f"- {it}" for it in state.inventory] or ["- (empty)"]
|
| 58 |
+
if state.enemy and state.enemy.alive:
|
| 59 |
+
e = state.enemy
|
| 60 |
+
lines += ["", f"### 🗡️ Combat", f"**{e.name}** — {e.hp}/{e.max_hp} HP"]
|
| 61 |
+
if state.npcs:
|
| 62 |
+
lines += ["", "**Known characters**"]
|
| 63 |
+
lines += [f"- {n.name} ({n.role or n.disposition})" for n in state.npcs.values()]
|
| 64 |
+
if state.game_over:
|
| 65 |
+
lines += ["", "### 💀 **GAME OVER**"]
|
| 66 |
+
return "\n".join(lines)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def choice_updates(choices: list[str]):
|
| 70 |
+
"""Map up to 3 model-proposed choices onto the three choice buttons."""
|
| 71 |
+
updates = []
|
| 72 |
+
for i in range(3):
|
| 73 |
+
if i < len(choices):
|
| 74 |
+
updates.append(gr.update(value=choices[i], visible=True))
|
| 75 |
+
else:
|
| 76 |
+
updates.append(gr.update(visible=False))
|
| 77 |
+
return updates
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# --------------------------------------------------------------------------- #
|
| 81 |
+
# event handlers (engine lives in gr.State so each browser session is isolated)
|
| 82 |
+
# --------------------------------------------------------------------------- #
|
| 83 |
+
def new_game():
|
| 84 |
+
engine = GameEngine(_backend)
|
| 85 |
+
turn = engine.start()
|
| 86 |
+
return (
|
| 87 |
+
engine,
|
| 88 |
+
render_story(engine.history),
|
| 89 |
+
render_stats(engine.state),
|
| 90 |
+
*choice_updates(turn.choices),
|
| 91 |
+
"", # clear the textbox
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def take_action(engine: GameEngine, action: str):
|
| 96 |
+
if engine is None:
|
| 97 |
+
engine = GameEngine(_backend)
|
| 98 |
+
engine.start()
|
| 99 |
+
if action and action.strip():
|
| 100 |
+
turn = engine.act(action)
|
| 101 |
+
else:
|
| 102 |
+
turn = engine.history[-1] if engine.history else engine.start()
|
| 103 |
+
return (
|
| 104 |
+
engine,
|
| 105 |
+
render_story(engine.history),
|
| 106 |
+
render_stats(engine.state),
|
| 107 |
+
*choice_updates(turn.choices),
|
| 108 |
+
"",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# --------------------------------------------------------------------------- #
|
| 113 |
+
# UI
|
| 114 |
+
# --------------------------------------------------------------------------- #
|
| 115 |
+
def build_ui():
|
| 116 |
+
css_path = os.path.join(os.path.dirname(__file__), "style.css")
|
| 117 |
+
css = open(css_path, encoding="utf-8").read() if os.path.exists(css_path) else ""
|
| 118 |
+
|
| 119 |
+
with gr.Blocks(css=css, title="Micro RPG Engine", theme=gr.themes.Base()) as demo:
|
| 120 |
+
engine_state = gr.State(None)
|
| 121 |
+
|
| 122 |
+
gr.Markdown(
|
| 123 |
+
f"# 🍄 Micro RPG Engine\n"
|
| 124 |
+
f"*A world dreamed up live by a small model — `{MODEL_ID}` "
|
| 125 |
+
f"({BACKEND_KIND}). No AI, no game.*",
|
| 126 |
+
elem_id="title-md",
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
with gr.Row():
|
| 130 |
+
with gr.Column(scale=3):
|
| 131 |
+
story = gr.Markdown(render_story([]), elem_id="story")
|
| 132 |
+
with gr.Column(scale=1):
|
| 133 |
+
stats = gr.Markdown("", elem_id="stats")
|
| 134 |
+
|
| 135 |
+
with gr.Row():
|
| 136 |
+
c1 = gr.Button("...", visible=False, variant="secondary")
|
| 137 |
+
c2 = gr.Button("...", visible=False, variant="secondary")
|
| 138 |
+
c3 = gr.Button("...", visible=False, variant="secondary")
|
| 139 |
+
|
| 140 |
+
with gr.Row():
|
| 141 |
+
action = gr.Textbox(
|
| 142 |
+
placeholder="...or type your own action and press Enter",
|
| 143 |
+
show_label=False,
|
| 144 |
+
elem_id="action-input",
|
| 145 |
+
scale=4,
|
| 146 |
+
)
|
| 147 |
+
send = gr.Button("Act", variant="primary", scale=1)
|
| 148 |
+
|
| 149 |
+
with gr.Row():
|
| 150 |
+
begin = gr.Button("🎲 Begin / Restart Adventure", variant="primary")
|
| 151 |
+
|
| 152 |
+
outputs = [engine_state, story, stats, c1, c2, c3, action]
|
| 153 |
+
|
| 154 |
+
begin.click(new_game, outputs=outputs)
|
| 155 |
+
send.click(take_action, inputs=[engine_state, action], outputs=outputs)
|
| 156 |
+
action.submit(take_action, inputs=[engine_state, action], outputs=outputs)
|
| 157 |
+
|
| 158 |
+
# Clicking a choice button sends that choice text as the action.
|
| 159 |
+
for btn in (c1, c2, c3):
|
| 160 |
+
btn.click(take_action, inputs=[engine_state, btn], outputs=outputs)
|
| 161 |
+
|
| 162 |
+
return demo
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
if __name__ == "__main__":
|
| 166 |
+
build_ui().launch()
|
engine/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Micro RPG Engine — a text RPG driven by a small (1B-4B) language model."""
|
| 2 |
+
|
| 3 |
+
from .game_state import GameState, NPC, Enemy
|
| 4 |
+
from .engine import GameEngine
|
| 5 |
+
from .llm import build_backend
|
| 6 |
+
|
| 7 |
+
__all__ = ["GameState", "NPC", "Enemy", "GameEngine", "build_backend"]
|
engine/engine.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The turn loop.
|
| 2 |
+
|
| 3 |
+
GameEngine ties the pieces together:
|
| 4 |
+
|
| 5 |
+
player action ─▶ build prompt from GameState ─▶ model.chat() ─▶ parser
|
| 6 |
+
◀─ narrative + updated state ◀────────────────────┘
|
| 7 |
+
|
| 8 |
+
It also enforces a couple of rules the model should not be trusted with:
|
| 9 |
+
* if the model forgets to make a defeated enemy stop attacking, combat still ends
|
| 10 |
+
when enemy HP hits 0 (handled in the parser);
|
| 11 |
+
* a guaranteed enemy counter-attack option exists even if the model is lazy
|
| 12 |
+
(the engine never *adds* damage on its own — it only ever clamps).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from .game_state import GameState
|
| 18 |
+
from .llm import Backend
|
| 19 |
+
from . import prompts, parser
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class GameEngine:
|
| 23 |
+
def __init__(self, backend: Backend, state: GameState | None = None):
|
| 24 |
+
self.backend = backend
|
| 25 |
+
self.state = state or GameState()
|
| 26 |
+
self.history: list[parser.TurnResult] = []
|
| 27 |
+
|
| 28 |
+
# ------------------------------------------------------------------ public
|
| 29 |
+
def start(self) -> parser.TurnResult:
|
| 30 |
+
"""Generate the opening scene."""
|
| 31 |
+
user = prompts.build_opening_prompt(self.state.context_snapshot())
|
| 32 |
+
return self._turn(user)
|
| 33 |
+
|
| 34 |
+
def act(self, player_action: str) -> parser.TurnResult:
|
| 35 |
+
"""Process one player action."""
|
| 36 |
+
if self.state.game_over:
|
| 37 |
+
return parser.TurnResult(
|
| 38 |
+
narrative="Your tale has ended. Start a new adventure to play again.",
|
| 39 |
+
choices=[],
|
| 40 |
+
applied=[],
|
| 41 |
+
raw="",
|
| 42 |
+
)
|
| 43 |
+
action = player_action.strip() or "look around"
|
| 44 |
+
user = prompts.build_turn_prompt(self.state.context_snapshot(), action)
|
| 45 |
+
return self._turn(user)
|
| 46 |
+
|
| 47 |
+
def reset(self) -> "GameEngine":
|
| 48 |
+
self.state = GameState()
|
| 49 |
+
self.history.clear()
|
| 50 |
+
return self
|
| 51 |
+
|
| 52 |
+
# ----------------------------------------------------------------- internal
|
| 53 |
+
def _turn(self, user_message: str) -> parser.TurnResult:
|
| 54 |
+
try:
|
| 55 |
+
raw = self.backend.chat(prompts.SYSTEM_PROMPT, user_message)
|
| 56 |
+
except Exception as exc: # network/model errors shouldn't crash the UI
|
| 57 |
+
return parser.TurnResult(
|
| 58 |
+
narrative=f"(The mists swirl — the storyteller faltered: {exc})",
|
| 59 |
+
choices=["Try again."],
|
| 60 |
+
applied=[],
|
| 61 |
+
raw="",
|
| 62 |
+
)
|
| 63 |
+
result = parser.run_turn(self.state, raw)
|
| 64 |
+
self.history.append(result)
|
| 65 |
+
return result
|
engine/game_state.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The authoritative game state.
|
| 2 |
+
|
| 3 |
+
The whole point of the engine is that *this* is the source of truth, not the
|
| 4 |
+
language model. The model proposes changes; `GameState` (via the parser) decides
|
| 5 |
+
what actually happens, clamping every value to a legal range. A small model can
|
| 6 |
+
hallucinate "you now have 9000 HP" — the state will not let it.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass, field, asdict
|
| 12 |
+
from typing import Optional
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
MAX_HP_CAP = 999
|
| 17 |
+
MAX_INVENTORY = 24
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class Enemy:
|
| 22 |
+
"""An enemy currently in combat. `None` on the state means no active fight."""
|
| 23 |
+
|
| 24 |
+
name: str
|
| 25 |
+
hp: int
|
| 26 |
+
max_hp: int
|
| 27 |
+
attack: int = 3
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def alive(self) -> bool:
|
| 31 |
+
return self.hp > 0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class NPC:
|
| 36 |
+
"""A named character the model has introduced. Persisted so the model can be
|
| 37 |
+
reminded who exists — this is a big lever for consistency."""
|
| 38 |
+
|
| 39 |
+
name: str
|
| 40 |
+
role: str = "" # "blacksmith", "old hermit", ...
|
| 41 |
+
disposition: str = "neutral" # friendly / neutral / hostile
|
| 42 |
+
note: str = "" # one-line memory, e.g. "owes you a favour"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class GameState:
|
| 47 |
+
# --- vitals ---
|
| 48 |
+
hp: int = 20
|
| 49 |
+
max_hp: int = 20
|
| 50 |
+
gold: int = 10
|
| 51 |
+
level: int = 1
|
| 52 |
+
xp: int = 0
|
| 53 |
+
|
| 54 |
+
# --- world ---
|
| 55 |
+
location: str = "The Crossroads"
|
| 56 |
+
inventory: list[str] = field(default_factory=lambda: ["Rusty Dagger", "Bread"])
|
| 57 |
+
npcs: dict[str, NPC] = field(default_factory=dict)
|
| 58 |
+
quest: str = "Discover why the village of Mossfall fell silent."
|
| 59 |
+
|
| 60 |
+
# --- combat (None when not fighting) ---
|
| 61 |
+
enemy: Optional[Enemy] = None
|
| 62 |
+
|
| 63 |
+
# --- meta ---
|
| 64 |
+
turn: int = 0
|
| 65 |
+
game_over: bool = False
|
| 66 |
+
log: list[str] = field(default_factory=list)
|
| 67 |
+
|
| 68 |
+
# ------------------------------------------------------------------ vitals
|
| 69 |
+
def damage(self, amount: int) -> None:
|
| 70 |
+
amount = max(0, int(amount))
|
| 71 |
+
self.hp = max(0, self.hp - amount)
|
| 72 |
+
if self.hp == 0:
|
| 73 |
+
self.game_over = True
|
| 74 |
+
|
| 75 |
+
def heal(self, amount: int) -> None:
|
| 76 |
+
amount = max(0, int(amount))
|
| 77 |
+
self.hp = min(self.max_hp, self.hp + amount)
|
| 78 |
+
|
| 79 |
+
def add_gold(self, amount: int) -> None:
|
| 80 |
+
self.gold = max(0, self.gold + int(amount))
|
| 81 |
+
|
| 82 |
+
def add_xp(self, amount: int) -> None:
|
| 83 |
+
self.xp += max(0, int(amount))
|
| 84 |
+
# simple, legible leveling curve: 10 * level to advance
|
| 85 |
+
while self.xp >= self.level * 10:
|
| 86 |
+
self.xp -= self.level * 10
|
| 87 |
+
self.level += 1
|
| 88 |
+
self.max_hp = min(MAX_HP_CAP, self.max_hp + 5)
|
| 89 |
+
self.hp = self.max_hp # full heal on level-up
|
| 90 |
+
self.log.append(f"LEVEL UP → {self.level} (max HP {self.max_hp})")
|
| 91 |
+
|
| 92 |
+
# --------------------------------------------------------------- inventory
|
| 93 |
+
def add_item(self, item: str) -> bool:
|
| 94 |
+
item = item.strip()
|
| 95 |
+
if not item or len(self.inventory) >= MAX_INVENTORY:
|
| 96 |
+
return False
|
| 97 |
+
self.inventory.append(item)
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
def remove_item(self, item: str) -> bool:
|
| 101 |
+
item = item.strip().lower()
|
| 102 |
+
for i, owned in enumerate(self.inventory):
|
| 103 |
+
if owned.lower() == item:
|
| 104 |
+
self.inventory.pop(i)
|
| 105 |
+
return True
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
def has_item(self, item: str) -> bool:
|
| 109 |
+
item = item.strip().lower()
|
| 110 |
+
return any(owned.lower() == item for owned in self.inventory)
|
| 111 |
+
|
| 112 |
+
# --------------------------------------------------------------------- npc
|
| 113 |
+
def upsert_npc(self, npc: NPC) -> None:
|
| 114 |
+
key = npc.name.strip().lower()
|
| 115 |
+
if not key:
|
| 116 |
+
return
|
| 117 |
+
if key in self.npcs:
|
| 118 |
+
# merge: keep old note unless a new non-empty one is given
|
| 119 |
+
old = self.npcs[key]
|
| 120 |
+
old.role = npc.role or old.role
|
| 121 |
+
old.disposition = npc.disposition or old.disposition
|
| 122 |
+
old.note = npc.note or old.note
|
| 123 |
+
else:
|
| 124 |
+
self.npcs[key] = npc
|
| 125 |
+
|
| 126 |
+
# ------------------------------------------------------------------ combat
|
| 127 |
+
def start_combat(self, enemy: Enemy) -> None:
|
| 128 |
+
self.enemy = enemy
|
| 129 |
+
|
| 130 |
+
def end_combat(self) -> None:
|
| 131 |
+
self.enemy = None
|
| 132 |
+
|
| 133 |
+
# ----------------------------------------------------------- (de)serialize
|
| 134 |
+
def to_dict(self) -> dict:
|
| 135 |
+
d = asdict(self)
|
| 136 |
+
return d
|
| 137 |
+
|
| 138 |
+
@classmethod
|
| 139 |
+
def from_dict(cls, d: dict) -> "GameState":
|
| 140 |
+
d = dict(d)
|
| 141 |
+
if d.get("enemy"):
|
| 142 |
+
d["enemy"] = Enemy(**d["enemy"])
|
| 143 |
+
if d.get("npcs"):
|
| 144 |
+
d["npcs"] = {k: NPC(**v) for k, v in d["npcs"].items()}
|
| 145 |
+
return cls(**d)
|
| 146 |
+
|
| 147 |
+
def to_json(self) -> str:
|
| 148 |
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
| 149 |
+
|
| 150 |
+
# ------------------------------------------------------------ for the LLM
|
| 151 |
+
def context_snapshot(self) -> str:
|
| 152 |
+
"""A compact, human-readable snapshot fed to the model every turn so it
|
| 153 |
+
never has to remember the numbers itself."""
|
| 154 |
+
lines = [
|
| 155 |
+
f"HP: {self.hp}/{self.max_hp}",
|
| 156 |
+
f"Level: {self.level} XP: {self.xp}/{self.level * 10}",
|
| 157 |
+
f"Gold: {self.gold}",
|
| 158 |
+
f"Location: {self.location}",
|
| 159 |
+
f"Inventory: {', '.join(self.inventory) if self.inventory else '(empty)'}",
|
| 160 |
+
f"Current quest: {self.quest}",
|
| 161 |
+
]
|
| 162 |
+
if self.enemy and self.enemy.alive:
|
| 163 |
+
e = self.enemy
|
| 164 |
+
lines.append(f"IN COMBAT with {e.name} ({e.hp}/{e.max_hp} HP, atk {e.attack})")
|
| 165 |
+
if self.npcs:
|
| 166 |
+
known = "; ".join(
|
| 167 |
+
f"{n.name} ({n.role or n.disposition})" for n in self.npcs.values()
|
| 168 |
+
)
|
| 169 |
+
lines.append(f"Known characters: {known}")
|
| 170 |
+
return "\n".join(lines)
|
engine/llm.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model backends.
|
| 2 |
+
|
| 3 |
+
Three interchangeable backends behind one tiny interface:
|
| 4 |
+
|
| 5 |
+
backend.chat(system: str, user: str) -> str
|
| 6 |
+
|
| 7 |
+
- `transformers` : load the small model locally (default; GPU or CPU).
|
| 8 |
+
- `inference_api` : call the Hugging Face serverless Inference API (no GPU).
|
| 9 |
+
- `mock` : a deterministic fake that emits valid tagged output, so the
|
| 10 |
+
parser, engine and UI can be tested with no weights / network.
|
| 11 |
+
|
| 12 |
+
Pick with the MICRORPG_BACKEND env var. See README for all knobs.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import random
|
| 19 |
+
from typing import Protocol
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
DEFAULT_MODEL = os.environ.get("MICRORPG_MODEL", "Qwen/Qwen3-4B-Instruct-2507")
|
| 23 |
+
MAX_NEW_TOKENS = int(os.environ.get("MICRORPG_MAX_TOKENS", "512"))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Backend(Protocol):
|
| 27 |
+
name: str
|
| 28 |
+
|
| 29 |
+
def chat(self, system: str, user: str) -> str: ...
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# --------------------------------------------------------------------------- #
|
| 33 |
+
# transformers (local)
|
| 34 |
+
# --------------------------------------------------------------------------- #
|
| 35 |
+
class TransformersBackend:
|
| 36 |
+
name = "transformers"
|
| 37 |
+
|
| 38 |
+
def __init__(self, model_id: str = DEFAULT_MODEL):
|
| 39 |
+
import torch
|
| 40 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 41 |
+
|
| 42 |
+
self.model_id = model_id
|
| 43 |
+
adapter = os.environ.get("MICRORPG_ADAPTER") # fine-tuned LoRA dir, optional
|
| 44 |
+
|
| 45 |
+
# If an adapter is given, the tokenizer was saved alongside it (and may carry
|
| 46 |
+
# the right chat template) — prefer it; otherwise load the base tokenizer.
|
| 47 |
+
self.tokenizer = AutoTokenizer.from_pretrained(adapter or model_id)
|
| 48 |
+
dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
|
| 49 |
+
self.model = AutoModelForCausalLM.from_pretrained(
|
| 50 |
+
model_id,
|
| 51 |
+
torch_dtype=dtype,
|
| 52 |
+
device_map="auto" if torch.cuda.is_available() else None,
|
| 53 |
+
)
|
| 54 |
+
if adapter:
|
| 55 |
+
from peft import PeftModel
|
| 56 |
+
self.model = PeftModel.from_pretrained(self.model, adapter)
|
| 57 |
+
print(f"[llm] loaded fine-tuned adapter: {adapter}")
|
| 58 |
+
self._torch = torch
|
| 59 |
+
|
| 60 |
+
def chat(self, system: str, user: str) -> str:
|
| 61 |
+
messages = [
|
| 62 |
+
{"role": "system", "content": system},
|
| 63 |
+
{"role": "user", "content": user},
|
| 64 |
+
]
|
| 65 |
+
inputs = self.tokenizer.apply_chat_template(
|
| 66 |
+
messages, add_generation_prompt=True, return_tensors="pt"
|
| 67 |
+
).to(self.model.device)
|
| 68 |
+
|
| 69 |
+
with self._torch.no_grad():
|
| 70 |
+
out = self.model.generate(
|
| 71 |
+
inputs,
|
| 72 |
+
max_new_tokens=MAX_NEW_TOKENS,
|
| 73 |
+
do_sample=True,
|
| 74 |
+
temperature=0.8,
|
| 75 |
+
top_p=0.9,
|
| 76 |
+
repetition_penalty=1.1,
|
| 77 |
+
pad_token_id=self.tokenizer.eos_token_id,
|
| 78 |
+
)
|
| 79 |
+
text = self.tokenizer.decode(
|
| 80 |
+
out[0][inputs.shape[-1]:], skip_special_tokens=True
|
| 81 |
+
)
|
| 82 |
+
return text.strip()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# --------------------------------------------------------------------------- #
|
| 86 |
+
# Hugging Face Inference API (serverless, no local GPU)
|
| 87 |
+
# --------------------------------------------------------------------------- #
|
| 88 |
+
class InferenceAPIBackend:
|
| 89 |
+
name = "inference_api"
|
| 90 |
+
|
| 91 |
+
def __init__(self, model_id: str = DEFAULT_MODEL):
|
| 92 |
+
from huggingface_hub import InferenceClient
|
| 93 |
+
|
| 94 |
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 95 |
+
self.model_id = model_id
|
| 96 |
+
self.client = InferenceClient(model=model_id, token=token)
|
| 97 |
+
|
| 98 |
+
def chat(self, system: str, user: str) -> str:
|
| 99 |
+
resp = self.client.chat_completion(
|
| 100 |
+
messages=[
|
| 101 |
+
{"role": "system", "content": system},
|
| 102 |
+
{"role": "user", "content": user},
|
| 103 |
+
],
|
| 104 |
+
max_tokens=MAX_NEW_TOKENS,
|
| 105 |
+
temperature=0.8,
|
| 106 |
+
top_p=0.9,
|
| 107 |
+
)
|
| 108 |
+
return resp.choices[0].message.content.strip()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# --------------------------------------------------------------------------- #
|
| 112 |
+
# mock (no weights, no network) — emits valid tagged output
|
| 113 |
+
# --------------------------------------------------------------------------- #
|
| 114 |
+
class MockBackend:
|
| 115 |
+
"""Deterministic-ish fake model. It reads the action out of the user message
|
| 116 |
+
and produces a plausible tagged turn so the rest of the stack can be exercised
|
| 117 |
+
end-to-end without any model. Not smart — just well-formed."""
|
| 118 |
+
|
| 119 |
+
name = "mock"
|
| 120 |
+
|
| 121 |
+
_SCENES = [
|
| 122 |
+
("A cold wind drags mist across {loc}. Something shifts in the dark ahead.",
|
| 123 |
+
"ENEMY: Mist Wraith|hp=10|atk=3"),
|
| 124 |
+
("You find a leather pouch half-buried in the mud. Coins glint inside.",
|
| 125 |
+
"GOLD: +7"),
|
| 126 |
+
("An old hermit beckons you toward a flickering lantern.",
|
| 127 |
+
"NPC: Aldric|hermit|friendly|knows the old roads"),
|
| 128 |
+
("A rusted chest yields a glimmer of steel.",
|
| 129 |
+
"ITEM_ADD: Iron Shortsword"),
|
| 130 |
+
("The path opens onto a ruined chapel, its bell long silent.",
|
| 131 |
+
"LOCATION: The Ruined Chapel"),
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
def __init__(self, model_id: str = "mock"):
|
| 135 |
+
self.model_id = model_id
|
| 136 |
+
self._rng = random.Random(7)
|
| 137 |
+
|
| 138 |
+
def chat(self, system: str, user: str) -> str:
|
| 139 |
+
action = user.lower()
|
| 140 |
+
loc = "the crossroads"
|
| 141 |
+
for line in user.splitlines():
|
| 142 |
+
if line.lower().startswith("location:"):
|
| 143 |
+
loc = line.split(":", 1)[1].strip()
|
| 144 |
+
|
| 145 |
+
# Combat-aware: if the player attacks, hurt the enemy and take a hit back.
|
| 146 |
+
if "in combat" in action and any(
|
| 147 |
+
w in action for w in ("attack", "strike", "hit", "swing", "stab")
|
| 148 |
+
):
|
| 149 |
+
narrative = "You lunge forward and your blade bites home; the creature shrieks and claws back."
|
| 150 |
+
state = "ENEMY_HP: -6\nHP: -3\nXP: +4"
|
| 151 |
+
choices = ["1. Press the attack.", "2. Back away and guard.", "3. Try to flee."]
|
| 152 |
+
else:
|
| 153 |
+
scene, change = self._rng.choice(self._SCENES)
|
| 154 |
+
narrative = scene.format(loc=loc)
|
| 155 |
+
state = change
|
| 156 |
+
choices = ["1. Investigate closely.", "2. Move on carefully.", "3. Call out."]
|
| 157 |
+
|
| 158 |
+
return (
|
| 159 |
+
f"<narrative>\n{narrative}\n</narrative>\n"
|
| 160 |
+
f"<state>\n{state}\n</state>\n"
|
| 161 |
+
f"<choices>\n" + "\n".join(choices) + "\n</choices>"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# --------------------------------------------------------------------------- #
|
| 166 |
+
# factory
|
| 167 |
+
# --------------------------------------------------------------------------- #
|
| 168 |
+
def build_backend(kind: str | None = None, model_id: str | None = None) -> Backend:
|
| 169 |
+
kind = (kind or os.environ.get("MICRORPG_BACKEND", "transformers")).lower()
|
| 170 |
+
model_id = model_id or DEFAULT_MODEL
|
| 171 |
+
|
| 172 |
+
if kind == "mock":
|
| 173 |
+
return MockBackend()
|
| 174 |
+
if kind in ("inference_api", "api", "inference"):
|
| 175 |
+
return InferenceAPIBackend(model_id)
|
| 176 |
+
if kind in ("transformers", "local"):
|
| 177 |
+
return TransformersBackend(model_id)
|
| 178 |
+
raise ValueError(f"Unknown backend: {kind!r}")
|
engine/parser.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Output parser.
|
| 2 |
+
|
| 3 |
+
Turns the model's raw text into (narrative, choices, list-of-deltas), then applies
|
| 4 |
+
the deltas to the GameState with full validation. This module is where "the model
|
| 5 |
+
proposes, Python disposes" actually happens.
|
| 6 |
+
|
| 7 |
+
It is intentionally forgiving about formatting (small models drift) but strict about
|
| 8 |
+
*effects*: an unparseable number is ignored, an unknown key is ignored, and every
|
| 9 |
+
applied change is clamped by GameState.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
from .game_state import GameState, NPC, Enemy
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class TurnResult:
|
| 22 |
+
narrative: str
|
| 23 |
+
choices: list[str]
|
| 24 |
+
applied: list[str] # human-readable list of what changed
|
| 25 |
+
raw: str # original model text (for debugging)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
_BLOCK = lambda tag, text: _extract_block(tag, text)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _extract_block(tag: str, text: str) -> str:
|
| 32 |
+
"""Grab the content between <tag> ... </tag>. Tolerates a missing closing tag
|
| 33 |
+
by reading to the next block or end of string."""
|
| 34 |
+
# normal, well-formed case
|
| 35 |
+
m = re.search(rf"<{tag}>(.*?)</{tag}>", text, re.DOTALL | re.IGNORECASE)
|
| 36 |
+
if m:
|
| 37 |
+
return m.group(1).strip()
|
| 38 |
+
# lenient: <tag> with no close — read until the next <...> or EOS
|
| 39 |
+
m = re.search(rf"<{tag}>(.*?)(?=<\w+>|\Z)", text, re.DOTALL | re.IGNORECASE)
|
| 40 |
+
if m:
|
| 41 |
+
return m.group(1).strip()
|
| 42 |
+
return ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def parse(raw: str) -> tuple[str, list[str], list[str]]:
|
| 46 |
+
"""Return (narrative, choices, raw_state_lines) from model text."""
|
| 47 |
+
narrative = _extract_block("narrative", raw)
|
| 48 |
+
state_block = _extract_block("state", raw)
|
| 49 |
+
choices_block = _extract_block("choices", raw)
|
| 50 |
+
|
| 51 |
+
# Fallback: if there were no tags at all, treat the whole thing as narrative.
|
| 52 |
+
if not narrative and not state_block and not choices_block:
|
| 53 |
+
narrative = raw.strip()
|
| 54 |
+
|
| 55 |
+
choices: list[str] = []
|
| 56 |
+
for line in choices_block.splitlines():
|
| 57 |
+
line = line.strip()
|
| 58 |
+
if not line:
|
| 59 |
+
continue
|
| 60 |
+
# strip leading "1." / "1)" / "- " markers
|
| 61 |
+
line = re.sub(r"^\s*(?:\d+[.)]|[-*•])\s*", "", line)
|
| 62 |
+
if line:
|
| 63 |
+
choices.append(line)
|
| 64 |
+
|
| 65 |
+
state_lines = [ln.strip() for ln in state_block.splitlines() if ln.strip()]
|
| 66 |
+
return narrative, choices, state_lines
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _parse_int(token: str) -> int | None:
|
| 70 |
+
m = re.search(r"[-+]?\d+", token)
|
| 71 |
+
return int(m.group()) if m else None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def apply_state_changes(state: GameState, state_lines: list[str]) -> list[str]:
|
| 75 |
+
"""Validate and apply each proposed change. Returns a human-readable changelog."""
|
| 76 |
+
applied: list[str] = []
|
| 77 |
+
|
| 78 |
+
for line in state_lines:
|
| 79 |
+
if ":" not in line and not line.upper().startswith(("ENEMY_DEFEATED", "GAME_OVER")):
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
key, _, value = line.partition(":")
|
| 83 |
+
key = key.strip().upper()
|
| 84 |
+
value = value.strip()
|
| 85 |
+
|
| 86 |
+
if key == "HP":
|
| 87 |
+
n = _parse_int(value)
|
| 88 |
+
if n is None:
|
| 89 |
+
continue
|
| 90 |
+
if n < 0:
|
| 91 |
+
state.damage(-n)
|
| 92 |
+
applied.append(f"HP {n}")
|
| 93 |
+
else:
|
| 94 |
+
state.heal(n)
|
| 95 |
+
applied.append(f"HP +{n}")
|
| 96 |
+
|
| 97 |
+
elif key == "GOLD":
|
| 98 |
+
n = _parse_int(value)
|
| 99 |
+
if n is not None:
|
| 100 |
+
state.add_gold(n)
|
| 101 |
+
applied.append(f"Gold {n:+d}")
|
| 102 |
+
|
| 103 |
+
elif key == "XP":
|
| 104 |
+
n = _parse_int(value)
|
| 105 |
+
if n is not None and n > 0:
|
| 106 |
+
before = state.level
|
| 107 |
+
state.add_xp(n)
|
| 108 |
+
applied.append(f"XP +{n}")
|
| 109 |
+
if state.level > before:
|
| 110 |
+
applied.append(f"Level up → {state.level}")
|
| 111 |
+
|
| 112 |
+
elif key == "ITEM_ADD":
|
| 113 |
+
if value and state.add_item(value):
|
| 114 |
+
applied.append(f"+ {value}")
|
| 115 |
+
|
| 116 |
+
elif key == "ITEM_REMOVE":
|
| 117 |
+
if value and state.remove_item(value):
|
| 118 |
+
applied.append(f"- {value}")
|
| 119 |
+
|
| 120 |
+
elif key == "LOCATION":
|
| 121 |
+
if value:
|
| 122 |
+
state.location = value
|
| 123 |
+
applied.append(f"Moved to {value}")
|
| 124 |
+
|
| 125 |
+
elif key == "QUEST":
|
| 126 |
+
if value:
|
| 127 |
+
state.quest = value
|
| 128 |
+
applied.append("Quest updated")
|
| 129 |
+
|
| 130 |
+
elif key == "NPC":
|
| 131 |
+
npc = _parse_npc(value)
|
| 132 |
+
if npc:
|
| 133 |
+
state.upsert_npc(npc)
|
| 134 |
+
applied.append(f"Met {npc.name}")
|
| 135 |
+
|
| 136 |
+
elif key == "ENEMY":
|
| 137 |
+
enemy = _parse_enemy(value)
|
| 138 |
+
if enemy:
|
| 139 |
+
state.start_combat(enemy)
|
| 140 |
+
applied.append(f"Combat: {enemy.name}")
|
| 141 |
+
|
| 142 |
+
elif key == "ENEMY_HP":
|
| 143 |
+
n = _parse_int(value)
|
| 144 |
+
if n is not None and state.enemy:
|
| 145 |
+
state.enemy.hp = max(0, state.enemy.hp + n)
|
| 146 |
+
applied.append(f"{state.enemy.name} HP {n:+d}")
|
| 147 |
+
if not state.enemy.alive:
|
| 148 |
+
applied.append(f"{state.enemy.name} defeated")
|
| 149 |
+
state.end_combat()
|
| 150 |
+
|
| 151 |
+
elif key.startswith("ENEMY_DEFEATED"):
|
| 152 |
+
if state.enemy:
|
| 153 |
+
applied.append(f"{state.enemy.name} defeated")
|
| 154 |
+
state.end_combat()
|
| 155 |
+
|
| 156 |
+
elif key.startswith("GAME_OVER"):
|
| 157 |
+
state.game_over = True
|
| 158 |
+
applied.append("GAME OVER")
|
| 159 |
+
|
| 160 |
+
return applied
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _parse_npc(value: str) -> NPC | None:
|
| 164 |
+
# format: Name|role|disposition|note (later fields optional)
|
| 165 |
+
parts = [p.strip() for p in value.split("|")]
|
| 166 |
+
if not parts or not parts[0]:
|
| 167 |
+
return None
|
| 168 |
+
name = parts[0]
|
| 169 |
+
role = parts[1] if len(parts) > 1 else ""
|
| 170 |
+
disp = parts[2] if len(parts) > 2 else "neutral"
|
| 171 |
+
note = parts[3] if len(parts) > 3 else ""
|
| 172 |
+
return NPC(name=name, role=role, disposition=disp, note=note)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _parse_enemy(value: str) -> Enemy | None:
|
| 176 |
+
# format: Name|hp=12|atk=4
|
| 177 |
+
parts = [p.strip() for p in value.split("|")]
|
| 178 |
+
if not parts or not parts[0]:
|
| 179 |
+
return None
|
| 180 |
+
name = parts[0]
|
| 181 |
+
hp, atk = 10, 3
|
| 182 |
+
for p in parts[1:]:
|
| 183 |
+
m = re.search(r"(hp|atk|attack)\s*=\s*(\d+)", p, re.IGNORECASE)
|
| 184 |
+
if m:
|
| 185 |
+
if m.group(1).lower() == "hp":
|
| 186 |
+
hp = int(m.group(2))
|
| 187 |
+
else:
|
| 188 |
+
atk = int(m.group(2))
|
| 189 |
+
hp = max(1, min(hp, 200))
|
| 190 |
+
atk = max(0, min(atk, 50))
|
| 191 |
+
return Enemy(name=name, hp=hp, max_hp=hp, attack=atk)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def run_turn(state: GameState, raw: str) -> TurnResult:
|
| 195 |
+
"""Full pipeline: parse model text, apply changes, advance the turn counter."""
|
| 196 |
+
narrative, choices, state_lines = parse(raw)
|
| 197 |
+
applied = apply_state_changes(state, state_lines)
|
| 198 |
+
state.turn += 1
|
| 199 |
+
if applied:
|
| 200 |
+
state.log.append(f"Turn {state.turn}: " + "; ".join(applied))
|
| 201 |
+
return TurnResult(narrative=narrative, choices=choices, applied=applied, raw=raw)
|
engine/prompts.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompt and the tag protocol.
|
| 2 |
+
|
| 3 |
+
The protocol is deliberately tiny and rigid. Small models follow a short, strict
|
| 4 |
+
format far more reliably than a verbose one. Every turn the model must answer with
|
| 5 |
+
exactly three blocks: <narrative>, <state>, <choices>. Anything outside them is
|
| 6 |
+
discarded by the parser.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
SYSTEM_PROMPT = """\
|
| 10 |
+
You are the engine of a dark-fantasy text RPG. You narrate a living world and run
|
| 11 |
+
its rules. You are NOT a chatbot — never break character, never mention being an AI,
|
| 12 |
+
never explain the rules to the player.
|
| 13 |
+
|
| 14 |
+
THE GAME STATE (HP, gold, inventory, location) is tracked by the program, not by
|
| 15 |
+
you. Before every turn you receive the current, authoritative state. You may only
|
| 16 |
+
PROPOSE changes to it using the tag protocol below. The program validates and
|
| 17 |
+
applies them. Never invent the player's HP or gold — read it from the state given.
|
| 18 |
+
|
| 19 |
+
You MUST reply with EXACTLY these three blocks, in this order, and nothing else:
|
| 20 |
+
|
| 21 |
+
<narrative>
|
| 22 |
+
2-4 vivid sentences describing what happens as a result of the player's action.
|
| 23 |
+
Second person, present tense. Be concrete and consistent with the state and the
|
| 24 |
+
characters already introduced. Do not list choices here.
|
| 25 |
+
</narrative>
|
| 26 |
+
|
| 27 |
+
<state>
|
| 28 |
+
One change per line, only when something actually changes. Allowed keys:
|
| 29 |
+
HP: -8 (damage; negative number)
|
| 30 |
+
HP: +5 (healing; positive number)
|
| 31 |
+
GOLD: +12 (or negative to spend)
|
| 32 |
+
XP: +6
|
| 33 |
+
ITEM_ADD: Iron Key
|
| 34 |
+
ITEM_REMOVE: Bread
|
| 35 |
+
LOCATION: The Sunken Crypt
|
| 36 |
+
QUEST: Find the three shards of the Moonglass
|
| 37 |
+
NPC: Borin|blacksmith|friendly|forged your blade
|
| 38 |
+
(name | role | friendly/neutral/hostile | one short memory)
|
| 39 |
+
ENEMY: Cave Goblin|hp=12|atk=4 (begins combat)
|
| 40 |
+
ENEMY_HP: -6 (damage the current enemy)
|
| 41 |
+
ENEMY_DEFEATED (ends combat; give XP/loot separately)
|
| 42 |
+
GAME_OVER: death (only when the player truly dies)
|
| 43 |
+
Leave this block empty (just the tags with nothing between) if nothing changed.
|
| 44 |
+
</state>
|
| 45 |
+
|
| 46 |
+
<choices>
|
| 47 |
+
1. A short actionable option.
|
| 48 |
+
2. A second, different option.
|
| 49 |
+
3. A third option (may be risky, clever, or a question to an NPC).
|
| 50 |
+
</choices>
|
| 51 |
+
|
| 52 |
+
RULES:
|
| 53 |
+
- Keep numbers small and fair. Early enemies have 8-15 HP and deal 2-6 damage.
|
| 54 |
+
- If the player attacks an enemy, deal damage via ENEMY_HP and let the enemy hit
|
| 55 |
+
back via HP, unless they dodge.
|
| 56 |
+
- Reward exploration and victories with small GOLD/XP and occasional items.
|
| 57 |
+
- Stay consistent: reuse NPC names, remember the location, honor the inventory.
|
| 58 |
+
- Never give the player items or gold they didn't earn just because they asked.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
# The very first turn: ask the model to open the adventure.
|
| 62 |
+
OPENING_INSTRUCTION = (
|
| 63 |
+
"Begin the adventure. Set an evocative opening scene at the player's current "
|
| 64 |
+
"location, hint at the quest, and offer the first choices. Introduce at most "
|
| 65 |
+
"one NPC."
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def build_turn_prompt(state_snapshot: str, player_action: str) -> str:
|
| 70 |
+
"""The user-role message for a normal turn."""
|
| 71 |
+
return (
|
| 72 |
+
"=== CURRENT STATE (authoritative — trust these numbers) ===\n"
|
| 73 |
+
f"{state_snapshot}\n"
|
| 74 |
+
"=== PLAYER ACTION ===\n"
|
| 75 |
+
f"{player_action}\n"
|
| 76 |
+
"=== YOUR TURN ===\n"
|
| 77 |
+
"Respond with the three blocks <narrative>, <state>, <choices>."
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_opening_prompt(state_snapshot: str) -> str:
|
| 82 |
+
return build_turn_prompt(state_snapshot, OPENING_INSTRUCTION)
|
finetune/build_dataset.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the fine-tuning dataset for Micro RPG Engine.
|
| 2 |
+
|
| 3 |
+
Strategy (the "Well-Tuned" bonus quest, done honestly):
|
| 4 |
+
|
| 5 |
+
We synthesize RPG *turns* in the EXACT tag protocol the engine expects, then —
|
| 6 |
+
the important part — we run every single generated turn through the real parser
|
| 7 |
+
(`engine.parser`) against a matching `GameState`. A turn is written to the dataset
|
| 8 |
+
ONLY if it parses cleanly and its <state> deltas apply without error. So 100% of
|
| 9 |
+
the training data is guaranteed well-formed and mechanically valid.
|
| 10 |
+
|
| 11 |
+
That's what teaches a 1B-4B model the thing that's actually hard: emitting the
|
| 12 |
+
three-block format and consistent mechanics, every turn.
|
| 13 |
+
|
| 14 |
+
No model and no network are needed to build the dataset — it's fully offline.
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python -m finetune.build_dataset --n 800 --out finetune/data
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
import random
|
| 26 |
+
import sys
|
| 27 |
+
|
| 28 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 29 |
+
|
| 30 |
+
from engine.game_state import GameState, Enemy, NPC
|
| 31 |
+
from engine import prompts, parser
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# --------------------------------------------------------------------------- #
|
| 35 |
+
# variation pools
|
| 36 |
+
# --------------------------------------------------------------------------- #
|
| 37 |
+
LOCATIONS = [
|
| 38 |
+
"The Crossroads", "The Sunken Crypt", "Mossfall Village", "The Whispering Woods",
|
| 39 |
+
"An Abandoned Watchtower", "The Drowned Cellar", "A Ruined Chapel",
|
| 40 |
+
"The Ashen Market", "The Frostbitten Pass", "The Hollow King's Tomb",
|
| 41 |
+
"A Mushroom Cavern", "The Broken Bridge",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
ENEMIES = [
|
| 45 |
+
("Cave Goblin", 9, 14, 2, 5), ("Mist Wraith", 8, 12, 3, 6),
|
| 46 |
+
("Giant Rat", 6, 10, 1, 4), ("Skeleton Warrior", 12, 18, 3, 7),
|
| 47 |
+
("Bog Lurker", 14, 22, 2, 6), ("Bandit Scout", 10, 16, 3, 6),
|
| 48 |
+
("Hungry Ghoul", 11, 17, 3, 7), ("Stone Gargoyle", 16, 24, 2, 5),
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
ITEMS = [
|
| 52 |
+
"Iron Shortsword", "Health Potion", "Torch", "Iron Key", "Silver Ring",
|
| 53 |
+
"Tattered Map", "Oak Shield", "Bundle of Arrows", "Moonglass Shard",
|
| 54 |
+
"Loaf of Bread", "Vial of Antidote", "Rusty Lantern",
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
NPCS = [
|
| 58 |
+
("Borin", "blacksmith", "friendly", "forged your blade"),
|
| 59 |
+
("Aldric", "old hermit", "neutral", "knows the old roads"),
|
| 60 |
+
("Mara", "merchant", "neutral", "always after coin"),
|
| 61 |
+
("Sister Wen", "healer", "friendly", "tends the wounded"),
|
| 62 |
+
("Grimm", "gravekeeper", "neutral", "guards the crypt"),
|
| 63 |
+
("Captain Voss", "guard captain", "hostile", "blames you for the fire"),
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
QUESTS = [
|
| 67 |
+
"Discover why the village of Mossfall fell silent.",
|
| 68 |
+
"Find the three shards of the Moonglass.",
|
| 69 |
+
"Escort the merchant safely through the pass.",
|
| 70 |
+
"Recover the Hollow King's stolen crown.",
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# --------------------------------------------------------------------------- #
|
| 75 |
+
# formatting + validation
|
| 76 |
+
# --------------------------------------------------------------------------- #
|
| 77 |
+
def fmt_response(narrative: str, state_lines: list[str], choices: list[str]) -> str:
|
| 78 |
+
state_body = "\n".join(state_lines)
|
| 79 |
+
choice_body = "\n".join(f"{i}. {c}" for i, c in enumerate(choices, 1))
|
| 80 |
+
return (
|
| 81 |
+
f"<narrative>\n{narrative}\n</narrative>\n"
|
| 82 |
+
f"<state>\n{state_body}\n</state>\n"
|
| 83 |
+
f"<choices>\n{choice_body}\n</choices>"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def is_valid(state: GameState, response: str, expect_changes: bool) -> bool:
|
| 88 |
+
"""Round-trip the crafted response through the real parser against a *copy*
|
| 89 |
+
of the state. Accept only if it parses into a narrative + 3 choices and its
|
| 90 |
+
deltas apply (changing something when changes were intended)."""
|
| 91 |
+
narrative, choices, state_lines = parser.parse(response)
|
| 92 |
+
if not narrative or len(choices) != 3:
|
| 93 |
+
return False
|
| 94 |
+
probe = GameState.from_dict(state.to_dict()) # deep-ish copy
|
| 95 |
+
applied = parser.apply_state_changes(probe, state_lines)
|
| 96 |
+
if expect_changes and not applied:
|
| 97 |
+
return False
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# --------------------------------------------------------------------------- #
|
| 102 |
+
# scene generators — each returns (state, action, response)
|
| 103 |
+
# --------------------------------------------------------------------------- #
|
| 104 |
+
def _base_state(rng: random.Random, **over) -> GameState:
|
| 105 |
+
s = GameState(
|
| 106 |
+
hp=rng.randint(8, 20), max_hp=20, gold=rng.randint(0, 30),
|
| 107 |
+
level=rng.randint(1, 3), location=rng.choice(LOCATIONS),
|
| 108 |
+
quest=rng.choice(QUESTS),
|
| 109 |
+
inventory=rng.sample(ITEMS, k=rng.randint(1, 3)),
|
| 110 |
+
)
|
| 111 |
+
for k, v in over.items():
|
| 112 |
+
setattr(s, k, v)
|
| 113 |
+
return s
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def scene_opening(rng):
|
| 117 |
+
s = _base_state(rng)
|
| 118 |
+
name, role, disp, note = rng.choice(NPCS)
|
| 119 |
+
narrative = rng.choice([
|
| 120 |
+
f"Cold rain needles the stones of {s.location}. A hooded figure watches you "
|
| 121 |
+
f"from beneath a dead tree, saying nothing.",
|
| 122 |
+
f"You arrive at {s.location} as the last light fails. Somewhere ahead, a bell "
|
| 123 |
+
f"tolls once and falls silent.",
|
| 124 |
+
f"The road ends at {s.location}. The air tastes of ash and old iron, and a "
|
| 125 |
+
f"lone lantern sways with no wind to move it.",
|
| 126 |
+
])
|
| 127 |
+
lines = [f"NPC: {name}|{role}|{disp}|{note}"]
|
| 128 |
+
choices = [
|
| 129 |
+
f"Approach {name} and ask what happened here.",
|
| 130 |
+
"Search the area before anyone notices you.",
|
| 131 |
+
"Press on toward the heart of the ruins.",
|
| 132 |
+
]
|
| 133 |
+
return s, prompts.OPENING_INSTRUCTION, fmt_response(narrative, lines, choices), True
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def scene_loot(rng):
|
| 137 |
+
s = _base_state(rng)
|
| 138 |
+
if rng.random() < 0.5:
|
| 139 |
+
gold = rng.randint(3, 15)
|
| 140 |
+
lines = [f"GOLD: +{gold}"]
|
| 141 |
+
narrative = f"You pry open a rotted strongbox. {gold} tarnished coins spill into your palm."
|
| 142 |
+
else:
|
| 143 |
+
item = rng.choice(ITEMS)
|
| 144 |
+
lines = [f"ITEM_ADD: {item}"]
|
| 145 |
+
narrative = f"Half-buried in the muck you find a {item}, still serviceable."
|
| 146 |
+
action = rng.choice(["I search the room", "look under the rubble", "I rummage through the chest"])
|
| 147 |
+
choices = ["Pocket it and move on.", "Keep searching for more.", "Listen for danger first."]
|
| 148 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def scene_combat_start(rng):
|
| 152 |
+
s = _base_state(rng)
|
| 153 |
+
name, lo, hi, alo, ahi = rng.choice(ENEMIES)
|
| 154 |
+
hp = rng.randint(lo, hi)
|
| 155 |
+
atk = rng.randint(alo, ahi)
|
| 156 |
+
lines = [f"ENEMY: {name}|hp={hp}|atk={atk}"]
|
| 157 |
+
narrative = rng.choice([
|
| 158 |
+
f"A {name} lunges from the shadows, teeth bared. There is no time to talk.",
|
| 159 |
+
f"The dark coalesces into a {name}. It blocks the only way forward.",
|
| 160 |
+
f"Gravel shifts — a {name} rises, weapon raised, eyes fixed on you.",
|
| 161 |
+
])
|
| 162 |
+
action = rng.choice(["I approach the shadowy figure", "go through the doorway", "I light my torch and step forward"])
|
| 163 |
+
choices = ["Strike first with your weapon.", "Raise your guard and wait.", "Try to slip past it."]
|
| 164 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def scene_combat_attack(rng):
|
| 168 |
+
name, lo, hi, alo, ahi = rng.choice(ENEMIES)
|
| 169 |
+
ehp = rng.randint(6, hi)
|
| 170 |
+
atk = rng.randint(alo, ahi)
|
| 171 |
+
s = _base_state(rng, hp=rng.randint(10, 20), enemy=Enemy(name, ehp, ehp, atk))
|
| 172 |
+
dmg = rng.randint(4, 8)
|
| 173 |
+
took = rng.randint(2, max(2, atk))
|
| 174 |
+
xp = rng.randint(2, 5)
|
| 175 |
+
lines = [f"ENEMY_HP: -{dmg}", f"HP: -{took}", f"XP: +{xp}"]
|
| 176 |
+
narrative = rng.choice([
|
| 177 |
+
f"Your blade bites deep; the {name} shrieks and rakes you in return.",
|
| 178 |
+
f"You land a solid blow, but the {name} answers with a glancing strike.",
|
| 179 |
+
f"Steel meets flesh. The {name} staggers, then claws back at you.",
|
| 180 |
+
])
|
| 181 |
+
action = rng.choice([f"I attack the {name}", "swing my sword", "I strike at it"])
|
| 182 |
+
choices = ["Press the attack.", "Fall back and guard.", "Attempt to flee."]
|
| 183 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def scene_killing_blow(rng):
|
| 187 |
+
name, lo, hi, alo, ahi = rng.choice(ENEMIES)
|
| 188 |
+
ehp = rng.randint(3, 7)
|
| 189 |
+
s = _base_state(rng, hp=rng.randint(8, 20), enemy=Enemy(name, ehp, ehp + 6, rng.randint(alo, ahi)))
|
| 190 |
+
xp = rng.randint(5, 10)
|
| 191 |
+
lines = [f"ENEMY_HP: -{ehp + rng.randint(1,4)}", f"XP: +{xp}"]
|
| 192 |
+
if rng.random() < 0.5:
|
| 193 |
+
lines.append(f"GOLD: +{rng.randint(4, 18)}")
|
| 194 |
+
else:
|
| 195 |
+
lines.append(f"ITEM_ADD: {rng.choice(ITEMS)}")
|
| 196 |
+
narrative = rng.choice([
|
| 197 |
+
f"With a final thrust the {name} crumples and goes still. Silence returns.",
|
| 198 |
+
f"You break through its guard — the {name} falls and does not rise.",
|
| 199 |
+
f"The {name} reels and collapses at your feet, defeated.",
|
| 200 |
+
])
|
| 201 |
+
action = rng.choice([f"I finish off the {name}", "deliver the killing blow", "I strike again"])
|
| 202 |
+
choices = ["Search the body.", "Catch your breath.", "Move deeper inside."]
|
| 203 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def scene_flee(rng):
|
| 207 |
+
name = rng.choice(ENEMIES)[0]
|
| 208 |
+
ehp = rng.randint(8, 16)
|
| 209 |
+
s = _base_state(rng, enemy=Enemy(name, ehp, ehp, 4))
|
| 210 |
+
dest = rng.choice(LOCATIONS)
|
| 211 |
+
took = rng.randint(0, 4)
|
| 212 |
+
lines = [f"LOCATION: {dest}"]
|
| 213 |
+
if took:
|
| 214 |
+
lines.append(f"HP: -{took}")
|
| 215 |
+
narrative = (
|
| 216 |
+
f"You break away and run. The {name} gives chase but loses you in the dark, "
|
| 217 |
+
f"and you stumble out into {dest}."
|
| 218 |
+
)
|
| 219 |
+
action = rng.choice(["I flee", "run away", "I try to escape"])
|
| 220 |
+
choices = ["Catch your breath.", "Keep moving.", "Look for another path."]
|
| 221 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def scene_npc_talk(rng):
|
| 225 |
+
name, role, disp, note = rng.choice(NPCS)
|
| 226 |
+
s = _base_state(rng)
|
| 227 |
+
s.upsert_npc(NPC(name, role, disp, note))
|
| 228 |
+
if rng.random() < 0.5:
|
| 229 |
+
quest = rng.choice(QUESTS)
|
| 230 |
+
lines = [f"QUEST: {quest}"]
|
| 231 |
+
narrative = (
|
| 232 |
+
f'"{name} leans close. \"If you seek answers,\" the {role} mutters, '
|
| 233 |
+
f'\"then there is something you must do.\" The path ahead grows clearer.'
|
| 234 |
+
)
|
| 235 |
+
else:
|
| 236 |
+
lines = [] # pure dialogue, no mechanics — important to teach empty <state>
|
| 237 |
+
narrative = (
|
| 238 |
+
f'{name} the {role} studies you a long moment. "Mind yourself out there," '
|
| 239 |
+
f'they say. "The dead don\'t stay buried in these parts."'
|
| 240 |
+
)
|
| 241 |
+
action = rng.choice([f"I ask {name} about the village", f"talk to {name}", f"question the {role}"])
|
| 242 |
+
choices = [f"Ask {name} for help.", "Thank them and leave.", "Press for more details."]
|
| 243 |
+
expect = bool(lines)
|
| 244 |
+
return s, action, fmt_response(narrative, lines, choices), expect
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def scene_shop(rng):
|
| 248 |
+
s = _base_state(rng, gold=rng.randint(15, 40), location="The Ashen Market")
|
| 249 |
+
item = rng.choice(["Health Potion", "Oak Shield", "Bundle of Arrows", "Vial of Antidote"])
|
| 250 |
+
cost = rng.randint(5, 14)
|
| 251 |
+
lines = [f"GOLD: -{cost}", f"ITEM_ADD: {item}"]
|
| 252 |
+
narrative = (
|
| 253 |
+
f"The merchant weighs your coins, then slides a {item} across the stall. "
|
| 254 |
+
f'"Pleasure doing business," she says without smiling.'
|
| 255 |
+
)
|
| 256 |
+
action = rng.choice([f"I buy a {item}", f"purchase the {item}", "I pay for the item"])
|
| 257 |
+
choices = ["Browse the other wares.", "Haggle over the price.", "Leave the market."]
|
| 258 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def scene_use_potion(rng):
|
| 262 |
+
s = _base_state(rng, hp=rng.randint(4, 12), inventory=["Health Potion", rng.choice(ITEMS)])
|
| 263 |
+
heal = rng.randint(6, 12)
|
| 264 |
+
lines = [f"ITEM_REMOVE: Health Potion", f"HP: +{heal}"]
|
| 265 |
+
narrative = (
|
| 266 |
+
f"You uncork the Health Potion and drink. Warmth floods your limbs as wounds "
|
| 267 |
+
f"knit closed."
|
| 268 |
+
)
|
| 269 |
+
action = rng.choice(["I drink the health potion", "use my potion", "I quaff the potion"])
|
| 270 |
+
choices = ["Continue on.", "Rest a moment longer.", "Check your surroundings."]
|
| 271 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def scene_move(rng):
|
| 275 |
+
s = _base_state(rng)
|
| 276 |
+
dest = rng.choice([l for l in LOCATIONS if l != s.location])
|
| 277 |
+
lines = [f"LOCATION: {dest}"]
|
| 278 |
+
narrative = rng.choice([
|
| 279 |
+
f"You follow the winding path until the trees give way to {dest}.",
|
| 280 |
+
f"After a long walk the way opens onto {dest}, quiet and watchful.",
|
| 281 |
+
])
|
| 282 |
+
action = rng.choice(["I head north", "go east through the trees", "I take the stone stairs down"])
|
| 283 |
+
choices = ["Explore the area.", "Stay alert for danger.", "Call out to see who answers."]
|
| 284 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def scene_trap(rng):
|
| 288 |
+
s = _base_state(rng, hp=rng.randint(10, 20))
|
| 289 |
+
dmg = rng.randint(2, 6)
|
| 290 |
+
lines = [f"HP: -{dmg}"]
|
| 291 |
+
narrative = rng.choice([
|
| 292 |
+
"The floor gives way to a hidden spike pit. You twist aside but a barb tears your leg.",
|
| 293 |
+
"A dart hisses from the wall and buries itself in your shoulder before you can move.",
|
| 294 |
+
])
|
| 295 |
+
action = rng.choice(["I open the chest", "step onto the tiles", "I pull the lever"])
|
| 296 |
+
choices = ["Bind the wound.", "Search for more traps.", "Press on, ignoring the pain."]
|
| 297 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def scene_death(rng):
|
| 301 |
+
name = rng.choice(ENEMIES)[0]
|
| 302 |
+
s = _base_state(rng, hp=rng.randint(2, 5), enemy=Enemy(name, 10, 10, 6))
|
| 303 |
+
lines = ["HP: -12", "GAME_OVER: death"]
|
| 304 |
+
narrative = (
|
| 305 |
+
f"The {name} is faster. Its blow lands true and the world tips sideways into "
|
| 306 |
+
f"cold and dark. Your story ends here."
|
| 307 |
+
)
|
| 308 |
+
action = rng.choice(["I charge recklessly", "I attack with everything I have", "stand and fight"])
|
| 309 |
+
choices = ["Begin a new tale.", "Reflect on your journey.", "Rest at last."]
|
| 310 |
+
return s, action, fmt_response(narrative, lines, choices), True
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
GENERATORS = [
|
| 314 |
+
scene_opening, scene_loot, scene_combat_start, scene_combat_attack,
|
| 315 |
+
scene_killing_blow, scene_flee, scene_npc_talk, scene_shop,
|
| 316 |
+
scene_use_potion, scene_move, scene_trap, scene_death,
|
| 317 |
+
]
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# --------------------------------------------------------------------------- #
|
| 321 |
+
# build
|
| 322 |
+
# --------------------------------------------------------------------------- #
|
| 323 |
+
def build(n: int, seed: int = 13):
|
| 324 |
+
rng = random.Random(seed)
|
| 325 |
+
examples = []
|
| 326 |
+
attempts = 0
|
| 327 |
+
rejected = 0
|
| 328 |
+
while len(examples) < n and attempts < n * 20:
|
| 329 |
+
attempts += 1
|
| 330 |
+
gen = rng.choice(GENERATORS)
|
| 331 |
+
state, action, response, expect_changes = gen(rng)
|
| 332 |
+
if not is_valid(state, response, expect_changes):
|
| 333 |
+
rejected += 1
|
| 334 |
+
continue
|
| 335 |
+
user = prompts.build_turn_prompt(state.context_snapshot(), action)
|
| 336 |
+
examples.append({
|
| 337 |
+
"messages": [
|
| 338 |
+
{"role": "system", "content": prompts.SYSTEM_PROMPT},
|
| 339 |
+
{"role": "user", "content": user},
|
| 340 |
+
{"role": "assistant", "content": response},
|
| 341 |
+
]
|
| 342 |
+
})
|
| 343 |
+
return examples, rejected
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def main():
|
| 347 |
+
ap = argparse.ArgumentParser()
|
| 348 |
+
ap.add_argument("--n", type=int, default=800, help="number of training examples")
|
| 349 |
+
ap.add_argument("--eval-frac", type=float, default=0.05)
|
| 350 |
+
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "data"))
|
| 351 |
+
ap.add_argument("--seed", type=int, default=13)
|
| 352 |
+
args = ap.parse_args()
|
| 353 |
+
|
| 354 |
+
examples, rejected = build(args.n, args.seed)
|
| 355 |
+
random.Random(args.seed).shuffle(examples)
|
| 356 |
+
|
| 357 |
+
n_eval = max(1, int(len(examples) * args.eval_frac))
|
| 358 |
+
eval_set, train_set = examples[:n_eval], examples[n_eval:]
|
| 359 |
+
|
| 360 |
+
os.makedirs(args.out, exist_ok=True)
|
| 361 |
+
train_path = os.path.join(args.out, "train.jsonl")
|
| 362 |
+
eval_path = os.path.join(args.out, "eval.jsonl")
|
| 363 |
+
for path, data in ((train_path, train_set), (eval_path, eval_set)):
|
| 364 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 365 |
+
for ex in data:
|
| 366 |
+
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
|
| 367 |
+
|
| 368 |
+
print(f"Generated {len(examples)} validated turns "
|
| 369 |
+
f"({rejected} rejected by the parser during generation).")
|
| 370 |
+
print(f" train: {len(train_set)} -> {train_path}")
|
| 371 |
+
print(f" eval : {len(eval_set)} -> {eval_path}")
|
| 372 |
+
print("Every example is guaranteed to parse and apply cleanly.")
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
if __name__ == "__main__":
|
| 376 |
+
main()
|
finetune/data/eval.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
finetune/data/train.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
finetune/train.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LoRA supervised fine-tuning for Micro RPG Engine.
|
| 2 |
+
|
| 3 |
+
Teaches a small base model (1B-4B) to reliably emit the three-block tag protocol
|
| 4 |
+
with valid mechanics, using the parser-validated dataset from build_dataset.py.
|
| 5 |
+
|
| 6 |
+
We use LoRA (PEFT) so it trains on a single consumer/Colab GPU and produces a tiny
|
| 7 |
+
adapter (a few MB). Point the engine at it with MICRORPG_ADAPTER to play with your
|
| 8 |
+
fine-tuned model.
|
| 9 |
+
|
| 10 |
+
Quickstart
|
| 11 |
+
----------
|
| 12 |
+
pip install -r requirements-train.txt
|
| 13 |
+
python -m finetune.build_dataset --n 1200
|
| 14 |
+
python -m finetune.train \
|
| 15 |
+
--model Qwen/Qwen3-4B-Instruct-2507 \
|
| 16 |
+
--out finetune/out/qwen3-4b-microrpg
|
| 17 |
+
|
| 18 |
+
Then play with it:
|
| 19 |
+
# PowerShell
|
| 20 |
+
$env:MICRORPG_ADAPTER = "finetune/out/qwen3-4b-microrpg"
|
| 21 |
+
python app.py
|
| 22 |
+
|
| 23 |
+
Notes
|
| 24 |
+
-----
|
| 25 |
+
* `--model` accepts any chat model with a chat template (Qwen3-4B, MiniCPM, a Llama
|
| 26 |
+
for the "Llama Champion" quest, etc.). Swap freely — the dataset is model-agnostic.
|
| 27 |
+
* For a 4B model on a small GPU, add `--load-4bit` (needs bitsandbytes).
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
import os
|
| 34 |
+
import sys
|
| 35 |
+
|
| 36 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main():
|
| 40 |
+
ap = argparse.ArgumentParser()
|
| 41 |
+
ap.add_argument("--model", default=os.environ.get("MICRORPG_MODEL", "Qwen/Qwen3-4B-Instruct-2507"))
|
| 42 |
+
ap.add_argument("--train", default="finetune/data/train.jsonl")
|
| 43 |
+
ap.add_argument("--eval", default="finetune/data/eval.jsonl")
|
| 44 |
+
ap.add_argument("--out", default="finetune/out/microrpg-adapter")
|
| 45 |
+
ap.add_argument("--epochs", type=float, default=3.0)
|
| 46 |
+
ap.add_argument("--lr", type=float, default=2e-4)
|
| 47 |
+
ap.add_argument("--batch", type=int, default=2)
|
| 48 |
+
ap.add_argument("--grad-accum", type=int, default=8)
|
| 49 |
+
ap.add_argument("--max-len", type=int, default=1536)
|
| 50 |
+
ap.add_argument("--lora-r", type=int, default=16)
|
| 51 |
+
ap.add_argument("--lora-alpha", type=int, default=32)
|
| 52 |
+
ap.add_argument("--load-4bit", action="store_true", help="QLoRA via bitsandbytes")
|
| 53 |
+
ap.add_argument("--merge", action="store_true",
|
| 54 |
+
help="after training, merge the adapter into the base and save full weights")
|
| 55 |
+
args = ap.parse_args()
|
| 56 |
+
|
| 57 |
+
# Heavy imports kept inside main so `--help` and import-checks stay light.
|
| 58 |
+
import torch
|
| 59 |
+
from datasets import load_dataset
|
| 60 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 61 |
+
from peft import LoraConfig
|
| 62 |
+
from trl import SFTConfig, SFTTrainer
|
| 63 |
+
|
| 64 |
+
print(f"Base model : {args.model}")
|
| 65 |
+
print(f"Train file : {args.train}")
|
| 66 |
+
|
| 67 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model)
|
| 68 |
+
if tokenizer.pad_token is None:
|
| 69 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 70 |
+
|
| 71 |
+
model_kwargs = {"torch_dtype": torch.bfloat16 if torch.cuda.is_available() else torch.float32}
|
| 72 |
+
if args.load_4bit:
|
| 73 |
+
from transformers import BitsAndBytesConfig
|
| 74 |
+
model_kwargs["quantization_config"] = BitsAndBytesConfig(
|
| 75 |
+
load_in_4bit=True,
|
| 76 |
+
bnb_4bit_quant_type="nf4",
|
| 77 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 78 |
+
bnb_4bit_use_double_quant=True,
|
| 79 |
+
)
|
| 80 |
+
if torch.cuda.is_available():
|
| 81 |
+
model_kwargs["device_map"] = "auto"
|
| 82 |
+
|
| 83 |
+
model = AutoModelForCausalLM.from_pretrained(args.model, **model_kwargs)
|
| 84 |
+
|
| 85 |
+
# LoRA adapter on attention + MLP projections — the standard, portable target set.
|
| 86 |
+
peft_config = LoraConfig(
|
| 87 |
+
r=args.lora_r,
|
| 88 |
+
lora_alpha=args.lora_alpha,
|
| 89 |
+
lora_dropout=0.05,
|
| 90 |
+
bias="none",
|
| 91 |
+
task_type="CAUSAL_LM",
|
| 92 |
+
target_modules=[
|
| 93 |
+
"q_proj", "k_proj", "v_proj", "o_proj",
|
| 94 |
+
"gate_proj", "up_proj", "down_proj",
|
| 95 |
+
],
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
data_files = {"train": args.train}
|
| 99 |
+
if os.path.exists(args.eval):
|
| 100 |
+
data_files["eval"] = args.eval
|
| 101 |
+
ds = load_dataset("json", data_files=data_files)
|
| 102 |
+
|
| 103 |
+
sft_config = SFTConfig(
|
| 104 |
+
output_dir=args.out,
|
| 105 |
+
num_train_epochs=args.epochs,
|
| 106 |
+
per_device_train_batch_size=args.batch,
|
| 107 |
+
gradient_accumulation_steps=args.grad_accum,
|
| 108 |
+
learning_rate=args.lr,
|
| 109 |
+
lr_scheduler_type="cosine",
|
| 110 |
+
warmup_ratio=0.05,
|
| 111 |
+
logging_steps=10,
|
| 112 |
+
save_strategy="epoch",
|
| 113 |
+
eval_strategy="epoch" if "eval" in ds else "no",
|
| 114 |
+
bf16=torch.cuda.is_available(),
|
| 115 |
+
gradient_checkpointing=True,
|
| 116 |
+
max_seq_length=args.max_len,
|
| 117 |
+
packing=False,
|
| 118 |
+
report_to="none",
|
| 119 |
+
# The dataset has a "messages" column → TRL applies the chat template and,
|
| 120 |
+
# by default, masks the prompt so loss is computed only on the assistant turn.
|
| 121 |
+
assistant_only_loss=True,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
trainer = SFTTrainer(
|
| 125 |
+
model=model,
|
| 126 |
+
args=sft_config,
|
| 127 |
+
train_dataset=ds["train"],
|
| 128 |
+
eval_dataset=ds.get("eval"),
|
| 129 |
+
peft_config=peft_config,
|
| 130 |
+
processing_class=tokenizer,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
trainer.train()
|
| 134 |
+
trainer.save_model(args.out)
|
| 135 |
+
tokenizer.save_pretrained(args.out)
|
| 136 |
+
print(f"\nAdapter saved to: {args.out}")
|
| 137 |
+
|
| 138 |
+
if args.merge:
|
| 139 |
+
print("Merging adapter into base weights...")
|
| 140 |
+
merged_dir = args.out.rstrip("/\\") + "-merged"
|
| 141 |
+
merged = trainer.model.merge_and_unload()
|
| 142 |
+
merged.save_pretrained(merged_dir)
|
| 143 |
+
tokenizer.save_pretrained(merged_dir)
|
| 144 |
+
print(f"Merged model saved to: {merged_dir}")
|
| 145 |
+
|
| 146 |
+
print("\nPlay with it: set MICRORPG_ADAPTER to the output dir, then run app.py")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
main()
|
requirements-train.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fine-tuning deps (NOT needed to run the app / Space — only to train an adapter).
|
| 2 |
+
# Install on a GPU machine or Colab: pip install -r requirements-train.txt
|
| 3 |
+
torch>=2.2.0
|
| 4 |
+
transformers>=4.46.0
|
| 5 |
+
datasets>=3.0.0
|
| 6 |
+
trl>=0.12.0
|
| 7 |
+
peft>=0.13.0
|
| 8 |
+
accelerate>=0.34.0
|
| 9 |
+
# Optional, only for --load-4bit (QLoRA):
|
| 10 |
+
bitsandbytes>=0.44.0 ; platform_system != "Windows"
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.9,<6
|
| 2 |
+
huggingface_hub>=0.25.0
|
| 3 |
+
transformers>=4.44.0
|
| 4 |
+
torch>=2.2.0
|
| 5 |
+
accelerate>=0.33.0
|
style.css
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Micro RPG Engine — parchment / arcane theme */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--parchment: #f4ecd8;
|
| 5 |
+
--ink: #2b2118;
|
| 6 |
+
--blood: #7c2d2d;
|
| 7 |
+
--gold: #b8860b;
|
| 8 |
+
--arcane: #4b3b6b;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.gradio-container {
|
| 12 |
+
background:
|
| 13 |
+
radial-gradient(circle at 20% 10%, #1a1320 0%, #0d0a12 60%),
|
| 14 |
+
#0d0a12 !important;
|
| 15 |
+
font-family: "Georgia", "Iowan Old Style", serif !important;
|
| 16 |
+
max-width: 1100px !important;
|
| 17 |
+
margin: auto !important;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
#title-md h1 {
|
| 21 |
+
text-align: center;
|
| 22 |
+
color: var(--gold);
|
| 23 |
+
letter-spacing: 1px;
|
| 24 |
+
text-shadow: 0 0 18px rgba(184, 134, 11, 0.35);
|
| 25 |
+
margin-bottom: 0;
|
| 26 |
+
}
|
| 27 |
+
#title-md p {
|
| 28 |
+
text-align: center;
|
| 29 |
+
color: #b9a98c;
|
| 30 |
+
font-style: italic;
|
| 31 |
+
margin-top: 4px;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* The story panel — looks like aged parchment */
|
| 35 |
+
#story {
|
| 36 |
+
background: linear-gradient(180deg, #f7f0dd 0%, #ece0c2 100%) !important;
|
| 37 |
+
color: var(--ink) !important;
|
| 38 |
+
border: 1px solid #6b5836 !important;
|
| 39 |
+
border-radius: 10px !important;
|
| 40 |
+
box-shadow: 0 0 30px rgba(0, 0, 0, 0.6), inset 0 0 60px rgba(120, 90, 40, 0.15);
|
| 41 |
+
min-height: 360px;
|
| 42 |
+
padding: 22px 26px !important;
|
| 43 |
+
font-size: 1.08rem;
|
| 44 |
+
line-height: 1.65;
|
| 45 |
+
}
|
| 46 |
+
/* Force the dark ink onto every text node Gradio nests inside the panel —
|
| 47 |
+
otherwise the theme's light --body-text-color wins on <p>/<span>/<li>. */
|
| 48 |
+
#story,
|
| 49 |
+
#story p,
|
| 50 |
+
#story li,
|
| 51 |
+
#story span,
|
| 52 |
+
#story strong,
|
| 53 |
+
#story em,
|
| 54 |
+
#story a {
|
| 55 |
+
color: var(--ink) !important;
|
| 56 |
+
}
|
| 57 |
+
#story h3 { color: var(--blood) !important; margin-top: 0; }
|
| 58 |
+
/* The changelog lines (rendered as blockquotes) — readable brown on parchment */
|
| 59 |
+
#story blockquote {
|
| 60 |
+
color: #5c4326 !important;
|
| 61 |
+
border-left: 3px solid var(--gold) !important;
|
| 62 |
+
background: rgba(120, 90, 40, 0.08);
|
| 63 |
+
font-style: italic;
|
| 64 |
+
}
|
| 65 |
+
#story blockquote * { color: #5c4326 !important; }
|
| 66 |
+
|
| 67 |
+
/* Stats sidebar */
|
| 68 |
+
#stats {
|
| 69 |
+
background: rgba(30, 22, 40, 0.85) !important;
|
| 70 |
+
border: 1px solid var(--arcane) !important;
|
| 71 |
+
border-radius: 10px !important;
|
| 72 |
+
color: #e8dfc8 !important;
|
| 73 |
+
padding: 16px 18px !important;
|
| 74 |
+
font-size: 0.98rem;
|
| 75 |
+
}
|
| 76 |
+
#stats .hpbar {
|
| 77 |
+
height: 14px; border-radius: 7px; background: #311; overflow: hidden;
|
| 78 |
+
border: 1px solid #511;
|
| 79 |
+
}
|
| 80 |
+
#stats .hpfill {
|
| 81 |
+
height: 100%; background: linear-gradient(90deg, #7c2d2d, #c0392b);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Buttons */
|
| 85 |
+
button.primary, .gr-button-primary {
|
| 86 |
+
background: linear-gradient(180deg, #5a4630, #3a2c1c) !important;
|
| 87 |
+
border: 1px solid var(--gold) !important;
|
| 88 |
+
color: var(--parchment) !important;
|
| 89 |
+
}
|
| 90 |
+
button.primary:hover { box-shadow: 0 0 14px rgba(184,134,11,0.5) !important; }
|
| 91 |
+
|
| 92 |
+
/* Input box */
|
| 93 |
+
#action-input textarea {
|
| 94 |
+
background: #1c1626 !important;
|
| 95 |
+
color: #efe6cf !important;
|
| 96 |
+
border: 1px solid #4b3b6b !important;
|
| 97 |
+
font-family: "Georgia", serif !important;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
footer { visibility: hidden; }
|
tests/test_parser.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Parser + engine smoke tests. Run with: python -m tests.test_parser
|
| 2 |
+
|
| 3 |
+
These use the mock backend, so no model weights or network are required."""
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 9 |
+
|
| 10 |
+
from engine.game_state import GameState, Enemy
|
| 11 |
+
from engine import parser
|
| 12 |
+
from engine.engine import GameEngine
|
| 13 |
+
from engine.llm import build_backend
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def check(name, cond):
|
| 17 |
+
status = "ok " if cond else "FAIL"
|
| 18 |
+
print(f"[{status}] {name}")
|
| 19 |
+
if not cond:
|
| 20 |
+
raise AssertionError(name)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_parse_blocks():
|
| 24 |
+
raw = (
|
| 25 |
+
"<narrative>You enter a dim hall.</narrative>"
|
| 26 |
+
"<state>\nHP: -5\nGOLD: +10\nITEM_ADD: Torch\nLOCATION: Dim Hall\n</state>"
|
| 27 |
+
"<choices>\n1. Go north.\n2. Light the torch.\n</choices>"
|
| 28 |
+
)
|
| 29 |
+
narrative, choices, lines = parser.parse(raw)
|
| 30 |
+
check("narrative extracted", narrative == "You enter a dim hall.")
|
| 31 |
+
check("choices extracted", choices == ["Go north.", "Light the torch."])
|
| 32 |
+
check("state lines count", len(lines) == 4)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_apply_changes_clamped():
|
| 36 |
+
state = GameState(hp=20, max_hp=20, gold=10)
|
| 37 |
+
parser.apply_state_changes(state, ["HP: -5", "GOLD: +10", "ITEM_ADD: Torch"])
|
| 38 |
+
check("hp reduced", state.hp == 15)
|
| 39 |
+
check("gold added", state.gold == 20)
|
| 40 |
+
check("item added", state.has_item("Torch"))
|
| 41 |
+
|
| 42 |
+
# over-heal is clamped to max_hp
|
| 43 |
+
parser.apply_state_changes(state, ["HP: +999"])
|
| 44 |
+
check("heal clamped to max", state.hp == 20)
|
| 45 |
+
|
| 46 |
+
# can't go below zero gold
|
| 47 |
+
parser.apply_state_changes(state, ["GOLD: -9999"])
|
| 48 |
+
check("gold floored at 0", state.gold == 0)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_death():
|
| 52 |
+
state = GameState(hp=5)
|
| 53 |
+
parser.apply_state_changes(state, ["HP: -50"])
|
| 54 |
+
check("hp floored at 0", state.hp == 0)
|
| 55 |
+
check("game over on death", state.game_over)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_combat_flow():
|
| 59 |
+
state = GameState()
|
| 60 |
+
parser.apply_state_changes(state, ["ENEMY: Goblin|hp=10|atk=4"])
|
| 61 |
+
check("combat started", state.enemy is not None and state.enemy.name == "Goblin")
|
| 62 |
+
parser.apply_state_changes(state, ["ENEMY_HP: -6"])
|
| 63 |
+
check("enemy damaged", state.enemy.hp == 4)
|
| 64 |
+
parser.apply_state_changes(state, ["ENEMY_HP: -10"])
|
| 65 |
+
check("combat ended on death", state.enemy is None)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_leveling():
|
| 69 |
+
state = GameState(level=1, xp=0, max_hp=20)
|
| 70 |
+
parser.apply_state_changes(state, ["XP: +10"])
|
| 71 |
+
check("leveled up", state.level == 2)
|
| 72 |
+
check("max hp grew", state.max_hp == 25)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_unparseable_ignored():
|
| 76 |
+
state = GameState(hp=20)
|
| 77 |
+
before = state.to_dict()
|
| 78 |
+
parser.apply_state_changes(state, ["HP: lots", "WUT: 5", "random gibberish"])
|
| 79 |
+
check("garbage ignored", state.to_dict() == before)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_full_engine_mock():
|
| 83 |
+
engine = GameEngine(build_backend("mock"))
|
| 84 |
+
opening = engine.start()
|
| 85 |
+
check("opening has narrative", len(opening.narrative) > 0)
|
| 86 |
+
check("opening has choices", len(opening.choices) == 3)
|
| 87 |
+
|
| 88 |
+
# force a combat then attack
|
| 89 |
+
engine.state.start_combat(Enemy("Wraith", hp=10, max_hp=10, attack=3))
|
| 90 |
+
res = engine.act("I attack the wraith with my dagger")
|
| 91 |
+
check("attack damaged enemy or ended combat",
|
| 92 |
+
engine.state.enemy is None or engine.state.enemy.hp < 10)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def main():
|
| 96 |
+
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
| 97 |
+
for t in tests:
|
| 98 |
+
t()
|
| 99 |
+
print(f"\nAll {len(tests)} test groups passed.")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
main()
|