Files
EVEngine/test/dialogue_proc.cpp
T
西风逍遥游 703b5e5353 feat(dialogue): procedural dialogue generation v1
Add data-driven procedural dialogue to eve.Dialogue:

- Variables: setVar/getVar*/hasVar/clearVar/clearVars with global and
  scene scopes; the scene scope auto-clears when the selected Scene host
  changes (polled in update()).
- Conditions: structured tables ({var,op,value} with eq/ne/gt/ge/lt/le/
  has/missing, all/any/not, script predicates) + evalCondition().
- Line pools: loadPoolsFromTable() with weight/when/meta/tags, seeded
  weighted selection (setRandomSeed), per-pool no-repeat history, and
  playLine()/playPool() that resolve {var} interpolation or i18n keys and
  auto-apply meta.expression/motion to bound avatars.
- .dnut authoring format compiled by a new C++ parser (DnutParser):
  loadPoolsFromDnut(source, path) / loadPoolsFromDnutFile(path); parse
  errors surface path:line via getLastPoolsError(). The previous Squirrel
  compiler script and its engine wiring are removed.
- Example (examples/dialogue) uses pools.dnut with mood/hour variables,
  scene-scope auto-clear demo, and hot reload via eve_asset_reload.
- Tests: C++ unit tests (dialogue_proc.cpp) covering variables,
  conditions, pools, selection/dedupe, meta, i18n, scene auto-clear and
  the .dnut parser; Squirrel integration test for the script-facing API.
- Docs: design doc updated with the new content model / API / format.
2026-08-18 21:08:19 +08:00

460 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "avatar/Avatar.h"
#include "dialogue/Dialogue.h"
#include "i18n/I18n.h"
#include "scene/NodeDesc.h"
#include "scene/Scene.h"
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
using namespace eve::dialogue;
using eve::avatar::Avatar;
using eve::avatar::AvatarInstance;
using eve::i18n::I18n;
namespace {
using Vars = std::unordered_map<std::string, Dialogue::VarValue>;
DataValue O(std::vector<std::pair<std::string, DataValue>> v) {
return DataValue::object(std::move(v));
}
DataValue A(std::vector<DataValue> v) { return DataValue::array(std::move(v)); }
DataValue S(const std::string &v) { return DataValue::string(v); }
DataValue I(long long v) { return DataValue::integer(v); }
DataValue F(double v) { return DataValue::number(v); }
DataValue B(bool v) { return DataValue::boolean(v); }
DataValue poolsRoot(DataValue pools) {
return O({{"pools", std::move(pools)}});
}
void resetDialogue(Dialogue *dlg) {
dlg->reset();
dlg->clearPools();
dlg->clearVars("all");
}
} // namespace
TEST_CASE("dialogueProc.varsScopes") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
CHECK(dlg->setVarValue("hp", Dialogue::VarValue::integer(100), "global"));
CHECK(dlg->setVarValue("mood", Dialogue::VarValue::string("happy"), "scene"));
CHECK(dlg->setVarValue("ratio", Dialogue::VarValue::number(1.5), "global"));
CHECK(dlg->setVarValue("flag", Dialogue::VarValue::boolean(true), "scene"));
CHECK(dlg->hasVar("hp", "global"));
CHECK(!dlg->hasVar("hp", "scene"));
CHECK_EQ(dlg->getVarType("mood", "scene"), std::string("string"));
CHECK_EQ(dlg->getVarType("hp", "global"), std::string("int"));
CHECK_EQ(dlg->getVarType("ratio", "global"), std::string("float"));
CHECK_EQ(dlg->getVarType("flag", "scene"), std::string("bool"));
CHECK_EQ(dlg->getVarType("missing", "global"), std::string(""));
CHECK_EQ(dlg->getVarInt("hp", -1, "global"), 100);
CHECK_EQ(dlg->getVarInt("hp", -1, "scene"), -1);
CHECK_EQ(dlg->getVarFloat("ratio", 0.f, "global"), 1.5f);
CHECK(dlg->getVarBool("flag", false, "scene"));
CHECK_EQ(dlg->getVarString("mood", "", "scene"), std::string("happy"));
CHECK_EQ(dlg->getVarString("mood", "", "global"), std::string(""));
dlg->clearVars("scene");
CHECK(!dlg->hasVar("mood", "scene"));
CHECK(dlg->hasVar("hp", "global"));
dlg->clearVars("global");
CHECK(!dlg->hasVar("hp", "global"));
resetDialogue(dlg);
}
TEST_CASE("dialogueProc.conditions") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
dlg->setVarValue("mood", Dialogue::VarValue::string("happy"), "global");
dlg->setVarValue("hour", Dialogue::VarValue::integer(20), "global");
dlg->setVarValue("flag", Dialogue::VarValue::boolean(true), "global");
CHECK(dlg->evalConditionData(O({
{"var", S("mood")}, {"op", S("eq")}, {"value", S("happy")},
})));
CHECK(!dlg->evalConditionData(O({
{"var", S("mood")}, {"op", S("eq")}, {"value", S("sad")},
})));
CHECK(dlg->evalConditionData(O({
{"var", S("hour")}, {"op", S("ge")}, {"value", I(18)},
})));
CHECK(!dlg->evalConditionData(O({
{"var", S("hour")}, {"op", S("lt")}, {"value", I(18)},
})));
CHECK(dlg->evalConditionData(O({{"var", S("flag")}, {"op", S("eq")}, {"value", B(true)}})));
CHECK(dlg->evalConditionData(O({{"var", S("flag")}, {"op", S("has")}})));
CHECK(dlg->evalConditionData(O({{"var", S("nope")}, {"op", S("missing")}})));
CHECK(!dlg->evalConditionData(O({{"var", S("nope")}, {"op", S("eq")}, {"value", S("x")}})));
CHECK(dlg->evalConditionData(O({
{"all", A({
O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("happy")}}),
O({{"var", S("hour")}, {"op", S("ge")}, {"value", I(18)}}),
})},
})));
CHECK(dlg->evalConditionData(O({
{"any", A({
O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("angry")}}),
O({{"var", S("hour")}, {"op", S("ge")}, {"value", I(18)}}),
})},
})));
CHECK(dlg->evalConditionData(O({
{"not", O({{"var", S("flag")}, {"op", S("eq")}, {"value", B(false)}})},
})));
// Script predicates need a VM; without one they evaluate false.
CHECK(!dlg->evalConditionData(O({{"script", S("missingPred")}})));
resetDialogue(dlg);
}
TEST_CASE("dialogueProc.poolLoading") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
const DataValue root = poolsRoot(O({
{"alice.greet", O({
{"noRepeat", I(4)},
{"lines", A({
O({{"id", S("a1")}, {"speaker", S("alice")}, {"text", S("你好")}}),
O({{"speaker", S("alice")}, {"i18n", S("line.hi")}, {"weight", I(2)},
{"when", O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("happy")}})},
{"meta", O({{"expression", S("happy")}, {"voice", S("vo/a1.ogg")}})},
{"tags", A({S("greet"), S("evening")})}}),
})},
})},
}));
CHECK_EQ(dlg->loadPoolsFromData(root), 1);
CHECK(dlg->hasPool("alice.greet"));
CHECK_EQ(dlg->getPoolCount(), 1);
CHECK_EQ(dlg->getPoolId(0), std::string("alice.greet"));
// Missing id -> auto-generated "poolId.序号".
CHECK(dlg->playLineWithParams("alice.greet.2", {}));
CHECK(dlg->getLastPoolsError().empty());
// Same pool id replaces the previous definition (no duplication).
CHECK_EQ(dlg->loadPoolsFromData(root), 1);
CHECK_EQ(dlg->getPoolCount(), 1);
// Invalid root / missing pools object.
CHECK_EQ(dlg->loadPoolsFromData(S("oops")), 0);
CHECK(!dlg->getLastPoolsError().empty());
resetDialogue(dlg);
}
TEST_CASE("dialogueProc.pickLine") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
const DataValue root = poolsRoot(O({
{"pool.abc", O({
{"lines", A({
O({{"id", S("p1")}, {"speaker", S("a")}, {"text", S("one")}, {"weight", I(1)}}),
O({{"id", S("p2")}, {"speaker", S("a")}, {"text", S("two")}, {"weight", I(1)}}),
})},
})},
{"pool.filt", O({
{"lines", A({
O({{"id", S("f1")}, {"speaker", S("a")}, {"text", S("happy line")},
{"when", O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("happy")}})}}),
O({{"id", S("f2")}, {"speaker", S("a")}, {"text", S("sad line")},
{"when", O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("sad")}})}}),
})},
})},
{"pool.zero", O({
{"lines", A({
O({{"id", S("z0")}, {"speaker", S("a")}, {"text", S("zero")}, {"weight", I(0)}}),
O({{"id", S("z1")}, {"speaker", S("a")}, {"text", S("one")}, {"weight", I(1)}}),
})},
})},
}));
CHECK_EQ(dlg->loadPoolsFromData(root), 3);
// Determinism: same seed reproduces the same pick sequence.
dlg->setRandomSeed(42);
const std::string first = dlg->pickLineWithParams("pool.abc", {});
CHECK(!first.empty());
dlg->setRandomSeed(42);
const std::string second = dlg->pickLineWithParams("pool.abc", {});
CHECK_EQ(second, first);
// Both lines appear across many picks (50/50 weights).
bool sawP1 = false, sawP2 = false;
dlg->setRandomSeed(7);
for (int i = 0; i < 200; ++i) {
const std::string id = dlg->pickLineWithParams("pool.abc", {});
if (id == "p1") sawP1 = true;
if (id == "p2") sawP2 = true;
}
CHECK(sawP1);
CHECK(sawP2);
// Unknown pool -> "".
CHECK_EQ(dlg->pickLineWithParams("nope", {}), std::string(""));
// when filtering by variables.
dlg->setVarValue("mood", Dialogue::VarValue::string("happy"), "global");
for (int i = 0; i < 10; ++i) CHECK_EQ(dlg->pickLineWithParams("pool.filt", {}), std::string("f1"));
dlg->setVarValue("mood", Dialogue::VarValue::string("sad"), "global");
for (int i = 0; i < 10; ++i) CHECK_EQ(dlg->pickLineWithParams("pool.filt", {}), std::string("f2"));
dlg->clearVars("global");
CHECK_EQ(dlg->pickLineWithParams("pool.filt", {}), std::string(""));
// weight <= 0 lines never win.
dlg->setRandomSeed(3);
for (int i = 0; i < 50; ++i)
CHECK_EQ(dlg->pickLineWithParams("pool.zero", {}), std::string("z1"));
resetDialogue(dlg);
}
TEST_CASE("dialogueProc.noRepeat") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
// noRepeat=1 with two lines: consecutive picks must differ.
const DataValue root = poolsRoot(O({
{"pool.alt", O({
{"noRepeat", I(1)},
{"lines", A({
O({{"id", S("n1")}, {"speaker", S("a")}, {"text", S("one")}}),
O({{"id", S("n2")}, {"speaker", S("a")}, {"text", S("two")}}),
})},
})},
// noRepeat=2 with two lines: third pick falls back to the full pool.
{"pool.fb", O({
{"noRepeat", I(2)},
{"lines", A({
O({{"id", S("m1")}, {"speaker", S("a")}, {"text", S("one")}}),
O({{"id", S("m2")}, {"speaker", S("a")}, {"text", S("two")}}),
})},
})},
}));
CHECK_EQ(dlg->loadPoolsFromData(root), 2);
dlg->setRandomSeed(11);
std::string prev = dlg->pickLineWithParams("pool.alt", {});
for (int i = 1; i < 40; ++i) {
const std::string id = dlg->pickLineWithParams("pool.alt", {});
CHECK(id != prev);
CHECK((id == "n1" || id == "n2"));
prev = id;
}
dlg->setRandomSeed(22);
for (int i = 0; i < 5; ++i) {
const std::string id = dlg->pickLineWithParams("pool.fb", {});
CHECK((id == "m1" || id == "m2"));
}
resetDialogue(dlg);
}
TEST_CASE("dialogueProc.playLineAndMeta") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
Avatar *avmod = Avatar::create();
CHECK(dlg->registerCharacter("alice", "Alice"));
AvatarInstance *alice = avmod->newImageAvatar();
alice->addLayer("face", nullptr, 0);
alice->defineExpression("happy", "face=1");
CHECK(dlg->bindAvatar("alice", alice));
const DataValue root = poolsRoot(O({
{"pool.play", O({
{"lines", A({
O({{"id", S("pl1")}, {"speaker", S("alice")}, {"text", S("你好,{name}!")},
{"meta", O({{"expression", S("happy")}, {"motion", S("wave")},
{"voice", S("vo/alice.ogg")}})},
{"tags", A({S("greet"), S("night")})}}),
O({{"id", S("pl2")}, {"speaker", S("")}, {"text", S("旁白")}}),
O({{"id", S("pl3")}, {"speaker", S("alice")}, {"i18n", S("greet.key")}}),
})},
})},
}));
CHECK_EQ(dlg->loadPoolsFromData(root), 1);
// Literal text: params override global vars in {var} substitution.
dlg->setVarValue("name", Dialogue::VarValue::string("Alice"), "global");
Vars params;
params["name"] = Dialogue::VarValue::string("Bob");
CHECK(dlg->playLineWithParams("pl1", params));
CHECK_EQ(dlg->getCurrentLineId(), std::string("pl1"));
CHECK_EQ(dlg->getFullText(), std::string("你好,Bob!"));
CHECK_EQ(dlg->getCurrentLineMeta("voice"), std::string("vo/alice.ogg"));
CHECK_EQ(dlg->getCurrentLineMeta("expression"), std::string("happy"));
CHECK_EQ(dlg->getCurrentLineMeta("missing"), std::string(""));
CHECK_EQ(dlg->getCurrentLineTags().size(), size_t(2));
CHECK_EQ(dlg->getCurrentLineTags()[0], std::string("greet"));
// Expression was auto-applied to the bound avatar.
CHECK_EQ(alice->getExpression(), std::string("happy"));
CHECK_EQ(alice->getMotion(), std::string("wave"));
// Narration line (empty speaker).
CHECK(dlg->playLineWithParams("pl2", {}));
CHECK_EQ(dlg->getSpeakerId(), std::string(""));
CHECK_EQ(dlg->getFullText(), std::string("旁白"));
// i18n key resolution through a real I18n instance.
I18n *i18n = I18n::create();
CHECK(i18n->loadFromJson("en", R"({"greet": { "key": "Hi, {name}!" } })"));
CHECK(i18n->setLanguage("en"));
dlg->setVarValue("name", Dialogue::VarValue::string("Carol"), "global");
CHECK(dlg->playLineWithParams("pl3", {}));
CHECK_EQ(dlg->getFullText(), std::string("Hi, Carol!"));
resetDialogue(dlg);
dlg->bindAvatar("alice", nullptr); // 解除绑定,避免后续 reset 触碰已释放的 avatar
alice->release();
delete alice;
}
TEST_CASE("dialogueProc.playPool") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
const DataValue root = poolsRoot(O({
{"pool.greet", O({
{"lines", A({
O({{"id", S("g1")}, {"speaker", S("npc")}, {"text", S("Hi {name}")}}),
})},
})},
}));
CHECK_EQ(dlg->loadPoolsFromData(root), 1);
Vars params;
params["name"] = Dialogue::VarValue::string("Alex");
CHECK(dlg->playPoolWithParams("pool.greet", params));
CHECK_EQ(dlg->getCurrentLineId(), std::string("g1"));
CHECK_EQ(dlg->getFullText(), std::string("Hi Alex"));
CHECK(!dlg->playPoolWithParams("pool.gone", params));
resetDialogue(dlg);
}
TEST_CASE("dialogueProc.dnutParser") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
const std::string src =
"pool alice.greet noRepeat=4 {\n"
" when mood == \"happy\" && hour >= 18 {\n"
" alice: \"晚上好,{name}!\" weight=2 meta(expression=\"happy\", motion=\"wave\") tags=[\"greet\",\"evening\"]\n"
" alice: \"你来啦。\" weight=1\n"
" }\n"
" when mood == \"shy\" {\n"
" alice: \"那个……你好。\" meta(expression=\"shy\")\n"
" }\n"
" - \"远处传来钟声。\"\n"
"}\n";
CHECK_EQ(dlg->loadPoolsFromDnut(src, "pools.dnut"), 1);
CHECK(dlg->hasPool("alice.greet"));
CHECK(dlg->getLastPoolsError().empty());
dlg->setVarValue("mood", Dialogue::VarValue::string("happy"), "global");
dlg->setVarValue("hour", Dialogue::VarValue::integer(20), "global");
// Line 1: compiled when = all(mood eq happy, hour ge 18); meta + tags.
CHECK(dlg->playLineWithParams("alice.greet.1", {}));
CHECK_EQ(dlg->getCurrentLineMeta("expression"), std::string("happy"));
CHECK_EQ(dlg->getCurrentLineMeta("motion"), std::string("wave"));
CHECK_EQ(dlg->getCurrentLineTags().size(), size_t(2));
CHECK_EQ(dlg->getCurrentLineTags()[0], std::string("greet"));
// When filtering: hour < 18 -> only the narration line matches.
dlg->setVarValue("hour", Dialogue::VarValue::integer(8), "global");
for (int i = 0; i < 10; ++i) {
const std::string id = dlg->pickLineWithParams("alice.greet", {});
CHECK_EQ(id, std::string("alice.greet.4"));
}
// Narration line: empty speaker.
CHECK(dlg->playLineWithParams("alice.greet.4", {}));
CHECK_EQ(dlg->getSpeakerId(), std::string(""));
CHECK_EQ(dlg->getFullText(), std::string("远处传来钟声。"));
// shy branch: line 3 命中;旁白行 4 无条件也合格,但 noRepeat 历史会先排除它。
dlg->setVarValue("mood", Dialogue::VarValue::string("shy"), "global");
dlg->setVarValue("hour", Dialogue::VarValue::integer(20), "global");
const std::string firstShy = dlg->pickLineWithParams("alice.greet", {});
CHECK_EQ(firstShy, std::string("alice.greet.3"));
for (int i = 0; i < 9; ++i) {
const std::string id = dlg->pickLineWithParams("alice.greet", {});
CHECK((id == "alice.greet.3" || id == "alice.greet.4"));
}
resetDialogue(dlg);
}
TEST_CASE("dialogueProc.dnutAttrsAndErrors") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
const std::string src =
"pool x {\n"
" alice: \"hi\" weight=3 i18n=\"line.hi\" id=\"custom\"\n"
"}\n";
CHECK_EQ(dlg->loadPoolsFromDnut(src, "a.dnut"), 1);
CHECK(dlg->playLineWithParams("custom", {}));
CHECK_EQ(dlg->getCurrentLineId(), std::string("custom"));
// weight=3 参与随机(相对池内其它行权重更高);这里单行池直接命中。
CHECK_EQ(dlg->pickLineWithParams("x", {}), std::string("custom"));
// 语法错误:行号与路径出现在 getLastPoolsError。
CHECK_EQ(dlg->loadPoolsFromDnut("pool x {\n alice: hi\n}\n", "bad.dnut"), 0);
CHECK(dlg->getLastPoolsError().find("bad.dnut:2") != std::string::npos);
// 未知属性。
CHECK_EQ(dlg->loadPoolsFromDnut("pool x { alice: \"hi\" bogus=1 }\n", "bad2.dnut"), 0);
CHECK(!dlg->getLastPoolsError().empty());
// 未闭合的 pool。
CHECK_EQ(dlg->loadPoolsFromDnut("pool x {\n alice: \"hi\"\n", "bad3.dnut"), 0);
CHECK(dlg->getLastPoolsError().find("bad3.dnut") != std::string::npos);
resetDialogue(dlg);
}
TEST_CASE("dialogueProc.sceneVarsAutoClear") {
Dialogue *dlg = Dialogue::create();
resetDialogue(dlg);
dlg->setVarValue("keep", Dialogue::VarValue::integer(1), "global");
eve::scene::Scene *scn = eve::scene::Scene::create();
scn->mountAs("dialogueSceneA", eve::scene::node("root"));
scn->mountAs("dialogueSceneB", eve::scene::node("root"));
scn->select("dialogueSceneA");
dlg->update(0.016f); // record current scene name
dlg->setVarValue("tmp", Dialogue::VarValue::integer(2), "scene");
CHECK(dlg->hasVar("tmp", "scene"));
scn->select("dialogueSceneB");
dlg->update(0.016f); // scene changed -> scene vars cleared
CHECK(!dlg->hasVar("tmp", "scene"));
CHECK(dlg->hasVar("keep", "global"));
resetDialogue(dlg);
}