// 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 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 { 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 { return Async([&]() -> std::optional { 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> 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("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; }