Files
EVEngine/test/dnut_interpreter.cpp
西风逍遥游 6c5ae409f5 feat(dnut): add the L1 .dnut sequence language and the RPG story dialect
New L1 module `dnut_interpreter` owns the shared lexer, the registry-driven
compiler, the compiled sequence model and the cross-frame interpreter. Domain
vocabularies are registered by the modules that own them; the core never
interprets a domain payload.

- `DnutLexer` / `DnutCompiler`: shared `.dnut` front end with statement-level
  error recovery, skipping of non-`story` top-level blocks, and step legality
  deferred entirely to the registry.
- `SequenceAsset` / `SequenceRuntime`: compiled sequence model plus a
  cross-frame interpreter with call frames, a bounded execution budget, and
  version-checked capture/restore of the cursor.
- `StepKindRegistry`: strict step validation that rejects undeclared fields and
  runs the descriptor's own domain invariants.
- `rpg`: `RpgDialect` registers the RPG vocabulary (movement, animation, object
  selection, skill/attribute/inventory/equipment changes) behind a Pimpl
  boundary, so public headers stay free of cross-module includes.
- 17 unit tests across `test/dnut_interpreter.cpp` and `test/rpg_dialect.cpp`.
2026-09-17 17:19:02 +08:00

372 lines
15 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "dnut_interpreter/DnutCompiler.h"
#include "dnut_interpreter/SequenceRuntime.h"
#include "dnut_interpreter/StepKindRegistry.h"
#include <algorithm>
#include <map>
#include <string>
#include <vector>
namespace {
using namespace eve::dnut;
StepField requiredField(std::string name, StepFieldType type) { return StepField{std::move(name), type, true}; }
StepField optionalField(std::string name, StepFieldType type) { return StepField{std::move(name), type, false}; }
/** @brief Registry with one `mark` instant step and two host-presented steps. */
StepKindRegistry makeRegistry(std::vector<std::string>* marks = nullptr) {
StepKindRegistry registry;
registry
.registerStep({"mark", "Mark", "test", StepShape::Instant,
{requiredField("id", StepFieldType::String)}})
.expect("mark descriptor");
registry
.registerHandler("mark", [marks](const SequenceNode& node, const StepContext&) -> StepOutcome {
if (marks) {
const eve::Value* id = node.payload.find("id");
marks->push_back(id && id->isString() ? id->asString() : std::string{});
}
return StepOutcome{};
})
.expect("mark handler");
registry.registerStep({"hold", "Hold", "test", StepShape::Await, {}}).expect("hold descriptor");
registry
.registerStep({"move", "Move", "test", StepShape::Await,
{requiredField("actor", StepFieldType::String), requiredField("x", StepFieldType::Number),
requiredField("y", StepFieldType::Number), optionalField("duration", StepFieldType::Number)}})
.expect("move descriptor");
registry
.registerStep({"message", "Message", "test", StepShape::Await,
{requiredField("text", StepFieldType::String)}})
.expect("message descriptor");
return registry;
}
const SequenceNode* findNode(const SequenceAsset& asset, const std::string& id) { return asset.findNode(id); }
} // namespace
TEST_CASE("dnut.language.compilesControlFlow") {
StepKindRegistry registry = makeRegistry();
const std::string source = R"(
// leading comment
story forest.arrival repeatable version=2 {
move actor=hero x=12 y=8 duration=0.8
if switch.gate == true {
mark id=inside
} else {
mark id=outside
}
choice {
option "Accept" when variable.gold >= 10 {
mark id=accepted
}
option "Decline" {
mark id=declined
}
}
call forest.epilogue
wait 0.5
end
}
pool village {
elder: "欢迎"
}
)";
DnutCompileOutput output = compileDnut(source, "story.dnut", registry);
REQUIRE(!output.hasErrors());
REQUIRE_EQ(output.assets.size(), 1u);
const SequenceAsset& asset = output.assets.front();
CHECK_EQ(asset.id, std::string("forest.arrival"));
CHECK_EQ(asset.version, 2);
CHECK(asset.repeatable);
REQUIRE(asset.validate().ok());
const SequenceNode* entry = findNode(asset, asset.entry);
REQUIRE(entry != nullptr);
CHECK_EQ(entry->type, std::string("move"));
const eve::Value* actor = entry->payload.find("actor");
REQUIRE(actor != nullptr);
CHECK_EQ(actor->asString(), std::string("hero"));
const SequenceNode* branch = asset.findNode(entry->next);
REQUIRE(branch != nullptr);
CHECK_EQ(branch->type, std::string("branch"));
REQUIRE_EQ(branch->routes.size(), 1u);
CHECK(!branch->routes.front().condition.isNull());
// `if` lowers to a branch whose route is the then-arm and whose `next` is the
// else-arm; both arms continue at the statement after the `if`.
const SequenceNode* thenArm = findNode(asset, branch->routes.front().target);
const SequenceNode* elseArm = findNode(asset, branch->next);
REQUIRE(thenArm != nullptr);
REQUIRE(elseArm != nullptr);
CHECK_EQ(thenArm->type, std::string("mark"));
CHECK_EQ(elseArm->type, std::string("mark"));
CHECK_EQ(thenArm->payload.find("id")->asString(), std::string("inside"));
CHECK_EQ(elseArm->payload.find("id")->asString(), std::string("outside"));
CHECK_EQ(thenArm->next, elseArm->next);
const SequenceNode* choice = asset.findNode(thenArm->next);
REQUIRE(choice != nullptr);
CHECK_EQ(choice->type, std::string("choice"));
REQUIRE_EQ(choice->routes.size(), 2u);
CHECK_EQ(choice->routes[0].label, std::string("Accept"));
CHECK_EQ(choice->routes[1].label, std::string("Decline"));
CHECK(!choice->routes[0].condition.isNull());
CHECK(choice->routes[1].condition.isNull());
// Each choice arm is its own node chain; both arms rejoin at the next statement.
const SequenceNode* acceptArm = findNode(asset, choice->routes[0].target);
const SequenceNode* declineArm = findNode(asset, choice->routes[1].target);
REQUIRE(acceptArm != nullptr);
REQUIRE(declineArm != nullptr);
CHECK_EQ(acceptArm->payload.find("id")->asString(), std::string("accepted"));
CHECK_EQ(declineArm->payload.find("id")->asString(), std::string("declined"));
CHECK_EQ(acceptArm->next, choice->next);
CHECK_EQ(declineArm->next, choice->next);
const SequenceNode* call = findNode(asset, choice->next);
REQUIRE(call != nullptr);
CHECK_EQ(call->type, std::string("call"));
const eve::Value* target = call->payload.find("target");
REQUIRE(target != nullptr);
CHECK_EQ(target->asString(), std::string("forest.epilogue"));
const SequenceNode* waitNode = findNode(asset, call->next);
REQUIRE(waitNode != nullptr);
CHECK_EQ(waitNode->type, std::string("wait"));
CHECK_EQ(waitNode->payload.find("duration")->asDouble(), 0.5);
const SequenceNode* endNode = findNode(asset, waitNode->next);
REQUIRE(endNode != nullptr);
CHECK_EQ(endNode->type, std::string("end"));
}
TEST_CASE("dnut.language.reportsUnknownStepsAndBadPayloads") {
StepKindRegistry registry = makeRegistry();
DnutCompileOutput output =
compileDnut("story broken {\n teleport actor=hero\n}\n", "broken.dnut", registry);
CHECK(output.hasErrors());
CHECK(output.assets.empty());
REQUIRE(!output.diagnostics.empty());
CHECK(output.diagnostics.front().path == std::string("broken.dnut"));
CHECK(output.diagnostics.front().line == 2);
CHECK(output.diagnostics.front().message.find("teleport") != std::string::npos);
DnutCompileOutput missing =
compileDnut("story broken {\n mark\n}\n", "broken.dnut", registry);
CHECK(missing.hasErrors());
CHECK(missing.diagnostics.front().message.find("requires field 'id'") != std::string::npos);
DnutCompileOutput unknownField =
compileDnut("story broken {\n mark id=ok extra=1\n}\n", "broken.dnut", registry);
CHECK(unknownField.hasErrors());
CHECK(unknownField.diagnostics.front().message.find("does not accept field 'extra'") != std::string::npos);
DnutCompileOutput instantWithoutHandler =
compileDnut("story broken {\n hold id=1\n}\n", "broken.dnut", registry);
CHECK(instantWithoutHandler.hasErrors());
}
TEST_CASE("dnut.language.recoversAfterABadStatement") {
StepKindRegistry registry = makeRegistry();
DnutCompileOutput output =
compileDnut("story mixed {\n teleport\n mark id=ok\n}\n", "mixed.dnut", registry);
CHECK(output.hasErrors());
// Recovery keeps the block parseable, so the following valid statement still
// resolves; the asset itself is withheld because the document has an error.
CHECK(output.assets.empty());
CHECK_EQ(output.diagnostics.size(), 1u);
}
TEST_CASE("dnut.runtime.executesAndBlocks") {
std::vector<std::string> marks;
StepKindRegistry registry = makeRegistry(&marks);
DnutCompileOutput output = compileDnut(
"story run {\n mark id=first\n hold\n mark id=second\n message text=hi\n}\n",
"run.dnut", registry);
REQUIRE(!output.hasErrors());
REQUIRE_EQ(output.assets.size(), 1u);
const SequenceAsset* asset = &output.assets.front();
SequenceRuntime runtime;
runtime.setStepRegistry(&registry);
REQUIRE(runtime.start(asset).ok());
REQUIRE_EQ(marks.size(), 1u);
CHECK_EQ(marks.front(), std::string("first"));
CHECK(runtime.isBlocked());
CHECK_EQ(runtime.currentNode()->type, std::string("hold"));
REQUIRE(runtime.advance().ok());
REQUIRE_EQ(marks.size(), 2u);
CHECK_EQ(marks.back(), std::string("second"));
CHECK(runtime.isBlocked());
CHECK_EQ(runtime.currentNode()->type, std::string("message"));
REQUIRE(runtime.advance().ok());
CHECK(!runtime.isActive());
}
TEST_CASE("dnut.runtime.choiceConditionsAndRejection") {
StepKindRegistry registry = makeRegistry();
DnutCompileOutput output = compileDnut(
"story pick {\n choice {\n option \"Yes\" when switch.ok == true {\n mark id=yes\n }\n"
" option \"No\" {\n mark id=no\n }\n }\n}\n",
"pick.dnut", registry);
REQUIRE(!output.hasErrors());
const SequenceAsset* asset = &output.assets.front();
SequenceRuntime runtime;
runtime.setStepRegistry(&registry);
runtime.setConditionEvaluator([](const eve::Value&) { return SequenceConditionOutcome{false, "value-mismatch"}; });
REQUIRE(runtime.start(asset).ok());
CHECK(runtime.isBlocked());
// A rejected conditional route keeps the cursor on the choice.
auto rejected = runtime.select("Yes");
CHECK(!rejected.ok());
CHECK(runtime.isBlocked());
CHECK_EQ(runtime.currentNode()->type, std::string("choice"));
const auto* rejectedDiagnostic = rejected.error();
REQUIRE(rejectedDiagnostic != nullptr);
CHECK(rejectedDiagnostic->message().find("choice condition rejected") != std::string::npos);
// An unknown label is a distinct, stable failure.
auto unknown = runtime.select("Nothing");
CHECK(!unknown.ok());
const auto* unknownDiagnostic = unknown.error();
REQUIRE(unknownDiagnostic != nullptr);
CHECK(unknownDiagnostic->message().find("unknown choice route") != std::string::npos);
// The unconditional route always works.
REQUIRE(runtime.select("No").ok());
CHECK(!runtime.isActive());
}
TEST_CASE("dnut.runtime.advanceRejectsChoice") {
StepKindRegistry registry = makeRegistry();
DnutCompileOutput output =
compileDnut("story pick {\n choice {\n option \"Only\" {\n mark id=x\n }\n }\n}\n",
"pick.dnut", registry);
REQUIRE(!output.hasErrors());
SequenceRuntime runtime;
runtime.setStepRegistry(&registry);
REQUIRE(runtime.start(&output.assets.front()).ok());
CHECK(!runtime.advance().ok());
CHECK(runtime.isBlocked());
}
TEST_CASE("dnut.runtime.callPushesAndPopsFrames") {
std::vector<std::string> marks;
StepKindRegistry registry = makeRegistry(&marks);
DnutCompileOutput output = compileDnut(
"story outer {\n mark id=before\n call inner\n mark id=after\n}\n"
"story inner {\n mark id=inside\n}\n",
"call.dnut", registry);
REQUIRE(!output.hasErrors());
REQUIRE_EQ(output.assets.size(), 2u);
std::map<std::string, const SequenceAsset*> byId;
for (const auto& asset : output.assets) byId.emplace(asset.id, &asset);
SequenceRuntime runtime;
runtime.setStepRegistry(&registry);
runtime.setAssetResolver([&byId](const std::string& id) -> const SequenceAsset* {
const auto found = byId.find(id);
return found == byId.end() ? nullptr : found->second;
});
REQUIRE(runtime.start(byId.at("outer")).ok());
REQUIRE_EQ(marks.size(), 3u);
CHECK_EQ(marks[0], std::string("before"));
CHECK_EQ(marks[1], std::string("inside"));
CHECK_EQ(marks[2], std::string("after"));
CHECK(!runtime.isActive());
}
TEST_CASE("dnut.runtime.enforcesExecutionBudget") {
StepKindRegistry registry = makeRegistry();
// A hand-built self-referential branch is the only way to express a cycle;
// the compiler cannot produce one, but authored content can be hand-edited.
SequenceAsset asset;
asset.id = "loop";
asset.entry = "n1";
SequenceNode branch;
branch.id = "n1";
branch.type = "branch";
branch.routes.push_back({"", eve::Value(eve::Value::Object{{"var", eve::Value::string("x")},
{"op", eve::Value::string("eq")},
{"value", eve::Value::integer(0)}}),
"n1"});
asset.nodes.push_back(std::move(branch));
SequenceRuntime runtime;
runtime.setStepRegistry(&registry);
runtime.setConditionEvaluator([](const eve::Value&) { return SequenceConditionOutcome{true, {}}; });
auto started = runtime.start(&asset);
CHECK(!started.ok());
const auto* diagnostic = started.error();
REQUIRE(diagnostic != nullptr);
CHECK(diagnostic->message().find("budget") != std::string::npos);
}
TEST_CASE("dnut.runtime.capturesAndRestoresCursor") {
std::vector<std::string> marks;
StepKindRegistry registry = makeRegistry(&marks);
DnutCompileOutput output = compileDnut(
"story save {\n mark id=first\n message text=hi\n mark id=second\n}\n", "save.dnut", registry);
REQUIRE(!output.hasErrors());
REQUIRE_EQ(output.assets.size(), 1u);
const SequenceAsset* asset = &output.assets.front();
SequenceRuntime runtime;
runtime.setStepRegistry(&registry);
REQUIRE(runtime.start(asset).ok());
CHECK_EQ(runtime.currentNode()->type, std::string("message"));
eve::Value captured;
REQUIRE(runtime.captureState(captured).ok());
SequenceRuntime restored;
restored.setStepRegistry(&registry);
restored.setAssetResolver([asset](const std::string& id) -> const SequenceAsset* {
return id == asset->id ? asset : nullptr;
});
REQUIRE(restored.restoreState(captured).ok());
CHECK(restored.isActive());
CHECK(restored.isBlocked());
CHECK_EQ(restored.currentNode()->type, std::string("message"));
REQUIRE(restored.advance().ok());
CHECK_EQ(marks.size(), 2u);
CHECK_EQ(marks.back(), std::string("second"));
}
TEST_CASE("dnut.runtime.rejectsVersionMismatchOnRestore") {
StepKindRegistry registry = makeRegistry();
DnutCompileOutput output =
compileDnut("story versioned {\n message text=hi\n}\n", "v.dnut", registry);
REQUIRE(!output.hasErrors());
SequenceAsset changed = output.assets.front();
changed.version = 7;
SequenceRuntime runtime;
runtime.setStepRegistry(&registry);
REQUIRE(runtime.start(&output.assets.front()).ok());
eve::Value captured;
REQUIRE(runtime.captureState(captured).ok());
SequenceRuntime restored;
restored.setStepRegistry(&registry);
restored.setAssetResolver([&changed](const std::string& id) -> const SequenceAsset* {
return id == changed.id ? &changed : nullptr;
});
CHECK(!restored.restoreState(captured).ok());
CHECK(!restored.isActive());
}