Phase 6.7 complete checkpoint - checked

This commit is contained in:
2026-10-28 13:38:24 +03:00
parent 682ccee856
commit c643d4388f
5 changed files with 490 additions and 52 deletions
+32 -13
View File
@@ -342,9 +342,12 @@ cd demos/demo-taxi
# ...or headless animation-travel probe (phase 6.7): on the default
# interior scene, a synthetic registry tree probes the animation state
# machine travel — startState boot marker, travelTo hop-through (no
# insta-switch, each hop completes the current clip), the setState
# cancel, the unreachable-target degrade to the plain switch and the
# nested endState hold (see "Animation state machine travel" below):
# insta-switch, each atEnd hop completes the current clip), the
# setState cancel and mid-flight replacement, the unreachable-target
# degrade to the plain switch, the nested endState hold, per-link
# immediate/atEnd wait modes, the forced first hop, unknown
# machine/target no-ops, the disabled-tree no-op and the deleted-link
# cancel (see "Animation state machine travel" below):
./demoTaxi --headless --test-anim-travel
# ...or probe natural (non-forced) high-detail spawns from a given player
# position; prints how many population NPCs are spawned plus per-NPC
@@ -2120,8 +2123,11 @@ each hop's clip before cross-fading to the next:
- **Robustness (rule 7):** deleted states/links, a lost machine
entry or a vanished target cancel the travel and warn ONCE per
(entity, reason, state) — the warning key is
`"smName|reason|stateName"`, never per frame. A disabled tree
degrades quietly (the disabled branch drops pendings anyway).
`"smName|reason|stateName"`, never per frame. A connect link
deleted mid-travel (registry edit + `markModified`) cancels the
pending hop instead of firing along the dead link
(`travelLinkLost`). A disabled tree degrades quietly (the
disabled branch drops pendings anyway).
- **Nested machines (rule 6):** `AnimationTreeNode::startState` /
`endState` mark the entry/exit states of a machine used as a state
of an upper machine (both optional, empty = legacy behavior).
@@ -2158,14 +2164,27 @@ unknown state names and duplicate links ARE errors).
UI: the node editor's Travel section (ui/AnimationTreeNodeEditor.cpp)
edits links (with the per-link wait mode), the start/end dropdowns per
machine; the registry editor validates on save. Coverage:
`tests/animation_travel_test.cpp` (CTest `animationTravelTest` — BFS
ties/cycles/unreachable, JSON round trip with backward compatibility,
link waitMode serialization, validation, degrade/cancel) and the
demo probe `demoTaxi --headless --test-anim-travel` (synthetic
registry tree `test_travel_nested` registered at runtime and removed
on PASS; asserts startState boot, hop order with no insta-switch,
cancel, degrade, the nested hold, immediate-vs-atEnd link timing and
the forced first hop).
`tests/animation_travel_test.cpp` (CTest `animationTravelTest`, 11
cases — BFS shortest/ties/cycles/self-links/empty endpoints, graph
validation incl. duplicates across differing wait modes, JSON round
trip + malformed-link tolerance + backward compatibility, link
waitMode serialization) and the demo probe
`demoTaxi --headless --test-anim-travel` (synthetic registry tree
`test_travel_nested` registered at runtime and removed on PASS; 11
phases: startState boot, hop order with no insta-switch, setState
cancel, unreachable degrade, the nested hold, immediate-vs-atEnd link
timing, forced first hop, mid-flight replacement, unknown
machine/target + self-travel no-ops, disabled-tree no-op, deleted-link
cancel).
Registry auto-save caveat for runtime probes:
`AnimationTreeRegistry::registerTree` / `removeTree` / `markModified`
all call `autoSave()`, which rewrites `animation_tree.json` in the
current working directory (indent 4, unordered-map tree order). A
probe that registers a synthetic tree therefore mutates the staged
file while it runs — always `removeTree` on PASS, and re-stage the
file from the source tree after test runs (a killed run otherwise
leaves the synthetic tree behind).
### Character facing convention (frontAxis)
+39 -5
View File
@@ -4209,16 +4209,18 @@ DONE.**
### Phase 6.7 — Animation state machine travel
**Status: DONE (2026-10-28) — 6.7.1 through 6.7.6 complete: connect
**Status: DONE (2026-10-28) — 6.7.1 through 6.7.7 complete: connect
links + start/end markers (data model, JSON round trip, registry/node
editors with save validation), the `travelTo` runtime (BFS over
connect links, per-hop clip completion, rule-2 degrade, rule-4 cancel),
nested rule-6 start/end gating with the hold-abort re-entry, the
controller migration (locomotion, vehicle enter/exit, taxi
board/alight, smart objects, path following), the stock-tree links and
the per-link wait modes with the `travelTo` first-hop escape hatch,
covered by `animation_travel_test` (8 cases), the demoTaxi
`--test-anim-travel` probe and the full demo battery.**
board/alight, smart objects, path following), the stock-tree links,
the per-link wait modes with the `travelTo` first-hop escape hatch and
the review-hardening pass (rule-7 deleted-link cancel plus the
expanded test matrix), covered by `animation_travel_test` (11 cases),
the demoTaxi `--test-anim-travel` probe (11 phases) and the full demo
battery.**
#### The idea (plain words)
@@ -4485,6 +4487,35 @@ feet sliding.
frame 4 / runs at frame 6 (was 608 / 683); quick battery
(switch / vehicle-drive / taxi / parking) PASS exit 0.
**Task 6.7.7 — Review hardening + full coverage audit. DONE
(2026-10-28).**
- Re-read the whole travel implementation against rules 1-7 and the
wait-mode contract, close any gap found, and make every rule
observable in a test.
- Done when: every contract rule has at least one discriminating
unit-test or probe assertion, the deleted-link anomaly cancels
instead of hopping, and docs match the code.
- **Implemented:** rule-7 gap fix — `processTravelCheck` now resolves
the hop's link up front and CANCELS with a `travelLinkLost`
warning when the link vanished mid-travel (previously a missing
link silently fell back to atEnd and the hop fired along the dead
link). Unit tests 8 -> 11: BFS cycles/self-links/empty endpoints,
duplicate detection across differing wait modes, malformed-link
JSON tolerance (missing endpoints, non-object entries, non-array
connectLinks, old files). Runtime probe 7 -> 11 phases, adding
rule-4 mid-flight replacement (new target wins, the machine never
continues to the old one), unknown machine/target + self-travel
no-ops (rule 7 warn-once paths), disabled-tree quiet degrade +
re-enable resume, and the deleted-link cancel (the machine must
stay put for >2 running loops). Docs: this section, the
`--test-anim-travel` flag help + listener header (tree layout and
all 11 phases) and editScene/AGENTS.md (rule-7 link loss, the
coverage matrix and the registry auto-save caveat for runtime
probes). Verified 2026-10-28: `animation_travel_test` 11/11;
`--test-anim-travel` PASS (16 assertions across 11 phases, ~9 s);
battery switch/vehicle-drive/taxi/parking PASS exit 0; ctest 6/6.
#### Delivery order
1. 6.7.1 data model (runtime can already read links).
@@ -4495,6 +4526,8 @@ feet sliding.
5. 6.7.5 final coverage + docs.
6. 6.7.6 per-link wait modes + the first-hop escape hatch (stock
trees immediate).
7. 6.7.7 review hardening: rule-7 link-loss cancel + the expanded
test matrix.
#### Explicit non-goals (for later phases)
@@ -4555,6 +4588,7 @@ taxi-specific):
- Headless: multi-floor grid with a stairwell — character walks from
ground floor to the top floor (physics), navmesh query finds a
ground->top path; grids without stair flags are unchanged.
- from DSL and scene components point of view, the easiest way to create stairwell would be to create a room with type of stairwell. However sometimes for lobby rooms we want a large stair which leads to next floor, like in some hotels, theatres, administrative buildings... Needs research.
### Phase 8 — Enterable buildings (on-the-fly interiors)
@@ -7472,14 +7472,17 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
* +- state "running" -> running
* +- state "stand" -> nested stateMachine "inner"
* (startState "idle-soft", endState "walk-slow";
* children run-fast, walk-slow, idle-soft;
* links idle-soft->walk-slow, walk-slow->run-fast)
* children run-fast, walk-slow, idle-soft plus the
* wait-mode states wm-a/wm-b/wm-c;
* links idle-soft->walk-slow, walk-slow->run-fast,
* idle-soft->wm-a IMMEDIATE, wm-a->wm-b, wm-b->wm-c,
* wm-c->wm-a - all atEnd unless noted)
*
* crouch and stand carry no connect links, so they are reachable
* only through the rule-2 degrade or a plain setState.
*
* Phases (assert ORDER, never exact frames; each hop waits for the
* current clip's loop boundary, so timings follow the clip lengths):
* Phases (assert ORDER, never exact frames; each atEnd hop waits for
* the current clip's loop boundary, so timings follow clip lengths):
* 0. boot: locomotion enters the startState "idle", not the first
* child "crouch".
* 1. travelTo("running") from idle: still "idle" immediately (the
@@ -7496,12 +7499,28 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
* endState (still "idle-soft" a few frames after the switch,
* then "walk-slow"); re-activating "stand" re-enters
* "idle-soft".
* 5. wait modes: the immediate idle-soft->wm-a hop lands within a
* few frames; the atEnd wm-a->wm-b hop then waits out the idle
* loop (hundreds of frames, never early).
* 6. forced first hop: travelTo(..., immediateFirstHop=true) lands
* wm-c at once over an atEnd link, and the following hop still
* waits for its clip (the flag is consumed).
* 7. replacement: travelTo("wm-c") re-issued as travelTo("wm-b")
* mid-wait settles at "wm-b" and never continues to "wm-c".
* 8. robust targets: unknown machine/state and self-travel leave
* the machine untouched.
* 9. disabled tree: travelTo is a quiet no-op; re-enabling resumes
* and the next request travels normally.
* 10. link loss: deleting the pending hop's link from the registry
* mid-travel cancels it - the machine must NOT hop along the
* deleted link.
*/
struct AnimTravelTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
/* 0 spawn+boot, 1 travel, 2 cancel, 3 degrade, 4 nested,
* 5 wait modes, 6 forced first hop, 7 done */
* 5 wait modes, 6 forced first hop, 7 replacement, 8 robust
* targets, 9 disabled tree, 10 link loss, 11 done */
int phase = 0;
/* Substep within the current phase. */
int step = 0;
@@ -7683,7 +7702,7 @@ struct AnimTravelTestListener : public Ogre::FrameListener {
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (failed || phase == 7) {
if (failed || phase == 11) {
app->getRoot()->queueEndRendering();
return true;
}
@@ -7714,6 +7733,18 @@ struct AnimTravelTestListener : public Ogre::FrameListener {
case 6:
phaseForcedFirst();
break;
case 7:
phaseReplace();
break;
case 8:
phaseRobustTargets();
break;
case 9:
phaseDisabled();
break;
case 10:
phaseLinkLoss();
break;
}
return true;
}
@@ -8075,16 +8106,7 @@ struct AnimTravelTestListener : public Ogre::FrameListener {
std::cout << "[test] post-force hop waited "
<< waited << " frames -> wm-a"
<< std::endl;
AnimationTreeRegistry::getSingleton()
.removeTree("test_travel_nested");
std::cout << "[test] PASS: animation travel "
"(startState boot, hop-through, "
"cancel, degrade, nested "
"endState hold, wait modes, "
"forced first hop)"
<< std::endl;
enterStep(7, 0);
app->getRoot()->queueEndRendering();
return;
}
if (s != "wm-c") {
@@ -8097,6 +8119,258 @@ struct AnimTravelTestListener : public Ogre::FrameListener {
return;
}
}
/* Rule 4 replacement: re-issuing travelTo with a DIFFERENT
* target while a travel waits must replace the pending path,
* not queue behind it. The machine must settle at the NEW
* target and never continue to the old one. */
void phaseReplace()
{
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
Ogre::String s = stateOf("inner");
if (step == 0) {
if (s != "wm-a") {
fail("replacement expected 'wm-a', got '" +
s + "'");
return;
}
/* Long atEnd wait ahead (wm-a plays the idle
* clip), plenty of time to re-issue. */
animSys->travelTo(character, "inner", "wm-c");
enterStep(7, 1);
return;
}
if (step == 1) {
animSys->travelTo(character, "inner", "wm-b");
if (s != "wm-a") {
fail("replacement switched early (got '" +
s + "')");
return;
}
enterStep(7, 2);
return;
}
if (step == 2) {
if (s == "wm-c") {
fail("replaced travel continued to the old "
"target");
return;
}
if (s == "wm-b") {
std::cout << "[test] replaced travel arrived "
"at the new target 'wm-b'"
<< std::endl;
enterStep(7, 3);
return;
}
if (s != "wm-a") {
fail("unexpected state '" + s +
"' during replacement");
return;
}
if (stepExpired())
fail("replaced travel never arrived");
return;
}
if (step == 3) {
/* wm-b plays walking (~75 frames per loop): a
* stale pending to wm-c would hop within one
* loop. Hold 90 frames to prove it settled. */
if (s != "wm-b") {
fail("stale travel continued past the "
"replacement target (state '" + s + "')");
return;
}
if (frame - stepFrame >= 90) {
enterStep(8, 0);
}
return;
}
}
/* Rule 7 robust targets: unknown machines/states warn once
* and change nothing; travelTo to the CURRENT state is a
* no-op. */
void phaseRobustTargets()
{
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
Ogre::String s = stateOf("inner");
if (step == 0) {
if (s != "wm-b") {
fail("robust-target phase expected 'wm-b', "
"got '" + s + "'");
return;
}
for (int i = 0; i < 2; ++i) {
animSys->travelTo(character, "no-such-machine",
"idle");
animSys->travelTo(character, "inner",
"no-such-state");
}
if (stateOf("inner") != "wm-b" ||
stateOf("locomotion") != "stand") {
fail("unknown machine/target changed state");
return;
}
/* Travel to the state we are already in: a
* no-op, never a restart or a hop. */
animSys->travelTo(character, "inner", "wm-b");
if (stateOf("inner") != "wm-b") {
fail("travelTo(current) changed state");
return;
}
std::cout << "[test] unknown machine/target and "
"self-travel left the machine alone"
<< std::endl;
enterStep(8, 1);
return;
}
if (step == 1) {
if (frame - stepFrame < 10)
return;
if (s != "wm-b") {
fail("state drifted after robust-target "
"calls ('" + s + "')");
return;
}
enterStep(9, 0);
return;
}
}
/* A disabled tree degrades quietly: travelTo is a no-op and
* re-enabling resumes without a surprise hop. */
void phaseDisabled()
{
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
Ogre::String s = stateOf("inner");
if (step == 0) {
auto &at = character.get_mut<AnimationTreeComponent>();
at.enabled = false;
animSys->travelTo(character, "inner", "wm-c");
enterStep(9, 1);
return;
}
if (step == 1) {
if (frame - stepFrame < 30)
return;
if (s != "wm-b") {
fail("travelTo ran on a disabled tree ('" +
s + "')");
return;
}
auto &at = character.get_mut<AnimationTreeComponent>();
at.enabled = true;
std::cout << "[test] disabled tree ignored travelTo"
<< std::endl;
enterStep(9, 2);
return;
}
if (step == 2) {
if (s == "wm-c") {
std::cout << "[test] re-enabled tree travelled "
"on the next request"
<< std::endl;
enterStep(10, 0);
return;
}
if (s != "wm-b") {
fail("unexpected state after re-enable ('" +
s + "')");
return;
}
animSys->travelTo(character, "inner", "wm-c");
if (stepExpired())
fail("re-enabled tree never travelled");
return;
}
}
/* Rule 7 link loss: deleting the hop's connect link from the
* registry mid-travel must cancel the travel (one warning),
* never hop along the deleted link. */
static bool eraseLink(AnimationTreeNode &node, const char *machine,
const char *from, const char *to)
{
if (node.type == "stateMachine" && node.name == machine) {
for (size_t i = 0; i < node.connectLinks.size(); ++i) {
if (node.connectLinks[i].from == from &&
node.connectLinks[i].to == to) {
node.connectLinks.erase(
node.connectLinks.begin() + i);
return true;
}
}
return false;
}
for (auto &child : node.children)
if (eraseLink(child, machine, from, to))
return true;
return false;
}
void phaseLinkLoss()
{
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
Ogre::String s = stateOf("inner");
if (step == 0) {
if (s != "wm-c") {
fail("link-loss phase expected 'wm-c', got '" +
s + "'");
return;
}
animSys->travelTo(character, "inner", "wm-a");
enterStep(10, 1);
return;
}
if (step == 1) {
auto *def = AnimationTreeRegistry::getSingleton()
.findTree("test_travel_nested");
if (!def) {
fail("synthetic tree vanished");
return;
}
if (!eraseLink(def->root, "inner", "wm-c", "wm-a")) {
fail("could not find the link to delete");
return;
}
AnimationTreeRegistry::getSingleton().markModified(
"test_travel_nested");
enterStep(10, 2);
return;
}
if (step == 2) {
/* The running loop is ~50 frames: without the
* cancel the hop to wm-a fires well inside this
* window. */
if (s == "wm-a") {
fail("travel hopped along a deleted link");
return;
}
if (s != "wm-c") {
fail("unexpected state after link loss ('" +
s + "')");
return;
}
if (frame - stepFrame < 120)
return;
std::cout << "[test] deleted-link travel cancelled, "
"machine stayed at 'wm-c'"
<< std::endl;
AnimationTreeRegistry::getSingleton().removeTree(
"test_travel_nested");
std::cout << "[test] PASS: animation travel "
"(startState boot, hop-through, cancel, "
"degrade, nested endState hold, wait "
"modes, forced first hop, replacement, "
"robust targets, disabled tree, link "
"loss)"
<< std::endl;
enterStep(11, 0);
app->getRoot()->queueEndRendering();
return;
}
}
};
/*
@@ -8254,15 +8528,19 @@ struct AnimTravelTestListener : public Ogre::FrameListener {
* state machine travel, on the default interior
* scene: a synthetic registry tree probes the
* startState boot marker, travelTo hop-through
* (pending anchor, no insta-switch, each hop
* (pending anchor, no insta-switch, each atEnd hop
* completes the current clip, arrival), the setState
* cancel (rule 4), the unreachable-target degrade to
* the plain switch (rule 2) and the nested rule-6
* contract (latch enters the nested startState,
* leaving the host state holds the deactivation
* until the nested machine travelled to its
* endState, re-activation re-enters the startState);
* exits 0 on PASS.
* cancel and mid-flight replacement (rule 4), the
* unreachable-target degrade to the plain switch
* (rule 2), the nested rule-6 contract (latch enters
* the nested startState, leaving the host state
* holds the deactivation until the nested machine
* travelled to its endState, re-activation re-enters
* the startState), per-link wait modes (immediate
* hop vs atEnd loop wait), the forced-first-hop
* escape hatch, robust unknown machine/target calls,
* the disabled-tree no-op and the deleted-link
* cancel (rule 7); exits 0 on PASS.
*/
int main(int argc, char *argv[])
{
@@ -9087,7 +9365,7 @@ int main(int argc, char *argv[])
app.closeApp();
return 1;
}
if (animTravelListener.phase != 7) {
if (animTravelListener.phase != 11) {
std::cerr << "[test] FAIL: incomplete (anim "
"travel phase "
<< animTravelListener.phase << ")"
@@ -1301,21 +1301,31 @@ void AnimationTreeSystem::processTravelCheck(
/* The hop's link decides when it may fire: the link
* current -> next is "immediate" (hop on the next update) or
* "atEnd" (wait for the current clip: one-shot end or a
* looping loop boundary, t >= len * 0.99). The one-shot
* travelTo escape hatch forces the first hop regardless of
* the link's mode; it is consumed by the hop that fires. */
* looping loop boundary, observed within one frame step). The
* one-shot travelTo escape hatch forces the first hop
* regardless of the link's mode; it is consumed by the hop
* that fires. */
const Ogre::String next = itPending->second.path.empty() ?
itPending->second.target :
itPending->second.path.front();
bool hopNow = itPending->second.forceFirst;
if (!hopNow) {
for (const auto &link : smNode.connectLinks) {
if (link.from == current && link.to == next) {
hopNow = link.isImmediate();
break;
}
const AnimationTreeLink *hopLink = nullptr;
for (const auto &link : smNode.connectLinks) {
if (link.from == current && link.to == next) {
hopLink = &link;
break;
}
}
if (!hopLink) {
/* Rule 7: the link this hop was anchored on vanished
* (the tree was edited mid-travel). Never hop along a
* deleted link — cancel with one log line. */
state.pendingTravels.erase(itPending);
warnTravelOnce(state, "travelLinkLost", smNode.name, next,
"travel cancelled: connect link vanished "
"mid-travel");
return;
}
bool hopNow = itPending->second.forceFirst || hopLink->isImmediate();
if (!hopNow) {
const AnimationTreeNode *stNode =
findStateNode(smNode, current);
@@ -233,6 +233,100 @@ int testValidationWarnings()
return 0;
}
int testBfsCycleAndSelfLinks()
{
TEST("BFS cycles, self links and empty endpoints");
/* A<->B cycle with no route to C: must terminate empty, not
* loop forever. */
std::vector<Link> cycle = { { "idle", "walking" },
{ "walking", "idle" } };
if (!paths(cycle, "idle", "running").empty())
FAIL("a cycle without an exit must yield no path");
/* Same cycle with an exit: the path is still found. */
cycle.push_back({ "walking", "running" });
std::vector<Ogre::String> p = paths(cycle, "idle", "running");
if (p.size() != 2 || p[0] != "walking" || p[1] != "running")
FAIL("BFS must escape a cycle via the exit link");
/* Self links never create a hop. */
std::vector<Link> self = { { "idle", "idle" },
{ "idle", "walking" } };
p = paths(self, "idle", "walking");
if (p.size() != 1 || p[0] != "walking")
FAIL("self links must be ignored");
/* Empty-endpoint links never create a hop either. */
std::vector<Link> empty = { { "", "walking" },
{ "idle", "" } };
if (!paths(empty, "idle", "walking").empty())
FAIL("empty-endpoint links must be ignored");
PASS();
return 0;
}
int testValidationDuplicateModes()
{
TEST("duplicate links error regardless of waitMode");
std::vector<Ogre::String> states = { "idle", "walking", "running" };
/* The duplicate check is (from, to)-only: the same edge twice
* with different wait modes is still a duplicate. */
std::vector<Link> dup = { { "idle", "walking" },
{ "idle", "walking", "immediate" } };
auto issues = validateStateMachine(states, dup, "idle", "");
bool duplicateError = false;
for (const auto &i : issues)
if (i.error &&
i.message.find("duplicate") != std::string::npos)
duplicateError = true;
if (!duplicateError)
FAIL("same edge with a different waitMode must error");
PASS();
return 0;
}
int testJsonSkipsBadLinks()
{
TEST("JSON tolerates malformed links and old files");
/* Links with a missing/empty endpoint and non-object entries
* are skipped on load; a non-array connectLinks is tolerated. */
nlohmann::json nodeJson = nlohmann::json::object();
nodeJson["type"] = "stateMachine";
nodeJson["name"] = "locomotion";
nodeJson["connectLinks"] = nlohmann::json::array();
nodeJson["connectLinks"].push_back(
{ { "from", "idle" }, { "to", "walking" } });
nodeJson["connectLinks"].push_back({ { "from", "walking" } });
nodeJson["connectLinks"].push_back({ { "to", "running" } });
nodeJson["connectLinks"].push_back("garbage");
AnimationTreeNode back;
animationTreeNodeFromJson(back, nodeJson);
if (back.connectLinks.size() != 1)
FAIL("malformed links must be skipped on load");
if (back.connectLinks[0].from != "idle" ||
back.connectLinks[0].to != "walking")
FAIL("the valid link must survive");
if (!back.startState.empty() || !back.endState.empty())
FAIL("missing markers must default to empty (old files)");
if (back.connectLinks[0].waitMode != "atEnd")
FAIL("missing waitMode must default to atEnd");
/* A non-array connectLinks must not throw or fabricate links. */
nodeJson["connectLinks"] = nlohmann::json::object();
animationTreeNodeFromJson(back, nodeJson);
if (!back.connectLinks.empty())
FAIL("non-array connectLinks must yield no links");
PASS();
return 0;
}
int testJsonRoundTrip()
{
TEST("connectLinks/startState/endState JSON round trip");
@@ -352,8 +446,11 @@ int main()
failures += testBfsTieBreaks();
failures += testBfsDegenerate();
failures += testBfsShortestWins();
failures += testBfsCycleAndSelfLinks();
failures += testValidationErrors();
failures += testValidationWarnings();
failures += testValidationDuplicateModes();
failures += testJsonSkipsBadLinks();
failures += testJsonRoundTrip();
failures += testWaitModeRoundTrip();
std::printf("%d/%d tests passed\n", passCount, testCount);