Files
EVEngine/test/play_trace.cpp
T
西风逍遥游andCursor 332695a482 feat(devtools): add Play Host, Play Trace, and evidence-gated session close
Agents pause, step host frames, observe game.agent.json, run mapped act, and replay observation digests without eve_eval. Required visual criteria need screenshots; eval evidence is rejected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 19:09:00 +08:00

382 lines
16 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "common/Diagnostic.h"
#include "common/Capability.h"
#include "common/GameplayControl.h"
#include "common/GameplayControlJson.h"
#include "common/Value.h"
#include "devtools/AgentDevelopmentSession.hpp"
#include "devtools/PlayHost.h"
#include "devtools/PlayTrace.h"
#include <string>
#include <vector>
using namespace eve;
using namespace eve::dev;
namespace {
class FakePlayHostRuntime final : public IPlayHostRuntime {
public:
bool paused() const override { return paused_; }
std::uint64_t hostFrame() const override { return hostFrame_; }
void pause() override { paused_ = true; }
void play() override { paused_ = false; }
Result<void> stepFrames(std::int64_t count) override {
hostFrame_ += static_cast<std::uint64_t>(count);
paused_ = true;
return Result<void>::success();
}
Result<Value> loadContract() const override { return Result<Value>::success(contract); }
Result<Value> observeScriptRoot(std::string_view root,
const std::vector<std::string>& fields) const override {
const auto* object = roots.getIf<Value::Object>();
REQUIRE(object);
const auto found = object->find(std::string(root));
REQUIRE(found != object->end());
if (fields.empty()) return Result<Value>::success(found->second);
Value::Object projected;
for (const auto& field : fields) {
const auto* node = found->second.getIf<Value::Object>();
REQUIRE(node);
auto child = node->find(field);
REQUIRE(child != node->end());
projected.emplace(field, child->second);
}
return Result<Value>::success(Value(std::move(projected)));
}
Result<Value> capturePng(std::string path) override {
return Result<Value>::success(Value(Value::Object{{"height", Value(std::int64_t{2})},
{"path", Value(std::move(path))},
{"width", Value(std::int64_t{3})}}));
}
Result<std::string> captureCheckpoint() override { return Result<std::string>::success(checkpoint); }
Result<void> restoreCheckpoint(std::string_view json) override {
auto parsed = Value::fromJson(json);
REQUIRE(parsed.ok());
const auto* object = parsed.value().getIf<Value::Object>();
REQUIRE(object);
const auto found = object->find("roots");
REQUIRE(found != object->end());
roots = found->second;
return Result<void>::success();
}
std::vector<std::string> gameplayDomains() const override { return {"json-fixture"}; }
Result<Value> invokeScriptAction(std::string_view id, std::string_view source) override {
lastAction = std::string(id);
lastScript = std::string(source);
if (id == "reset") {
auto* object = roots.getIf<Value::Object>();
if (object) {
auto gameState = object->find("gameState");
if (gameState != object->end()) {
auto* state = gameState->second.getIf<Value::Object>();
if (state) {
Value::Object copy = *state;
copy["tick"] = Value(std::int64_t{0});
(*object)["gameState"] = Value(std::move(copy));
}
}
}
}
return Result<Value>::success(Value(Value::Object{{"action", Value(std::string(id))}}));
}
Value contract;
Value roots;
std::string checkpoint;
std::string lastAction;
std::string lastScript;
private:
bool paused_ = false;
std::uint64_t hostFrame_ = 0;
};
Value parse(std::string_view json) {
auto value = Value::fromJson(json);
REQUIRE(value.ok());
return std::move(value).takeValue();
}
SubjectRef subject(const char* text) {
const auto id = PersistentId::parse(text);
REQUIRE(id.has_value());
return SubjectRef::fromPersistentId(*id);
}
LogicalId logical(const char* text) {
const auto id = LogicalId::parse(text);
REQUIRE(id.has_value());
return *id;
}
class JsonProvider final : public IGameplayControlProvider {
public:
explicit JsonProvider(SubjectRef instance) : instance_(instance) {
cap::addListener<IGameplayControlProvider>(this);
}
~JsonProvider() override { cap::removeListener<IGameplayControlProvider>(this); }
std::string_view gameplayDomain() const noexcept override { return "json-fixture"; }
[[nodiscard]] Result<GameplayObservation> observeGameplay(const GameplaySession&,
SubjectRef instance) const override {
if (instance != instance_)
return Result<GameplayObservation>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "fixture not found", "instance"));
GameplayObservation result;
result.domain = logical("gameplay:json-fixture");
result.instance = instance_;
result.tick = tick_;
result.revision = revision_;
result.state = Value(Value::Object{{"count", Value(count_)}});
return Result<GameplayObservation>::success(std::move(result));
}
[[nodiscard]] Result<std::vector<GameplayActionDescriptor>> availableGameplayActions(
const GameplaySession&, SubjectRef, SubjectRef) const override {
return Result<std::vector<GameplayActionDescriptor>>::success(
{{logical("fixture:increment"), Value(Value::Object{})}});
}
[[nodiscard]] Result<GameplayCommandReceipt> submitGameplay(const GameplaySession&, SubjectRef,
const GameplayCommand& command) override {
++count_;
++revision_;
GameplayCommandReceipt result;
result.commandId = command.id;
result.executionId = "fixture-execution";
result.acceptedTick = tick_;
result.resultingRevision = revision_;
result.details = Value(Value::Object{{"count", Value(count_)}});
return Result<GameplayCommandReceipt>::success(std::move(result));
}
[[nodiscard]] Result<GameplayObservation> advanceGameplay(const GameplaySession& session, SubjectRef instance,
const SimulationStep& step) override {
tick_ = step.tick;
return observeGameplay(session, instance);
}
[[nodiscard]] Result<std::vector<GameplayEvent>> gameplayEvents(const GameplaySession&, SubjectRef,
std::uint64_t) const override {
return Result<std::vector<GameplayEvent>>::success({});
}
private:
SubjectRef instance_;
SimulationTick tick_ = SimulationTick::zero();
std::uint64_t revision_ = 1;
int count_ = 0;
};
const char* kScriptContract = R"json({
"schemaId": "evengine.game-agent-contract",
"schemaVersion": 1,
"id": "examples/ai-game",
"entry": "main.nut",
"clock": {"defaultStep": "frame"},
"observations": [
{"id": "combat-alive", "kind": "script-root", "path": "gameState", "fields": ["tick"]}
],
"actions": {"source": "script-map", "map": [{"id": "reset", "script": "game.reset()"}]},
"capture": {"requiredFor": ["visual"], "backend": "engine-readback"}
})json";
const char* kGameplayContract = R"json({
"schemaId": "evengine.game-agent-contract",
"schemaVersion": 1,
"id": "json-fixture-play",
"entry": "main.nut",
"clock": {"defaultStep": "frame"},
"observations": [
{"id": "combat-alive", "kind": "script-root", "path": "gameState", "fields": ["tick"]}
],
"actions": {
"source": "gameplay-domain",
"domain": "json-fixture",
"map": [{"id": "increment", "action": "fixture:increment"}]
}
})json";
} // namespace
TEST_CASE("devtools.playTrace.rejectsUnknownFieldsAndReplaysStableDigest") {
PlayTraceBuffer::instance().clear();
FakePlayHostRuntime runtime;
runtime.contract = parse(kScriptContract);
runtime.roots = parse(R"({"gameState":{"tick":4}})");
runtime.checkpoint =
R"({"version":2,"roots":{"gameState":{"tick":4}}})";
auto unknown = parsePlayTrace(parse(R"({"schemaId":"evengine.play-trace","schemaVersion":1,"steps":[],"extra":true})"));
CHECK(!unknown.ok());
CHECK_EQ(unknown.status().diagnostics().front().code(), DiagnosticCode::ParseError);
auto observed = executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"observe","observation":"combat-alive","trace":"append"})"),
runtime);
REQUIRE(observed.ok());
auto acted = executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"act","action":"reset","trace":"append"})"),
runtime);
REQUIRE(acted.ok());
CHECK_EQ(runtime.lastAction, std::string("reset"));
CHECK_EQ(runtime.lastScript, std::string("game.reset()"));
auto afterReset = executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"observe","observation":"combat-alive","trace":"append"})"),
runtime);
REQUIRE(afterReset.ok());
CHECK_EQ(afterReset.value().getIf<Value::Object>()->at("state").getIf<Value::Object>()->at("tick").asInt(),
std::int64_t{0});
auto exported = PlayTraceBuffer::instance().exportTrace();
const auto* traceObject = exported.getIf<Value::Object>();
REQUIRE(traceObject);
Value recording = exported;
recording.set("startCheckpoint", Value(std::string(R"({"version":2,"roots":{"gameState":{"tick":4}}})")));
runtime.roots = parse(R"({"gameState":{"tick":99}})");
auto first = replayPlayTrace(recording, runtime);
REQUIRE(first.ok());
auto digestA = playObservationDigest(runtime.roots.getIf<Value::Object>()->at("gameState"));
REQUIRE(digestA.ok());
runtime.roots = parse(R"({"gameState":{"tick":77}})");
auto second = replayPlayTrace(recording, runtime);
REQUIRE(second.ok());
auto digestB = playObservationDigest(runtime.roots.getIf<Value::Object>()->at("gameState"));
REQUIRE(digestB.ok());
CHECK_EQ(digestA.value(), digestB.value());
PlayTraceBuffer::instance().clear();
runtime.roots = parse(R"({"gameState":{"tick":4}})");
REQUIRE(executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"observe","observation":"combat-alive","trace":"append"})"),
runtime)
.ok());
auto passOne = PlayTraceBuffer::instance().exportTrace();
PlayTraceBuffer::instance().clear();
runtime.roots = parse(R"({"gameState":{"tick":4}})");
REQUIRE(executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"observe","observation":"combat-alive","trace":"append"})"),
runtime)
.ok());
auto passTwo = PlayTraceBuffer::instance().exportTrace();
const std::string firstDigest =
passOne.getIf<Value::Object>()->at("steps").getIf<Value::Array>()->at(0)
.getIf<Value::Object>()
->at("observationDigest")
.asString();
const std::string secondDigest =
passTwo.getIf<Value::Object>()->at("steps").getIf<Value::Array>()->at(0)
.getIf<Value::Object>()
->at("observationDigest")
.asString();
CHECK_EQ(firstDigest, secondDigest);
}
TEST_CASE("devtools.playHost.actMatchesGameplayControlSubmit") {
const auto instance = subject("00000000-0000-7000-8000-000000000a01");
const std::string instanceText = instance.format();
FakePlayHostRuntime runtime;
runtime.contract = parse(kGameplayContract);
runtime.roots = parse(R"({"gameState":{"tick":1}})");
Value::Object command{{"id", Value("json-command-1")},
{"action", Value("fixture:increment")},
{"subject", Value(instanceText)},
{"observedTick", Value(std::int64_t{0})},
{"expectedRevision", Value(std::int64_t{1})},
{"parameters", Value(Value::Object{})}};
Value session{Value::Object{{"id", Value("test")},
{"access", Value("player")},
{"controlledSubjects", Value(Value::Array{Value(instanceText)})}}};
std::int64_t playRevision = 0;
{
JsonProvider provider(instance);
Value playRequest{Value::Object{
{"schemaId", Value("evengine.play-request")},
{"schemaVersion", Value(std::int64_t{1})},
{"op", Value("act")},
{"action", Value("increment")},
{"instance", Value(instanceText)},
{"session", session},
{"command", Value(command)},
}};
auto viaPlay = executePlayRequest(playRequest, runtime);
REQUIRE(viaPlay.ok());
playRevision =
viaPlay.value().getIf<Value::Object>()->at("gameplay").getIf<Value::Object>()->at("receipt")
.getIf<Value::Object>()
->at("resultingRevision")
.asInt();
}
std::int64_t jsonRevision = 0;
{
JsonProvider provider(instance);
Value gameplayRequest{Value::Object{
{"schemaId", Value("evengine.gameplay-control-request")},
{"schemaVersion", Value(std::int64_t{1})},
{"op", Value("submit")},
{"domain", Value("json-fixture")},
{"instance", Value(instanceText)},
{"session", session},
{"command", Value(command)},
}};
auto viaJson = executeGameplayControlRequest(gameplayRequest);
REQUIRE(viaJson.ok());
jsonRevision = viaJson.value().getIf<Value::Object>()->at("receipt").getIf<Value::Object>()->at("resultingRevision").asInt();
}
CHECK_EQ(playRevision, jsonRevision);
CHECK_EQ(playRevision, std::int64_t{2});
}
TEST_CASE("devtools.playHost.sessionAutoRecordsEvidenceWithoutEval") {
PlayTraceBuffer::instance().clear();
AgentDevelopmentSession::instance().reset();
FakePlayHostRuntime runtime;
runtime.contract = parse(kScriptContract);
runtime.roots = parse(R"({"gameState":{"tick":4}})");
auto& session = AgentDevelopmentSession::instance();
REQUIRE(session
.start("Play a mapped action",
{{"combat-alive", "Combat state is observable", true, false},
{"visual", "Frame is captured", true, false}})
.isAccepted());
const std::string id = session.sessionId();
REQUIRE(session.advance(id, AgentDevelopmentPhase::Modify).isAccepted());
REQUIRE(session.advance(id, AgentDevelopmentPhase::Run).isAccepted());
REQUIRE(session.advance(id, AgentDevelopmentPhase::Observe).isAccepted());
REQUIRE(executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"observe","observation":"combat-alive"})"),
runtime)
.ok());
REQUIRE(executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"capture","path":"frame.png"})"),
runtime)
.ok());
bool sawObserve = false;
bool sawShot = false;
for (const auto& evidence : session.evidence()) {
if (evidence.kind == "runtime-observation" && evidence.criterionId == "combat-alive") sawObserve = true;
if (evidence.kind == "screenshot" && evidence.criterionId == "visual") sawShot = true;
CHECK_NE(evidence.kind, std::string("eval"));
}
CHECK(sawObserve);
CHECK(sawShot);
REQUIRE(session.advance(id, AgentDevelopmentPhase::Verify).isAccepted());
REQUIRE(session.complete(id, "play evidence closed the session").isAccepted());
session.reset();
PlayTraceBuffer::instance().clear();
}