115 lines
5.4 KiB
C++
115 lines
5.4 KiB
C++
|
|
// 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;
|
||
|
|
}
|