ravensight-playtest 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +319 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +120 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +233 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +647 -70
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/lists.js +206 -0
  24. package/src/commands/profile.js +25 -6
  25. package/src/commands/punch.js +116 -0
  26. package/src/commands/replay.js +204 -0
  27. package/src/commands/resume.js +7 -0
  28. package/src/commands/run.js +200 -24
  29. package/src/commands/tape.js +74 -0
  30. package/src/commands/template.js +70 -0
  31. package/src/commands/validate.js +414 -0
  32. package/src/commands/validateHosted.js +292 -0
  33. package/src/create/scaffold.js +192 -0
  34. package/src/run/acceptance.js +212 -0
  35. package/src/run/args.js +18 -1
  36. package/src/run/checks.js +705 -0
  37. package/src/run/dimension.js +95 -0
  38. package/src/run/drivers/godot-observation.js +59 -11
  39. package/src/run/drivers/godot-project.js +201 -31
  40. package/src/run/drivers/godot.js +532 -45
  41. package/src/run/drivers/web.js +18 -0
  42. package/src/run/evidence.js +170 -0
  43. package/src/run/featureLoop.js +288 -0
  44. package/src/run/fixLoop.js +82 -12
  45. package/src/run/git.js +110 -20
  46. package/src/run/json.js +65 -9
  47. package/src/run/labsOutcome.js +136 -0
  48. package/src/run/liveWork.js +103 -0
  49. package/src/run/model.js +105 -5
  50. package/src/run/openPr.js +107 -0
  51. package/src/run/paths.js +4 -1
  52. package/src/run/pendingRegister.js +81 -0
  53. package/src/run/personaLoop.js +111 -4
  54. package/src/run/profile.js +68 -7
  55. package/src/run/replay.js +236 -0
  56. package/src/run/repoTools.js +227 -27
  57. package/src/run/report.js +7 -1
  58. package/src/run/resume.js +19 -1
  59. package/src/run/secretScan.js +35 -0
  60. package/src/run/settle.js +41 -0
  61. package/src/run/tools.js +33 -4
  62. package/src/run/trace.js +124 -0
  63. package/src/simulation/godotPack.js +112 -0
  64. package/src/ui/index.js +61 -9
  65. package/src/upload/allowlist.js +6 -2
  66. package/src/upload/index.js +5 -4
  67. package/templates/godot-2d/README.md +36 -0
  68. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  69. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  70. package/templates/godot-2d/export_presets.cfg +27 -0
  71. package/templates/godot-2d/gitignore +10 -0
  72. package/templates/godot-2d/project.godot +58 -0
  73. package/templates/godot-2d/scenes/main.tscn +67 -0
  74. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  75. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  76. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  77. package/templates/godot-2d/scripts/main.gd +32 -0
  78. package/templates/godot-2d/scripts/player.gd +10 -0
  79. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  80. package/templates/godot-2d/template.json +14 -0
  81. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  82. package/templates/godot-2d/tools/selftest.gd +70 -0
  83. package/templates/godot-3d/README.md +49 -0
  84. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  85. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  86. package/templates/godot-3d/export_presets.cfg +27 -0
  87. package/templates/godot-3d/gitignore +10 -0
  88. package/templates/godot-3d/project.godot +56 -0
  89. package/templates/godot-3d/scenes/main.tscn +154 -0
  90. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  91. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  92. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  93. package/templates/godot-3d/scripts/beacon.gd +20 -0
  94. package/templates/godot-3d/scripts/main.gd +49 -0
  95. package/templates/godot-3d/scripts/player.gd +19 -0
  96. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  97. package/templates/godot-3d/template.json +16 -0
  98. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  99. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -0,0 +1,360 @@
1
+ extends Node
2
+ ## Ravensight simulate addon.
3
+ ##
4
+ ## Speaks the Simulation Runner protocol (simulation.start, simulation.step, one
5
+ ## JSON object per line) on stdin and stdout, so a Godot game can be played with
6
+ ## no window, no network and no model, on a hosted runner or on this machine.
7
+ ##
8
+ ## The CLI injects this into a throwaway copy of the project at export time, so
9
+ ## it is never in the customer's project. It does nothing unless the game was
10
+ ## started with `-- --ravensight-simulate` AND the build carries the setting
11
+ ## below, and it then blocks the main thread between steps: the game can only
12
+ ## move when the runner asks it to.
13
+ ##
14
+ ## Determinism: start the engine with `--fixed-fps 60`. Every frame is then one
15
+ ## physics tick and one process pass with a fixed delta. The seed is taken in
16
+ ## this node's _ready, before the first scene exists, so a scene's first randi()
17
+ ## already comes from it.
18
+ ##
19
+ ## Stdout: the protocol owns it. Every print() the game makes is off for the
20
+ ## whole process (Engine.print_to_stdout), and this node turns printing on only
21
+ ## for the length of one answer. That needs the project setting
22
+ ## application/run/flush_stdout_on_print, or a pipe would hold the answer back
23
+ ## forever; the CLI writes it into the throwaway copy.
24
+
25
+ const FLAG := "--ravensight-simulate"
26
+ const STATE_GROUP := "ravensight_state"
27
+ const TICKS_PER_STEP := 15
28
+ const BOOT_TICKS := 2
29
+ const MAX_NODES := 4000
30
+ const MAX_DIGEST_SUBTREES := 40
31
+ const MAX_TEXT_CHARS := 120
32
+ const MAX_ACTIONS := 64 # The protocol allows 256; nothing generic needs more.
33
+ const MAX_METRICS := 32
34
+ const WAIT_ACTION := "wait"
35
+ const BUTTON_PREFIX := "button:"
36
+ const MENU_ACTIONS := ["ui_accept", "ui_up", "ui_down", "ui_left", "ui_right"]
37
+ const STEP_CAP_OUTCOME := "step_cap"
38
+ const QUIT_OUTCOME := "game_quit"
39
+ const PROTOCOL_VERSION := 1
40
+
41
+ var _enabled := false
42
+ var _request_id: Variant = null
43
+ var _ticks_left := 0
44
+ var _held: Array[String] = []
45
+ var _offered: Array[String] = []
46
+ var _steps := 0
47
+ var _max_steps := 1
48
+
49
+
50
+ func _enter_tree() -> void:
51
+ if FLAG not in OS.get_cmdline_user_args():
52
+ return
53
+ _enabled = true
54
+ Engine.print_to_stdout = false
55
+ process_mode = Node.PROCESS_MODE_ALWAYS
56
+ process_priority = -2147483648
57
+
58
+
59
+ func _ready() -> void:
60
+ if not _enabled:
61
+ set_process(false)
62
+ queue_free()
63
+ return
64
+ # Before the first scene, so the scene sees the seed.
65
+ var message := _read_message()
66
+ if message.get("method", "") != "simulation.start":
67
+ _die()
68
+ return
69
+ var params: Dictionary = message.get("params", {})
70
+ _request_id = message.get("id")
71
+ _max_steps = maxi(1, int(params.get("max_steps", 1)))
72
+ seed(str(params.get("seed", "")).hash())
73
+ _ticks_left = BOOT_TICKS
74
+
75
+
76
+ ## The game quit in the middle of a step (a Quit button, a clean game over). That
77
+ ## is an ending and not a crash, so the step gets a terminal answer on the way out.
78
+ func _exit_tree() -> void:
79
+ if not _enabled or _request_id == null:
80
+ return
81
+ _send({"done": true, "state_hash": ("quit:%d" % _steps).sha256_text().substr(0, 32), "actions": [], "metrics": {}, "outcome": QUIT_OUTCOME})
82
+
83
+
84
+ func _process(_delta: float) -> void:
85
+ if not _enabled:
86
+ return
87
+ if _ticks_left > 0:
88
+ _ticks_left -= 1
89
+ if _ticks_left > 0:
90
+ return
91
+ _release_held()
92
+ _answer()
93
+ var message := _read_message()
94
+ if message.get("method", "") != "simulation.step":
95
+ _die()
96
+ return
97
+ _request_id = message.get("id")
98
+ var action := str((message.get("params", {}) as Dictionary).get("action", ""))
99
+ if action not in _offered:
100
+ _die()
101
+ return
102
+ _steps += 1
103
+ if action.begins_with(BUTTON_PREFIX):
104
+ var button: BaseButton = _buttons().get(action)
105
+ if button != null:
106
+ button.pressed.emit()
107
+ elif action != WAIT_ACTION:
108
+ Input.action_press(action)
109
+ _held.append(action)
110
+ var input := InputEventAction.new()
111
+ input.action = action
112
+ input.pressed = true
113
+ Input.parse_input_event(input)
114
+ _ticks_left = TICKS_PER_STEP
115
+
116
+
117
+ func _release_held() -> void:
118
+ for action in _held:
119
+ Input.action_release(action)
120
+ var input := InputEventAction.new()
121
+ input.action = action
122
+ input.pressed = false
123
+ Input.parse_input_event(input)
124
+ _held.clear()
125
+
126
+
127
+ ## Blocks until the runner writes a line. An empty read is the runner gone.
128
+ func _read_message() -> Dictionary:
129
+ var line := OS.read_string_from_stdin().strip_edges()
130
+ if line.is_empty():
131
+ _die()
132
+ return {}
133
+ var parsed: Variant = JSON.parse_string(line)
134
+ if typeof(parsed) != TYPE_DICTIONARY:
135
+ _die()
136
+ return {}
137
+ return parsed
138
+
139
+
140
+ ## Never a clean quit in the middle of a request: the runner must see this as a
141
+ ## game that stopped answering, not as an answer.
142
+ func _die() -> void:
143
+ OS.kill(OS.get_process_id())
144
+
145
+
146
+ func _answer() -> void:
147
+ var states := _game_states()
148
+ var flat := _flat(states)
149
+ var done := bool(flat.get("simulation_done", false))
150
+ var outcome: Variant = flat.get("simulation_outcome")
151
+ if not done and _steps >= _max_steps:
152
+ done = true
153
+ outcome = STEP_CAP_OUTCOME
154
+ _offered = [] if done else _action_ids()
155
+ var result := {
156
+ "done": done,
157
+ "state_hash": _state_hash(states),
158
+ "actions": _offered.map(func(id: String) -> Dictionary: return {"id": id}),
159
+ "metrics": _metrics(flat),
160
+ }
161
+ if done and outcome != null:
162
+ result["outcome"] = str(outcome).substr(0, 80)
163
+ _send(result)
164
+
165
+
166
+ func _send(result: Dictionary) -> void:
167
+ var text := JSON.stringify({"jsonrpc": "2.0", "id": _request_id, "result": result}, "", false)
168
+ _request_id = null
169
+ Engine.print_to_stdout = true
170
+ print(text)
171
+ Engine.print_to_stdout = false
172
+
173
+
174
+ ## What can be done now: wait, the game's own input actions, the menu keys, and
175
+ ## one action per button on screen, so a generic policy can get past a title
176
+ ## screen. Sorted, so the list does not depend on declaration order.
177
+ func _action_ids() -> Array[String]:
178
+ var ids: Array[String] = [WAIT_ACTION]
179
+ for action in InputMap.get_actions():
180
+ var id := str(action)
181
+ if id in MENU_ACTIONS or not (id.begins_with("ui_") or id.begins_with("spatial_editor") or id.begins_with("editor")):
182
+ ids.append(id)
183
+ for id in _buttons().keys():
184
+ ids.append(str(id))
185
+ ids.sort()
186
+ return ids.slice(0, MAX_ACTIONS)
187
+
188
+
189
+ ## Visible, enabled buttons in the current scene, keyed by their action id. A
190
+ ## second button with the same text is not offered: the id must name one thing.
191
+ func _buttons() -> Dictionary:
192
+ var out := {}
193
+ var scene := get_tree().current_scene
194
+ if scene != null:
195
+ _collect_buttons(scene, out)
196
+ return out
197
+
198
+
199
+ func _collect_buttons(node: Node, out: Dictionary) -> void:
200
+ if node is BaseButton:
201
+ var button := node as BaseButton
202
+ var label := _text_of(button).strip_edges().substr(0, 100)
203
+ var id := BUTTON_PREFIX + label
204
+ if button.is_visible_in_tree() and not button.disabled and not label.is_empty() and not out.has(id):
205
+ out[id] = button
206
+ for child in node.get_children():
207
+ _collect_buttons(child, out)
208
+
209
+
210
+ func _game_states() -> Dictionary:
211
+ var states := {}
212
+ for node in get_tree().get_nodes_in_group(STATE_GROUP):
213
+ if not node.has_method("_ravensight_state"):
214
+ continue
215
+ var raw: Variant = node.call("_ravensight_state")
216
+ if typeof(raw) == TYPE_DICTIONARY:
217
+ states[str(node.get_path())] = raw
218
+ return states
219
+
220
+
221
+ func _flat(states: Dictionary) -> Dictionary:
222
+ var out := {}
223
+ var paths := states.keys()
224
+ paths.sort()
225
+ for path in paths:
226
+ out.merge(states[path], true)
227
+ return out
228
+
229
+
230
+ ## Numbers and booleans only, sorted and capped: a metric is an aggregate the
231
+ ## server may keep, and a string could be anything.
232
+ func _metrics(flat: Dictionary) -> Dictionary:
233
+ var keys := flat.keys()
234
+ keys.sort()
235
+ var out := {}
236
+ for key in keys:
237
+ if out.size() >= MAX_METRICS:
238
+ break
239
+ var value: Variant = flat[key]
240
+ var name_text := str(key)
241
+ if name_text.is_empty() or name_text.length() > 80 or name_text.begins_with("simulation_"):
242
+ continue
243
+ if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT:
244
+ if is_finite(float(value)) and absf(float(value)) <= 1e15:
245
+ out[name_text] = value
246
+ elif typeof(value) == TYPE_BOOL:
247
+ out[name_text] = 1 if value else 0
248
+ return out
249
+
250
+
251
+ # The digest below is the driver addon's (addons/ravensight_driver/driver.gd), so
252
+ # hosted and local read a game the same way. src/simulation/godotPack.test.js
253
+ # fails when the two drift.
254
+
255
+ func _state_hash(states: Dictionary) -> String:
256
+ var scene := get_tree().current_scene
257
+ var budget := {"left": MAX_NODES, "truncated": false, "count": 0}
258
+ var parts := PackedStringArray()
259
+ if scene != null:
260
+ parts.append(_digest_fields(scene, 0))
261
+ var index := 0
262
+ for child in scene.get_children():
263
+ if child == self:
264
+ index += 1
265
+ continue
266
+ _digest_node(child, index, parts, budget)
267
+ index += 1
268
+ parts.append(JSON.stringify(_jsonify(states, 0), "", true))
269
+ return "".join(parts).sha256_text().substr(0, 32)
270
+
271
+
272
+ func _jsonify(value: Variant, depth: int) -> Variant:
273
+ if depth > 8:
274
+ return null
275
+ match typeof(value):
276
+ TYPE_DICTIONARY:
277
+ var out := {}
278
+ for key in (value as Dictionary).keys():
279
+ out[str(key)] = _jsonify(value[key], depth + 1)
280
+ return out
281
+ TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_FLOAT32_ARRAY:
282
+ var list := []
283
+ for item in value:
284
+ list.append(_jsonify(item, depth + 1))
285
+ return list
286
+ TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_STRING:
287
+ return value
288
+ TYPE_FLOAT:
289
+ return snappedf(value, 0.001)
290
+ _:
291
+ return str(value)
292
+
293
+
294
+ # BEGIN digest shared with driver.gd
295
+ func _digest_node(node: Node, index: int, parts: PackedStringArray, budget: Dictionary) -> void:
296
+ if int(budget["left"]) <= 0:
297
+ budget["truncated"] = true
298
+ return
299
+ budget["left"] = int(budget["left"]) - 1
300
+ budget["count"] = int(budget["count"]) + 1
301
+ parts.append(_digest_fields(node, index))
302
+ var child_index := 0
303
+ for child in node.get_children():
304
+ _digest_node(child, child_index, parts, budget)
305
+ child_index += 1
306
+
307
+
308
+ func _digest_name(node: Node, index: int) -> String:
309
+ var node_name := str(node.name)
310
+ return "#%d" % index if node_name.begins_with("@") else node_name
311
+
312
+
313
+ func _digest_fields(node: Node, index: int) -> String:
314
+ var fields := "%s|%s" % [node.get_class(), _digest_name(node, index)]
315
+ if node is CanvasItem:
316
+ fields += "|v%d" % int((node as CanvasItem).is_visible_in_tree())
317
+ if node is Node2D:
318
+ var n2 := node as Node2D
319
+ fields += "|%.3f,%.3f,%.3f" % [n2.global_position.x, n2.global_position.y, n2.global_rotation]
320
+ elif node is Node3D:
321
+ var n3 := node as Node3D
322
+ var p3 := n3.global_position
323
+ var a3 := n3.global_rotation
324
+ fields += "|v%d|%.3f,%.3f,%.3f|%.3f,%.3f,%.3f" % [int(n3.is_visible_in_tree()), p3.x, p3.y, p3.z, a3.x, a3.y, a3.z]
325
+ elif node is Control:
326
+ var rect := (node as Control).get_global_rect()
327
+ fields += "|%.3f,%.3f,%.3f,%.3f" % [rect.position.x, rect.position.y, rect.size.x, rect.size.y]
328
+ if node is CharacterBody2D:
329
+ var v2 := (node as CharacterBody2D).velocity
330
+ fields += "|%.3f,%.3f" % [v2.x, v2.y]
331
+ elif node is CharacterBody3D:
332
+ var v3 := (node as CharacterBody3D).velocity
333
+ fields += "|%.3f,%.3f,%.3f" % [v3.x, v3.y, v3.z]
334
+ elif node is RigidBody2D:
335
+ var r2 := (node as RigidBody2D).linear_velocity
336
+ fields += "|%.3f,%.3f" % [r2.x, r2.y]
337
+ elif node is RigidBody3D:
338
+ var r3 := (node as RigidBody3D).linear_velocity
339
+ fields += "|%.3f,%.3f,%.3f" % [r3.x, r3.y, r3.z]
340
+ var text := _text_of(node)
341
+ if not text.is_empty():
342
+ fields += "|t:" + text.substr(0, MAX_TEXT_CHARS)
343
+ if node is BaseButton:
344
+ fields += "|d%d" % int((node as BaseButton).disabled)
345
+ return fields + ";"
346
+ # END digest shared with driver.gd
347
+
348
+
349
+ func _text_of(node: Node) -> String:
350
+ if node is RichTextLabel:
351
+ return (node as RichTextLabel).get_parsed_text()
352
+ if node is Label:
353
+ return (node as Label).text
354
+ if node is Button:
355
+ return (node as Button).text
356
+ if node is LineEdit:
357
+ return (node as LineEdit).text
358
+ if node is TextEdit:
359
+ return (node as TextEdit).text
360
+ return ""
@@ -8,8 +8,8 @@ by launching the developer's own Godot on their own machine. It has two halves:
8
8
  - `src/run/drivers/godot.js` (Node): starts Godot, speaks that protocol, turns
9
9
  the scene tree into an observation, and writes screenshots.
10
10
 
11
- Spec 07 is the contract for both halves. This document is how the two sides fit
12
- together and what the driver is and is not able to do.
11
+ This document is how the two sides fit together and what the driver is and is
12
+ not able to do.
13
13
 
14
14
  ## The driver never reaches players
15
15
 
@@ -108,8 +108,9 @@ a project cannot be playtested.
108
108
  <godot> --path <temp copy> --resolution 1280x720 -- --ravensight-driver --ravensight-driver-port=<port>
109
109
  ```
110
110
 
111
- The binary is found the way spec 17 describes: `--godot-path`, then `$GODOT`,
112
- then `PATH` (`godot`, `godot4`), then the platform's usual install locations.
111
+ The binary is found in this order: `--godot-path` (accepted by `check`, `run`,
112
+ `fix` and `resume`), then `$RAVENSIGHT_GODOT`, `$GODOT` and `$GODOT_PATH`, then
113
+ `PATH` (`godot`, `godot4`), then the platform's usual install locations.
113
114
  When the project declares an engine version in `config/features`, a binary with
114
115
  that major.minor wins over a newer one. Anything below 4.2 is refused.
115
116
 
@@ -130,7 +131,7 @@ Only those, plus a fixed allowlist of variables a process needs to start at all
130
131
  `LIBGL_ALWAYS_SOFTWARE` set that the Xvfb path needs, the Windows equivalents),
131
132
  reach Godot. The environment is **not** inherited wholesale: the game is the
132
133
  customer's own code running on the developer's machine, and it has no business
133
- seeing `ANTHROPIC_API_KEY`, `RAVENSIGHT_TOKEN`, cloud credentials or CI secrets.
134
+ seeing a model provider's key, `RAVENSIGHT_TOKEN`, cloud credentials or CI secrets.
134
135
  `ENV_ALLOWLIST` is the list, and `extraEnv` is the deliberate escape hatch for a
135
136
  caller that needs one more variable through.
136
137
 
@@ -169,12 +170,12 @@ plus the two optional extras spec 03 lists, `consoleErrors()` and
169
170
 
170
171
  | Action | Driver calls |
171
172
  |---|---|
172
- | `key` | `key` press, `wait_frames{1}`, `key` release |
173
+ | `key` | `key` press for the key and any `keys` held with it, then `wait_frames{1}` for a tap or `holdMs` of game time on the clock (at most 5 s), then a release of each key in reverse order |
173
174
  | `type` | one press and release per character, each carrying its unicode, then Enter when `submit` |
174
- | `click` with a string target | `click{node_path}` (the center of the Control's global rect) |
175
+ | `click` with a string target | `click{node_path}` (the center of a Control's global rect, or where the active camera draws a Node3D; a Node3D behind the camera is not clickable) |
175
176
  | `click` with coordinates | `click{x,y}` |
176
177
  | `press_action` | `press_action{action, duration_ms}` |
177
- | `wait` | `wait_frames`, milliseconds converted at 60 fps |
178
+ | `wait` | the milliseconds on the clock, at most 10 s |
178
179
  | `set_time_scale` | `set_time_scale` |
179
180
  | `quit` | `quit` |
180
181
 
@@ -194,6 +195,52 @@ A driver level error is returned, not thrown: an undefined input action comes
194
195
  back as `{ ok: false, code: -32012, data: { available_actions } }` so the
195
196
  persona can pick a real one instead of the run ending.
196
197
 
198
+ ## Holding keys
199
+
200
+ A tap is one frame, which in a game that moves a character by `speed * delta`
201
+ is a pixel or two. Holds and waits are timed on the clock, divided by the game's
202
+ time scale, not counted in frames: a frame count depends on the display, and on
203
+ a 120 Hz Mac 120 frames was one game second. The persona's `key` tool therefore takes `hold_ms` (up to
204
+ 5000) and `with_keys` (up to two more keys held at the same time, for a
205
+ diagonal). Every key that went down is released, in reverse order, even when a
206
+ later key was refused, so nothing stays held for the rest of the run. A driver
207
+ that cannot hold keys refuses a hold as a driver error rather than tapping.
208
+
209
+ ## The game is paused between actions
210
+
211
+ A model takes seconds to decide, and a game with a clock keeps running while it
212
+ does. So after launch the driver sets `time_scale` to 0, and around each action
213
+ it restores the scale the game was running at, performs the action (a held key
214
+ holds for that much game time), waits the settle frames, and freezes the clock
215
+ again. Observations and screenshots are taken with the clock stopped. A game
216
+ that sets its own `time_scale` keeps it: the scale read back when the clock is
217
+ frozen is the one restored next. `run --realtime` turns this off for a game
218
+ that has to keep running in real time.
219
+
220
+ `time_scale` 0 stops what reads `delta` or a timer. A game that moves on wall
221
+ clock time (`Time.get_ticks_msec()`) is not paused by it.
222
+
223
+ ## Importing a fresh clone
224
+
225
+ Godot resolves `class_name` through `.godot/global_script_class_cache.cfg`,
226
+ which is gitignored. A fresh clone, a CI checkout or a git worktree has none,
227
+ and every script that names a global class fails to parse. Before launching,
228
+ the driver checks the developer's directory (read only): no `.godot/`, a
229
+ `class_name` with no class cache, or a script newer than the cache, and it runs
230
+ `godot --headless --path <temp copy> --import`, bounded at 3 minutes, on the
231
+ copy. Its output goes to the run's `godot.log`. A failed import is logged and
232
+ the launch goes ahead, so the boot check below decides.
233
+
234
+ ## A game that never boots fails the launch
235
+
236
+ After `ping`, the driver lets the game run ten frames, then reads its output.
237
+ A `Parse Error:`, a `Failed to load script ... with error`, a main scene that
238
+ failed to load, or no current scene at all fails the launch with an error that
239
+ starts `game_boot_failed:`. The run is completed as `failed` with that reason,
240
+ so it is refunded as a failed run, and a fix verify whose game did not boot is
241
+ reported "not verified, the game did not boot", never fixed. An ordinary
242
+ runtime `ERROR:` line does not fail the boot.
243
+
197
244
  ## Observations cite files
198
245
 
199
246
  Every node in a `get_state` snapshot carries its `path`, and also its `scene`
@@ -308,3 +355,18 @@ Godot installed.
308
355
 
309
356
  The fixture is never written to: every case copies it to a temp directory first,
310
357
  so `--import` and `--export-pack` leave no `.godot/` or `out/` behind.
358
+
359
+ ## Lockstep
360
+
361
+ `RAVENSIGHT_DRIVER_LOCKSTEP=1` plus `RAVENSIGHT_DRIVER_SEED` put the addon in the
362
+ deterministic mode the CLI uses for every persona run unless `--realtime` is set. See
363
+ [REPLAY.md](REPLAY.md). Two requests only work there or matter there:
364
+
365
+ - `seed{value}` reseeds the global generator and answers `{ seed, lockstep, tick, ticks_per_second }`.
366
+ - `step{events, ticks, digest}` applies `events` (`{type: "key", keycode, pressed, unicode}`,
367
+ `{type: "action", action, pressed}`, `{type: "click", x, y | node_path, button, double}`),
368
+ runs exactly `ticks` ticks (1 to 3600), and answers `{ tick, frame, digest }`. Every event is
369
+ checked first, so a refused step changes nothing. Outside lockstep it answers `-32030`.
370
+
371
+ `digest` answers the same shape without moving anything. `ping` also reports
372
+ `lockstep`, `tick`, `seed`, `ticks_per_second` and `user_dir`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Local AI playtests and seeded game simulations with reports in Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
@@ -27,6 +27,7 @@
27
27
  "bin",
28
28
  "src",
29
29
  "addons",
30
+ "templates",
30
31
  "docs/ADAPTERS.md",
31
32
  "docs/NATIVE_RUNNER.md",
32
33
  "docs/godot-driver.md",
@@ -45,8 +46,8 @@
45
46
  "./api": "./src/api/index.js"
46
47
  },
47
48
  "scripts": {
48
- "test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e).test.js'",
49
- "test:godot": "node --test --test-reporter=spec src/run/drivers/godot-real.test.js",
49
+ "test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e|scaffold.godot|template3d.godot).test.js'",
50
+ "test:godot": "node --test --test-reporter=spec src/run/drivers/godot-real.test.js src/create/scaffold.godot.test.js src/create/template3d.godot.test.js",
50
51
  "test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js",
51
52
  "lint": "node scripts/no-dashes.mjs"
52
53
  },
package/src/api/README.md CHANGED
@@ -128,6 +128,7 @@ All game-scoped, all under `/api/v1/games/:gameId/playtest`.
128
128
  | `api.jobs.patch(gameId, jobId, { state, reason })` | `PATCH /jobs/:jobId` -> `{ job }` |
129
129
  | `api.jobs.cancel(gameId, jobId)` | `POST /jobs/:jobId/cancel` |
130
130
  | `api.jobs.finish(gameId, jobId, { state, reason })` | `POST /jobs/:jobId/finish` |
131
+ | `api.jobs.labsOutcomePr(gameId, jobId, { pr })` | `PUT /jobs/:jobId/labs-outcome/pr` |
131
132
  | `api.jobs.remove(gameId, jobId)` | `DELETE /jobs/:jobId` |
132
133
  | `api.jobs.uploads(gameId, jobId, { files })` | `POST /jobs/:jobId/uploads`, job-level keys |
133
134
  | `api.jobs.capabilityReport(gameId, jobId, body)` | `POST /jobs/:jobId/capability-report` |
@@ -166,7 +167,9 @@ Refusals worth handling by code:
166
167
  | Status | `error` | What it means |
167
168
  |---|---|---|
168
169
  | 400 | `idempotency_key_required`, `invalid_job` | fix the request |
169
- | 402 | `insufficient_balance` | carries `estimate_cents`, `balance_cents`, `shortfall_cents`, `topup_url` |
170
+ | 402 | `funding_required` | a Labs job on an account below $10 of lifetime top-ups; carries `topup_url`. Fix v2 falls back to an ordinary fix |
171
+ | 402 | `insufficient_balance` | carries `estimate_cents`, `balance_cents`, `shortfall_cents`, `topup_url`, and on a Labs job `minimum_balance_cents`, which the shortfall is measured against; the two balance figures are null for a role without billing access |
172
+ | 409 | `settle_contended` | the job's price moved while it was being settled, nothing happened, retry after `Retry-After` |
170
173
  | 409 | `price_changed` | carries the new `estimate_cents` and `breakdown`; re-confirm |
171
174
  | 409 | `brief_required` | finish the brief first |
172
175
  | 409 | `cli_outdated` | carries `min_version` |