ravensight-playtest 0.3.2 → 0.5.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 CHANGED
@@ -4,6 +4,11 @@ AI personas play your game, then file a report you can read, mark up and argue
4
4
  with. The personas run on your machine, against your build; Ravensight stores
5
5
  the reports and renders them.
6
6
 
7
+ Simulation Runner adds seeded headless batches for mechanics, economy and
8
+ progression checks through your own game adapter. It runs locally without
9
+ model calls and saves bounded results to the same Playtest workspace. See
10
+ [Simulation Runner](docs/SIMULATION.md) for setup.
11
+
7
12
  You need a Ravensight account and Node 22 or newer. You do not need an
8
13
  Anthropic API key: model calls go through Ravensight under the same token you
9
14
  log in with, so there is one credential on your machine and you can revoke it
@@ -369,7 +374,10 @@ splitting the job in two is what gets everything played.
369
374
  | `--max-actions <n>` | Lower the actions one persona may take. It can only lower the persona's own patience (and the server's per run ceiling), never raise it. |
370
375
  | `--long` | A long persona run: twice the actions, model budget and wall time of a standard run, at twice the price. The price is read from the estimate and confirmed before any charge. |
371
376
  | `--concurrency <n>` | Personas at once. Default 2; `check` recommends a number for this machine. |
372
- | `--upload-video` | Upload `session.webm` as well as the reports. |
377
+ | `--controller <name>` | One playthrough using `standard` or an available System One controller. The server must advertise and quote that exact choice. |
378
+ | `--compare-with jev` | Separate agent/skill and Jev playthroughs for each chosen persona. Shows their combined quote before purchase. Comparisons run one playthrough at a time. |
379
+ | `--record-video` | Capture the selected web or graphical Godot game locally. Does not upload the video. |
380
+ | `--upload-video` | Record and explicitly upload `session.webm` or `session.mp4` with the reports. |
373
381
  | `--upload-transcript` | Upload `transcript.jsonl` as well as the reports. |
374
382
  | `--dry-upload` | List every file and byte that would be sent, and send nothing. |
375
383
  | `--key-modifier <meta\|control>` | The modifier the browser driver uses for editing shortcuts such as select all. Defaults to this machine's own (Command on macOS, Control elsewhere); set it when the browser's platform is not this machine's. `RAVENSIGHT_KEY_MODIFIER` does the same. |
@@ -775,7 +783,7 @@ to it. The short version:
775
783
 
776
784
  Uploaded by default: `report.md`, `report.json`, `usage.json`, `changeset.json`,
777
785
  the aggregate and capability reports, and `screenshots/`. Uploaded only when you ask:
778
- `transcript.jsonl` (`--upload-transcript`) and `session.webm`
786
+ `transcript.jsonl` (`--upload-transcript`) and `session.webm` or `session.mp4`
779
787
  (`--upload-video`). **Source code never, at any path.** The upload allowlist has
780
788
  no entry for it, the CLI refuses it locally, and the server refuses anything not
781
789
  on its own copy of the list. `src/upload/allowlist.js` is that copy, and a test
@@ -876,6 +884,59 @@ before registering a paid job. No provider key belongs in the CLI. Selection cos
876
884
  share the existing persona-run budget and do not create another wallet charge.
877
885
  Resume preserves the controller recorded in the job journal.
878
886
 
887
+ ### Recorded playthroughs and comparisons
888
+
889
+ The `0.5.0` candidate adds local recording and explicit comparisons. It requires a
890
+ matching server release for controller plans; an older server is refused before
891
+ purchase. Rune is recognized as a future controller but is unavailable until
892
+ provider access, pricing and acceptance are complete. These commands do not
893
+ activate graphical AWS hosting.
894
+
895
+ To keep one playthrough's video on your computer:
896
+
897
+ ```sh
898
+ ravensight-playtest run --driver playwright_web --build-url https://your-game.example/ --record-video
899
+ ```
900
+
901
+ To request two reports, one from the agent/skill controller and one from Jev:
902
+
903
+ ```sh
904
+ ravensight-playtest run --driver playwright_web --build-url https://your-game.example/ --personas curious-kid --compare-with jev --record-video
905
+ ```
906
+
907
+ The server quotes every persona/controller run before the CLI asks for payment.
908
+ Each keeps its own existing model budget and settlement rules. Two standard runs
909
+ at the current $1 unit price total $2; the displayed server quote is authoritative.
910
+ No comparison model call or extra recording fee is added. Use `--upload-video`
911
+ instead of `--record-video` only when you want the videos uploaded for private
912
+ dashboard playback. Recording is silent and captures only the selected game,
913
+ not your desktop. Browser popups and navigation to another origin are blocked
914
+ while recording.
915
+
916
+ Web recording produces WebM. Graphical Godot recording needs an existing FFmpeg
917
+ installation and produces MP4; a headless Godot run or missing encoder is reported
918
+ as unsupported. Capture is bounded to 15 minutes, 200 MiB of video, 1280 by 720
919
+ pixels and 10 frames per second for the Godot encoder. Godot raw frames have a
920
+ separate 256 MiB bound. This is evidence capture, not a promise of smooth video
921
+ at the game's original frame rate. Cancellation, crashes and limits can leave a
922
+ playable partial video; local `recording.json` records that outcome. Neither that
923
+ file nor raw frames are uploaded as artifacts.
924
+
925
+ With `--upload-video`, completion also sends a bounded capture status, duration
926
+ and stop reason. This is the runner's account of capture, separate from verified
927
+ upload or report success. Without video-upload consent, no capture status is sent.
928
+ Older recordings and later byte-only uploads can show status as unreported.
929
+ Malformed local status is omitted so it cannot prevent an existing report from
930
+ being completed.
931
+
932
+ Each controller starts a separate game session and produces its own report.
933
+ Saved progress and random events may still differ; this is not a guaranteed
934
+ identical-state benchmark. The aggregate compares evidence-bearing findings and
935
+ marks missing, failed or partial playthroughs as inconclusive. Agreement between
936
+ reports is evidence to review, not confirmation that a bug exists. In the
937
+ dashboard, choose a playthrough and its Recording tab; timestamp links refer only
938
+ to that run's uploaded video. Headless Simulation Runner has no rendered video.
939
+
879
940
  For Jump Bunny, use `https://reality-software-entertainment.github.io/poc-jump-bunny/`
880
941
  and the public player label **Ravensight Playtester**. This is a local browser run,
881
942
  not a hosted browser service. Human playtesting is still needed for subjective
@@ -884,3 +945,17 @@ feedback and timing-sensitive gameplay.
884
945
  ## Build adapters
885
946
 
886
947
  Browser and Godot runners share the paid playtest lifecycle with desktop, Unity/Unreal export, Android/iOS and custom JSON-RPC adapters. Native adapters require a locally configured automation server and remain experimental until validated against your build. See [adapter setup and validation boundaries](docs/ADAPTERS.md) and the [native runner starter](docs/NATIVE_RUNNER.md); `check` probes the automation server for free before a paid native run.
948
+
949
+ ## Simulation Runner
950
+
951
+ Available since `0.4.0`. Simulation Runner requires macOS or Linux (including WSL); native Windows is refused before purchase.
952
+
953
+ Run seeded mechanics tests through a local game adapter, compare explicit strategies and replay selected traces. A batch uses the existing standard Playtest purchase of 5 AI units from prepaid balance, confirmed against the server quote. Only the summary is uploaded; game execution and replay traces stay local. It does not automatically call a model or replace testing the player interface.
954
+
955
+ ```sh
956
+ ravensight-playtest simulate --config simulation.json --driver-config bridge.json
957
+ ravensight-playtest simulate --resume .ravensight/simulations/BATCH_ID/state.json
958
+ ravensight-playtest simulation-replay trace-00000.json --driver-config bridge.json
959
+ ```
960
+
961
+ See [Simulation Runner setup, adapter protocol and limits](docs/SIMULATION.md). The package includes `examples/simulation/economy.mjs` as a deterministic starting adapter. Local replay creates no new paid job.
@@ -0,0 +1,91 @@
1
+ # Simulation Runner
2
+
3
+ Available since `0.4.0`, with the matching released Ravensight server.
4
+
5
+ Simulation Runner exercises game mechanics through a developer-owned local adapter. It can run seeded games across explicit strategies, summarize outcomes and metrics, and save selected replay traces. Use it for questions such as whether an economy supports several viable strategies or whether a progression rule traps a simulated player. It does not judge animation, interface usability, feel or subjective fun.
6
+
7
+ A batch uses the existing standard Playtest purchase: **5 AI units from prepaid balance**. The CLI obtains the current server quote and asks for confirmation before registration. One batch is one purchase, not one purchase per simulated game. It does not consume the monthly shared AI allowance. Local replay is a debugging operation with no new job or purchase. Automatic AI interpretation is not included; an agent can use the existing report tools when explicitly asked to interpret the summary.
8
+
9
+ ## Setup and run
10
+
11
+ Run Simulation Runner on macOS or Linux, including WSL. Native Windows is rejected during preflight before quoting or registering a purchase because this feature requires POSIX process-group cleanup. This restriction applies to Simulation Runner, not every Playtest adapter. The recorded real-process and 10,000-game validation was run on macOS; native Windows and WSL execution were not validated in this review.
12
+
13
+ Sign in and initialize your Ravensight project as usual. Implement the protocol below, or start with the packaged `examples/simulation/economy.mjs`. The example is deterministic and contains no networking or telemetry.
14
+
15
+ Create `bridge.json` with absolute paths for the Node executable and adapter:
16
+
17
+ ```json
18
+ {
19
+ "command": "/absolute/path/to/node",
20
+ "args": ["/absolute/path/to/economy.mjs"],
21
+ "cwd": "/absolute/path/to/game"
22
+ }
23
+ ```
24
+
25
+ The adapter is trusted local code, **not a sandbox**. No shell is used to spawn it. Account/provider credential environment variables are stripped. Paid execution injects a synthetic Playtest token and run/job IDs; preflight and replay inject an invalid token sentinel so an inherited token cannot accidentally mark the session as production.
26
+
27
+ Create `simulation.json`:
28
+
29
+ ```json
30
+ {
31
+ "version": 1,
32
+ "seed": "economy-review-1",
33
+ "runs": 100,
34
+ "max_steps": 12,
35
+ "workers": 4,
36
+ "strategies": [
37
+ { "name": "random", "policy": "random" },
38
+ { "name": "collector", "policy": "scored" },
39
+ { "name": "saver", "policy": "scripted", "actions": ["earn", "earn", "earn", "earn", "earn", "earn", "earn", "earn"] }
40
+ ]
41
+ }
42
+ ```
43
+
44
+ ```sh
45
+ ravensight-playtest simulate --config simulation.json --driver-config bridge.json
46
+ ```
47
+
48
+ A `driver_config` path inside the simulation file may replace `--driver-config`. The CLI probes the adapter before purchasing. A probe only starts a game and validates its first observation; it does not run the batch. Keep adapter initialization free of destructive side effects.
49
+
50
+ Strategy names start with a lowercase letter and contain at most 64 lowercase letters, digits, underscores or hyphens. Strategies are assigned round-robin. Each run receives a seed derived from the root seed and its zero-based run index. Worker count changes concurrency, not action selection. `random` chooses among legal action IDs deterministically. `scripted` follows supplied IDs and fails on an illegal action or exhaustion before a terminal state. `scored` chooses the highest score supplied by the game for that strategy name, with a deterministic action-ID tie break. Names such as "aggressive" have no built-in gameplay meaning.
51
+
52
+ ## Adapter protocol
53
+
54
+ Use one JSON-RPC 2.0 JSON object per line on stdout. Send diagnostics to stderr. The runner starts a fresh subprocess for each game, with one request outstanding at a time.
55
+
56
+ ```json
57
+ {"jsonrpc":"2.0","id":1,"method":"simulation.start","params":{"version":1,"seed":"derived-seed","strategy":"collector","max_steps":12}}
58
+ {"jsonrpc":"2.0","id":1,"result":{"done":false,"state_hash":"state-0","actions":[{"id":"earn","scores":{"collector":1}},{"id":"buy","scores":{"collector":3}}],"metrics":{"coins":4}}}
59
+ {"jsonrpc":"2.0","id":2,"method":"simulation.step","params":{"action":"buy"}}
60
+ {"jsonrpc":"2.0","id":2,"result":{"done":true,"state_hash":"state-1","actions":[],"metrics":{"coins":2,"inventory":1},"outcome":"collected"}}
61
+ ```
62
+
63
+ The game owns state hashing and must include every state component relevant to reproducibility. Return `done` as a boolean, a nonempty `state_hash` of at most 128 characters, and at most 256 unique action IDs of at most 128 characters. Nonterminal observations need legal actions. Optional action scores contain at most 16 finite numeric values keyed by strategy. Optional metrics contain at most 32 finite numeric values between -1e15 and 1e15 with keys of at most 80 characters. Optional outcomes are nonempty strings of at most 80 characters. Reserved names `__proto__`, `constructor` and `prototype` are rejected.
64
+
65
+ ## Limits and results
66
+
67
+ - 1 to 10,000 requested games, 1 to 10,000 actions per game, 1 to 16 workers and 1 to 16 strategies.
68
+ - At most 1,000,000 actions across a batch and at most 15 minutes of execution, subject to the registered server budget.
69
+ - At most 30 seconds per response and 256 KiB per response. Cancellation terminates the subprocess group, escalating from SIGTERM to SIGKILL.
70
+ - At most ten selected traces: the first ten run indices, not an exhaustive failure archive. Each trace is bounded to 4 MiB and 10,000 actions.
71
+ - Per-strategy summaries retain at most 32 distinct metric names and 128 outcome buckets. Additional outcome names are grouped as `other`; keep metric names stable between games.
72
+
73
+ `duration_ms` reports execution time within the batch deadline, excluding any cleanup or post-stop serialization beyond that deadline. `results.jsonl` streams one local result per requested run. `summary.json` contains completed, failed and truncated totals, total actions, terminal outcome counts and terminal metric count/min/max/mean by strategy. Metrics are run-level terminal values, not a mean across every observation. A terminal result without an outcome uses `unspecified`. Reaching `max_steps` is **truncated**, never a successful terminal outcome. Games not started because of cancellation or a batch limit count as failed, with the stop reason retained in the local report.
74
+
75
+ Only the bounded summary is uploaded to the existing Playtest job. Adapter scripts, raw states, per-game rows, replay traces and scripted action lists remain local. The public configuration hash covers the registered configuration and strategy names/policies; trace metadata also includes a separate full local configuration hash covering scripts.
76
+
77
+ ## Recovery and replay
78
+
79
+ The CLI prints a durable recovery journal path. Recover the same purchase rather than starting another batch:
80
+
81
+ ```sh
82
+ ravensight-playtest simulate --resume .ravensight/simulations/BATCH_ID/state.json
83
+ ```
84
+
85
+ Resume preserves the API host, game, configuration and purchase identity. Do not combine it with a new config or bridge argument. A changed bridge configuration is rejected. Keep the adapter build and its dependencies unchanged for reproducible recovery. After a process interruption, any restarted local attempt has its own output directory. If the summary was already saved, resume uploads that summary without executing the games again. A terminal server job is not restarted.
86
+
87
+ ```sh
88
+ ravensight-playtest simulation-replay /absolute/path/to/trace-00000.json --driver-config bridge.json
89
+ ```
90
+
91
+ Replay checks each recorded state hash, terminal flag, outcome and metrics and reports the first divergence. A matching replay verifies that recording with the current adapter; it does not prove the game is deterministic. External services, clocks, platform differences and ignored seeds can cause divergence. A trace from a max-step-truncated game only covers the recorded window, not a finished game. Size-capped or failed recordings can verify their captured prefix, reported as `matched_prefix: true` and `fully_replayed: false`. A recorded failed protocol action is retried after that prefix; `failure_reproduced` states whether the same sanitized protocol error recurred. A failed initial start has no state prefix to verify. Policy failures such as an exhausted script only support prefix replay, not recreation of the policy failure. These results never claim a fully reproduced terminal game.
@@ -0,0 +1,310 @@
1
+ # The Godot driver
2
+
3
+ `godot_driver` lets a playtest persona observe and control a native Godot game
4
+ by launching the developer's own Godot on their own machine. It has two halves:
5
+
6
+ - `addons/ravensight_driver/` (GDScript, Godot 4.2+): a TCP server that answers
7
+ JSON-RPC requests from inside the running game.
8
+ - `src/run/drivers/godot.js` (Node): starts Godot, speaks that protocol, turns
9
+ the scene tree into an observation, and writes screenshots.
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.
13
+
14
+ ## The driver never reaches players
15
+
16
+ The addon refuses to start unless every one of these holds:
17
+
18
+ 1. The build is not an export template, **or** it was exported with the custom
19
+ feature tag `ravensight_driver`. A normal release export never activates it.
20
+ 2. **And** either `--ravensight-driver` is in the user args (after the `--`
21
+ separator) or `RAVENSIGHT_DRIVER=1` is set.
22
+ 3. It binds `127.0.0.1` only. There is no code path that binds any other
23
+ address, and the Node client refuses to dial a non loopback host.
24
+ 4. If `RAVENSIGHT_DRIVER_TOKEN` is set, the first request on a connection must
25
+ be `hello{token}`. A wrong token gets `-32002` and the connection closes.
26
+ 5. One client at a time. A second connection is answered with `-32001 busy` and
27
+ hung up on.
28
+
29
+ When any of that fails the autoload calls `queue_free()` on itself in
30
+ `_ready()`, so an inactive driver is not a node that idles in `_process`, it is
31
+ not in the tree at all.
32
+
33
+ On top of refusing to run, `export_plugin.gd` **strips the whole addon** from
34
+ any export whose preset was not built with the `ravensight_driver` feature tag,
35
+ so the usual release build does not contain the driver's code.
36
+
37
+ One file does not simply vanish. A developer who installs the addon in their own
38
+ project gets `RavensightDriver` written into `project.godot`, and `project.godot`
39
+ ships in the export. Deleting `driver.gd` outright would leave that autoload
40
+ pointing at a file that is not there, and the exported game would greet the
41
+ player with a load error before its first frame. So the strip leaves a four line
42
+ inert stub at `driver.gd`'s path: `extends Node` and a `_ready()` that calls
43
+ `queue_free()`. The autoload resolves, nothing listens, and no driver code
44
+ ships. The alternative, failing the export with a message, was rejected because
45
+ it punishes a developer for having installed the addon at all.
46
+
47
+ This is covered by a test, not an assertion of intent: `godot-real.test.js`
48
+ exports the same project twice with `--export-pack` (which needs no export
49
+ templates) and checks that the plain pack contains the stub and none of the
50
+ driver's source, while a pack built with the feature tag contains all of it.
51
+
52
+ ## How the addon gets into the project
53
+
54
+ The CLI never writes to the developer's project. `prepareProjectCopy` copies the
55
+ project to a temp directory (mode `0700`, `.git` and `node_modules` excluded,
56
+ refused above 2 GB), drops the addon into `addons/ravensight_driver/` in the
57
+ copy, and registers the autoload **twice** in that copy:
58
+
59
+ - in `override.cfg`, which is the mechanism spec 07 describes, and
60
+ - in the copy's own `project.godot`.
61
+
62
+ Spec 07's open question 1 asks whether an `override.cfg` autoload works for
63
+ every project layout. Rather than leave that unanswered at runtime, both are
64
+ written. They name the same script, so whichever Godot honors, the result is
65
+ identical. A project that already ships an `override.cfg` or already registers
66
+ other autoloads keeps them: only the one `RavensightDriver` key is added.
67
+
68
+ **Symlinks are the trap here.** `project.godot`, `override.cfg` and `addons/`
69
+ can each be a symlink pointing outside the project, which is what a developer
70
+ gets from a shared settings repo or a vendored addons checkout. Writing to a
71
+ symlink writes to its target, so a naive copy would put the injection into the
72
+ developer's real files, permanently, breaking the one promise spec 07 makes
73
+ about the project directory. Three things prevent it:
74
+
75
+ - the copy runs with `dereference: true`, so a symlink becomes a real file or
76
+ directory in the copy rather than a pointer back out of it;
77
+ - every path this module writes to is materialized first, component by
78
+ component, replacing any surviving link with a real copy of its content;
79
+ - `measureProject` follows symlinked directories when it sizes the project,
80
+ because sizing that ignored them reported a few hundred bytes for a tree the
81
+ copy then dereferenced into gigabytes, and the 2 GB cap did nothing.
82
+
83
+ Dereferencing brings its own failure mode, so the copy filter drops two shapes
84
+ outright. A link resolving to the project directory or any ancestor of it
85
+ (`selfref -> .`, `up -> ..`) is dropped: dereferenced, the copy walks the project
86
+ into itself, finds the link again, and repeats until `PATH_MAX`, which one such
87
+ link turned into 33 nested copies. And a directory already dereferenced once is
88
+ not dereferenced again, because `measureProject` counts a shared target once and
89
+ the two have to agree or the cap stops meaning anything.
90
+
91
+ Both rules compare **realpaths**, not `path.resolve()` output. On macOS
92
+ `os.tmpdir()` is `/var/folders/...`, a symlink to `/private/var/folders/...`, so
93
+ a project living there never matches its own symlink target unless both sides are
94
+ fully resolved, and the rule quietly does nothing. That is a test that caught it,
95
+ not a hypothetical.
96
+
97
+ A failed `prepareProjectCopy` deletes the temp root it created, so a project that
98
+ cannot be prepared does not leave a half written copy on disk. A `tempRoot` the
99
+ caller passed in is left alone: it is theirs.
100
+
101
+ A dangling symlink is dropped from the copy instead of failing the launch:
102
+ `fs.cp` throws on one it cannot dereference, and one broken link is not a reason
103
+ a project cannot be playtested.
104
+
105
+ ## Launch
106
+
107
+ ```
108
+ <godot> --path <temp copy> --resolution 1280x720 -- --ravensight-driver --ravensight-driver-port=<port>
109
+ ```
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.
113
+ When the project declares an engine version in `config/features`, a binary with
114
+ that major.minor wins over a newer one. Anything below 4.2 is refused.
115
+
116
+ The environment matters as much as the arguments:
117
+
118
+ | Variable | Read by | Why |
119
+ |---|---|---|
120
+ | `RAVENSIGHT_DRIVER=1` | the addon | activation |
121
+ | `RAVENSIGHT_DRIVER_PORT` | the addon | loopback port, default 47800 |
122
+ | `RAVENSIGHT_DRIVER_TOKEN` | the addon | per run handshake secret |
123
+ | `RAVENSIGHT_PLAYTEST_TOKEN` | the game's own `Ravensight.gd` | playtest session auth |
124
+ | `RAVENSIGHT_PLAYTEST_RUN_ID` | the game's own `Ravensight.gd` | `pt_run` tag, throwaway device id |
125
+ | `RAVENSIGHT_PLAYTEST_JOB_ID` | the game's own `Ravensight.gd` | `pt_job` tag |
126
+ | `RAVENSIGHT_PLAYTEST_PERSONA` | the game's own `Ravensight.gd` | `persona` tag |
127
+
128
+ Only those, plus a fixed allowlist of variables a process needs to start at all
129
+ (`PATH`, `HOME`, the `XDG_*`, `DISPLAY`, `LD_LIBRARY_PATH` and
130
+ `LIBGL_ALWAYS_SOFTWARE` set that the Xvfb path needs, the Windows equivalents),
131
+ reach Godot. The environment is **not** inherited wholesale: the game is the
132
+ 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
+ `ENV_ALLOWLIST` is the list, and `extraEnv` is the deliberate escape hatch for a
135
+ caller that needs one more variable through.
136
+
137
+ The last four are the whole reason a driven run shows up correctly in the
138
+ dashboard: the SDK reads them in `_ready()` and every event the game already
139
+ tracks gets `synthetic: true`, `persona`, `pt_job`, `pt_run` and `pt_tier`
140
+ without the developer writing anything. The driver sets them and otherwise
141
+ stays out of the SDK's way. When the SDK autoload is present the driver also
142
+ connects to its `event_tracked` signal and mirrors each tracked event to the
143
+ runner as a `ravensight_event` notification.
144
+
145
+ The runner then dials `127.0.0.1`, retrying every 250 ms for up to 60 s, sends
146
+ `hello`, and calls `ping`. Godot's stdout and stderr go to `<run dir>/godot.log`
147
+ and are scanned for `SCRIPT ERROR` / `ERROR:` lines, which `consoleErrors()`
148
+ returns with the `at:` source line attached. That is spec 07's stdout fallback:
149
+ the engine's `Logger` hook is not available on every 4.x version the CLI
150
+ supports, so it is not used.
151
+
152
+ ## The driver interface
153
+
154
+ `src/run/drivers/driver.js` does not exist on this branch, so `godot.js` writes
155
+ the five method shape out in its own header instead of importing it:
156
+
157
+ ```js
158
+ launch({ recordVideo, viewport }) // -> {}
159
+ observe({ vision }) // -> Observation
160
+ act(action) // -> { ok, error?, code?, data?, changed }
161
+ screenshot(name) // -> { path } | { path: null, skipped }
162
+ stop({ reason }) // -> { videoPath, recording, frameCount, chapters, logPath, exitCode }
163
+ ```
164
+
165
+ plus the two optional extras spec 03 lists, `consoleErrors()` and
166
+ `chapter(title)`.
167
+
168
+ `act` maps the spec 03 action kinds onto driver methods:
169
+
170
+ | Action | Driver calls |
171
+ |---|---|
172
+ | `key` | `key` press, `wait_frames{1}`, `key` release |
173
+ | `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 coordinates | `click{x,y}` |
176
+ | `press_action` | `press_action{action, duration_ms}` |
177
+ | `wait` | `wait_frames`, milliseconds converted at 60 fps |
178
+ | `set_time_scale` | `set_time_scale` |
179
+ | `quit` | `quit` |
180
+
181
+ `changed` compares the hash of the scene tree outline before and after the
182
+ action. Two details make that answer honest:
183
+
184
+ - input reaches the game a frame after it is parsed, and a handler that updates
185
+ a label runs a frame after that, so `act` waits `settleFrames` (default 2)
186
+ before taking the comparison snapshot;
187
+ - `observe()` always takes its own `get_state`. An earlier version reused the
188
+ snapshot `act()` had just taken, to save a round trip, and that handed the
189
+ persona a screen one action out of date, which is the opposite of what
190
+ `observe()` is for. Two `get_state` calls a turn is the price of the
191
+ observation being current.
192
+
193
+ A driver level error is returned, not thrown: an undefined input action comes
194
+ back as `{ ok: false, code: -32012, data: { available_actions } }` so the
195
+ persona can pick a real one instead of the run ending.
196
+
197
+ ## Observations cite files
198
+
199
+ Every node in a `get_state` snapshot carries its `path`, and also its `scene`
200
+ (`scene_file_path`) and `script` (the script's `resource_path`) when it has
201
+ them. `buildObservation` collects all of those and puts them at the top of the
202
+ observation text:
203
+
204
+ ```
205
+ scene: res://main.tscn
206
+ scene node: /root/Main
207
+ scripts: res://ravensight_stub.gd, res://main.gd
208
+
209
+ Window root
210
+ Node2D Main @(0,0) [groups=ravensight_state] {res://main.tscn} <res://main.gd>
211
+ CanvasLayer HUD
212
+ Label TrustLabel "Trust: 4" @(24,16)
213
+ Button BeginButton "Begin" @(600,400) [focus]
214
+
215
+ game state:
216
+ /root/Main: {"phase":"HEALING","trust":4}
217
+ ```
218
+
219
+ That is what lets a finding say which scene and which script to open. The
220
+ driver's own autoload is filtered out of the snapshot, so its script path never
221
+ appears in that list.
222
+
223
+ Subtrees with nothing a player could see or press are dropped, the outline is
224
+ capped at 12k characters, and `needsVision()` asks for a screenshot when the
225
+ remaining outline is under 200 characters, which is the canvas drawn or 3D case.
226
+
227
+ Spec 07's `extensions/navigation.gd` (`nav_sample`, `nav_path`, `teleport`,
228
+ `raycast`) is **not** part of this addon: those methods exist for spec 06 level
229
+ probing, not for a persona run, and they can be added later through
230
+ `register_method()` without touching the protocol. `list_actions` is the one
231
+ extension method implemented here, because a persona that gets `-32012` needs to
232
+ know what it may press instead.
233
+
234
+ The author hook is unchanged from the spec: a node in the `ravensight_state`
235
+ group with a `_ravensight_state() -> Dictionary` method. Vectors become arrays,
236
+ recursion stops at 8 levels, each result is capped at 64 KB, and a hook that
237
+ returns something other than a Dictionary gets `-32020` naming the node path.
238
+ A broken hook is recorded as a console error against the game, not treated as a
239
+ driver failure.
240
+
241
+ ## Headless is for smoke tests, not for playtests
242
+
243
+ Verified against Godot on macOS: in `--headless` mode the engine routes no
244
+ synthesized mouse or GUI input at all. A `click` on a Button whose rect is right
245
+ there does nothing, and `screenshot` returns `-32010` because there is no
246
+ viewport. The same clicks work windowed.
247
+
248
+ So `--headless` is only useful for a game driven entirely by InputMap actions
249
+ that manages its own focus. `launch()` pushes a console entry saying so when it
250
+ detects a headless run, and the local CLI default stays windowed.
251
+
252
+ `--record-video` opts into local capture of this game's viewport; it does not
253
+ consent to upload. `--upload-video` still enables both recording and optional
254
+ video upload. Audio capture is off. Godot samples up to ten frames per second,
255
+ records their actual timestamps, and uses optional local FFmpeg to create
256
+ `session.mp4`. Set `RAVENSIGHT_FFMPEG` to an existing executable when it is not
257
+ on PATH. No package or encoder is downloaded automatically.
258
+
259
+ Recording is capped at 15 minutes, 1280 by 720 pixels, 256 MiB of temporary
260
+ frames and the existing 200 MiB final video limit. Temporary frames are removed
261
+ after encoding. A missing encoder or headless viewport is reported as
262
+ `unsupported` in local `recording.json`; frames are never labeled as video.
263
+ Cancellation, a game crash, or a recording limit can produce a playable partial
264
+ video, explicitly marked with its stop reason. Encoding failure produces no
265
+ uploadable video. Normal stop and concurrent cancellation share one bounded
266
+ finalization. A hard kill of the CLI or machine cannot guarantee finalization.
267
+
268
+ `recording.json` stays local and includes timestamps, duration, size, status,
269
+ reason and `audio: false`. When optional FFprobe is available, duration is read
270
+ from the actual silent media stream; otherwise Godot uses frame timestamps and
271
+ Playwright uses capture clock time, labeled by `durationSource`. This metadata
272
+ is not part of the upload allowlist. Video upload remains separately opted in,
273
+ with the existing per-run limits and server retention unchanged.
274
+
275
+ ## Verifying a change
276
+
277
+ The GDScript is parse checked with a real Godot binary, which is the only thing
278
+ that catches an undeclared identifier:
279
+
280
+ ```bash
281
+ /Users/.../game_tracker/scripts/check-gdscript.sh \
282
+ /Applications/Godot.app/Contents/MacOS/Godot \
283
+ addons/ravensight_driver/driver.gd \
284
+ addons/ravensight_driver/plugin.gd \
285
+ addons/ravensight_driver/export_plugin.gd
286
+ ```
287
+
288
+ It works on this standalone package: `--check-only --script` needs no
289
+ `project.godot`, and `res://` resolves against the working directory, so
290
+ `plugin.gd`'s `preload` of `export_plugin.gd` is checked too. Run it from the
291
+ package root.
292
+
293
+ Most Node tests (`npm test`) run against `testing/fake-godot.js`, a fake driver
294
+ socket that speaks the same wire protocol and enforces the same single client,
295
+ hello before anything, and keycode resolution rules. No Godot binary is involved
296
+ there, so those tests prove the Node side's shapes and nothing about the
297
+ GDScript.
298
+
299
+ `godot-real.test.js` covers what only a real engine can: the activation matrix
300
+ (nothing listens without the flag, a wrong token is refused, a second client is
301
+ refused, an unknown method and a bad param get the right codes), the author hook,
302
+ typing a space, and the export strip. It drives the fixture project at
303
+ `test/fixtures/godot-project/` and **skips itself** when no Godot 4.2+ binary is
304
+ found, the same way the Ravensight repo's ClickHouse integration tests skip
305
+ without a server. A skip there is expected, not a failure to fix, but it does
306
+ mean a change to `driver.gd` is unverified until it has run on a machine with
307
+ Godot installed.
308
+
309
+ The fixture is never written to: every case copies it to a temp directory first,
310
+ so `--import` and `--export-pack` leave no `.godot/` or `out/` behind.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Trusted local adapter example. It has no networking or telemetry.
3
+ import readline from 'node:readline';
4
+ import { createHash } from 'node:crypto';
5
+ let seed, turn, coins, inventory;
6
+ function observe() {
7
+ const done = turn >= 8;
8
+ return { done, state_hash: createHash('sha256').update(JSON.stringify([seed, turn, coins, inventory])).digest('hex'),
9
+ actions: done ? [] : [{ id: 'earn', scores: { saver: 2, collector: 1 } }, ...(coins >= 2 ? [{ id: 'buy', scores: { saver: 0, collector: 3 } }] : [])],
10
+ metrics: { coins, inventory }, ...(done ? { outcome: inventory >= 2 ? 'collected' : 'saved' } : {}) };
11
+ }
12
+ for await (const line of readline.createInterface({ input: process.stdin })) {
13
+ let request;
14
+ try {
15
+ request = JSON.parse(line);
16
+ if (request.method === 'simulation.start') { seed = request.params.seed; turn = 0; inventory = 0; coins = parseInt(createHash('sha256').update(seed).digest('hex').slice(0, 4), 16) % 3; }
17
+ else if (request.method === 'simulation.step' && !observe().done && observe().actions.some(a => a.id === request.params.action)) {
18
+ turn++; if (request.params.action === 'earn') coins++; else { coins -= 2; inventory++; }
19
+ } else throw new Error('Illegal simulation request');
20
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: observe() }) + '\n');
21
+ } catch { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request?.id, error: { code: -32602, message: 'Invalid request' } }) + '\n'); }
22
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.3.2",
4
- "description": "AI personas play your game and file reports to Ravensight.",
3
+ "version": "0.5.0",
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",
7
7
  "bugs": {
@@ -12,6 +12,8 @@
12
12
  "playtest",
13
13
  "game-analytics",
14
14
  "ai-personas",
15
+ "simulation",
16
+ "game-balance",
15
17
  "ravensight"
16
18
  ],
17
19
  "type": "module",
@@ -27,6 +29,9 @@
27
29
  "addons",
28
30
  "docs/ADAPTERS.md",
29
31
  "docs/NATIVE_RUNNER.md",
32
+ "docs/godot-driver.md",
33
+ "docs/SIMULATION.md",
34
+ "examples/simulation",
30
35
  "README.md",
31
36
  "LICENSE",
32
37
  "!src/**/*.test.js",
package/src/api/client.js CHANGED
@@ -194,9 +194,9 @@ export function createClient(options = {}) {
194
194
  * persona run's price; anything else leaves `run_length` out, which the
195
195
  * server reads as a standard run.
196
196
  */
197
- estimate: (gameId, { modules = [], personas = [], runLength = null } = {}) =>
197
+ estimate: (gameId, { modules = [], personas = [], runLength = null, simulation, controllers, driver } = {}) =>
198
198
  request('POST', `${gameBase(gameId)}/estimate`, {
199
- body: { modules, personas, ...(runLength === 'long' ? { run_length: 'long' } : {}) }
199
+ body: { modules, personas, ...(controllers ? { controllers, driver } : {}), ...(runLength === 'long' ? { run_length: 'long' } : {}), ...(simulation ? { simulation } : {}) }
200
200
  }).then(answer => answer.body),
201
201
 
202
202
  /**
package/src/cli.js CHANGED
@@ -54,7 +54,9 @@ export const RUNNER_HELP = `
54
54
  Run options (parsed by the runner, see src/run/args.js):
55
55
  --personas <list> comma separated persona slugs, default one (curious-kid); "personas" lists them
56
56
  --driver <name> playwright_web, godot_driver, desktop_driver, unity_driver, unreal_driver, android_driver, ios_driver or cli_stdio
57
- --controller <name> standard or jev action selection (default standard)
57
+ --controller <name> one controller: standard, jev or rune (if enabled)
58
+ --compare-with <names> separate standard + jev and/or rune playthroughs; quotes every run
59
+ --record-video record this game locally; --upload-video also uploads it
58
60
  --build-url <url> the web build to play
59
61
  --godot-project <dir> the Godot project directory
60
62
  --driver-config <file> local WebDriver or JSON-RPC bridge configuration
@@ -316,6 +318,24 @@ export function buildProgram() {
316
318
  return run(flagsFrom(options, { game: options.game, argv: command.args }));
317
319
  }));
318
320
 
321
+ common(program.command('simulate').description('run a paid, seeded batch against a local headless game'))
322
+ .option('--game <gameId>', 'the game, when this repo is not linked')
323
+ .option('--config <file>', 'simulation JSON: seed, runs, max_steps, workers and strategies')
324
+ .option('--driver-config <file>', 'local JSON-RPC executable configuration')
325
+ .option('--resume <file>', 'resume the same paid batch from its state.json, without another charge')
326
+ .action(capture(slot, async options => {
327
+ const { simulateCommand } = await import('./commands/simulate.js');
328
+ return simulateCommand(flagsFrom(options, { game: options.game, configFile: options.config, driverConfig: options.driverConfig, resume: options.resume }));
329
+ }));
330
+
331
+ common(program.command('simulation-replay').description('verify a recorded simulation trace locally; creates no paid job'))
332
+ .argument('<trace>', 'a selected trace.json written by simulate')
333
+ .requiredOption('--driver-config <file>', 'local JSON-RPC executable configuration')
334
+ .action(capture(slot, async (trace, options) => {
335
+ const { replayCommand } = await import('./commands/simulate.js');
336
+ return replayCommand(trace, flagsFrom(options, { driverConfig: options.driverConfig }));
337
+ }));
338
+
319
339
  return program;
320
340
  }
321
341