Image-Text-to-Text
Transformers
Safetensors
English
gamescaling
game-agent
embodied-agent
keyboard-mouse
continued-pretraining
qwen3_5
Instructions to use THUSI-Lab/GameScaling with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use THUSI-Lab/GameScaling with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="THUSI-Lab/GameScaling")# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("THUSI-Lab/GameScaling", device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use THUSI-Lab/GameScaling with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "THUSI-Lab/GameScaling" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "THUSI-Lab/GameScaling", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker
docker model run hf.co/THUSI-Lab/GameScaling
- SGLang
How to use THUSI-Lab/GameScaling with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "THUSI-Lab/GameScaling" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "THUSI-Lab/GameScaling", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "THUSI-Lab/GameScaling" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "THUSI-Lab/GameScaling", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }' - Docker Model Runner
How to use THUSI-Lab/GameScaling with Docker Model Runner:
docker model run hf.co/THUSI-Lab/GameScaling
Single <think>-prefilled prompt format, no-reasoning mode, full Doom example; drop benchmark presets
Browse files- README.md +140 -130
- gamescaling/__init__.py +13 -6
- gamescaling/action.py +3 -4
- gamescaling/keymaps.py +2 -3
- gamescaling/policy.py +21 -45
- gamescaling/prompt.py +15 -69
- prompts/example_doom_battle1.txt +22 -3
- requirements.txt +0 -2
- tests/test_core.py +19 -13
README.md
CHANGED
|
@@ -105,10 +105,10 @@ THUSI-Lab/GameScaling
|
|
| 105 |
│ └── 27b_s3_1600h_gs7020/
|
| 106 |
├── prompts/
|
| 107 |
│ ├── system_prompt_template.txt standard prompt, 3 slots (§2)
|
| 108 |
-
│ └── example_doom_battle1.txt
|
| 109 |
├── gamescaling/ inference client
|
| 110 |
│ ├── prompt.py system prompt builders
|
| 111 |
-
│ ├── policy.py client
|
| 112 |
│ ├── action.py action format and parser
|
| 113 |
│ └── keymaps.py key names <-> browser keys
|
| 114 |
├── scripts/serve_vllm.sh start a vLLM server
|
|
@@ -162,145 +162,185 @@ Things to keep as shipped:
|
|
| 162 |
- **One server per GPU group.** One vLLM replica can serve several games at once, but long
|
| 163 |
prefills (one image per history turn) make each step slower when many games share it.
|
| 164 |
|
| 165 |
-
## 2. System prompt
|
| 166 |
|
| 167 |
`prompts/system_prompt_template.txt` is the standard system prompt. Fill in three placeholders
|
| 168 |
-
and keep everything else
|
| 169 |
-
|
| 170 |
|
| 171 |
-
| Placeholder | What to write |
|
| 172 |
-
|---|---|
|
| 173 |
-
| `{GAME_NAME}` | The game's name. It appears twice: in the opening and the key-list heading. |
|
| 174 |
-
| `{GAME_RULES}` |
|
| 175 |
-
| `{KEY_BINDINGS}` | One `- <key> -> <effect>` line per key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
-
|
|
|
|
| 178 |
|
| 179 |
```text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
- W -> Move forward
|
| 181 |
-
- S -> Move backward
|
| 182 |
- A -> Strafe left
|
|
|
|
| 183 |
- D -> Strafe right
|
| 184 |
-
- LB -> Fire
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
```
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
-
|
| 194 |
|
| 195 |
```python
|
| 196 |
tpl = open("prompts/system_prompt_template.txt").read().rstrip("\n")
|
| 197 |
system = (tpl.replace("{GAME_NAME}", "Doom Battle-1")
|
| 198 |
-
.replace("{GAME_RULES}",
|
| 199 |
-
.replace("{KEY_BINDINGS}", "- W -> Move forward\n-
|
| 200 |
-
"- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)"))
|
| 201 |
```
|
| 202 |
|
| 203 |
-
The result is byte-identical to what `build_system_prompt(...)` below produces;
|
| 204 |
-
`tests/test_core.py` checks this.
|
| 205 |
-
|
| 206 |
## 3. Minimal usage
|
| 207 |
|
| 208 |
```python
|
| 209 |
from PIL import Image
|
| 210 |
-
from gamescaling import GameScalingPolicy, PolicyConfig
|
| 211 |
|
| 212 |
-
|
| 213 |
-
"Doom Battle-1",
|
| 214 |
-
rules="Kill as many monsters as possible; collect health and ammo.",
|
| 215 |
-
key_bindings={"W": "Move forward", "S": "Move backward", "A": "Strafe left",
|
| 216 |
-
"D": "Strafe right", "LB": "Fire"},
|
| 217 |
-
mouse_aim=True, # the mouse controls the camera
|
| 218 |
-
) # reasoning=True by default, matching the <think> prefill
|
| 219 |
-
policy = GameScalingPolicy(system, PolicyConfig()) # prefills <think>; frame sent at native size
|
| 220 |
policy.reset() # call at the start of every episode
|
| 221 |
decision = policy.act(Image.open("frame.png")) # one call = one 200 ms decision
|
| 222 |
a = decision.action
|
| 223 |
a.mouse # (X, Y, Z): mouse movement for this chunk (per-mille of the screen) + scroll
|
| 224 |
a.steps # 6 groups: the keys held during each 33 ms step
|
| 225 |
a.parsed # False = could not be parsed; execute as a no-op
|
|
|
|
| 226 |
```
|
| 227 |
|
| 228 |
Pass `a` to your executor. Run 6 steps of 33 ms, holding that step's keys during each one. Keys
|
| 229 |
that appear in two consecutive groups stay held rather than being pressed again. Mouse movement
|
| 230 |
is `X/1000 * screen width` and `Y/1000 * screen height`. You can spread it over the 200 ms or
|
| 231 |
-
send it at once.
|
| 232 |
-
|
| 233 |
-
Defaults are not tied to any benchmark: any game frame, the standard system prompt, and a
|
| 234 |
-
`<think>` prefill. To reproduce a paper table, use
|
| 235 |
-
`PolicyConfig.preset("omnigamearena" | "vizdoom" | "gameworld")` instead (§6).
|
| 236 |
-
`TRAINING_SYSTEM_PROMPT` is the generic pre-training prompt. Its Output Format is action-only and
|
| 237 |
-
does not match a `<think>` prefill, so use it only for ablations.
|
| 238 |
-
|
| 239 |
-
## 4. Prompt and message structure
|
| 240 |
|
| 241 |
-
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
2. `**Game Rules**`: what the game is and how it is scored;
|
| 246 |
-
3. **Controls**: the keys the game uses, **written in the training vocabulary** (§5);
|
| 247 |
-
4. `**Task Instruction**`: GameWorld only (one game can have several tasks);
|
| 248 |
-
5. `**Output Format**`: how the model picks its output format;
|
| 249 |
-
6. `**Explanation**` + `**Key Naming Rules**`.
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
- `build_system_prompt(...)`: key list (`**Common Keys in <game>**` + `- W -> Move forward`).
|
| 254 |
-
Used for ViZDoom, OmniGameArena and **any new game**. Add `mouse_aim=True` for games where the
|
| 255 |
-
mouse controls the camera.
|
| 256 |
-
- `build_gameworld_system_prompt(...)`: free-text paragraphs (`**Role and Controls**`), for
|
| 257 |
-
games that come with their own written controls, as in GameWorld. `translate_controls`
|
| 258 |
-
rewrites browser key names into the training vocabulary (`ArrowLeft` -> `Left`, `a/d` ->
|
| 259 |
-
`A/D`, `1-9:` -> `one-nine:`).
|
| 260 |
-
|
| 261 |
-
The Output Format **must match whether the client prefills `<think>`**:
|
| 262 |
-
|
| 263 |
-
| `reasoning` | Client | Model output |
|
| 264 |
|---|---|---|
|
| 265 |
-
| `
|
| 266 |
-
| `False` |
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
Each user turn is the text `current game screen` followed by the frame. Multi-turn messages
|
| 269 |
(`policy.py`):
|
| 270 |
|
| 271 |
-
```
|
| 272 |
-
system system prompt
|
| 273 |
user "current game screen" + frame t-k ┐ last history_len turns (default 5)
|
| 274 |
assistant action taken at t-k (no reasoning) ┘
|
| 275 |
...
|
| 276 |
user "current game screen" + current frame
|
| 277 |
-
assistant "<think>"
|
| 278 |
```
|
| 279 |
|
| 280 |
-
Past turns keep only the action (`history_think=0`
|
| 281 |
-
|
| 282 |
-
raw bad output.
|
| 283 |
|
| 284 |
-
|
| 285 |
`skip_special_tokens=false`, JPEG frames (quality 90) at native resolution.
|
| 286 |
|
| 287 |
## 5. Action format
|
| 288 |
|
| 289 |
-
```
|
| 290 |
<|action_start|>X Y Z ; g1 ; g2 ; g3 ; g4 ; g5 ; g6<|action_end|>
|
| 291 |
```
|
| 292 |
|
| 293 |
- `X Y`: mouse movement over the whole 200 ms, in per-mille of the screen
|
| 294 |
(`X = Σdx / screen width × 1000`), clipped to ±1000. X>0 is right, Y>0 is down.
|
| 295 |
- `Z`: scroll notches, clipped to ±5. Z>0 is up.
|
| 296 |
-
- `g1..g6`: six 33 ms steps. Each group lists the keys
|
| 297 |
empty group releases all keys, but the `;` must stay.
|
| 298 |
- `<|action_start|>` / `<|action_end|>` are special tokens. Requests must set
|
| 299 |
`skip_special_tokens: false` (`build_request` does), or the server strips the tags.
|
| 300 |
`parse_action` also accepts a bare `X Y Z ; ...` as a fallback.
|
| 301 |
-
- The action is read
|
| 302 |
-
|
| 303 |
-
|
| 304 |
|
| 305 |
Training key vocabulary (74 tokens, case-sensitive, `gamescaling.action.KEY_VOCAB`):
|
| 306 |
|
|
@@ -308,64 +348,34 @@ Training key vocabulary (74 tokens, case-sensitive, `gamescaling.action.KEY_VOCA
|
|
| 308 |
|---|---|
|
| 309 |
| Letters | `A`..`Z` |
|
| 310 |
| Number row | `zero` `one` .. `nine` |
|
| 311 |
-
| F1-F12 | `One` `Two` .. `Twelve` (
|
| 312 |
| Mouse buttons | `LB` `RB` `MB` |
|
| 313 |
| Arrows | `Up` `Down` `Left` `Right` |
|
| 314 |
| Other | `Shift` `Ctrl` `Alt` `Tab` `Caps` `Esc` `Space` `Enter` `Back` `Delete` `Insert` `Home` `End` `Pause` `Equal` `Minus` `Period` `Slash` `Quote` |
|
| 315 |
|
| 316 |
Tokens outside the vocabulary are dropped and recorded in `Action.dropped`.
|
| 317 |
-
`keymaps.TO_PLAYWRIGHT`
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
**Checklist before reading a score:**
|
| 340 |
-
|
| 341 |
-
- [ ] The system prompt is the template from §2 (or `build_system_prompt`), with
|
| 342 |
-
`reasoning=True` when the client prefills `<think>`, and `reasoning=False` for `gameworld`.
|
| 343 |
-
- [ ] The parse rate is high. `Decision.action.parsed` is `False` for unparseable replies, and
|
| 344 |
-
`Action.truncated` is `True` when `<think>` was never closed. Many of either usually means a
|
| 345 |
-
wrong template, a stripped special token, or a too-small `max_tokens`.
|
| 346 |
-
- [ ] Executed actions actually move the game. Each chunk runs in wall-clock time (6 × 33 ms), so
|
| 347 |
-
a slow or shared renderer makes held keys barely move, with no error message. For
|
| 348 |
-
OmniGameArena, run **one UE5 instance per GPU**.
|
| 349 |
-
- [ ] Episodes are real. In OmniGameArena, start a fresh UE5 process for every `solo_craft`
|
| 350 |
-
episode; a reused instance ends episodes 2 and 3 immediately and repeats the first score.
|
| 351 |
-
|
| 352 |
-
**Scoring.** On OmniGameArena, `solo_craft` is scored as `min(V/50, 1)` from the raw points `V`;
|
| 353 |
-
the overall score is the mean over the seven games. ViZDoom counts kills on the battle maps and
|
| 354 |
-
kills minus deaths on the defend maps.
|
| 355 |
-
|
| 356 |
-
## 7. Adding a new game
|
| 357 |
-
|
| 358 |
-
1. Fill in `prompts/system_prompt_template.txt` (§2), or call `build_system_prompt` with the game
|
| 359 |
-
name, rules and `key_bindings` in the training vocabulary. Add `mouse_aim=True` if the mouse
|
| 360 |
-
controls the camera.
|
| 361 |
-
2. Capture one frame every 200 ms (or at every decision under a paused clock) and call
|
| 362 |
-
`policy.act(frame)`.
|
| 363 |
-
3. Execute the returned `Action` as in §5: held keys for 6 steps of 33 ms, plus mouse movement and
|
| 364 |
-
scroll.
|
| 365 |
-
4. Keep the default client (native frame size, `<think>` prefill, standard prompt). Switch to a
|
| 366 |
-
preset (§6) only to match a paper table.
|
| 367 |
-
|
| 368 |
-
## 8. Tests
|
| 369 |
|
| 370 |
```bash
|
| 371 |
python tests/test_core.py # or: python -m pytest tests/
|
|
|
|
| 105 |
│ └── 27b_s3_1600h_gs7020/
|
| 106 |
├── prompts/
|
| 107 |
│ ├── system_prompt_template.txt standard prompt, 3 slots (§2)
|
| 108 |
+
│ └── example_doom_battle1.txt complete Doom prompt (§2)
|
| 109 |
├── gamescaling/ inference client
|
| 110 |
│ ├── prompt.py system prompt builders
|
| 111 |
+
│ ├── policy.py client and request body
|
| 112 |
│ ├── action.py action format and parser
|
| 113 |
│ └── keymaps.py key names <-> browser keys
|
| 114 |
├── scripts/serve_vllm.sh start a vLLM server
|
|
|
|
| 162 |
- **One server per GPU group.** One vLLM replica can serve several games at once, but long
|
| 163 |
prefills (one image per history turn) make each step slower when many games share it.
|
| 164 |
|
| 165 |
+
## 2. System prompt
|
| 166 |
|
| 167 |
`prompts/system_prompt_template.txt` is the standard system prompt. Fill in three placeholders
|
| 168 |
+
and keep everything else exactly as written: the model decides its output tags from the exact
|
| 169 |
+
text of the Output Format section.
|
| 170 |
|
| 171 |
+
| Placeholder | What to write |
|
| 172 |
+
|---|---|
|
| 173 |
+
| `{GAME_NAME}` | The game's name. It appears twice: in the opening and in the key-list heading. |
|
| 174 |
+
| `{GAME_RULES}` | What the game is, what is on screen, the goal and how it is scored. Plain text; several lines are fine. |
|
| 175 |
+
| `{KEY_BINDINGS}` | One `- <key> -> <effect>` line per key, in training key names (§5). End with the mouse line below if the mouse turns the camera. |
|
| 176 |
+
|
| 177 |
+
The mouse line, copied verbatim:
|
| 178 |
+
|
| 179 |
+
```text
|
| 180 |
+
- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
> **Note.** Lines such as `**Game Rules**`, `**Output Format**` and
|
| 184 |
+
> `**Explanation**` are plain-text section headers that the model saw in training. They are part
|
| 185 |
+
> of the prompt, not Markdown formatting: send them to the model unchanged, including the
|
| 186 |
+
> asterisks.
|
| 187 |
+
|
| 188 |
+
### Complete example: Doom Battle-1
|
| 189 |
|
| 190 |
+
This is the full system prompt for the ViZDoom Battle-1 map, as in
|
| 191 |
+
`prompts/example_doom_battle1.txt`. It is the same text in both reasoning modes (§4).
|
| 192 |
|
| 193 |
```text
|
| 194 |
+
You are a gaming expert, and you are currently playing Doom Battle-1. You are proficient in keyboard and mouse operations. You understand game mechanics and combat pacing, can quickly extract key information from the current screen, and can think and make precise decisions at critical moments. Based on the current screen, plan the next 200ms of actions, consisting of 6 steps spaced 33ms apart. Each step lasts 33ms, until the next step begins. If the current situation continues the previous strategy, output the action directly. Think only when the situation changes significantly, the previous analysis is no longer valid, or a new objective appears.
|
| 195 |
+
**Game Rules**
|
| 196 |
+
You are playing Doom, a first-person shooter, in an arena full of monsters. Survive and kill as many monsters as you can.
|
| 197 |
+
|
| 198 |
+
## What you see on screen
|
| 199 |
+
- First-person view.
|
| 200 |
+
- The crosshair at screen centre marks where shots land.
|
| 201 |
+
- The status bar along the bottom shows HEALTH on the left and AMMO on the right.
|
| 202 |
+
- Monsters keep arriving for the whole episode; some throw fireballs from range.
|
| 203 |
+
|
| 204 |
+
## Rules
|
| 205 |
+
- Score is the number of monsters you kill in the episode.
|
| 206 |
+
- Firing costs ammo, and the episode ends as soon as your health reaches 0, so dodging matters as much as shooting: standing still in the open is how most episodes end early.
|
| 207 |
+
- Health packs and ammo boxes lie around the map; walk over one to pick it up. Running out of either is fatal, so collect them while fighting.
|
| 208 |
+
- A target is hit when it is under the crosshair, so turn with the mouse until the monster is centred before firing.
|
| 209 |
+
- The map is one large open arena, so monsters can close in from any direction.
|
| 210 |
+
**Common Keys in Doom Battle-1**
|
| 211 |
- W -> Move forward
|
|
|
|
| 212 |
- A -> Strafe left
|
| 213 |
+
- S -> Move backward
|
| 214 |
- D -> Strafe right
|
| 215 |
+
- LB -> Fire weapon
|
| 216 |
+
- Ctrl -> Fire weapon (keyboard alternative)
|
| 217 |
+
- Shift -> Run
|
| 218 |
+
- Space -> Open doors / use switches
|
| 219 |
+
- one -> Select weapon slot 1
|
| 220 |
+
- two -> Select weapon slot 2
|
| 221 |
+
- three -> Select weapon slot 3
|
| 222 |
- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
|
| 223 |
+
**Output Format**
|
| 224 |
+
**Action only (usual)**
|
| 225 |
+
<think></think><|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10<|action_end|>
|
| 226 |
+
**Thinking + action (when necessary)**
|
| 227 |
+
<think>reasoning</think><|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10<|action_end|>
|
| 228 |
+
**Explanation**
|
| 229 |
+
1. **Mouse Movement**: First, specify the relative displacement X, Y (X>0 means move right, Y>0 means move down) and scroll amount Z (Z>0 means scroll up).
|
| 230 |
+
2. **Key Sequence**: Then list 6 groups of keys; within each group, keys are separated by spaces, and groups are separated by semicolons.
|
| 231 |
+
- Each group can contain up to 4 keys.
|
| 232 |
+
- If a group has no keys, leave it empty but keep the `;`.
|
| 233 |
+
3. Only output a plain string that conforms to the above format — no line breaks and no quotation marks.
|
| 234 |
+
**Key Naming Rules**
|
| 235 |
+
- Number keys `0-9`: use lowercase English words, e.g., `zero` for `0`, `one` for `1`, ... `nine` for `9`.
|
| 236 |
+
- Function keys `F1-F12`: use capitalized English words, e.g., `One` represents `F1`, `Two` represents `F2`, and so on.
|
| 237 |
+
- Mouse buttons: use `LB`, `RB`, `MB` for the left, right, and middle mouse button.
|
| 238 |
+
- Letters: use the uppercase letter, e.g., `A`, `D`, `W`, `S`.
|
| 239 |
+
- Arrow keys: use `Up`, `Down`, `Left`, `Right`.
|
| 240 |
+
- Modifier/special keys: `Shift`, `Ctrl`, `Alt`, `Tab`, `Caps`, `Esc`, `Space`, `Enter`, `Back` (backspace), `Delete`, `Insert`, `Home`, `End`, `Pause`.
|
| 241 |
```
|
| 242 |
|
| 243 |
+
The same prompt from code (`tests/test_core.py` checks that it matches the file byte for byte):
|
| 244 |
+
|
| 245 |
+
```python
|
| 246 |
+
from gamescaling import build_system_prompt
|
| 247 |
+
|
| 248 |
+
DOOM_RULES = open("prompts/example_doom_battle1.txt").read() \
|
| 249 |
+
.split("**Game Rules**\n", 1)[1].split("\n**Common Keys in", 1)[0]
|
| 250 |
+
|
| 251 |
+
system = build_system_prompt(
|
| 252 |
+
"Doom Battle-1",
|
| 253 |
+
rules=DOOM_RULES,
|
| 254 |
+
key_bindings=[
|
| 255 |
+
("W", "Move forward"), ("A", "Strafe left"), ("S", "Move backward"), ("D", "Strafe right"),
|
| 256 |
+
("LB", "Fire weapon"), ("Ctrl", "Fire weapon (keyboard alternative)"), ("Shift", "Run"),
|
| 257 |
+
("Space", "Open doors / use switches"), ("one", "Select weapon slot 1"),
|
| 258 |
+
("two", "Select weapon slot 2"), ("three", "Select weapon slot 3"),
|
| 259 |
+
],
|
| 260 |
+
mouse_aim=True, # appends the "Mouse X Y -> ..." line
|
| 261 |
+
)
|
| 262 |
+
```
|
| 263 |
|
| 264 |
+
Filling the text file directly gives the same result:
|
| 265 |
|
| 266 |
```python
|
| 267 |
tpl = open("prompts/system_prompt_template.txt").read().rstrip("\n")
|
| 268 |
system = (tpl.replace("{GAME_NAME}", "Doom Battle-1")
|
| 269 |
+
.replace("{GAME_RULES}", DOOM_RULES)
|
| 270 |
+
.replace("{KEY_BINDINGS}", "- W -> Move forward\n- A -> Strafe left\n..."
|
| 271 |
+
"\n- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)"))
|
| 272 |
```
|
| 273 |
|
|
|
|
|
|
|
|
|
|
| 274 |
## 3. Minimal usage
|
| 275 |
|
| 276 |
```python
|
| 277 |
from PIL import Image
|
| 278 |
+
from gamescaling import GameScalingPolicy, PolicyConfig
|
| 279 |
|
| 280 |
+
policy = GameScalingPolicy(system, PolicyConfig()) # system prompt from §2; prefills <think>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
policy.reset() # call at the start of every episode
|
| 282 |
decision = policy.act(Image.open("frame.png")) # one call = one 200 ms decision
|
| 283 |
a = decision.action
|
| 284 |
a.mouse # (X, Y, Z): mouse movement for this chunk (per-mille of the screen) + scroll
|
| 285 |
a.steps # 6 groups: the keys held during each 33 ms step
|
| 286 |
a.parsed # False = could not be parsed; execute as a no-op
|
| 287 |
+
decision.reasoning # the model's thinking for this step ("" if it acted directly)
|
| 288 |
```
|
| 289 |
|
| 290 |
Pass `a` to your executor. Run 6 steps of 33 ms, holding that step's keys during each one. Keys
|
| 291 |
that appear in two consecutive groups stay held rather than being pressed again. Mouse movement
|
| 292 |
is `X/1000 * screen width` and `Y/1000 * screen height`. You can spread it over the 200 ms or
|
| 293 |
+
send it at once. The frame is sent at its native size; set `PolicyConfig(image_size=(w, h))` to
|
| 294 |
+
resize it first.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
+
## 4. Reasoning modes and message structure
|
| 297 |
|
| 298 |
+
The assistant turn is always prefilled, and the request continues it
|
| 299 |
+
(`continue_final_message=True`). The system prompt is the same in both modes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
+
| Mode | Prefill | What the model does |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|---|---|---|
|
| 303 |
+
| `PolicyConfig()` (default, `reasoning=True`) | `<think>` | Decides whether and how long to think, closes `</think>`, then outputs the action |
|
| 304 |
+
| `PolicyConfig(reasoning=False)` | `<think>\n\n</think>` | Thinking is closed and empty; the model outputs `<|action_start|>...<|action_end|>` directly |
|
| 305 |
+
|
| 306 |
+
`<think>\n\n</think>` is exactly how the chat template renders a turn without reasoning, so the
|
| 307 |
+
no-reasoning mode stays in the training format. Do not send the request without a prefill.
|
| 308 |
|
| 309 |
Each user turn is the text `current game screen` followed by the frame. Multi-turn messages
|
| 310 |
(`policy.py`):
|
| 311 |
|
| 312 |
+
```text
|
| 313 |
+
system system prompt (§2)
|
| 314 |
user "current game screen" + frame t-k ┐ last history_len turns (default 5)
|
| 315 |
assistant action taken at t-k (no reasoning) ┘
|
| 316 |
...
|
| 317 |
user "current game screen" + current frame
|
| 318 |
+
assistant "<think>" or "<think>\n\n</think>" (prefill, continued by the model)
|
| 319 |
```
|
| 320 |
|
| 321 |
+
Past turns keep only the action (`history_think=0`). A turn that failed to parse is stored as an
|
| 322 |
+
explicit no-op, `<|action_start|>0 0 0 ; ; ; ; ; ; <|action_end|>`, not as the raw bad output.
|
|
|
|
| 323 |
|
| 324 |
+
Client defaults: `temperature=1.0`, `max_tokens=2048`, `stop=["<|action_end|>"]`,
|
| 325 |
`skip_special_tokens=false`, JPEG frames (quality 90) at native resolution.
|
| 326 |
|
| 327 |
## 5. Action format
|
| 328 |
|
| 329 |
+
```text
|
| 330 |
<|action_start|>X Y Z ; g1 ; g2 ; g3 ; g4 ; g5 ; g6<|action_end|>
|
| 331 |
```
|
| 332 |
|
| 333 |
- `X Y`: mouse movement over the whole 200 ms, in per-mille of the screen
|
| 334 |
(`X = Σdx / screen width × 1000`), clipped to ±1000. X>0 is right, Y>0 is down.
|
| 335 |
- `Z`: scroll notches, clipped to ±5. Z>0 is up.
|
| 336 |
+
- `g1..g6`: six 33 ms steps. Each group lists the keys held during that step, at most 4. An
|
| 337 |
empty group releases all keys, but the `;` must stay.
|
| 338 |
- `<|action_start|>` / `<|action_end|>` are special tokens. Requests must set
|
| 339 |
`skip_special_tokens: false` (`build_request` does), or the server strips the tags.
|
| 340 |
`parse_action` also accepts a bare `X Y Z ; ...` as a fallback.
|
| 341 |
+
- The action is read only after the last `</think>`: a model that thinks often quotes the format
|
| 342 |
+
inside its reasoning. An unclosed `<think>` means the generation was cut off; it is executed as
|
| 343 |
+
a no-op (`Action.truncated=True`).
|
| 344 |
|
| 345 |
Training key vocabulary (74 tokens, case-sensitive, `gamescaling.action.KEY_VOCAB`):
|
| 346 |
|
|
|
|
| 348 |
|---|---|
|
| 349 |
| Letters | `A`..`Z` |
|
| 350 |
| Number row | `zero` `one` .. `nine` |
|
| 351 |
+
| F1-F12 | `One` `Two` .. `Twelve` (capitalized = function key) |
|
| 352 |
| Mouse buttons | `LB` `RB` `MB` |
|
| 353 |
| Arrows | `Up` `Down` `Left` `Right` |
|
| 354 |
| Other | `Shift` `Ctrl` `Alt` `Tab` `Caps` `Esc` `Space` `Enter` `Back` `Delete` `Insert` `Home` `End` `Pause` `Equal` `Minus` `Period` `Slash` `Quote` |
|
| 355 |
|
| 356 |
Tokens outside the vocabulary are dropped and recorded in `Action.dropped`.
|
| 357 |
+
`keymaps.TO_PLAYWRIGHT` and `keymaps.TO_UE5` map them to browser and Unreal Engine key names;
|
| 358 |
+
`translate_controls` rewrites browser key names in a text into training tokens.
|
| 359 |
+
|
| 360 |
+
## 6. Running it in a game
|
| 361 |
+
|
| 362 |
+
1. Write the system prompt for your game (§2).
|
| 363 |
+
2. Capture the current frame and call `policy.act(frame)` once per decision.
|
| 364 |
+
3. Execute the returned `Action` as in §5: held keys for 6 steps of 33 ms, plus mouse movement
|
| 365 |
+
and scroll.
|
| 366 |
+
4. Call `policy.reset()` when an episode ends.
|
| 367 |
+
|
| 368 |
+
Before trusting the results, check:
|
| 369 |
+
|
| 370 |
+
- **Parse rate.** `Decision.action.parsed` should almost always be `True`. Many failures, or many
|
| 371 |
+
`Action.truncated`, usually mean a replaced chat template, stripped special tokens, a missing
|
| 372 |
+
prefill, or a too-small `max_tokens`.
|
| 373 |
+
- **Actions take effect.** Each chunk runs in wall-clock time (6 × 33 ms). If the game renders
|
| 374 |
+
slowly, for example several game instances sharing one GPU, held keys barely move the
|
| 375 |
+
character and nothing reports an error. Watch a recording before reading any numbers.
|
| 376 |
+
- **Several episodes.** Single episodes vary a lot at `temperature=1.0`.
|
| 377 |
+
|
| 378 |
+
## 7. Tests
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
```bash
|
| 381 |
python tests/test_core.py # or: python -m pytest tests/
|
gamescaling/__init__.py
CHANGED
|
@@ -12,11 +12,18 @@ from .action import (
|
|
| 12 |
parse_action,
|
| 13 |
split_reasoning,
|
| 14 |
)
|
| 15 |
-
from .policy import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from .prompt import (
|
| 17 |
-
TRAINING_SYSTEM_PROMPT,
|
| 18 |
USER_TURN_TEXT,
|
| 19 |
-
build_gameworld_system_prompt,
|
| 20 |
build_system_prompt,
|
| 21 |
translate_controls,
|
| 22 |
)
|
|
@@ -26,7 +33,7 @@ __version__ = "0.1.0"
|
|
| 26 |
__all__ = [
|
| 27 |
"ACTION_END", "ACTION_START", "CHUNK_MS", "CHUNK_STEPS", "KEY_VOCAB", "NOOP_ACTION_TEXT",
|
| 28 |
"STEP_MS", "Action", "parse_action", "split_reasoning",
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"build_system_prompt", "translate_controls",
|
| 32 |
]
|
|
|
|
| 12 |
parse_action,
|
| 13 |
split_reasoning,
|
| 14 |
)
|
| 15 |
+
from .policy import (
|
| 16 |
+
NO_THINK_PREFILL,
|
| 17 |
+
THINK_PREFILL,
|
| 18 |
+
Decision,
|
| 19 |
+
GameScalingPolicy,
|
| 20 |
+
PolicyConfig,
|
| 21 |
+
build_request,
|
| 22 |
+
encode_image,
|
| 23 |
+
finalize,
|
| 24 |
+
)
|
| 25 |
from .prompt import (
|
|
|
|
| 26 |
USER_TURN_TEXT,
|
|
|
|
| 27 |
build_system_prompt,
|
| 28 |
translate_controls,
|
| 29 |
)
|
|
|
|
| 33 |
__all__ = [
|
| 34 |
"ACTION_END", "ACTION_START", "CHUNK_MS", "CHUNK_STEPS", "KEY_VOCAB", "NOOP_ACTION_TEXT",
|
| 35 |
"STEP_MS", "Action", "parse_action", "split_reasoning",
|
| 36 |
+
"NO_THINK_PREFILL", "THINK_PREFILL", "Decision", "GameScalingPolicy", "PolicyConfig",
|
| 37 |
+
"build_request", "encode_image", "finalize",
|
| 38 |
+
"USER_TURN_TEXT", "build_system_prompt", "translate_controls",
|
| 39 |
]
|
gamescaling/action.py
CHANGED
|
@@ -24,7 +24,6 @@ from dataclasses import dataclass, field
|
|
| 24 |
|
| 25 |
ACTION_START = "<|action_start|>"
|
| 26 |
ACTION_END = "<|action_end|>"
|
| 27 |
-
ACTION_END_TOKEN_ID = 151670
|
| 28 |
THINK_OPEN = "<think>"
|
| 29 |
THINK_CLOSE = "</think>"
|
| 30 |
|
|
@@ -159,7 +158,7 @@ def _parse_strict(tail: str) -> Action:
|
|
| 159 |
|
| 160 |
|
| 161 |
def _parse_lenient(text: str) -> Action:
|
| 162 |
-
"""
|
| 163 |
text = text.replace(THINK_OPEN, " ").replace(THINK_CLOSE, " ")
|
| 164 |
m = _WRAPPED_INT_RE.search(text)
|
| 165 |
if m:
|
|
@@ -197,10 +196,10 @@ def _parse_lenient(text: str) -> Action:
|
|
| 197 |
def parse_action(text: str, *, lenient: bool = False) -> Action:
|
| 198 |
"""Raw model reply -> :class:`Action`. Never raises; failures are no-ops.
|
| 199 |
|
| 200 |
-
``lenient=False`` (
|
| 201 |
last ``</think>`` counts, either tagged or as a bare ``X Y Z ; ...`` body.
|
| 202 |
|
| 203 |
-
``lenient=True``
|
| 204 |
well-trained checkpoint rarely emits -- an action inside an unclosed
|
| 205 |
``<think>``, a 2-number mouse prefix, a keys-only skeleton -- by locating the
|
| 206 |
``;``-separated six-group skeleton anywhere in the reply.
|
|
|
|
| 24 |
|
| 25 |
ACTION_START = "<|action_start|>"
|
| 26 |
ACTION_END = "<|action_end|>"
|
|
|
|
| 27 |
THINK_OPEN = "<think>"
|
| 28 |
THINK_CLOSE = "</think>"
|
| 29 |
|
|
|
|
| 158 |
|
| 159 |
|
| 160 |
def _parse_lenient(text: str) -> Action:
|
| 161 |
+
"""Recovery for malformed replies (see `parse_action`)."""
|
| 162 |
text = text.replace(THINK_OPEN, " ").replace(THINK_CLOSE, " ")
|
| 163 |
m = _WRAPPED_INT_RE.search(text)
|
| 164 |
if m:
|
|
|
|
| 196 |
def parse_action(text: str, *, lenient: bool = False) -> Action:
|
| 197 |
"""Raw model reply -> :class:`Action`. Never raises; failures are no-ops.
|
| 198 |
|
| 199 |
+
``lenient=False`` (default): only a chunk after the
|
| 200 |
last ``</think>`` counts, either tagged or as a bare ``X Y Z ; ...`` body.
|
| 201 |
|
| 202 |
+
``lenient=True``: additionally recover shapes a
|
| 203 |
well-trained checkpoint rarely emits -- an action inside an unclosed
|
| 204 |
``<think>``, a 2-number mouse prefix, a keys-only skeleton -- by locating the
|
| 205 |
``;``-separated six-group skeleton anywhere in the reply.
|
gamescaling/keymaps.py
CHANGED
|
@@ -10,7 +10,7 @@ from .action import LETTER_KEYS
|
|
| 10 |
|
| 11 |
MOUSE_BUTTONS: dict[str, str] = {"LB": "left", "RB": "right", "MB": "middle"}
|
| 12 |
|
| 13 |
-
# Unreal Engine 5 FKey names
|
| 14 |
# used for the L/R-merged modifier tokens.
|
| 15 |
TO_UE5: dict[str, str] = {
|
| 16 |
**{c: c for c in LETTER_KEYS},
|
|
@@ -28,8 +28,7 @@ TO_UE5: dict[str, str] = {
|
|
| 28 |
"Quote": "Apostrophe",
|
| 29 |
}
|
| 30 |
|
| 31 |
-
# Playwright `page.keyboard` names (
|
| 32 |
-
# GameWorld's legal-key lists are written that way.
|
| 33 |
TO_PLAYWRIGHT: dict[str, str] = {
|
| 34 |
**{c: c.lower() for c in LETTER_KEYS},
|
| 35 |
"zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
|
|
|
|
| 10 |
|
| 11 |
MOUSE_BUTTONS: dict[str, str] = {"LB": "left", "RB": "right", "MB": "middle"}
|
| 12 |
|
| 13 |
+
# Unreal Engine 5 FKey names. Left variants are
|
| 14 |
# used for the L/R-merged modifier tokens.
|
| 15 |
TO_UE5: dict[str, str] = {
|
| 16 |
**{c: c for c in LETTER_KEYS},
|
|
|
|
| 28 |
"Quote": "Apostrophe",
|
| 29 |
}
|
| 30 |
|
| 31 |
+
# Playwright `page.keyboard` names (browser games). Letters are lower case.
|
|
|
|
| 32 |
TO_PLAYWRIGHT: dict[str, str] = {
|
| 33 |
**{c: c.lower() for c in LETTER_KEYS},
|
| 34 |
"zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
|
gamescaling/policy.py
CHANGED
|
@@ -7,7 +7,8 @@ One call to :meth:`GameScalingPolicy.act` is one 200 ms decision:
|
|
| 7 |
... then the action the model took (its reasoning is dropped
|
| 8 |
unless `history_think` asks to keep it)
|
| 9 |
user "current game screen" + the current frame
|
| 10 |
-
assistant
|
|
|
|
| 11 |
|
| 12 |
The checkpoint's own chat template renders a past assistant turn without
|
| 13 |
reasoning as `<think>\\n\\n</think>\\n\\n<|action_start|>...`, exactly as in
|
|
@@ -22,7 +23,7 @@ import logging
|
|
| 22 |
import re
|
| 23 |
import time
|
| 24 |
from collections import deque
|
| 25 |
-
from dataclasses import dataclass, field
|
| 26 |
from typing import Any, NamedTuple
|
| 27 |
|
| 28 |
import requests
|
|
@@ -30,7 +31,6 @@ from PIL import Image
|
|
| 30 |
|
| 31 |
from .action import (
|
| 32 |
ACTION_END,
|
| 33 |
-
ACTION_END_TOKEN_ID,
|
| 34 |
ACTION_START,
|
| 35 |
THINK_OPEN,
|
| 36 |
Action,
|
|
@@ -41,6 +41,11 @@ from .prompt import USER_TURN_TEXT
|
|
| 41 |
|
| 42 |
logger = logging.getLogger(__name__)
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
_RESAMPLE = {"bilinear": Image.BILINEAR, "lanczos": Image.LANCZOS, "bicubic": Image.BICUBIC}
|
| 45 |
|
| 46 |
|
|
@@ -54,45 +59,24 @@ class PolicyConfig:
|
|
| 54 |
# How many of the most recent past turns keep their <think> text; -1 = all.
|
| 55 |
history_think: int = 0
|
| 56 |
# Frame sent to the model. None keeps the given game screen as-is.
|
| 57 |
-
# An exact (w, h), or image_height, is only for a benchmark preset.
|
| 58 |
image_size: tuple[int, int] | None = None
|
| 59 |
-
image_height: int | None = None
|
| 60 |
image_format: str = "jpeg" # jpeg | png
|
| 61 |
jpeg_quality: int = 90
|
| 62 |
resample: str = "bilinear"
|
| 63 |
-
#
|
| 64 |
-
#
|
| 65 |
-
|
| 66 |
temperature: float = 1.0
|
| 67 |
max_tokens: int = 2048
|
| 68 |
stop: list[str] = field(default_factory=lambda: [ACTION_END])
|
| 69 |
-
|
| 70 |
-
|
| 71 |
lenient_parse: bool = False
|
| 72 |
timeout: float = 120.0
|
| 73 |
|
| 74 |
-
@
|
| 75 |
-
def
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
Default ``PolicyConfig()`` prefills ``<think>`` and sends the given game
|
| 79 |
-
frame unchanged. Use a preset only when reproducing a paper table.
|
| 80 |
-
"""
|
| 81 |
-
presets = {
|
| 82 |
-
"omnigamearena": cls(image_size=(854, 480)),
|
| 83 |
-
# Free-form reasoning without a stop string: a model that quotes the
|
| 84 |
-
# format inside <think> would otherwise be cut off mid-thought.
|
| 85 |
-
"vizdoom": cls(image_size=(854, 480), stop=[]),
|
| 86 |
-
"gameworld": cls(
|
| 87 |
-
image_size=None, image_height=480, image_format="png", resample="lanczos",
|
| 88 |
-
think_prefill=False, max_tokens=4096, stop=[ACTION_END],
|
| 89 |
-
stop_token_ids=[ACTION_END_TOKEN_ID], include_stop_str_in_output=True,
|
| 90 |
-
lenient_parse=True,
|
| 91 |
-
),
|
| 92 |
-
}
|
| 93 |
-
if benchmark not in presets:
|
| 94 |
-
raise ValueError(f"unknown benchmark {benchmark!r}; known: {sorted(presets)}")
|
| 95 |
-
return replace(presets[benchmark], **overrides)
|
| 96 |
|
| 97 |
|
| 98 |
class Turn(NamedTuple):
|
|
@@ -119,9 +103,6 @@ def encode_image(image: Image.Image, cfg: PolicyConfig) -> str:
|
|
| 119 |
img = image
|
| 120 |
if cfg.image_size is not None and img.size != tuple(cfg.image_size):
|
| 121 |
img = img.resize(tuple(cfg.image_size), _RESAMPLE[cfg.resample])
|
| 122 |
-
elif cfg.image_height and img.height != cfg.image_height:
|
| 123 |
-
width = max(1, round(img.width * cfg.image_height / img.height))
|
| 124 |
-
img = img.resize((width, cfg.image_height), _RESAMPLE[cfg.resample])
|
| 125 |
buf = io.BytesIO()
|
| 126 |
if cfg.image_format == "png":
|
| 127 |
img.convert("RGB").save(buf, format="PNG")
|
|
@@ -157,14 +138,9 @@ def build_request(messages: list[dict], cfg: PolicyConfig) -> dict:
|
|
| 157 |
}
|
| 158 |
if cfg.stop:
|
| 159 |
body["stop"] = list(cfg.stop)
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
body["include_stop_str_in_output"] = True
|
| 164 |
-
if cfg.think_prefill:
|
| 165 |
-
body["messages"].append({"role": "assistant", "content": THINK_OPEN})
|
| 166 |
-
body["continue_final_message"] = True
|
| 167 |
-
body["add_generation_prompt"] = False
|
| 168 |
return body
|
| 169 |
|
| 170 |
|
|
@@ -175,8 +151,8 @@ def finalize(text: str, finish_reason: str | None, cfg: PolicyConfig) -> str:
|
|
| 175 |
from it. Both are restored so the reply parses the same way as training data.
|
| 176 |
"""
|
| 177 |
text = text or ""
|
| 178 |
-
if
|
| 179 |
-
text =
|
| 180 |
_, tail, closed = split_reasoning(text)
|
| 181 |
if closed and ACTION_START not in tail:
|
| 182 |
bare = tail.strip()
|
|
|
|
| 7 |
... then the action the model took (its reasoning is dropped
|
| 8 |
unless `history_think` asks to keep it)
|
| 9 |
user "current game screen" + the current frame
|
| 10 |
+
assistant prefilled with "<think>" (reasoning=True) or
|
| 11 |
+
"<think>\\n\\n</think>" (reasoning=False); the model continues
|
| 12 |
|
| 13 |
The checkpoint's own chat template renders a past assistant turn without
|
| 14 |
reasoning as `<think>\\n\\n</think>\\n\\n<|action_start|>...`, exactly as in
|
|
|
|
| 23 |
import re
|
| 24 |
import time
|
| 25 |
from collections import deque
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
from typing import Any, NamedTuple
|
| 28 |
|
| 29 |
import requests
|
|
|
|
| 31 |
|
| 32 |
from .action import (
|
| 33 |
ACTION_END,
|
|
|
|
| 34 |
ACTION_START,
|
| 35 |
THINK_OPEN,
|
| 36 |
Action,
|
|
|
|
| 41 |
|
| 42 |
logger = logging.getLogger(__name__)
|
| 43 |
|
| 44 |
+
# Assistant prefill. Both modes use the same system prompt; the empty think
|
| 45 |
+
# block is exactly how training renders a turn without reasoning.
|
| 46 |
+
THINK_PREFILL = THINK_OPEN
|
| 47 |
+
NO_THINK_PREFILL = "<think>\n\n</think>"
|
| 48 |
+
|
| 49 |
_RESAMPLE = {"bilinear": Image.BILINEAR, "lanczos": Image.LANCZOS, "bicubic": Image.BICUBIC}
|
| 50 |
|
| 51 |
|
|
|
|
| 59 |
# How many of the most recent past turns keep their <think> text; -1 = all.
|
| 60 |
history_think: int = 0
|
| 61 |
# Frame sent to the model. None keeps the given game screen as-is.
|
|
|
|
| 62 |
image_size: tuple[int, int] | None = None
|
|
|
|
| 63 |
image_format: str = "jpeg" # jpeg | png
|
| 64 |
jpeg_quality: int = 90
|
| 65 |
resample: str = "bilinear"
|
| 66 |
+
# True: prefill "<think>" and let the model decide whether and how long to
|
| 67 |
+
# think. False: prefill an empty think block so the model acts directly.
|
| 68 |
+
reasoning: bool = True
|
| 69 |
temperature: float = 1.0
|
| 70 |
max_tokens: int = 2048
|
| 71 |
stop: list[str] = field(default_factory=lambda: [ACTION_END])
|
| 72 |
+
# Also recover malformed replies (an action inside an unclosed <think>, a
|
| 73 |
+
# keys-only skeleton). Off by default.
|
| 74 |
lenient_parse: bool = False
|
| 75 |
timeout: float = 120.0
|
| 76 |
|
| 77 |
+
@property
|
| 78 |
+
def prefill(self) -> str:
|
| 79 |
+
return THINK_PREFILL if self.reasoning else NO_THINK_PREFILL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
class Turn(NamedTuple):
|
|
|
|
| 103 |
img = image
|
| 104 |
if cfg.image_size is not None and img.size != tuple(cfg.image_size):
|
| 105 |
img = img.resize(tuple(cfg.image_size), _RESAMPLE[cfg.resample])
|
|
|
|
|
|
|
|
|
|
| 106 |
buf = io.BytesIO()
|
| 107 |
if cfg.image_format == "png":
|
| 108 |
img.convert("RGB").save(buf, format="PNG")
|
|
|
|
| 138 |
}
|
| 139 |
if cfg.stop:
|
| 140 |
body["stop"] = list(cfg.stop)
|
| 141 |
+
body["messages"].append({"role": "assistant", "content": cfg.prefill})
|
| 142 |
+
body["continue_final_message"] = True
|
| 143 |
+
body["add_generation_prompt"] = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
return body
|
| 145 |
|
| 146 |
|
|
|
|
| 151 |
from it. Both are restored so the reply parses the same way as training data.
|
| 152 |
"""
|
| 153 |
text = text or ""
|
| 154 |
+
if not text.lstrip().startswith(THINK_OPEN):
|
| 155 |
+
text = cfg.prefill + text
|
| 156 |
_, tail, closed = split_reasoning(text)
|
| 157 |
if closed and ACTION_START not in tail:
|
| 158 |
bare = tail.strip()
|
gamescaling/prompt.py
CHANGED
|
@@ -1,18 +1,16 @@
|
|
| 1 |
-
"""System
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
1. opening: gaming expert, the game's name, the 200 ms / 6 x 33 ms contract;
|
| 6 |
2. ``**Game Rules**``: what the game is and how it is scored;
|
| 7 |
-
3.
|
| 8 |
-
4. ``**
|
| 9 |
-
5. ``**
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
two layouts below. Copy the strings, do not paraphrase them: the checkpoints key
|
| 15 |
-
their output tag off the exact Output Format section.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
@@ -34,10 +32,7 @@ OPENING = (
|
|
| 34 |
"analysis is no longer valid, or a new objective appears."
|
| 35 |
)
|
| 36 |
|
| 37 |
-
|
| 38 |
-
"<|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10<|action_end|>"
|
| 39 |
-
)
|
| 40 |
-
OUTPUT_FORMAT_THINK = (
|
| 41 |
"**Action only (usual)**\n"
|
| 42 |
"<think></think><|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10"
|
| 43 |
"<|action_end|>\n"
|
|
@@ -71,47 +66,23 @@ TAIL = (
|
|
| 71 |
MOUSE_AIM_LINE = "- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)"
|
| 72 |
_MOUSE_BUTTON_ALIASES = {"LMB": "LB", "RMB": "RB", "MMB": "MB"}
|
| 73 |
|
| 74 |
-
# The generic prompt of the pre-training mixture: no game information at all,
|
| 75 |
-
# action-only Output Format. Kept for ablations; it does not match a <think>
|
| 76 |
-
# prefill. Inference uses `build_system_prompt`.
|
| 77 |
-
TRAINING_SYSTEM_PROMPT = '''You are an experienced PC game player, proficient in keyboard and mouse operations. Based on the current game screen and the corresponding historical trajectory, plan the next 200ms of actions, consisting of 6 steps. Each step is spaced 33ms apart. Every step lasts 33ms from its start time until the next step begins.
|
| 78 |
-
**Output Format**
|
| 79 |
-
<|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10<|action_end|>
|
| 80 |
-
**Explanation**
|
| 81 |
-
1. **Mouse Movement**: First, specify the relative displacement X, Y (X>0 means move right, Y>0
|
| 82 |
-
means move down) and scroll amount Z (Z>0 means scroll up).
|
| 83 |
-
2. **Key Sequence**: Then list 6 groups of keys; within each group, keys are separated by spaces,
|
| 84 |
-
and groups are separated by semicolons.
|
| 85 |
-
- Each group can contain up to 4 keys.
|
| 86 |
-
- If a group has no keys, leave it empty but keep the `;`.
|
| 87 |
-
3. Only output a plain string that conforms to the above format — no line breaks and no quotation marks.
|
| 88 |
-
**Key Naming Rules**
|
| 89 |
-
- Number keys `1-9`: use lowercase English words, e.g., `one` represents the `1` key on the keyboard.
|
| 90 |
-
- Function keys `F1-F12`: use capitalized English words, e.g., `One` represents `F1`, `Two` represents `F2`, and so on.
|
| 91 |
-
- Other keys (letters, Shift, Tab, Space, etc.): use the real keyboard name with an initial capital letter, e.g., `A`, `D`, `Shift`, `Space`, etc.'''
|
| 92 |
-
|
| 93 |
USER_TURN_TEXT = "current game screen"
|
| 94 |
|
| 95 |
|
| 96 |
-
def _output_format(reasoning: bool) -> str:
|
| 97 |
-
return OUTPUT_FORMAT_THINK if reasoning else OUTPUT_FORMAT_PLAIN
|
| 98 |
-
|
| 99 |
-
|
| 100 |
def build_system_prompt(
|
| 101 |
game_name: str,
|
| 102 |
*,
|
| 103 |
rules: str | None = None,
|
| 104 |
key_bindings: Iterable[tuple[str, str]] | dict[str, str] | None = None,
|
| 105 |
mouse_aim: bool = True,
|
| 106 |
-
reasoning: bool = True,
|
| 107 |
) -> str:
|
| 108 |
-
"""
|
| 109 |
|
| 110 |
``key_bindings`` maps training tokens to what they do, e.g.
|
| 111 |
``{"W": "Move forward", "LB": "Fire"}``; ``LMB``/``RMB``/``MMB`` are accepted
|
| 112 |
as aliases of ``LB``/``RB``/``MB``. ``mouse_aim`` adds the camera line for
|
| 113 |
-
games that are steered with the mouse.
|
| 114 |
-
|
| 115 |
"""
|
| 116 |
parts = [OPENING.format(name=game_name)]
|
| 117 |
if rules and rules.strip():
|
|
@@ -122,36 +93,11 @@ def build_system_prompt(
|
|
| 122 |
common.append(MOUSE_AIM_LINE)
|
| 123 |
if common:
|
| 124 |
parts.append(f"**Common Keys in {game_name}**\n" + "\n".join(common))
|
| 125 |
-
parts.append("**Output Format**\n" +
|
| 126 |
parts.append(TAIL)
|
| 127 |
return "\n".join(parts)
|
| 128 |
|
| 129 |
|
| 130 |
-
def build_gameworld_system_prompt(
|
| 131 |
-
game_name: str | None,
|
| 132 |
-
*,
|
| 133 |
-
rules: str | None = None,
|
| 134 |
-
controls: str | None = None,
|
| 135 |
-
task: str | None = None,
|
| 136 |
-
reasoning: bool = False,
|
| 137 |
-
) -> str:
|
| 138 |
-
"""Free-text layout (GameWorld): rules, role/controls and task as paragraphs.
|
| 139 |
-
|
| 140 |
-
``controls`` must already use training key names; see
|
| 141 |
-
:func:`translate_controls`.
|
| 142 |
-
"""
|
| 143 |
-
opening = OPENING if game_name else OPENING.replace(", and you are currently playing {name}", "")
|
| 144 |
-
out = opening.format(name=game_name) + "\n"
|
| 145 |
-
if rules:
|
| 146 |
-
out += "\n**Game Rules**\n" + rules + "\n"
|
| 147 |
-
if controls:
|
| 148 |
-
out += "\n**Role and Controls**\n" + controls + "\n"
|
| 149 |
-
if task:
|
| 150 |
-
out += "\n**Task Instruction**\n" + task + "\n"
|
| 151 |
-
out += "\n**Output Format**\n" + _output_format(reasoning) + "\n" + TAIL
|
| 152 |
-
return out.strip()
|
| 153 |
-
|
| 154 |
-
|
| 155 |
_PW_TO_TOKEN = PLAYWRIGHT_TO_TOKEN
|
| 156 |
_PW_KEY_RE = re.compile(r"\b(" + "|".join(map(re.escape, _PW_TO_TOKEN)) + r")\b")
|
| 157 |
_DIGIT_WORD = {"0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
|
|
@@ -165,7 +111,7 @@ _LIST_DIGIT_RE = re.compile(r"(?m)^(\s*-\s*)(\d)(?=\s*[:/])")
|
|
| 165 |
|
| 166 |
|
| 167 |
def translate_controls(text: str | None) -> str:
|
| 168 |
-
"""Rewrite
|
| 169 |
|
| 170 |
``ArrowLeft`` -> ``Left``, ``Escape`` -> ``Esc``, ...; single letters become
|
| 171 |
upper case and digits become words, but only where they stand as keys
|
|
|
|
| 1 |
+
"""System prompt for GameScaling checkpoints.
|
| 2 |
|
| 3 |
+
One skeleton, in this order:
|
| 4 |
|
| 5 |
1. opening: gaming expert, the game's name, the 200 ms / 6 x 33 ms contract;
|
| 6 |
2. ``**Game Rules**``: what the game is and how it is scored;
|
| 7 |
+
3. ``**Common Keys in <game>**``: the keys this game uses, in the training vocabulary;
|
| 8 |
+
4. ``**Output Format**``: empty-think action, or think + action;
|
| 9 |
+
5. ``**Explanation**`` + ``**Key Naming Rules**``.
|
| 10 |
+
|
| 11 |
+
The ``**...**`` markers are literal plain-text section headers that the model
|
| 12 |
+
saw in training, not Markdown. Copy the strings, do not paraphrase them: the
|
| 13 |
+
checkpoints key their output tags off the exact Output Format section.
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
|
|
| 32 |
"analysis is no longer valid, or a new objective appears."
|
| 33 |
)
|
| 34 |
|
| 35 |
+
OUTPUT_FORMAT = (
|
|
|
|
|
|
|
|
|
|
| 36 |
"**Action only (usual)**\n"
|
| 37 |
"<think></think><|action_start|>X Y Z ; k1 k2 k3 ; k4 k5 ; k6 ; k7 ; k8 ; k9 k10"
|
| 38 |
"<|action_end|>\n"
|
|
|
|
| 66 |
MOUSE_AIM_LINE = "- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)"
|
| 67 |
_MOUSE_BUTTON_ALIASES = {"LMB": "LB", "RMB": "RB", "MMB": "MB"}
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
USER_TURN_TEXT = "current game screen"
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
def build_system_prompt(
|
| 73 |
game_name: str,
|
| 74 |
*,
|
| 75 |
rules: str | None = None,
|
| 76 |
key_bindings: Iterable[tuple[str, str]] | dict[str, str] | None = None,
|
| 77 |
mouse_aim: bool = True,
|
|
|
|
| 78 |
) -> str:
|
| 79 |
+
"""The standard system prompt for any game.
|
| 80 |
|
| 81 |
``key_bindings`` maps training tokens to what they do, e.g.
|
| 82 |
``{"W": "Move forward", "LB": "Fire"}``; ``LMB``/``RMB``/``MMB`` are accepted
|
| 83 |
as aliases of ``LB``/``RB``/``MB``. ``mouse_aim`` adds the camera line for
|
| 84 |
+
games that are steered with the mouse. The same prompt serves both
|
| 85 |
+
``PolicyConfig(reasoning=True)`` and ``reasoning=False``.
|
| 86 |
"""
|
| 87 |
parts = [OPENING.format(name=game_name)]
|
| 88 |
if rules and rules.strip():
|
|
|
|
| 93 |
common.append(MOUSE_AIM_LINE)
|
| 94 |
if common:
|
| 95 |
parts.append(f"**Common Keys in {game_name}**\n" + "\n".join(common))
|
| 96 |
+
parts.append("**Output Format**\n" + OUTPUT_FORMAT)
|
| 97 |
parts.append(TAIL)
|
| 98 |
return "\n".join(parts)
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
_PW_TO_TOKEN = PLAYWRIGHT_TO_TOKEN
|
| 102 |
_PW_KEY_RE = re.compile(r"\b(" + "|".join(map(re.escape, _PW_TO_TOKEN)) + r")\b")
|
| 103 |
_DIGIT_WORD = {"0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
def translate_controls(text: str | None) -> str:
|
| 114 |
+
"""Rewrite key names written for a browser / keyboard API into training tokens.
|
| 115 |
|
| 116 |
``ArrowLeft`` -> ``Left``, ``Escape`` -> ``Esc``, ...; single letters become
|
| 117 |
upper case and digits become words, but only where they stand as keys
|
prompts/example_doom_battle1.txt
CHANGED
|
@@ -1,12 +1,31 @@
|
|
| 1 |
You are a gaming expert, and you are currently playing Doom Battle-1. You are proficient in keyboard and mouse operations. You understand game mechanics and combat pacing, can quickly extract key information from the current screen, and can think and make precise decisions at critical moments. Based on the current screen, plan the next 200ms of actions, consisting of 6 steps spaced 33ms apart. Each step lasts 33ms, until the next step begins. If the current situation continues the previous strategy, output the action directly. Think only when the situation changes significantly, the previous analysis is no longer valid, or a new objective appears.
|
| 2 |
**Game Rules**
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
**Common Keys in Doom Battle-1**
|
| 5 |
- W -> Move forward
|
| 6 |
-
- S -> Move backward
|
| 7 |
- A -> Strafe left
|
|
|
|
| 8 |
- D -> Strafe right
|
| 9 |
-
- LB -> Fire
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
|
| 11 |
**Output Format**
|
| 12 |
**Action only (usual)**
|
|
|
|
| 1 |
You are a gaming expert, and you are currently playing Doom Battle-1. You are proficient in keyboard and mouse operations. You understand game mechanics and combat pacing, can quickly extract key information from the current screen, and can think and make precise decisions at critical moments. Based on the current screen, plan the next 200ms of actions, consisting of 6 steps spaced 33ms apart. Each step lasts 33ms, until the next step begins. If the current situation continues the previous strategy, output the action directly. Think only when the situation changes significantly, the previous analysis is no longer valid, or a new objective appears.
|
| 2 |
**Game Rules**
|
| 3 |
+
You are playing Doom, a first-person shooter, in an arena full of monsters. Survive and kill as many monsters as you can.
|
| 4 |
+
|
| 5 |
+
## What you see on screen
|
| 6 |
+
- First-person view.
|
| 7 |
+
- The crosshair at screen centre marks where shots land.
|
| 8 |
+
- The status bar along the bottom shows HEALTH on the left and AMMO on the right.
|
| 9 |
+
- Monsters keep arriving for the whole episode; some throw fireballs from range.
|
| 10 |
+
|
| 11 |
+
## Rules
|
| 12 |
+
- Score is the number of monsters you kill in the episode.
|
| 13 |
+
- Firing costs ammo, and the episode ends as soon as your health reaches 0, so dodging matters as much as shooting: standing still in the open is how most episodes end early.
|
| 14 |
+
- Health packs and ammo boxes lie around the map; walk over one to pick it up. Running out of either is fatal, so collect them while fighting.
|
| 15 |
+
- A target is hit when it is under the crosshair, so turn with the mouse until the monster is centred before firing.
|
| 16 |
+
- The map is one large open arena, so monsters can close in from any direction.
|
| 17 |
**Common Keys in Doom Battle-1**
|
| 18 |
- W -> Move forward
|
|
|
|
| 19 |
- A -> Strafe left
|
| 20 |
+
- S -> Move backward
|
| 21 |
- D -> Strafe right
|
| 22 |
+
- LB -> Fire weapon
|
| 23 |
+
- Ctrl -> Fire weapon (keyboard alternative)
|
| 24 |
+
- Shift -> Run
|
| 25 |
+
- Space -> Open doors / use switches
|
| 26 |
+
- one -> Select weapon slot 1
|
| 27 |
+
- two -> Select weapon slot 2
|
| 28 |
+
- three -> Select weapon slot 3
|
| 29 |
- Mouse X Y -> aim / rotate the camera (X>0 right, Y>0 down)
|
| 30 |
**Output Format**
|
| 31 |
**Action only (usual)**
|
requirements.txt
CHANGED
|
@@ -2,7 +2,5 @@
|
|
| 2 |
requests>=2.31
|
| 3 |
Pillow>=10.0
|
| 4 |
|
| 5 |
-
# ViZDoom suite (integrations/vizdoom):
|
| 6 |
-
# pip install vizdoom==1.2.4
|
| 7 |
# Serving the checkpoint (separate environment is fine):
|
| 8 |
# pip install "vllm>=0.17" # Qwen3.5 architecture; tested with 0.17.0
|
|
|
|
| 2 |
requests>=2.31
|
| 3 |
Pillow>=10.0
|
| 4 |
|
|
|
|
|
|
|
| 5 |
# Serving the checkpoint (separate environment is fine):
|
| 6 |
# pip install "vllm>=0.17" # Qwen3.5 architecture; tested with 0.17.0
|
tests/test_core.py
CHANGED
|
@@ -10,7 +10,6 @@ from PIL import Image # noqa: E402
|
|
| 10 |
from gamescaling import ( # noqa: E402
|
| 11 |
NOOP_ACTION_TEXT,
|
| 12 |
PolicyConfig,
|
| 13 |
-
build_gameworld_system_prompt,
|
| 14 |
build_request,
|
| 15 |
build_system_prompt,
|
| 16 |
finalize,
|
|
@@ -61,29 +60,30 @@ def test_lenient_recovers_skeleton():
|
|
| 61 |
|
| 62 |
|
| 63 |
def test_finalize_restores_prefill_and_stop():
|
| 64 |
-
|
| 65 |
-
out = finalize("\n\n</think>\n\n<|action_start|>0 0 0 ; W ; ; ; ; ; ", "stop", cfg)
|
| 66 |
assert out.startswith("<think>") and out.endswith("<|action_end|>")
|
| 67 |
assert parse_action(out).steps[0] == ["W"]
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
def test_request_shape():
|
| 71 |
cfg = PolicyConfig(model="m")
|
| 72 |
-
assert cfg.image_size is None
|
| 73 |
body = build_request([{"role": "system", "content": "s"}], cfg)
|
| 74 |
assert body["messages"][-1] == {"role": "assistant", "content": "<think>"}
|
| 75 |
assert body["continue_final_message"] and not body["add_generation_prompt"]
|
| 76 |
assert body["stop"] == ["<|action_end|>"] and body["skip_special_tokens"] is False
|
| 77 |
-
|
| 78 |
-
assert
|
| 79 |
-
|
| 80 |
-
assert "continue_final_message" not in gw and gw["stop_token_ids"] == [151670]
|
| 81 |
|
| 82 |
|
| 83 |
def test_image_encoding():
|
| 84 |
img = Image.new("RGB", (1280, 720))
|
| 85 |
assert encode_image(img, PolicyConfig()).startswith("data:image/jpeg;base64,")
|
| 86 |
-
assert encode_image(img, PolicyConfig
|
| 87 |
|
| 88 |
|
| 89 |
def test_prompt_sections_in_order():
|
|
@@ -92,10 +92,6 @@ def test_prompt_sections_in_order():
|
|
| 92 |
"**Action only (usual)**", "**Explanation**", "**Key Naming Rules**"]
|
| 93 |
idx = [p.index(s) for s in order]
|
| 94 |
assert idx == sorted(idx)
|
| 95 |
-
gw = build_gameworld_system_prompt("2048", rules="Merge tiles.", controls="Use the arrow keys.",
|
| 96 |
-
task="Reach 256.")
|
| 97 |
-
assert "\n\n**Game Rules**\nMerge tiles.\n\n**Role and Controls**\n" in gw
|
| 98 |
-
assert "**Action only" not in gw
|
| 99 |
|
| 100 |
|
| 101 |
def test_prompt_template_file_matches_builder():
|
|
@@ -109,6 +105,16 @@ def test_prompt_template_file_matches_builder():
|
|
| 109 |
assert filled == build_system_prompt("Doom", rules="Kill monsters.", key_bindings=keys)
|
| 110 |
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
def test_translate_controls():
|
| 113 |
text = "- ArrowLeft: move left\n- a/d: strafe\n- 1-9: pick item\nPress Escape to pause."
|
| 114 |
out = translate_controls(text)
|
|
|
|
| 10 |
from gamescaling import ( # noqa: E402
|
| 11 |
NOOP_ACTION_TEXT,
|
| 12 |
PolicyConfig,
|
|
|
|
| 13 |
build_request,
|
| 14 |
build_system_prompt,
|
| 15 |
finalize,
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def test_finalize_restores_prefill_and_stop():
|
| 63 |
+
out = finalize("\n\n</think>\n\n<|action_start|>0 0 0 ; W ; ; ; ; ; ", "stop", PolicyConfig())
|
|
|
|
| 64 |
assert out.startswith("<think>") and out.endswith("<|action_end|>")
|
| 65 |
assert parse_action(out).steps[0] == ["W"]
|
| 66 |
+
out = finalize("\n\n<|action_start|>0 0 0 ; ; D ; ; ; ; ", "stop", PolicyConfig(reasoning=False))
|
| 67 |
+
assert out.startswith("<think>\n\n</think>\n\n<|action_start|>")
|
| 68 |
+
assert parse_action(out).steps[1] == ["D"]
|
| 69 |
|
| 70 |
|
| 71 |
def test_request_shape():
|
| 72 |
cfg = PolicyConfig(model="m")
|
| 73 |
+
assert cfg.image_size is None and cfg.reasoning
|
| 74 |
body = build_request([{"role": "system", "content": "s"}], cfg)
|
| 75 |
assert body["messages"][-1] == {"role": "assistant", "content": "<think>"}
|
| 76 |
assert body["continue_final_message"] and not body["add_generation_prompt"]
|
| 77 |
assert body["stop"] == ["<|action_end|>"] and body["skip_special_tokens"] is False
|
| 78 |
+
body = build_request([{"role": "system", "content": "s"}], PolicyConfig(reasoning=False))
|
| 79 |
+
assert body["messages"][-1] == {"role": "assistant", "content": "<think>\n\n</think>"}
|
| 80 |
+
assert body["continue_final_message"]
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def test_image_encoding():
|
| 84 |
img = Image.new("RGB", (1280, 720))
|
| 85 |
assert encode_image(img, PolicyConfig()).startswith("data:image/jpeg;base64,")
|
| 86 |
+
assert encode_image(img, PolicyConfig(image_format="png")).startswith("data:image/png;base64,")
|
| 87 |
|
| 88 |
|
| 89 |
def test_prompt_sections_in_order():
|
|
|
|
| 92 |
"**Action only (usual)**", "**Explanation**", "**Key Naming Rules**"]
|
| 93 |
idx = [p.index(s) for s in order]
|
| 94 |
assert idx == sorted(idx)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
def test_prompt_template_file_matches_builder():
|
|
|
|
| 105 |
assert filled == build_system_prompt("Doom", rules="Kill monsters.", key_bindings=keys)
|
| 106 |
|
| 107 |
|
| 108 |
+
def test_doom_example_matches_builder():
|
| 109 |
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 110 |
+
with open(os.path.join(root, "prompts", "example_doom_battle1.txt")) as f:
|
| 111 |
+
example = f.read().rstrip("\n")
|
| 112 |
+
rules = example.split("**Game Rules**\n", 1)[1].split("\n**Common Keys in", 1)[0]
|
| 113 |
+
keys = example.split("**Common Keys in Doom Battle-1**\n", 1)[1].split("\n**Output Format**", 1)[0]
|
| 114 |
+
bindings = [line[2:].split(" -> ", 1) for line in keys.splitlines() if not line.startswith("- Mouse X Y")]
|
| 115 |
+
assert example == build_system_prompt("Doom Battle-1", rules=rules, key_bindings=bindings)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
def test_translate_controls():
|
| 119 |
text = "- ArrowLeft: move left\n- a/d: strafe\n- 1-9: pick item\nPress Escape to pause."
|
| 120 |
out = translate_controls(text)
|