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;
33Result<const Value::Object*> asObject(
const Value&
value, std::string
path) {
41Result<void> knownFields(
const Value::Object&
object,
const std::set<std::string>& allowed,
42 std::string_view
path) {
45 if (!allowed.contains(
name))
53 std::string_view
path) {
54 const auto found =
object.find(std::string(
name));
61Result<std::string> stringMember(
const Value::Object&
object, std::string_view
name,
62 std::string_view
path) {
68Result<std::int64_t> intMember(
const Value::Object&
object, std::string_view
name, std::string_view
path) {
76 const auto found =
object.find(std::string(
name));
78 if (!
found->second.isString())
80 std::string(
path) +
"." + std::string(
name)));
81 *out =
found->second.asString();
85std::vector<std::string> splitPath(std::string_view dotted) {
86 std::vector<std::string>
parts;
88 for (
char ch : dotted) {
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));
107struct ObservationSpec {
120struct ParsedContract {
130Result<std::vector<std::string>> stringArray(
const Value&
value, std::string
path) {
133 return Result<std::vector<std::string>>::failure(
135 std::vector<std::string> out;
137 if (!(*array)[
index].isString())
140 out.push_back((*array)[
index].asString());
142 return Result<std::vector<std::string>>::success(std::move(out));
145Result<ParsedContract> parseContract(
const Value& contract) {
146 auto root = asObject(contract,
"contract");
149 knownFields(*
root.value(),
150 {
"actions",
"capture",
"clock",
"entry",
"id",
"observations",
"schemaId",
"schemaVersion",
151 "smoke",
"tolerances"},
154 auto schemaId = stringMember(*
root.value(),
"schemaId",
"contract");
156 auto version = intMember(*
root.value(),
"schemaVersion",
"contract");
158 auto id = stringMember(*
root.value(),
"id",
"contract");
160 auto entry = stringMember(*
root.value(),
"entry",
"contract");
162 if (schemaId.value() != kContractSchemaId || version.value() != kSchemaVersion)
165 if (
id.
value().empty())
169 ParsedContract parsed;
170 parsed.id =
id.value();
171 parsed.defaultStep =
"frame";
173 const auto clock =
root.value()->find(
"clock");
174 if (clock !=
root.value()->end()) {
175 auto object = asObject(clock->second,
"contract.clock");
177 auto clockKnown = knownFields(*
object.
value(), {
"defaultStep"},
"contract.clock");
179 auto step = stringMember(*
object.
value(),
"defaultStep",
"contract.clock");
181 parsed.defaultStep =
step.value();
182 if (parsed.defaultStep !=
"frame")
184 "P0 play host only supports frame defaultStep",
185 "contract.clock.defaultStep"));
194 const std::string itemPath =
"contract.observations." + std::to_string(
index);
195 auto object = asObject((*array)[
index], itemPath);
197 auto itemKnown = knownFields(*
object.
value(), {
"fields",
"id",
"kind",
"path"}, itemPath);
199 ObservationSpec spec;
200 auto specId = stringMember(*
object.
value(),
"id", itemPath);
202 auto kind = stringMember(*
object.
value(),
"kind", itemPath);
204 auto path = stringMember(*
object.
value(),
"path", itemPath);
206 spec.id = specId.value();
207 spec.kind =
kind.value();
208 spec.path =
path.value();
209 const auto fields =
object.value()->find(
"fields");
211 auto parsedFields = stringArray(
fields->second, itemPath +
".fields");
213 spec.fields = std::move(parsedFields).takeValue();
215 parsed.observations.push_back(std::move(spec));
218 const auto smoke =
root.value()->find(
"smoke");
219 if (smoke !=
root.value()->end()) {
220 auto object = asObject(smoke->second,
"contract.smoke");
223 knownFields(*
object.
value(), {
"maxHostFrames",
"mustReach",
"scene",
"seed"},
"contract.smoke");
225 auto scene = stringMember(*
object.
value(),
"scene",
"contract.smoke");
227 const auto mustReach =
object.value()->find(
"mustReach");
228 if (mustReach !=
object.
value()->
end()) {
229 auto reached = stringArray(mustReach->second,
"contract.smoke.mustReach");
234 const auto capture =
root.value()->find(
"capture");
235 if (capture !=
root.value()->end()) {
236 auto object = asObject(capture->second,
"contract.capture");
238 auto captureKnown = knownFields(*
object.
value(), {
"backend",
"requiredFor"},
"contract.capture");
240 auto backend = stringMember(*
object.
value(),
"backend",
"contract.capture");
242 if (backend.value() !=
"engine-readback")
245 const auto requiredFor =
object.value()->find(
"requiredFor");
246 if (requiredFor !=
object.
value()->
end()) {
247 auto names = stringArray(requiredFor->second,
"contract.capture.requiredFor");
249 parsed.captureRequiredFor = std::move(names).takeValue();
253 const auto actions =
root.value()->find(
"actions");
255 auto object = asObject(
actions->second,
"contract.actions");
257 auto actionsKnown = knownFields(*
object.
value(), {
"domain",
"map",
"source"},
"contract.actions");
259 auto source = stringMember(*
object.
value(),
"source",
"contract.actions");
261 parsed.actionSource =
source.value();
262 if (parsed.actionSource !=
"script-map" && parsed.actionSource !=
"gameplay-domain")
265 auto domainField = optionalString(*
object.
value(),
"domain",
"contract.actions", &parsed.actionDomain);
267 const auto map =
object.value()->find(
"map");
274 const std::string itemPath =
"contract.actions.map." + std::to_string(
index);
275 auto item = asObject((*array)[
index], itemPath);
277 auto itemKnown = knownFields(*item.value(), {
"action",
"id",
"script"}, itemPath);
280 auto actionId = stringMember(*item.value(),
"id", itemPath);
282 spec.id = actionId.value();
283 auto scriptField = optionalString(*item.value(),
"script", itemPath, &spec.script);
285 auto mappedAction = optionalString(*item.value(),
"action", itemPath, &spec.action);
287 if (parsed.actionSource ==
"script-map" && spec.script.empty())
290 if (parsed.actionSource ==
"gameplay-domain" && spec.action.empty())
293 parsed.actions.push_back(std::move(spec));
298 const auto tolerances =
root.value()->find(
"tolerances");
299 if (tolerances !=
root.value()->end()) {
300 auto object = asObject(tolerances->second,
"contract.tolerances");
302 auto tolKnown = knownFields(*
object.
value(), {
"numericAbs",
"screenshot"},
"contract.tolerances");
309Result<const ObservationSpec*> findObservation(
const ParsedContract& contract, std::string_view
id) {
317Result<const ActionSpec*>
findAction(
const ParsedContract& contract, std::string_view
id) {
318 for (
const auto& spec : contract.
actions) {
325Result<Value> requireContract(IPlayHostRuntime& runtime) {
326 auto loaded = runtime.loadContract();
336Value encodeDomains(
const std::vector<std::string>& domains) {
338 for (
const auto& domain : domains)
output.emplace_back(domain);
342Result<Value> opStatus(IPlayHostRuntime& runtime) {
344 {
"hostFrame",
Value(
static_cast<std::int64_t
>(runtime.hostFrame()))},
345 {
"domains", encodeDomains(runtime.gameplayDomains())}};
346 auto loaded = runtime.loadContract();
348 auto parsed = parseContract(loaded.value());
350 extra.emplace(
"contractId",
Value(parsed.value().id));
351 extra.emplace(
"contractLoaded",
Value(
true));
353 extra.emplace(
"contractLoaded",
Value(
false));
361 auto mode = stringMember(
root,
"mode",
"request");
363 if (mode.value() ==
"pause") runtime.pause();
364 else if (mode.value() ==
"play") runtime.play();
369 playResponse(
"clock", {{
"mode",
Value(mode.value())}, {
"paused",
Value(runtime.paused())}}));
373 std::string clock =
"frame";
374 auto clockField = optionalString(
root,
"clock",
"request", &clock);
376 if (clock !=
"frame")
379 auto count = intMember(
root,
"count",
"request");
381 if (
count.value() < 1 ||
count.value() > kMaxFrameSteps)
384 auto stepped = runtime.stepFrames(
count.value());
387 "step", {{
"clock",
Value(std::move(clock))},
389 {
"hostFrame",
Value(
static_cast<std::int64_t
>(runtime.hostFrame()))},
390 {
"paused",
Value(runtime.paused())}}));
394 auto observation = stringMember(
root,
"observation",
"request");
396 auto contractValue = requireContract(runtime);
398 auto parsed = parseContract(contractValue.value());
400 auto spec = findObservation(parsed.value(), observation.value());
402 if (spec.value()->kind !=
"script-root")
405 auto state = runtime.observeScriptRoot(spec.value()->path, spec.value()->fields);
408 {
"state", std::move(state).takeValue()}}));
412 std::string
path =
"mcp_screenshot.png";
413 auto pathField = optionalString(
root,
"path",
"request", &
path);
415 if (
path.empty())
path =
"mcp_screenshot.png";
416 auto captured = runtime.capturePng(std::move(
path));
418 auto object = asObject(captured.value(),
"capture");
421 extra.emplace(
"backend",
Value(
"engine-readback"));
425Result<Value> opCheckpoint(
const Value::Object&
root, IPlayHostRuntime& runtime) {
426 auto mode = stringMember(
root,
"mode",
"request");
428 if (mode.value() ==
"capture") {
429 auto json = runtime.captureCheckpoint();
432 playResponse(
"checkpoint", {{
"mode",
Value(
"capture")}, {
"json",
Value(std::move(json).takeValue())}}));
434 if (mode.value() ==
"restore") {
435 auto json = stringMember(
root,
"json",
"request");
437 auto restored = runtime.restoreCheckpoint(json.value());
446 auto action = stringMember(
root,
"action",
"request");
448 auto contractValue = requireContract(runtime);
450 auto parsed = parseContract(contractValue.value());
454 if (parsed.value().actionSource ==
"script-map") {
455 auto invoked = runtime.invokeScriptAction(spec.value()->id, spec.value()->script);
458 {
"source",
Value(
"script-map")},
459 {
"receipt", std::move(invoked).takeValue()}}));
461 if (parsed.value().actionSource !=
"gameplay-domain")
464 const auto commandField =
root.find(
"command");
465 if (commandField ==
root.end())
468 auto commandObject = asObject(commandField->second,
"request.command");
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);
478 const auto sessionField =
root.find(
"session");
479 const auto instanceField =
root.find(
"instance");
480 if (sessionField ==
root.end() || instanceField ==
root.end())
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))}};
493 {
"source",
Value(
"gameplay-domain")},
494 {
"gameplay", std::move(submitted).takeValue()}}));
497Result<Value> opTrace() {
503 const auto recording =
root.find(
"recording");
504 if (recording ==
root.end())
510bool sessionWantsTrace() {
512 if (!
session.active())
return false;
518void tryRecordEvidence(std::string criterionId, std::string
kind, std::string summary, std::string artifact) {
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);
530bool hasCriterion(
const AgentDevelopmentSession&
session, std::string_view
id) {
531 for (
const auto& criterion :
session.criteria()) {
532 if (criterion.id ==
id)
return true;
537void maybeAutoEvidence(
const Value::Object&
request,
const Value& response, IPlayHostRuntime& runtime) {
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, {});
553 if (op ==
"capture") {
555 if (responseObject) {
556 const auto found = responseObject->find(
"path");
557 if (
found != responseObject->end() &&
found->second.isString())
path =
found->second.asString();
559 if (criterionId.empty() && hasCriterion(
session,
"visual")) criterionId =
"visual";
560 if (criterionId.empty()) {
561 auto loaded = runtime.loadContract();
563 auto parsed = parseContract(loaded.value());
574 tryRecordEvidence(std::move(criterionId),
"screenshot",
"play capture", std::move(
path));
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", {});
584 if (criterionId.empty() && hasCriterion(
session,
"smoke")) criterionId =
"smoke";
585 tryRecordEvidence(std::move(criterionId),
"play-trace",
"play batch", {});
589void afterPlaySuccess(
const Value&
request,
const Value& response, IPlayHostRuntime& runtime) {
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();
606 auto parsed = parseContract(loaded.value());
607 if (parsed) contractId = parsed.value().id;
609 if (
digest) contractHash = std::move(
digest).takeValue();
615 maybeAutoEvidence(*
root, response, runtime);
618Result<Value> dispatchOne(
const Value&
request, IPlayHostRuntime& runtime,
bool inBatch);
623 const auto* array = requests.value()->getIf<
Value::Array>();
626 auto one = dispatchOne((*array)[
index], runtime,
true);
628 results.push_back(std::move(one).takeValue());
630 const auto count =
static_cast<std::int64_t
>(results.size());
632 playResponse(
"batch", {{
"count",
Value(
count)}, {
"results",
Value(std::move(results))}}));
635Result<Value> dispatchOne(
const Value&
request, IPlayHostRuntime& runtime,
bool inBatch) {
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");
644 auto schemaId = stringMember(*
root.value(),
"schemaId",
"request");
646 auto version = intMember(*
root.value(),
"schemaVersion",
"request");
648 if (schemaId.value() != kPlaySchemaId || version.value() != kSchemaVersion)
652 auto traceField = optionalString(*
root.value(),
"trace",
"request", &
trace);
657 auto op = stringMember(*
root.value(),
"op",
"request");
659 auto executed = [&]() -> Result<Value> {
660 if (op.value() ==
"batch") {
664 return opBatch(*
root.value(), runtime);
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);
678 if (!executed)
return executed;
679 afterPlaySuccess(
request, executed.value(), runtime);
683Result<Value> snapshotRoot(
HSQUIRRELVM vm, std::string_view rootName,
const std::vector<std::string>&
fields) {
686 "play observe requires an attached script VM",
"observation"));
688 if (std::find(roots.begin(), roots.end(), rootName) == roots.end())
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)))
702 const SQInteger rootIndex = sq_gettop(
vm);
703 script::SquirrelValueOptions
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)
714 for (
const auto& part :
parts) {
716 if (sq_gettype(
vm, -1) != OT_TABLE)
719 sq_pushstring(
vm, part.c_str(), -1);
720 if (SQ_FAILED(sq_rawget(
vm, -2)))
732class EnginePlayHostRuntime final :
public IPlayHostRuntime {
735 std::uint64_t hostFrame()
const override {
return hostFrame_; }
739 Result<void> stepFrames(std::int64_t
count)
override {
741 hostFrame_ +=
static_cast<std::uint64_t
>(
count);
745 Result<Value> loadContract()
const override {
749 root = std::filesystem::current_path(ec);
754 const auto path =
root /
"game.agent.json";
756 if (!std::filesystem::is_regular_file(
path, ec))
759 std::ifstream in(
path, std::ios::binary);
763 std::ostringstream contents;
764 contents << in.rdbuf();
768 Result<Value> observeScriptRoot(std::string_view
root,
769 const std::vector<std::string>&
fields)
const override {
773 Result<Value> capturePng(std::string
path)
override {
774 auto* capture = eve::cap::query<IRenderCapture>();
785 {
"width",
Value(
static_cast<std::int64_t
>(
width))}}));
788 Result<std::string> captureCheckpoint()
override {
801 Result<void> restoreCheckpoint(std::string_view json)
override {
813 Result<Value> invokeScriptAction(std::string_view
id, std::string_view
source)
override {
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))) {
827 sq_pushroottable(
vm);
828 if (SQ_FAILED(sq_call(
vm, 1, SQFalse, SQTrue))) {
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());
843 std::sort(domains.begin(), domains.end());
848 std::uint64_t hostFrame_ = 0;
851EnginePlayHostRuntime& liveRuntime() {
852 static EnginePlayHostRuntime runtime;
859 return dispatchOne(
request, runtime,
false);
871 return response.value().toJson();
std::vector< eve::artifact::PartView > parts
struct SQVM * HSQUIRRELVM
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::vector< ObservationSpec > observations
std::vector< std::string > captureRequiredFor
std::vector< std::string > fields
std::vector< ActionSpec > actions
Versioned Play Host protocol for Agent pause / step / observe / capture.
Versioned Play Trace recording, digest and replay.
std::weak_ptr< PrimitiveScene > scene
const SquirrelValueOptions & options
The single Squirrel projection for common Result, Status and Value.
std::optional< InteractionSession > session
float(ui::Theme::* member)[4]
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.
Move-only operation result carrying either a value or Status.
static Result success(T value)
Construct a successful result owning value.
static Result failure(Status status)
Construct a failed result from a structured status.
The canonical owning dynamic value used by data-facing protocols.
static Result< Value > fromJson(std::string_view json)
Parse one strict JSON value into an owning Value.
std::map< std::string, Value > Object
Type
Runtime kind of the owned value.
std::vector< Value > Array
static AgentDevelopmentSession & instance()
Return the process-lifetime session authority.
void stepFrames(int count)
Run count game frames then pause. Each frame is consumed by shouldRunUpdate / notifyFrameDone on the ...
void pause(PauseReason reason=PauseReason::PauseKey)
Frame-level pause without a script site (PauseKey clears pauseLocation). For Exception / Snapshot,...
static Debugger & instance()
Injected runtime used by Play Host. Tests supply a fake; MCP uses the engine adapter....
const std::string & gameRoot() const
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.
void append(const Value &request, const Value &response, std::uint64_t hostFrame)
Append.
bool isReplaying() const
True while replayPlayTrace is executing.
Value exportTrace() const
Export trace.
std::vector< std::string > rootsFor(HSQUIRRELVM vm) const
Marked roots, or heuristic roots when none marked (script-facing).
std::string capture(HSQUIRRELVM vm, std::string *error=nullptr) const
Capture marked roots, or heuristic roots when none marked.
static Snapshot & instance()
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
Result< std::string > executePlayJson(std::string_view requestJson)
Parse, execute and serialize one Play JSON request against the live engine.
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.
Result< Value > executePlayRequest(const Value &request, IPlayHostRuntime &runtime)
Execute one versioned Play request against an injected runtime.
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.