luizbarbedo commited on
Commit
7fe39f3
·
verified ·
1 Parent(s): 77b6853

Upload folder using huggingface_hub

Browse files
.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 Rpg Engine
3
- emoji: 🏆
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>_ &nbsp;•&nbsp; **📣 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} &nbsp; (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()