载入中...
搜索中...
未找到
PlayHost.cpp
浏览该文件的文档.
1#include "devtools/PlayHost.h"
2
3#include "common/Capability.h"
10#include "devtools/Debugger.hpp"
12#include "devtools/PlayTrace.h"
13#include "devtools/Snapshot.hpp"
14
15#include <squirrel.h>
16
17#include <algorithm>
18#include <filesystem>
19#include <fstream>
20#include <set>
21#include <sstream>
22#include <utility>
23
24namespace eve::dev {
25namespace {
26
27constexpr std::string_view kPlaySchemaId = "evengine.play-request";
28constexpr std::string_view kPlayResponseId = "evengine.play-response";
29constexpr std::string_view kContractSchemaId = "evengine.game-agent-contract";
30constexpr std::int64_t kSchemaVersion = 1;
31constexpr std::int64_t kMaxFrameSteps = 1024;
32
33Result<const Value::Object*> asObject(const Value& value, std::string path) {
34 const auto* object = value.getIf<Value::Object>();
35 if (!object)
37 Diagnostic::error(DiagnosticCode::ParseError, "play value must be an object", path));
39}
40
41Result<void> knownFields(const Value::Object& object, const std::set<std::string>& allowed,
42 std::string_view path) {
43 for (const auto& [name, value] : object) {
44 (void)value;
45 if (!allowed.contains(name))
47 Diagnostic::error(DiagnosticCode::ParseError, "unknown play field", std::string(path) + "." + name));
48 }
49 return Result<void>::success();
50}
51
52Result<const Value*> member(const Value::Object& object, std::string_view name, Value::Type type,
53 std::string_view path) {
54 const auto found = object.find(std::string(name));
55 if (found == object.end() || found->second.type() != type)
57 DiagnosticCode::ParseError, "missing or invalid play field", std::string(path) + "." + std::string(name)));
58 return Result<const Value*>::success(&found->second);
59}
60
61Result<std::string> stringMember(const Value::Object& object, std::string_view name,
62 std::string_view path) {
63 auto found = member(object, name, Value::Type::String, path);
64 if (!found) return Result<std::string>::failure(found.status());
65 return Result<std::string>::success(found.value()->asString());
66}
67
68Result<std::int64_t> intMember(const Value::Object& object, std::string_view name, std::string_view path) {
69 auto found = member(object, name, Value::Type::Int64, path);
70 if (!found) return Result<std::int64_t>::failure(found.status());
71 return Result<std::int64_t>::success(found.value()->asInt());
72}
73
74Result<void> optionalString(const Value::Object& object, std::string_view name, std::string_view path,
75 std::string* out) {
76 const auto found = object.find(std::string(name));
77 if (found == object.end()) return Result<void>::success();
78 if (!found->second.isString())
79 return Result<void>::failure(Diagnostic::error(DiagnosticCode::ParseError, "play field must be a string",
80 std::string(path) + "." + std::string(name)));
81 *out = found->second.asString();
82 return Result<void>::success();
83}
84
85std::vector<std::string> splitPath(std::string_view dotted) {
86 std::vector<std::string> parts;
87 std::string current;
88 for (char ch : dotted) {
89 if (ch == '.') {
90 if (!current.empty()) parts.push_back(std::move(current));
91 current.clear();
92 } else {
93 current.push_back(ch);
94 }
95 }
96 if (!current.empty()) parts.push_back(std::move(current));
97 return parts;
98}
99
100Value playResponse(std::string op, Value::Object extra) {
101 extra.emplace("op", Value(std::move(op)));
102 extra.emplace("schemaId", Value(std::string(kPlayResponseId)));
103 extra.emplace("schemaVersion", Value(kSchemaVersion));
104 return Value(std::move(extra));
105}
106
107struct ObservationSpec {
108 std::string id;
109 std::string kind;
110 std::string path;
111 std::vector<std::string> fields;
112};
113
114struct ActionSpec {
115 std::string id;
116 std::string script;
117 std::string action;
118};
119
120struct ParsedContract {
121 std::string id;
122 std::string defaultStep;
123 std::string actionSource;
124 std::string actionDomain;
125 std::vector<ObservationSpec> observations;
126 std::vector<ActionSpec> actions;
127 std::vector<std::string> captureRequiredFor;
128};
129
130Result<std::vector<std::string>> stringArray(const Value& value, std::string path) {
131 const auto* array = value.getIf<Value::Array>();
132 if (!array)
133 return Result<std::vector<std::string>>::failure(
134 Diagnostic::error(DiagnosticCode::ParseError, "play array must contain strings", path));
135 std::vector<std::string> out;
136 for (std::size_t index = 0; index < array->size(); ++index) {
137 if (!(*array)[index].isString())
138 return Result<std::vector<std::string>>::failure(Diagnostic::error(
139 DiagnosticCode::ParseError, "play array entry must be a string", path + "." + std::to_string(index)));
140 out.push_back((*array)[index].asString());
141 }
142 return Result<std::vector<std::string>>::success(std::move(out));
143}
144
145Result<ParsedContract> parseContract(const Value& contract) {
146 auto root = asObject(contract, "contract");
147 if (!root) return Result<ParsedContract>::failure(root.status());
148 auto known =
149 knownFields(*root.value(),
150 {"actions", "capture", "clock", "entry", "id", "observations", "schemaId", "schemaVersion",
151 "smoke", "tolerances"},
152 "contract");
153 if (!known) return Result<ParsedContract>::failure(known.status());
154 auto schemaId = stringMember(*root.value(), "schemaId", "contract");
155 if (!schemaId) return Result<ParsedContract>::failure(schemaId.status());
156 auto version = intMember(*root.value(), "schemaVersion", "contract");
157 if (!version) return Result<ParsedContract>::failure(version.status());
158 auto id = stringMember(*root.value(), "id", "contract");
159 if (!id) return Result<ParsedContract>::failure(id.status());
160 auto entry = stringMember(*root.value(), "entry", "contract");
161 if (!entry) return Result<ParsedContract>::failure(entry.status());
162 if (schemaId.value() != kContractSchemaId || version.value() != kSchemaVersion)
164 DiagnosticCode::Unsupported, "unsupported game agent contract schema", "contract.schemaVersion"));
165 if (id.value().empty())
167 Diagnostic::error(DiagnosticCode::ParseError, "contract id must not be empty", "contract.id"));
168
169 ParsedContract parsed;
170 parsed.id = id.value();
171 parsed.defaultStep = "frame";
172
173 const auto clock = root.value()->find("clock");
174 if (clock != root.value()->end()) {
175 auto object = asObject(clock->second, "contract.clock");
176 if (!object) return Result<ParsedContract>::failure(object.status());
177 auto clockKnown = knownFields(*object.value(), {"defaultStep"}, "contract.clock");
178 if (!clockKnown) return Result<ParsedContract>::failure(clockKnown.status());
179 auto step = stringMember(*object.value(), "defaultStep", "contract.clock");
180 if (!step) return Result<ParsedContract>::failure(step.status());
181 parsed.defaultStep = step.value();
182 if (parsed.defaultStep != "frame")
184 "P0 play host only supports frame defaultStep",
185 "contract.clock.defaultStep"));
186 }
187
188 const auto observations = root.value()->find("observations");
189 if (observations == root.value()->end() || !observations->second.isArray())
191 Diagnostic::error(DiagnosticCode::ParseError, "contract requires observations", "contract.observations"));
192 const auto* array = observations->second.getIf<Value::Array>();
193 for (std::size_t index = 0; index < array->size(); ++index) {
194 const std::string itemPath = "contract.observations." + std::to_string(index);
195 auto object = asObject((*array)[index], itemPath);
196 if (!object) return Result<ParsedContract>::failure(object.status());
197 auto itemKnown = knownFields(*object.value(), {"fields", "id", "kind", "path"}, itemPath);
198 if (!itemKnown) return Result<ParsedContract>::failure(itemKnown.status());
199 ObservationSpec spec;
200 auto specId = stringMember(*object.value(), "id", itemPath);
201 if (!specId) return Result<ParsedContract>::failure(specId.status());
202 auto kind = stringMember(*object.value(), "kind", itemPath);
203 if (!kind) return Result<ParsedContract>::failure(kind.status());
204 auto path = stringMember(*object.value(), "path", itemPath);
205 if (!path) return Result<ParsedContract>::failure(path.status());
206 spec.id = specId.value();
207 spec.kind = kind.value();
208 spec.path = path.value();
209 const auto fields = object.value()->find("fields");
210 if (fields != object.value()->end()) {
211 auto parsedFields = stringArray(fields->second, itemPath + ".fields");
212 if (!parsedFields) return Result<ParsedContract>::failure(parsedFields.status());
213 spec.fields = std::move(parsedFields).takeValue();
214 }
215 parsed.observations.push_back(std::move(spec));
216 }
217
218 const auto smoke = root.value()->find("smoke");
219 if (smoke != root.value()->end()) {
220 auto object = asObject(smoke->second, "contract.smoke");
221 if (!object) return Result<ParsedContract>::failure(object.status());
222 auto smokeKnown =
223 knownFields(*object.value(), {"maxHostFrames", "mustReach", "scene", "seed"}, "contract.smoke");
224 if (!smokeKnown) return Result<ParsedContract>::failure(smokeKnown.status());
225 auto scene = stringMember(*object.value(), "scene", "contract.smoke");
226 if (!scene) return Result<ParsedContract>::failure(scene.status());
227 const auto mustReach = object.value()->find("mustReach");
228 if (mustReach != object.value()->end()) {
229 auto reached = stringArray(mustReach->second, "contract.smoke.mustReach");
230 if (!reached) return Result<ParsedContract>::failure(reached.status());
231 }
232 }
233
234 const auto capture = root.value()->find("capture");
235 if (capture != root.value()->end()) {
236 auto object = asObject(capture->second, "contract.capture");
237 if (!object) return Result<ParsedContract>::failure(object.status());
238 auto captureKnown = knownFields(*object.value(), {"backend", "requiredFor"}, "contract.capture");
239 if (!captureKnown) return Result<ParsedContract>::failure(captureKnown.status());
240 auto backend = stringMember(*object.value(), "backend", "contract.capture");
241 if (!backend) return Result<ParsedContract>::failure(backend.status());
242 if (backend.value() != "engine-readback")
244 DiagnosticCode::Unsupported, "capture backend must be engine-readback", "contract.capture.backend"));
245 const auto requiredFor = object.value()->find("requiredFor");
246 if (requiredFor != object.value()->end()) {
247 auto names = stringArray(requiredFor->second, "contract.capture.requiredFor");
248 if (!names) return Result<ParsedContract>::failure(names.status());
249 parsed.captureRequiredFor = std::move(names).takeValue();
250 }
251 }
252
253 const auto actions = root.value()->find("actions");
254 if (actions != root.value()->end()) {
255 auto object = asObject(actions->second, "contract.actions");
256 if (!object) return Result<ParsedContract>::failure(object.status());
257 auto actionsKnown = knownFields(*object.value(), {"domain", "map", "source"}, "contract.actions");
258 if (!actionsKnown) return Result<ParsedContract>::failure(actionsKnown.status());
259 auto source = stringMember(*object.value(), "source", "contract.actions");
260 if (!source) return Result<ParsedContract>::failure(source.status());
261 parsed.actionSource = source.value();
262 if (parsed.actionSource != "script-map" && parsed.actionSource != "gameplay-domain")
264 DiagnosticCode::Unsupported, "unsupported actions.source", "contract.actions.source"));
265 auto domainField = optionalString(*object.value(), "domain", "contract.actions", &parsed.actionDomain);
266 if (!domainField) return Result<ParsedContract>::failure(domainField.status());
267 const auto map = object.value()->find("map");
268 if (map != object.value()->end()) {
269 const auto* array = map->second.getIf<Value::Array>();
270 if (!array)
272 DiagnosticCode::ParseError, "actions.map must be an array", "contract.actions.map"));
273 for (std::size_t index = 0; index < array->size(); ++index) {
274 const std::string itemPath = "contract.actions.map." + std::to_string(index);
275 auto item = asObject((*array)[index], itemPath);
276 if (!item) return Result<ParsedContract>::failure(item.status());
277 auto itemKnown = knownFields(*item.value(), {"action", "id", "script"}, itemPath);
278 if (!itemKnown) return Result<ParsedContract>::failure(itemKnown.status());
279 ActionSpec spec;
280 auto actionId = stringMember(*item.value(), "id", itemPath);
281 if (!actionId) return Result<ParsedContract>::failure(actionId.status());
282 spec.id = actionId.value();
283 auto scriptField = optionalString(*item.value(), "script", itemPath, &spec.script);
284 if (!scriptField) return Result<ParsedContract>::failure(scriptField.status());
285 auto mappedAction = optionalString(*item.value(), "action", itemPath, &spec.action);
286 if (!mappedAction) return Result<ParsedContract>::failure(mappedAction.status());
287 if (parsed.actionSource == "script-map" && spec.script.empty())
289 DiagnosticCode::ParseError, "script-map action requires script", itemPath + ".script"));
290 if (parsed.actionSource == "gameplay-domain" && spec.action.empty())
292 DiagnosticCode::ParseError, "gameplay-domain action requires action", itemPath + ".action"));
293 parsed.actions.push_back(std::move(spec));
294 }
295 }
296 }
297
298 const auto tolerances = root.value()->find("tolerances");
299 if (tolerances != root.value()->end()) {
300 auto object = asObject(tolerances->second, "contract.tolerances");
301 if (!object) return Result<ParsedContract>::failure(object.status());
302 auto tolKnown = knownFields(*object.value(), {"numericAbs", "screenshot"}, "contract.tolerances");
303 if (!tolKnown) return Result<ParsedContract>::failure(tolKnown.status());
304 }
305
306 return Result<ParsedContract>::success(std::move(parsed));
307}
308
309Result<const ObservationSpec*> findObservation(const ParsedContract& contract, std::string_view id) {
310 for (const auto& spec : contract.observations) {
311 if (spec.id == id) return Result<const ObservationSpec*>::success(&spec);
312 }
314 Diagnostic::error(DiagnosticCode::NotFound, "observation id was not declared", "request.observation"));
315}
316
317Result<const ActionSpec*> findAction(const ParsedContract& contract, std::string_view id) {
318 for (const auto& spec : contract.actions) {
319 if (spec.id == id) return Result<const ActionSpec*>::success(&spec);
320 }
322 Diagnostic::error(DiagnosticCode::NotFound, "action id was not declared", "request.action"));
323}
324
325Result<Value> requireContract(IPlayHostRuntime& runtime) {
326 auto loaded = runtime.loadContract();
327 if (!loaded) {
328 if (loaded.code() == StatusCode::NotFound)
330 Diagnostic::error(DiagnosticCode::Unsupported, "play observe requires game.agent.json", "contract"));
331 return Result<Value>::failure(loaded.status());
332 }
333 return loaded;
334}
335
336Value encodeDomains(const std::vector<std::string>& domains) {
338 for (const auto& domain : domains) output.emplace_back(domain);
339 return Value(std::move(output));
340}
341
342Result<Value> opStatus(IPlayHostRuntime& runtime) {
343 Value::Object extra{{"paused", Value(runtime.paused())},
344 {"hostFrame", Value(static_cast<std::int64_t>(runtime.hostFrame()))},
345 {"domains", encodeDomains(runtime.gameplayDomains())}};
346 auto loaded = runtime.loadContract();
347 if (loaded) {
348 auto parsed = parseContract(loaded.value());
349 if (!parsed) return Result<Value>::failure(parsed.status());
350 extra.emplace("contractId", Value(parsed.value().id));
351 extra.emplace("contractLoaded", Value(true));
352 } else if (loaded.code() == StatusCode::NotFound) {
353 extra.emplace("contractLoaded", Value(false));
354 } else {
355 return Result<Value>::failure(loaded.status());
356 }
357 return Result<Value>::success(playResponse("status", std::move(extra)));
358}
359
360Result<Value> opClock(const Value::Object& root, IPlayHostRuntime& runtime) {
361 auto mode = stringMember(root, "mode", "request");
362 if (!mode) return Result<Value>::failure(mode.status());
363 if (mode.value() == "pause") runtime.pause();
364 else if (mode.value() == "play") runtime.play();
365 else
367 Diagnostic::error(DiagnosticCode::ParseError, "clock mode must be pause or play", "request.mode"));
369 playResponse("clock", {{"mode", Value(mode.value())}, {"paused", Value(runtime.paused())}}));
370}
371
372Result<Value> opStep(const Value::Object& root, IPlayHostRuntime& runtime) {
373 std::string clock = "frame";
374 auto clockField = optionalString(root, "clock", "request", &clock);
375 if (!clockField) return Result<Value>::failure(clockField.status());
376 if (clock != "frame")
378 DiagnosticCode::Unsupported, "P0 play host only steps the host frame clock", "request.clock"));
379 auto count = intMember(root, "count", "request");
380 if (!count) return Result<Value>::failure(count.status());
381 if (count.value() < 1 || count.value() > kMaxFrameSteps)
383 Diagnostic::error(DiagnosticCode::ParseError, "step count must be between 1 and 1024", "request.count"));
384 auto stepped = runtime.stepFrames(count.value());
385 if (!stepped) return Result<Value>::failure(stepped.status());
386 return Result<Value>::success(playResponse(
387 "step", {{"clock", Value(std::move(clock))},
388 {"count", Value(count.value())},
389 {"hostFrame", Value(static_cast<std::int64_t>(runtime.hostFrame()))},
390 {"paused", Value(runtime.paused())}}));
391}
392
393Result<Value> opObserve(const Value::Object& root, IPlayHostRuntime& runtime) {
394 auto observation = stringMember(root, "observation", "request");
395 if (!observation) return Result<Value>::failure(observation.status());
396 auto contractValue = requireContract(runtime);
397 if (!contractValue) return Result<Value>::failure(contractValue.status());
398 auto parsed = parseContract(contractValue.value());
399 if (!parsed) return Result<Value>::failure(parsed.status());
400 auto spec = findObservation(parsed.value(), observation.value());
401 if (!spec) return Result<Value>::failure(spec.status());
402 if (spec.value()->kind != "script-root")
404 DiagnosticCode::Unsupported, "P0 play host only observes script-root", "request.observation"));
405 auto state = runtime.observeScriptRoot(spec.value()->path, spec.value()->fields);
406 if (!state) return Result<Value>::failure(state.status());
407 return Result<Value>::success(playResponse("observe", {{"observation", Value(observation.value())},
408 {"state", std::move(state).takeValue()}}));
409}
410
411Result<Value> opCapture(const Value::Object& root, IPlayHostRuntime& runtime) {
412 std::string path = "mcp_screenshot.png";
413 auto pathField = optionalString(root, "path", "request", &path);
414 if (!pathField) return Result<Value>::failure(pathField.status());
415 if (path.empty()) path = "mcp_screenshot.png";
416 auto captured = runtime.capturePng(std::move(path));
417 if (!captured) return Result<Value>::failure(captured.status());
418 auto object = asObject(captured.value(), "capture");
419 if (!object) return Result<Value>::failure(object.status());
420 Value::Object extra = *object.value();
421 extra.emplace("backend", Value("engine-readback"));
422 return Result<Value>::success(playResponse("capture", std::move(extra)));
423}
424
425Result<Value> opCheckpoint(const Value::Object& root, IPlayHostRuntime& runtime) {
426 auto mode = stringMember(root, "mode", "request");
427 if (!mode) return Result<Value>::failure(mode.status());
428 if (mode.value() == "capture") {
429 auto json = runtime.captureCheckpoint();
430 if (!json) return Result<Value>::failure(json.status());
432 playResponse("checkpoint", {{"mode", Value("capture")}, {"json", Value(std::move(json).takeValue())}}));
433 }
434 if (mode.value() == "restore") {
435 auto json = stringMember(root, "json", "request");
436 if (!json) return Result<Value>::failure(json.status());
437 auto restored = runtime.restoreCheckpoint(json.value());
438 if (!restored) return Result<Value>::failure(restored.status());
439 return Result<Value>::success(playResponse("checkpoint", {{"mode", Value("restore")}}));
440 }
442 Diagnostic::error(DiagnosticCode::ParseError, "checkpoint mode must be capture or restore", "request.mode"));
443}
444
445Result<Value> opAct(const Value::Object& root, IPlayHostRuntime& runtime) {
446 auto action = stringMember(root, "action", "request");
447 if (!action) return Result<Value>::failure(action.status());
448 auto contractValue = requireContract(runtime);
449 if (!contractValue) return Result<Value>::failure(contractValue.status());
450 auto parsed = parseContract(contractValue.value());
451 if (!parsed) return Result<Value>::failure(parsed.status());
452 auto spec = findAction(parsed.value(), action.value());
453 if (!spec) return Result<Value>::failure(spec.status());
454 if (parsed.value().actionSource == "script-map") {
455 auto invoked = runtime.invokeScriptAction(spec.value()->id, spec.value()->script);
456 if (!invoked) return Result<Value>::failure(invoked.status());
457 return Result<Value>::success(playResponse("act", {{"action", Value(action.value())},
458 {"source", Value("script-map")},
459 {"receipt", std::move(invoked).takeValue()}}));
460 }
461 if (parsed.value().actionSource != "gameplay-domain")
463 Diagnostic::error(DiagnosticCode::Unsupported, "unsupported actions.source", "contract.actions.source"));
464 const auto commandField = root.find("command");
465 if (commandField == root.end())
467 Diagnostic::error(DiagnosticCode::ParseError, "gameplay-domain act requires command", "request.command"));
468 auto commandObject = asObject(commandField->second, "request.command");
469 if (!commandObject) return Result<Value>::failure(commandObject.status());
470 Value::Object command = *commandObject.value();
471 if (command.find("action") == command.end()) command.emplace("action", Value(spec.value()->action));
472 std::string domain = parsed.value().actionDomain;
473 auto domainField = optionalString(root, "domain", "request", &domain);
474 if (!domainField) return Result<Value>::failure(domainField.status());
475 if (domain.empty())
477 Diagnostic::error(DiagnosticCode::ParseError, "gameplay-domain act requires domain", "request.domain"));
478 const auto sessionField = root.find("session");
479 const auto instanceField = root.find("instance");
480 if (sessionField == root.end() || instanceField == root.end())
482 DiagnosticCode::ParseError, "gameplay-domain act requires session and instance", "request.session"));
483 Value::Object gameplay{{"schemaId", Value("evengine.gameplay-control-request")},
484 {"schemaVersion", Value(std::int64_t{1})},
485 {"op", Value("submit")},
486 {"domain", Value(std::move(domain))},
487 {"instance", instanceField->second},
488 {"session", sessionField->second},
489 {"command", Value(std::move(command))}};
490 auto submitted = executeGameplayControlRequest(Value(std::move(gameplay)));
491 if (!submitted) return Result<Value>::failure(submitted.status());
492 return Result<Value>::success(playResponse("act", {{"action", Value(action.value())},
493 {"source", Value("gameplay-domain")},
494 {"gameplay", std::move(submitted).takeValue()}}));
495}
496
497Result<Value> opTrace() {
499 playResponse("trace", {{"recording", PlayTraceBuffer::instance().exportTrace()}}));
500}
501
502Result<Value> opReplay(const Value::Object& root, IPlayHostRuntime& runtime) {
503 const auto recording = root.find("recording");
504 if (recording == root.end())
506 Diagnostic::error(DiagnosticCode::ParseError, "replay requires recording", "request.recording"));
507 return replayPlayTrace(recording->second, runtime);
508}
509
510bool sessionWantsTrace() {
512 if (!session.active()) return false;
513 const auto phase = session.phase();
516}
517
518void tryRecordEvidence(std::string criterionId, std::string kind, std::string summary, std::string artifact) {
520 if (!session.active() || PlayTraceBuffer::instance().isReplaying() || criterionId.empty()) return;
521 AgentDevelopmentEvidence evidence;
522 evidence.criterionId = std::move(criterionId);
523 evidence.kind = std::move(kind);
524 evidence.status = "pass";
525 evidence.summary = std::move(summary);
526 evidence.artifact = std::move(artifact);
527 (void)session.record(session.sessionId(), std::move(evidence));
528}
529
530bool hasCriterion(const AgentDevelopmentSession& session, std::string_view id) {
531 for (const auto& criterion : session.criteria()) {
532 if (criterion.id == id) return true;
533 }
534 return false;
535}
536
537void maybeAutoEvidence(const Value::Object& request, const Value& response, IPlayHostRuntime& runtime) {
539 if (!session.active() || PlayTraceBuffer::instance().isReplaying()) return;
540 const auto opField = request.find("op");
541 if (opField == request.end() || !opField->second.isString()) return;
542 const std::string& op = opField->second.asString();
543 std::string criterionId;
544 optionalString(request, "criterionId", "request", &criterionId).ignore("optional play criterionId");
545 const auto* responseObject = response.getIf<Value::Object>();
546 if (op == "observe") {
547 std::string observation;
548 optionalString(request, "observation", "request", &observation).ignore("optional play observation");
549 if (criterionId.empty() && hasCriterion(session, observation)) criterionId = observation;
550 tryRecordEvidence(std::move(criterionId), "runtime-observation", "play observe " + observation, {});
551 return;
552 }
553 if (op == "capture") {
554 std::string path;
555 if (responseObject) {
556 const auto found = responseObject->find("path");
557 if (found != responseObject->end() && found->second.isString()) path = found->second.asString();
558 }
559 if (criterionId.empty() && hasCriterion(session, "visual")) criterionId = "visual";
560 if (criterionId.empty()) {
561 auto loaded = runtime.loadContract();
562 if (loaded) {
563 auto parsed = parseContract(loaded.value());
564 if (parsed) {
565 for (const auto& name : parsed.value().captureRequiredFor) {
566 if (hasCriterion(session, name)) {
567 criterionId = name;
568 break;
569 }
570 }
571 }
572 }
573 }
574 tryRecordEvidence(std::move(criterionId), "screenshot", "play capture", std::move(path));
575 return;
576 }
577 if (op == "checkpoint") {
578 if (criterionId.empty() && hasCriterion(session, "checkpoint")) criterionId = "checkpoint";
579 if (criterionId.empty() && hasCriterion(session, "recovery")) criterionId = "recovery";
580 tryRecordEvidence(std::move(criterionId), "checkpoint", "play checkpoint", {});
581 return;
582 }
583 if (op == "batch") {
584 if (criterionId.empty() && hasCriterion(session, "smoke")) criterionId = "smoke";
585 tryRecordEvidence(std::move(criterionId), "play-trace", "play batch", {});
586 }
587}
588
589void afterPlaySuccess(const Value& request, const Value& response, IPlayHostRuntime& runtime) {
590 auto root = request.getIf<Value::Object>();
591 if (!root) return;
593 std::string trace;
594 optionalString(*root, "trace", "request", &trace).ignore("trace already validated by dispatch");
595 const auto opField = root->find("op");
596 const std::string op = (opField != root->end() && opField->second.isString()) ? opField->second.asString() : "";
597 const bool skipTrace = op == "status" || op == "clock" || op == "trace" || op == "replay" || op == "batch";
598 bool append = trace == "append" || (trace.empty() && sessionWantsTrace());
599 if (trace == "off") append = false;
600 if (append && !skipTrace) {
602 std::string contractId;
603 std::string contractHash;
604 auto loaded = runtime.loadContract();
605 if (loaded) {
606 auto parsed = parseContract(loaded.value());
607 if (parsed) contractId = parsed.value().id;
608 auto digest = playObservationDigest(loaded.value());
609 if (digest) contractHash = std::move(digest).takeValue();
610 }
611 PlayTraceBuffer::instance().begin(std::move(contractId), std::move(contractHash), 0, {});
612 }
613 PlayTraceBuffer::instance().append(request, response, runtime.hostFrame());
614 }
615 maybeAutoEvidence(*root, response, runtime);
616}
617
618Result<Value> dispatchOne(const Value& request, IPlayHostRuntime& runtime, bool inBatch);
619
620Result<Value> opBatch(const Value::Object& root, IPlayHostRuntime& runtime) {
621 auto requests = member(root, "requests", Value::Type::Array, "request");
622 if (!requests) return Result<Value>::failure(requests.status());
623 const auto* array = requests.value()->getIf<Value::Array>();
624 Value::Array results;
625 for (std::size_t index = 0; index < array->size(); ++index) {
626 auto one = dispatchOne((*array)[index], runtime, true);
627 if (!one) return Result<Value>::failure(one.status());
628 results.push_back(std::move(one).takeValue());
629 }
630 const auto count = static_cast<std::int64_t>(results.size());
632 playResponse("batch", {{"count", Value(count)}, {"results", Value(std::move(results))}}));
633}
634
635Result<Value> dispatchOne(const Value& request, IPlayHostRuntime& runtime, bool inBatch) {
636 auto root = asObject(request, "request");
637 if (!root) return Result<Value>::failure(root.status());
638 static const std::set<std::string> allowed{
639 "action", "clock", "command", "count", "criterionId", "domain",
640 "instance", "json", "mode", "observation", "op", "path",
641 "recording", "requests", "schemaId", "schemaVersion","session", "trace"};
642 auto known = knownFields(*root.value(), allowed, "request");
643 if (!known) return Result<Value>::failure(known.status());
644 auto schemaId = stringMember(*root.value(), "schemaId", "request");
645 if (!schemaId) return Result<Value>::failure(schemaId.status());
646 auto version = intMember(*root.value(), "schemaVersion", "request");
647 if (!version) return Result<Value>::failure(version.status());
648 if (schemaId.value() != kPlaySchemaId || version.value() != kSchemaVersion)
650 Diagnostic::error(DiagnosticCode::Unsupported, "unsupported play request schema", "request.schemaVersion"));
651 std::string trace;
652 auto traceField = optionalString(*root.value(), "trace", "request", &trace);
653 if (!traceField) return Result<Value>::failure(traceField.status());
654 if (!trace.empty() && trace != "off" && trace != "append")
656 Diagnostic::error(DiagnosticCode::ParseError, "trace must be off or append", "request.trace"));
657 auto op = stringMember(*root.value(), "op", "request");
658 if (!op) return Result<Value>::failure(op.status());
659 auto executed = [&]() -> Result<Value> {
660 if (op.value() == "batch") {
661 if (inBatch)
663 Diagnostic::error(DiagnosticCode::Unsupported, "nested play batch is not allowed", "request.op"));
664 return opBatch(*root.value(), runtime);
665 }
666 if (op.value() == "status") return opStatus(runtime);
667 if (op.value() == "clock") return opClock(*root.value(), runtime);
668 if (op.value() == "step") return opStep(*root.value(), runtime);
669 if (op.value() == "observe") return opObserve(*root.value(), runtime);
670 if (op.value() == "capture") return opCapture(*root.value(), runtime);
671 if (op.value() == "checkpoint") return opCheckpoint(*root.value(), runtime);
672 if (op.value() == "act") return opAct(*root.value(), runtime);
673 if (op.value() == "trace") return opTrace();
674 if (op.value() == "replay") return opReplay(*root.value(), runtime);
676 Diagnostic::error(DiagnosticCode::Unsupported, "unsupported play operation", "request.op"));
677 }();
678 if (!executed) return executed;
679 afterPlaySuccess(request, executed.value(), runtime);
680 return executed;
681}
682
683Result<Value> snapshotRoot(HSQUIRRELVM vm, std::string_view rootName, const std::vector<std::string>& fields) {
684 if (!vm)
686 "play observe requires an attached script VM", "observation"));
687 const auto roots = Snapshot::instance().rootsFor(vm);
688 if (std::find(roots.begin(), roots.end(), rootName) == roots.end())
690 DiagnosticCode::NotFound, "script root is excluded by snapshot policy", "observation.path"));
691 struct StackGuard {
693 SQInteger top;
694 ~StackGuard() { sq_settop(vm, top); }
695 } guard{vm, sq_gettop(vm)};
696 sq_pushroottable(vm);
697 const std::string name(rootName);
698 sq_pushstring(vm, name.c_str(), -1);
699 if (SQ_FAILED(sq_rawget(vm, -2)))
701 Diagnostic::error(DiagnosticCode::NotFound, "script root was not found", "observation.path"));
702 const SQInteger rootIndex = sq_gettop(vm);
703 script::SquirrelValueOptions options;
704 options.source = "observation." + name;
705 if (fields.empty()) return script::valueFromSquirrel(vm, rootIndex, options);
707 for (const auto& field : fields) {
708 sq_settop(vm, rootIndex);
709 sq_push(vm, rootIndex);
710 const auto parts = splitPath(field);
711 if (parts.empty() || field.front() == '.' || field.back() == '.' || field.find("..") != std::string::npos)
713 Diagnostic::error(DiagnosticCode::ParseError, "invalid observation field path", "observation.fields"));
714 for (const auto& part : parts) {
715 // Raw table access never invokes a script delegate or getter.
716 if (sq_gettype(vm, -1) != OT_TABLE)
718 DiagnosticCode::NotFound, "observation path is not a table", "observation.fields." + field));
719 sq_pushstring(vm, part.c_str(), -1);
720 if (SQ_FAILED(sq_rawget(vm, -2)))
722 DiagnosticCode::NotFound, "observation field was not found", "observation.fields." + field));
723 sq_remove(vm, -2);
724 }
726 if (!value) return Result<Value>::failure(value.status());
727 projected.emplace(field, std::move(value).takeValue());
728 }
729 return Result<Value>::success(Value(std::move(projected)));
730}
731
732class EnginePlayHostRuntime final : public IPlayHostRuntime {
733public:
734 bool paused() const override { return Debugger::instance().isPaused(); }
735 std::uint64_t hostFrame() const override { return hostFrame_; }
736 void pause() override { Debugger::instance().pause(PauseReason::PauseKey); }
737 void play() override { Debugger::instance().resume(); }
738
739 Result<void> stepFrames(std::int64_t count) override {
740 Debugger::instance().stepFrames(static_cast<int>(count));
741 hostFrame_ += static_cast<std::uint64_t>(count);
742 return Result<void>::success();
743 }
744
745 Result<Value> loadContract() const override {
746 std::filesystem::path root = McpServer::instance().gameRoot();
747 if (root.empty()) {
748 std::error_code ec;
749 root = std::filesystem::current_path(ec);
750 if (ec)
752 Diagnostic::error(DiagnosticCode::Failed, "cannot resolve game root", "contract"));
753 }
754 const auto path = root / "game.agent.json";
755 std::error_code ec;
756 if (!std::filesystem::is_regular_file(path, ec))
758 Diagnostic::error(DiagnosticCode::NotFound, "game.agent.json was not found", "contract"));
759 std::ifstream in(path, std::ios::binary);
760 if (!in)
762 Diagnostic::error(DiagnosticCode::Failed, "cannot read game.agent.json", "contract"));
763 std::ostringstream contents;
764 contents << in.rdbuf();
765 return Value::fromJson(contents.str());
766 }
767
768 Result<Value> observeScriptRoot(std::string_view root,
769 const std::vector<std::string>& fields) const override {
770 return snapshotRoot(Debugger::instance().vm(), root, fields);
771 }
772
773 Result<Value> capturePng(std::string path) override {
774 auto* capture = eve::cap::query<IRenderCapture>();
775 if (!capture)
777 Diagnostic::error(DiagnosticCode::Unsupported, "IRenderCapture is not available", "capture"));
778 int width = 0, height = 0;
779 std::string error;
780 if (!capture->savePng(path, &width, &height, &error))
782 DiagnosticCode::Failed, error.empty() ? "engine screenshot failed" : error, "capture"));
783 return Result<Value>::success(Value(Value::Object{{"height", Value(static_cast<std::int64_t>(height))},
784 {"path", Value(std::move(path))},
785 {"width", Value(static_cast<std::int64_t>(width))}}));
786 }
787
788 Result<std::string> captureCheckpoint() override {
790 if (!vm)
792 DiagnosticCode::Unsupported, "play checkpoint requires an attached script VM", "checkpoint"));
793 std::string error;
794 std::string json = Snapshot::instance().capture(vm, &error);
795 if (json.empty())
797 DiagnosticCode::Failed, error.empty() ? "checkpoint capture failed" : error, "checkpoint"));
798 return Result<std::string>::success(std::move(json));
799 }
800
801 Result<void> restoreCheckpoint(std::string_view json) override {
803 if (!vm)
805 DiagnosticCode::Unsupported, "play checkpoint requires an attached script VM", "checkpoint"));
806 std::string error;
807 if (!Snapshot::instance().restore(vm, std::string(json), &error))
809 DiagnosticCode::Failed, error.empty() ? "checkpoint restore failed" : error, "checkpoint"));
810 return Result<void>::success();
811 }
812
813 Result<Value> invokeScriptAction(std::string_view id, std::string_view source) override {
815 if (!vm)
817 DiagnosticCode::Unsupported, "play act requires an attached script VM", "request.action"));
818 const SQInteger top = sq_gettop(vm);
819 const std::string snippet(source);
820 if (SQ_FAILED(sq_compilebuffer(vm, snippet.c_str(), static_cast<SQInteger>(snippet.size()),
821 "<play-action>", SQTrue))) {
822 sq_settop(vm, top);
825 "request.action"));
826 }
827 sq_pushroottable(vm);
828 if (SQ_FAILED(sq_call(vm, 1, SQFalse, SQTrue))) {
829 sq_settop(vm, top);
832 "request.action"));
833 }
834 sq_settop(vm, top);
835 return Result<Value>::success(Value(Value::Object{{"action", Value(std::string(id))}}));
836 }
837
838 std::vector<std::string> gameplayDomains() const override {
839 std::vector<std::string> domains;
840 eve::cap::forEach<IGameplayControlProvider>([&](auto* candidate) {
841 if (candidate) domains.emplace_back(candidate->gameplayDomain());
842 });
843 std::sort(domains.begin(), domains.end());
844 return domains;
845 }
846
847private:
848 std::uint64_t hostFrame_ = 0;
849};
850
851EnginePlayHostRuntime& liveRuntime() {
852 static EnginePlayHostRuntime runtime;
853 return runtime;
854}
855
856} // namespace
857
859 return dispatchOne(request, runtime, false);
860}
861
863 return executePlayRequest(request, liveRuntime());
864}
865
866Result<std::string> executePlayJson(std::string_view requestJson) {
867 auto request = Value::fromJson(requestJson);
868 if (!request) return Result<std::string>::failure(request.status());
869 auto response = executePlayRequest(request.value());
870 if (!response) return Result<std::string>::failure(response.status());
871 return response.value().toJson();
872}
873
874} // namespace eve::dev
double value
SQInteger top
Trace trace
Definition Agent.cpp:49
int root
Definition AnimSmr.cpp:119
std::vector< eve::artifact::PartView > parts
std::string output
Vec3 projected
Definition CaveMesh.cpp:122
float phase
Definition CaveMesh.cpp:58
struct SQVM * HSQUIRRELVM
HSQUIRRELVM vm
Definition ECS.cpp:20
Versioned JSON facade shared by scripting, MCP and scenario tooling.
Host-neutral player-equivalent command and observation protocol.
const GltfImportRequest & request
wgpu::PopErrorScopeStatus status
std::uint32_t height
std::uint32_t width
TokenKind kind
std::string name
std::string error
Definition Package.cpp:60
std::string action
Definition PlayHost.cpp:117
std::string script
Definition PlayHost.cpp:116
std::string path
Definition PlayHost.cpp:110
std::string defaultStep
Definition PlayHost.cpp:122
std::vector< ObservationSpec > observations
Definition PlayHost.cpp:125
std::vector< std::string > captureRequiredFor
Definition PlayHost.cpp:127
std::vector< std::string > fields
Definition PlayHost.cpp:111
std::vector< ActionSpec > actions
Definition PlayHost.cpp:126
std::string actionSource
Definition PlayHost.cpp:123
std::string actionDomain
Definition PlayHost.cpp:124
std::string id
Definition PlayHost.cpp:108
Versioned Play Host protocol for Agent pause / step / observe / capture.
Versioned Play Trace recording, digest and replay.
std::weak_ptr< PrimitiveScene > scene
bool found
double current
std::string digest
std::uint32_t count
const SquirrelValueOptions & options
The single Squirrel projection for common Result, Status and Value.
std::optional< InteractionSession > session
Definition Tactics.cpp:39
Json object
float step
Definition TreeMesh.cpp:314
float(ui::Theme::* member)[4]
uint32_t index
const UnitySourceAsset & source
static Diagnostic error(DiagnosticCode code, std::string message, std::string path={}, DiagnosticDetails details={}, std::string source={})
Construct an error diagnostic with the standard error severity.
Definition Diagnostic.h:125
Move-only operation result carrying either a value or Status.
Definition Result.h:155
static Result success(T value)
Construct a successful result owning value.
Definition Result.h:164
static Result failure(Status status)
Construct a failed result from a structured status.
Definition Result.h:175
The canonical owning dynamic value used by data-facing protocols.
Definition Value.h:31
static Result< Value > fromJson(std::string_view json)
Parse one strict JSON value into an owning Value.
Definition Value.cpp:57
std::map< std::string, Value > Object
Definition Value.h:34
Type
Runtime kind of the owned value.
Definition Value.h:38
std::vector< Value > Array
Definition Value.h:33
static AgentDevelopmentSession & instance()
Return the process-lifetime session authority.
HSQUIRRELVM vm() const
Definition Debugger.hpp:96
void stepFrames(int count)
Run count game frames then pause. Each frame is consumed by shouldRunUpdate / notifyFrameDone on the ...
Definition Debugger.cpp:587
bool isPaused() const
Definition Debugger.hpp:133
void pause(PauseReason reason=PauseReason::PauseKey)
Frame-level pause without a script site (PauseKey clears pauseLocation). For Exception / Snapshot,...
Definition Debugger.cpp:553
static Debugger & instance()
Definition Debugger.cpp:387
Injected runtime used by Play Host. Tests supply a fake; MCP uses the engine adapter....
Definition PlayHost.h:25
const std::string & gameRoot() const
Definition McpServer.hpp:67
static McpServer & instance()
void begin(std::string contractId, std::string contractHash, std::int64_t seed, std::string startCheckpoint)
Begins .
static PlayTraceBuffer & instance()
Instance.
bool isRecording() const
True when recording.
Definition PlayTrace.h:74
void append(const Value &request, const Value &response, std::uint64_t hostFrame)
Append.
bool isReplaying() const
True while replayPlayTrace is executing.
Definition PlayTrace.h:66
Value exportTrace() const
Export trace.
std::vector< std::string > rootsFor(HSQUIRRELVM vm) const
Marked roots, or heuristic roots when none marked (script-facing).
Definition Snapshot.hpp:62
std::string capture(HSQUIRRELVM vm, std::string *error=nullptr) const
Capture marked roots, or heuristic roots when none marked.
Definition Snapshot.cpp:438
static Snapshot & instance()
Definition Snapshot.cpp:290
const ClimbingActionDefinition * findAction(const ClimbingProfile &profile, std::string_view id)
Optional action. @borrowed From profile; lifetime ends when the caller-owned profile changes or is de...
std::variant< std::monostate, std::int64_t, double, std::string, bool > Value
Definition Database.h:26
Result< std::string > executePlayJson(std::string_view requestJson)
Parse, execute and serialize one Play JSON request against the live engine.
Definition PlayHost.cpp:866
Result< Value > replayPlayTrace(const Value &recording, IPlayHostRuntime &runtime)
Replay a parsed recording against a host runtime.
Result< std::string > playObservationDigest(const Value &state)
Canonical digest of a Play observation state object.
Definition PlayTrace.cpp:57
Result< Value > executePlayRequest(const Value &request, IPlayHostRuntime &runtime)
Execute one versioned Play request against an injected runtime.
Definition PlayHost.cpp:858
const EditorValue * field(const EditorValue &value, const char *name)
std::string formatScriptError(const ScriptErrorContext &ctx)
Formats a context into a human-readable multi-line report.
ScriptErrorContext takeLastScriptError(HSQUIRRELVM vm)
Consumes and clears the last recorded error for a VM.
ScriptErrorContext captureCompileError(HSQUIRRELVM vm)
Captures the last compilation error recorded by the VM.
Result< Value > valueFromSquirrel(HSQUIRRELVM vm, SQInteger index, const SquirrelValueOptions &options)
Convert one Squirrel value into the canonical owning Value tree.
Result< Value > executeGameplayControlRequest(const Value &request)
Routes one versioned owning request to the uniquely matching gameplay provider.