feat(core): Value, reflection, codecs and results, shared by app and client

Crafter.Debug.Core is what the app side and the crafter-debug client
both need, without the session:

- Value: a JSON value with its own parser and printer, so no consumer
  interface sees a JSON library. Integers stay integers and whole
  doubles stay doubles; NaN and the infinities, which JSON cannot
  spell, are written as strings rather than hidden, because a debugger
  must not lose a NaN; invalid UTF-8 is replaced so output always
  parses. Objects keep insertion order, the order an agent reads.
- Reflect: aggregate members and names through structured-binding
  packs and source_location, taken from Crafter.DB, plus scoped-enum
  names by probing a range of values. Self-tested with static_asserts
  so a compiler that changes function_name()'s shape fails the build.
- Codec<T>: encode, decode and describe for bool, integers, floating
  point, strings, enums, optional, vector, array, string-keyed maps,
  Value, Required<T> and every aggregate. Decode reports every
  problem in a call with its path and, for a typo, the likely intended
  name, and never throws: a bad call cannot take an app down, which a
  wrongly typed parameter could in 3DForts' hand-written from_json.
- Result, Error and Async: handler results, including ones that take
  frames to arrive, so callers need no ready:false polling.

project.cpp now builds Crafter.Debug.Core as its own library, used by
both the app library and the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 01:31:58 +01:00
commit 89a12bcd61
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
13 changed files with 1951 additions and 11 deletions

View file

@ -0,0 +1,78 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A command call with several mistakes gets one reply that names each of
// them: a missing Required field, a wrong type naming both kinds, a nested
// path ("path[1].x"), an out-of-range integer with the range, and for an
// unknown field or enum name the one the caller most likely meant. Decoding
// never throws, so a bad call cannot take the app down. Guards the replies an
// agent corrects its calls from.
import std;
import Crafter.Debug.Core;
using namespace Crafter::Debug;
namespace Game {
enum class Mode : std::uint8_t { Idle, Build, Fight };
struct Vec3 {
float x = 0;
float y = 0;
float z = 0;
};
struct Aim {
Required<std::uint8_t> slot;
float yaw = 0;
float pitch = 0;
std::vector<Vec3> path;
Mode mode = Mode::Idle;
};
}
namespace {
std::int32_t failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
bool Has(const DecodeContext& context, std::string_view path, std::string_view text) {
return std::ranges::any_of(context.Issues(), [&](const DecodeIssue& issue) {
return issue.path == path && issue.message.find(text) != std::string::npos;
});
}
}
int main() {
Game::Aim aim;
DecodeContext context;
Decode(Value::Parse(R"({"yw": 1, "pitch": "up", "path": [{"x": 1}, {"x": "no"}], "mode": "Fihgt"})"), aim, context);
std::println("report: {}", context.Report());
Check(context.Issues().size() == 5, std::format("five mistakes, five issues ({})", context.Issues().size()));
Check(Has(context, "slot", "required"), "a missing Required field is reported");
Check(Has(context, "pitch", "expected a number, got a string"), "a wrong type names both kinds");
Check(Has(context, "path[1].x", "expected a number"), "a nested path names the item and the member");
Check(Has(context, "mode", "did you mean 'Fight'"), "a mistyped enum name gets a suggestion");
Check(Has(context, "yw", "did you mean 'yaw'"), "an unknown field gets a suggestion");
Check(context.Report().find("slot: missing") != std::string::npos, "the report prefixes each issue with its path");
Game::Aim outOfRange;
DecodeContext rangeContext;
Decode(Value::Parse(R"({"slot": 300})"), outOfRange, rangeContext);
Check(Has(rangeContext, "slot", "300 is out of range (0..255)"), "an out-of-range integer names the range");
Game::Aim notAnObject;
DecodeContext objectContext;
Decode(Value::Parse("[1]"), notAnObject, objectContext);
Check(objectContext.Issues().size() == 1 && objectContext.Issues()[0].path.empty()
&& objectContext.Issues()[0].message == "expected an object, got an array",
"a non-object is one issue at the root");
Game::Aim farOff;
DecodeContext farContext;
Decode(Value::Parse(R"({"slot": 1, "zzzzzz": 1})"), farOff, farContext);
Check(Has(farContext, "zzzzzz", "unknown field (fields: slot, yaw, pitch, path, mode)"), "an unknown field with no close match lists the fields");
return failures == 0 ? 0 : 1;
}