feat(registry): commands, watches, samples, the timeline and conditions

The app side of Crafter.Debug, minus the session:

- Command(name, description, handler): the handler takes nothing or
  one argument struct and returns void, a value, Result<T> or
  Async<T>. Arguments are decoded before it runs, so a mistyped call
  is answered with every mistake and never reaches it; a throwing
  handler becomes an Exception error instead of unwinding into the
  frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
  read-only, and Sample(path, value) from any thread with a
  frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
  and set the same way, writing a member writes the whole value back.
  Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
  movable, so neither is its owner: its watches refer to the owner's
  members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
  timeline, with source locations captured without macros. A check
  failing every frame is counted every time but stored sparsely, and
  the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
  condition that cannot be decided reports why instead of being false,
  which would make a wait hang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 01:40:59 +01:00
commit 3500b73ecf
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
12 changed files with 1829 additions and 7 deletions

View file

@ -0,0 +1,115 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Commands registered with Debug::Command are found by name and invoked with
// JSON parameters decoded into their argument struct: defaults fill what the
// call leaves out, a bad call is refused with InvalidParams before the
// handler runs, a throwing handler becomes an Exception error instead of
// unwinding into the app, Result errors pass through with their code, an
// Async result is polled until it yields, and a Group's commands disappear
// with the Group. Guards what replaces 3DForts' hand-written dispatch table.
import std;
import Crafter.Debug;
using namespace Crafter::Debug;
namespace {
std::int32_t failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
struct AimArgs {
Required<std::int32_t> slot;
float yaw = 10;
float pitch = 0;
};
struct Aimed {
std::int32_t slot = 0;
float yaw = 0;
};
Detail::CommandOutcome Call(std::string_view name, std::string_view params) {
const auto command = Detail::Registry::Instance().FindCommand(name);
if (!command) throw std::runtime_error(std::format("no command {}", name));
return command->invoke(Value::Parse(params));
}
bool Failed(const Detail::CommandOutcome& outcome, std::string_view code) {
return outcome.done && !outcome.done->Ok() && outcome.done->GetError().code == code;
}
}
int main() {
std::int32_t fired = 0;
Command("Player_Fire", "Fire the weapon.", [&] { ++fired; });
Command("Player_SetAim", "Aim the turret, in degrees.", [](const AimArgs& args) -> Result<Aimed> {
if (*args.slot < 0) return Error{ "NoSlot", "slots start at 0" };
return Aimed{ *args.slot, args.yaw };
});
Command("Game_Score", "The score.", [] { return 42; });
Command("Game_Crash", "Throws.", []() -> std::int32_t { throw std::runtime_error("boom"); });
std::int32_t polls = 0;
Command("Sim_StateHash", "A hash that takes frames.", [&]() -> Async<std::string> {
return Async<std::string>([&]() -> std::optional<std::string> {
if (++polls < 3) return std::nullopt;
return std::string("cafe");
});
});
const auto fire = Call("Player_Fire", "{}");
Check(fire.done && fire.done->Ok() && fired == 1, "a void command runs and succeeds");
Check(Failed(Call("Player_Fire", R"({"x":1})"), "InvalidParams"), "a command without arguments refuses some");
const auto aim = Call("Player_SetAim", R"({"slot": 2})");
Check(aim.done && aim.done->Ok() && aim.done->Get().Find("yaw")->AsNumber() == 10 && aim.done->Get().Find("slot")->AsInteger() == 2,
"a field left out takes its default, and the result is encoded");
const auto mistyped = Call("Player_SetAim", R"({"yw": 1})");
Check(Failed(mistyped, "InvalidParams") && mistyped.done->GetError().message.find("did you mean 'yaw'") != std::string::npos
&& mistyped.done->GetError().message.find("slot: missing") != std::string::npos,
"a mistyped call is refused with every mistake in it");
const auto refused = Call("Player_SetAim", R"({"slot": -1})");
Check(Failed(refused, "NoSlot") && refused.done->GetError().message == "slots start at 0", "a Result error keeps its code and message");
const auto score = Call("Game_Score", "null");
Check(score.done && score.done->Ok() && score.done->Get().AsInteger() == 42, "a plain return value is encoded");
const auto crash = Call("Game_Crash", "{}");
Check(Failed(crash, "Exception") && crash.done->GetError().message.find("boom") != std::string::npos, "an exception becomes an Exception error");
auto hash = Call("Sim_StateHash", "{}");
Check(!hash.done && hash.pending, "an Async result is pending");
std::optional<Result<Value>> polled;
std::int32_t pumps = 0;
while (!polled && pumps < 10) {
polled = hash.pending();
++pumps;
}
Check(polled && polled->Ok() && polled->Get().AsString() == "cafe" && pumps == 3, "an Async result is polled until it yields");
const auto described = Detail::Registry::Instance().FindCommand("Player_SetAim");
Check(described->args.Find("fields")->Size() == 3 && described->returns.Find("fields")->Size() == 2 && described->description == "Aim the turret, in degrees.",
"a command describes its arguments and its result");
Check(Detail::Registry::Instance().FindCommand("Player_Fire")->args.IsNull(), "a command without arguments describes none");
Check(Detail::Registry::Instance().SuggestCommand("Player_SetAm") == std::optional<std::string>("Player_SetAim"), "a mistyped command name gets a suggestion");
{
Group fort("fort1");
fort.Command("Explode", "Blow it up.", [] {});
Check(Detail::Registry::Instance().FindCommand("fort1.Explode") != nullptr, "a Group prefixes its commands");
}
Check(Detail::Registry::Instance().FindCommand("fort1.Explode") == nullptr, "a Group's commands go with it");
Check(Detail::Registry::Instance().Commands("Player_*").size() == 2, "commands can be listed by glob");
bool threw = false;
try {
Command("has space", "", [] {});
} catch (const std::invalid_argument&) {
threw = true;
}
Check(threw, "a name with whitespace is refused at registration");
return failures == 0 ? 0 : 1;
}