Files
EVEngine/test/play_host.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

282 lines
11 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "common/Diagnostic.h"
#include "common/Value.h"
#include "devtools/Debugger.hpp"
#include "devtools/PlayHost.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 {
if (contractMissing)
return Result<Value>::failure(Diagnostic::error(DiagnosticCode::NotFound, "missing contract", "contract"));
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>();
if (!object)
return Result<Value>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "no roots", "observation"));
const auto found = object->find(std::string(root));
if (found == object->end())
return Result<Value>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "root missing", "observation.path"));
if (fields.empty()) return Result<Value>::success(found->second);
Value::Object projected;
for (const auto& field : fields) {
Value current = found->second;
std::string token;
bool ok = true;
for (char ch : field + std::string(".")) {
if (ch != '.') {
token.push_back(ch);
continue;
}
const auto* node = current.getIf<Value::Object>();
if (!node) {
ok = false;
break;
}
auto child = node->find(token);
if (child == node->end()) {
ok = false;
break;
}
Value next = child->second;
current = std::move(next);
token.clear();
}
if (!ok)
return Result<Value>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "field missing", "observation.fields"));
projected.emplace(field, std::move(current));
}
return Result<Value>::success(Value(std::move(projected)));
}
Result<Value> capturePng(std::string path) override {
if (!capture)
return Result<Value>::failure(Diagnostic::error(DiagnosticCode::Unsupported, "no capture", "capture"));
lastCapturePath = path;
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 {
restored = std::string(json);
auto parsed = Value::fromJson(json);
if (parsed) {
const auto* object = parsed.value().getIf<Value::Object>();
if (object) {
const auto found = object->find("roots");
if (found != object->end()) roots = found->second;
}
}
return Result<void>::success();
}
std::vector<std::string> gameplayDomains() const override { return domains; }
Result<Value> invokeScriptAction(std::string_view id, std::string_view source) override {
lastAction = std::string(id);
lastActionScript = 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 = R"({"version":2,"roots":{"gameState":{"tick":1}}})";
std::string restored;
std::string lastCapturePath;
std::string lastAction;
std::string lastActionScript;
std::vector<std::string> domains{"tactics"};
bool contractMissing = false;
bool capture = true;
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();
}
const char* kContract = R"json({
"schemaId": "evengine.game-agent-contract",
"schemaVersion": 1,
"id": "examples/ai-game",
"entry": "main.nut",
"smoke": {"scene": "default", "seed": 42, "maxHostFrames": 180, "mustReach": ["combat-alive"]},
"clock": {"defaultStep": "frame"},
"observations": [
{"id": "combat-alive", "kind": "script-root", "path": "gameState", "fields": ["tick", "enemy.hp"]}
],
"actions": {"source": "script-map", "map": [{"id": "reset", "script": "game.reset()"}]},
"capture": {"requiredFor": ["visual"], "backend": "engine-readback"},
"tolerances": {"screenshot": "human-or-vlm", "numericAbs": 0.0001}
})json";
Value play(FakePlayHostRuntime& runtime, std::string_view json) {
auto result = executePlayRequest(parse(json), runtime);
REQUIRE(result.ok());
return std::move(result).takeValue();
}
} // namespace
TEST_CASE("devtools.playHost.rejectsUnknownFieldsAndRequiresContractForObserve") {
FakePlayHostRuntime runtime;
runtime.contract = parse(kContract);
auto unknown = executePlayRequest(parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"status","extra":true})"),
runtime);
CHECK(!unknown.ok());
CHECK_EQ(unknown.code(), StatusCode::Failed);
CHECK_EQ(unknown.status().diagnostics().front().code(), DiagnosticCode::ParseError);
runtime.contractMissing = true;
auto observe = executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"observe","observation":"combat-alive"})"),
runtime);
CHECK(!observe.ok());
CHECK_EQ(observe.status().diagnostics().front().code(), DiagnosticCode::Unsupported);
auto status = play(runtime, R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"status"})");
CHECK_EQ(status.getIf<Value::Object>()->at("contractLoaded").asBool(), false);
}
TEST_CASE("devtools.playHost.pausesStepsObservesCapturesAndRestores") {
FakePlayHostRuntime runtime;
runtime.contract = parse(kContract);
runtime.roots = parse(R"({"gameState":{"tick":4,"enemy":{"hp":20.0},"player":{"hp":100.0}}})");
auto clock = play(runtime, R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"clock","mode":"pause"})");
CHECK(clock.getIf<Value::Object>()->at("paused").asBool());
auto stepped =
play(runtime,
R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"step","clock":"frame","count":10})");
CHECK_EQ(stepped.getIf<Value::Object>()->at("count").asInt(), std::int64_t{10});
CHECK_EQ(runtime.hostFrame(), std::uint64_t{10});
auto observed = play(
runtime,
R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"observe","observation":"combat-alive"})");
const auto* state = observed.getIf<Value::Object>()->at("state").getIf<Value::Object>();
REQUIRE(state);
CHECK_EQ(state->at("tick").asInt(), std::int64_t{4});
CHECK_EQ(state->at("enemy.hp").asDouble(), 20.0);
auto captured =
play(runtime, R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"capture","path":"frame.png"})");
CHECK_EQ(captured.getIf<Value::Object>()->at("path").asString(), std::string("frame.png"));
CHECK_EQ(captured.getIf<Value::Object>()->at("backend").asString(), std::string("engine-readback"));
runtime.capture = false;
auto missingCapture =
executePlayRequest(parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"capture"})"), runtime);
CHECK(!missingCapture.ok());
CHECK_EQ(missingCapture.status().diagnostics().front().code(), DiagnosticCode::Unsupported);
auto checkpoint = play(
runtime, R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"checkpoint","mode":"capture"})");
const std::string json = checkpoint.getIf<Value::Object>()->at("json").asString();
Value restoreRequest{Value::Object{
{"schemaId", Value("evengine.play-request")},
{"schemaVersion", Value(std::int64_t{1})},
{"op", Value("checkpoint")},
{"mode", Value("restore")},
{"json", Value(json)},
}};
auto restored = executePlayRequest(restoreRequest, runtime);
REQUIRE(restored.ok());
CHECK_EQ(runtime.restored, json);
auto batched = play(runtime, R"json({
"schemaId": "evengine.play-request",
"schemaVersion": 1,
"op": "batch",
"requests": [
{"schemaId":"evengine.play-request","schemaVersion":1,"op":"clock","mode":"play"},
{"schemaId":"evengine.play-request","schemaVersion":1,"op":"status"}
]
})json");
CHECK_EQ(batched.getIf<Value::Object>()->at("count").asInt(), std::int64_t{2});
CHECK(!runtime.paused());
auto nested = executePlayRequest(parse(R"json({
"schemaId": "evengine.play-request",
"schemaVersion": 1,
"op": "batch",
"requests": [
{"schemaId":"evengine.play-request","schemaVersion":1,"op":"batch","requests":[]}
]
})json"),
runtime);
CHECK(!nested.ok());
CHECK_EQ(nested.status().diagnostics().front().code(), DiagnosticCode::Unsupported);
auto gameplayStep = executePlayRequest(
parse(R"({"schemaId":"evengine.play-request","schemaVersion":1,"op":"step","clock":"gameplay","count":1})"),
runtime);
CHECK(!gameplayStep.ok());
CHECK_EQ(gameplayStep.status().diagnostics().front().code(), DiagnosticCode::Unsupported);
}
TEST_CASE("devtools.debugger.stepFramesReArmsUntilCountConsumed") {
Debugger& debugger = Debugger::instance();
debugger.detach();
debugger.pause(PauseReason::PauseKey);
debugger.stepFrames(3);
CHECK(debugger.shouldRunUpdate());
debugger.notifyFrameDone();
CHECK(debugger.shouldRunUpdate());
debugger.notifyFrameDone();
CHECK(debugger.shouldRunUpdate());
debugger.notifyFrameDone();
CHECK(debugger.isPaused());
debugger.resume();
}