#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 #include 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 stepFrames(std::int64_t count) override { hostFrame_ += static_cast(count); paused_ = true; return Result::success(); } Result loadContract() const override { return Result::success(contract); } Result observeScriptRoot(std::string_view root, const std::vector& fields) const override { const auto* object = roots.getIf(); REQUIRE(object); const auto found = object->find(std::string(root)); REQUIRE(found != object->end()); if (fields.empty()) return Result::success(found->second); Value::Object projected; for (const auto& field : fields) { const auto* node = found->second.getIf(); REQUIRE(node); auto child = node->find(field); REQUIRE(child != node->end()); projected.emplace(field, child->second); } return Result::success(Value(std::move(projected))); } Result capturePng(std::string path) override { return Result::success(Value(Value::Object{{"height", Value(std::int64_t{2})}, {"path", Value(std::move(path))}, {"width", Value(std::int64_t{3})}})); } Result captureCheckpoint() override { return Result::success(checkpoint); } Result restoreCheckpoint(std::string_view json) override { auto parsed = Value::fromJson(json); REQUIRE(parsed.ok()); const auto* object = parsed.value().getIf(); REQUIRE(object); const auto found = object->find("roots"); REQUIRE(found != object->end()); roots = found->second; return Result::success(); } std::vector gameplayDomains() const override { return {"json-fixture"}; } Result 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(); if (object) { auto gameState = object->find("gameState"); if (gameState != object->end()) { auto* state = gameState->second.getIf(); if (state) { Value::Object copy = *state; copy["tick"] = Value(std::int64_t{0}); (*object)["gameState"] = Value(std::move(copy)); } } } } return Result::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(this); } ~JsonProvider() override { cap::removeListener(this); } std::string_view gameplayDomain() const noexcept override { return "json-fixture"; } [[nodiscard]] Result observeGameplay(const GameplaySession&, SubjectRef instance) const override { if (instance != instance_) return Result::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::success(std::move(result)); } [[nodiscard]] Result> availableGameplayActions( const GameplaySession&, SubjectRef, SubjectRef) const override { return Result>::success( {{logical("fixture:increment"), Value(Value::Object{})}}); } [[nodiscard]] Result 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::success(std::move(result)); } [[nodiscard]] Result advanceGameplay(const GameplaySession& session, SubjectRef instance, const SimulationStep& step) override { tick_ = step.tick; return observeGameplay(session, instance); } [[nodiscard]] Result> gameplayEvents(const GameplaySession&, SubjectRef, std::uint64_t) const override { return Result>::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()->at("state").getIf()->at("tick").asInt(), std::int64_t{0}); auto exported = PlayTraceBuffer::instance().exportTrace(); const auto* traceObject = exported.getIf(); 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()->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()->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()->at("steps").getIf()->at(0) .getIf() ->at("observationDigest") .asString(); const std::string secondDigest = passTwo.getIf()->at("steps").getIf()->at(0) .getIf() ->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()->at("gameplay").getIf()->at("receipt") .getIf() ->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()->at("receipt").getIf()->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(); }