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>
80 lines
3.9 KiB
C++
80 lines
3.9 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// The conditions `wait` and `pause --when` take: comparisons, && || ! and
|
|
// parentheses with the usual precedence, truthiness of a lone operand,
|
|
// strings in either quote, paths with indices, and short-circuiting. A
|
|
// condition that cannot be decided says why instead of being false, since a
|
|
// silently false condition makes `wait` hang: an unknown path, ordering a
|
|
// string against a number. Malformed text is refused with its offset, and a
|
|
// lone = is caught as the typo it is.
|
|
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;
|
|
}
|
|
|
|
std::expected<Value, std::string> Resolve(std::string_view path) {
|
|
static const Value state = Value::Parse(R"({"hp": 0, "scene": "Game", "paused": false, "bodies": [{"y": 2}, {"y": -12}], "name": "", "lives": 3})");
|
|
if (path.starts_with("bodies[")) {
|
|
const std::size_t index = static_cast<std::size_t>(path[7] - '0');
|
|
return state.Find("bodies")->Items()[index].Find("y") ? *state.Find("bodies")->Items()[index].Find("y") : Value();
|
|
}
|
|
if (const Value* value = state.Find(path)) return *value;
|
|
return std::unexpected(std::format("no watch '{}'", path));
|
|
}
|
|
|
|
std::expected<bool, std::string> Evaluate(std::string_view text) {
|
|
return Condition::Parse(text).Evaluate(Resolve);
|
|
}
|
|
|
|
bool Is(std::string_view text, bool expected) {
|
|
const auto result = Evaluate(text);
|
|
return result.has_value() && *result == expected;
|
|
}
|
|
|
|
std::size_t RefusedAt(std::string_view text) {
|
|
try {
|
|
(void)Condition::Parse(text);
|
|
} catch (const ConditionError& error) {
|
|
return error.offset;
|
|
}
|
|
return std::string_view::npos;
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
Check(Is("hp <= 0", true), "a comparison with a literal");
|
|
Check(Is("scene == \"Game\" && !paused", true), "&& and ! combine");
|
|
Check(Is("scene == 'Menu' || lives >= 3", true), "|| and single-quoted strings");
|
|
Check(Is("hp > 0 || lives > 5", false), "false || false is false");
|
|
Check(Is("!(hp == 0 && lives == 3)", false), "parentheses group");
|
|
Check(Is("hp == 1 || hp == 0 && lives == 4", false), "&& binds tighter than ||");
|
|
Check(Is("bodies[1].y < -10", true), "a path with an index, against a negative number");
|
|
Check(Is("lives", true) && Is("name", false) && Is("paused", false), "a lone operand tests truthiness");
|
|
Check(Is("lives == 3.0", true), "an integer equals the same number");
|
|
Check(Is("hp > 0 && missing == 1", false), "&& short-circuits past an unknown path");
|
|
|
|
const auto unknown = Evaluate("missing > 1");
|
|
Check(!unknown && unknown.error() == "no watch 'missing'", "an unknown path is an error, not false");
|
|
const auto ordering = Evaluate("scene < 3");
|
|
Check(!ordering && ordering.error().find("cannot order") != std::string::npos && ordering.error().find("scene < 3") != std::string::npos,
|
|
"ordering a string against a number is an error that quotes the comparison");
|
|
Check(Is("scene == 3", false), "== between different kinds is false, not an error");
|
|
|
|
Check(RefusedAt("hp = 0") == 3, "a lone = is refused where it stands");
|
|
Check(RefusedAt("(hp == 0") == 8, "an unclosed parenthesis is refused at the end");
|
|
Check(RefusedAt("hp == 0 lives") == 8, "two conditions without && or || are refused");
|
|
Check(RefusedAt("hp == ") == 6, "a missing operand is refused");
|
|
|
|
const Condition parsed = Condition::Parse("fort1.core.hp <= 0 && bodies[3].pos.y > 1");
|
|
Check(parsed.Paths().size() == 2 && parsed.Paths()[0] == "fort1.core.hp" && parsed.Paths()[1] == "bodies[3].pos.y", "a condition lists the paths it reads");
|
|
|
|
return failures == 0 ? 0 : 1;
|
|
}
|