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.
- package/README.md +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- 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 ""
|
package/docs/godot-driver.md
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
|
112
|
-
|
|
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
|
|
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}
|
|
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
|
|
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` |
|
|
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.
|
|
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 | `
|
|
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` |
|