diff --git a/examples/Basic/AGENTS.template.md b/examples/Basic/AGENTS.template.md index 246dd41..cea1504 100644 --- a/examples/Basic/AGENTS.template.md +++ b/examples/Basic/AGENTS.template.md @@ -1,3 +1,17 @@ # Basic -A Crafter.Debug example. Build with `crafter-build --debug-api` to include the debug server. +A Crafter.Debug example: a headless simulation of balls dropped onto a floor slab, 60 ticks a second. + +- **Plain build:** `crafter-build`, run with `crafter-build -r`. It simulates ten seconds as fast as it can and prints how many balls ended up below the floor. +- **Debug build:** this project's debug flag is `--debug-api` and its define `BASIC_DEBUG_API`. The build adds Crafter.Debug, and the app runs in real time until an agent tells it to quit. + +## Layout + +- `interfaces/Basic.World.cppm`, `implementations/Basic.World.cpp`: the simulation. +- `interfaces/Basic.DebugShim.cppm`: Crafter.Debug's frame-loop and timeline calls, in every build. In the plain build they are stand-ins that do nothing, so the simulation and `main` need no `#ifdef`. +- `interfaces/Basic.DebugCommands.cppm`, `implementations/Basic.DebugCommands.cpp`: the commands and watches, compiled only when `Debug::Use` added Crafter.Debug. +- `implementations/main.cpp`: the loop, with the session under the define. + +## Working on Crafter.Debug from here + +`project.cpp` loads Crafter.Debug from this checkout (`#pragma crafter dependency "../.."`). Crafter.Network's callback API, which the transport needs, may not be on Network's `master` yet. If it isn't, build with `CRAFTER_DEBUG_NETWORK=/project.cpp`. diff --git a/examples/Basic/implementations/Basic.DebugCommands.cpp b/examples/Basic/implementations/Basic.DebugCommands.cpp new file mode 100644 index 0000000..83a84ac --- /dev/null +++ b/examples/Basic/implementations/Basic.DebugCommands.cpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Basic.DebugCommands; +import std; +import Crafter.Debug; +import Basic.World; +namespace Debug = Crafter::Debug; + +namespace Basic { + struct SpawnArgs { + Debug::Required y; + float x = 0; + float vy = 0; + float radius = 0.1f; + }; + + struct BallArgs { + Debug::Required id; + }; +} + +void Basic::RegisterDebug(World& world) { + // Commands run on the main thread, between ticks, so they touch the + // world as the simulation does. + Debug::Command("World_Spawn", "Drop a ball from height y (m), moving at vy (m/s, up is positive); returns its id.", + [&world](const SpawnArgs& args) -> Debug::Result { + if (args.radius <= 0) return Debug::Error{ "BadRadius", "radius must be above 0" }; + if (*args.y - args.radius <= world.floor.top) { + return Debug::Error{ "BelowFloor", std::format("y must clear the floor's top ({} m) by the radius", world.floor.top) }; + } + return world.Spawn(args.x, *args.y, args.vy, args.radius); + }); + Debug::Command("World_Reset", "Remove every ball and drop the scene's five again.", [&world] { world.Reset(); }); + Debug::Command("World_Clear", "Remove every ball.", [&world] { world.Clear(); }); + Debug::Command("Ball_Get", "One ball's position, velocity and state.", [&world](const BallArgs& args) -> Debug::Result { + if (const Ball* ball = world.Find(*args.id)) return *ball; + return Debug::Error{ "NoBall", std::format("no ball {}; `get balls` lists them", *args.id) }; + }); + + Debug::Watch("physics.gravity", world.physics.gravity, { .min = -50, .max = 0, .help = "m/s^2" }); + Debug::Watch("physics.restitution", world.physics.restitution, { .min = 0, .max = 1, .help = "share of speed kept by a bounce" }); + Debug::Watch("physics.substeps", world.physics.substeps, { .min = 1, .max = 16, .help = "collision checks per tick" }); + Debug::Watch("floor.top", world.floor.top, { .help = "m" }); + Debug::Watch("floor.thickness", world.floor.thickness, { .min = 0.01, .max = 10, .help = "m" }); + Debug::Watch("world.time", [&world] { return world.Time(); }, { .help = "simulated seconds since the last reset" }); + Debug::Watch("world.count", [&world] { return world.Balls().size(); }, { .help = "balls in the scene" }); + Debug::Watch("world.lost", [&world] { return world.Lost(); }, { .help = "balls below the floor" }); + Debug::Watch("world.fastest", [&world] { return world.Fastest(); }, { .help = "m/s, last tick" }); + Debug::Watch("balls", [&world] { return std::vector(world.Balls().begin(), world.Balls().end()); }); +} diff --git a/examples/Basic/implementations/Basic.World.cpp b/examples/Basic/implementations/Basic.World.cpp new file mode 100644 index 0000000..14459f5 --- /dev/null +++ b/examples/Basic/implementations/Basic.World.cpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Basic.World; +import std; +import Basic.DebugShim; +namespace Debug = Crafter::Debug; + +void Basic::World::Reset() { + Clear(); + constexpr std::array heights = { 1, 3, 8, 20, 60 }; + for (std::size_t i = 0; i < heights.size(); ++i) { + Spawn(static_cast(i) - 2.0f, heights[i], 0, 0.1f); + } +} + +void Basic::World::Clear() { + balls.clear(); + time = 0; + fastest = 0; +} + +std::int32_t Basic::World::Spawn(float x, float y, float vy, float radius) { + const std::int32_t id = nextId++; + balls.push_back({ .id = id, .x = x, .y = y, .vy = vy, .radius = radius }); + Debug::Log("world", "ball {} dropped from {:.1f} m", id, y); + return id; +} + +void Basic::World::Tick(float dt) { + const std::int32_t steps = std::max(1, physics.substeps); + const float h = dt / static_cast(steps); + const float bottom = floor.top - floor.thickness; + fastest = 0; + for (Ball& ball : balls) { + for (std::int32_t step = 0; step < steps; ++step) { + ball.vy += physics.gravity * h; + ball.x += ball.vx * h; + ball.y += ball.vy * h; + if (ball.lost) continue; + // A falling ball that overlaps the slab bounces off its top. + if (ball.vy < 0 && ball.y - ball.radius < floor.top && ball.y + ball.radius > bottom) { + const float speed = -ball.vy; + ball.y = floor.top + ball.radius; + ball.vy = speed * physics.restitution; + if (speed > 1) Debug::Emit("Bounce", BounceEvent{ .ball = ball.id, .speed = speed }); + } + } + if (!ball.lost && !Debug::Check(ball.y + ball.radius >= bottom, "physics", "ball {} is below the floor: y {:.2f} m, vy {:.1f} m/s", ball.id, ball.y, ball.vy)) { + ball.lost = true; + } + fastest = std::max(fastest, std::hypot(ball.vx, ball.vy)); + } + Debug::Sample("solver.fastest", fastest); + time += dt; +} + +const Basic::Ball* Basic::World::Find(std::int32_t id) const { + const auto found = std::ranges::find(balls, id, &Ball::id); + return found == balls.end() ? nullptr : &*found; +} + +std::int32_t Basic::World::Lost() const { + return static_cast(std::ranges::count(balls, true, &Ball::lost)); +} diff --git a/examples/Basic/implementations/main.cpp b/examples/Basic/implementations/main.cpp index a931796..0118182 100644 --- a/examples/Basic/implementations/main.cpp +++ b/examples/Basic/implementations/main.cpp @@ -1,16 +1,58 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// A headless simulation at 60 ticks a second. The plain build simulates ten +// seconds as fast as it can and prints what happened; the debug build runs in +// real time, serving Crafter.Debug, until an agent says quit. import std; +import Basic.DebugShim; +import Basic.World; #ifdef BASIC_DEBUG_API -import Crafter.Debug; +import Crafter.Thread; +import Basic.DebugCommands; #endif +namespace Debug = Crafter::Debug; +using Clock = std::chrono::steady_clock; + +namespace { + constexpr float dt = 1.0f / 60.0f; + +#ifdef BASIC_DEBUG_API + constexpr bool realTime = true; +#else + constexpr bool realTime = false; +#endif +} int main() { + Basic::World world; + world.Reset(); + #ifdef BASIC_DEBUG_API - std::println("basic: debug build, Crafter.Debug {}", Crafter::Debug::Version()); -#else - std::println("basic: plain build"); + // The session's transport runs on Crafter.Thread's pool. An app that + // already uses the pool starts it as usual; this one only needs it here. + Crafter::ThreadPool::Start(); + Debug::Session debug({ .name = "basic" }); + Basic::RegisterDebug(world); + std::println("basic: running; drive it with ./build/crafter-debug, stop it with ./build/crafter-debug quit"); #endif - return 0; + + const auto tick = std::chrono::duration_cast(std::chrono::duration(dt)); + Clock::time_point next = Clock::now(); + while (!Debug::QuitRequested() && (realTime || world.Time() < 10.0)) { + Debug::Pump(); + if (Debug::Advance()) world.Tick(dt); + if constexpr (realTime) { + next += tick; + // After a stop in a debugger, carry on from now instead of + // racing to catch up. + if (Clock::now() - next > std::chrono::seconds(1)) next = Clock::now(); + std::this_thread::sleep_until(next); + } + } + + std::println("basic: {:.1f} s simulated; {} balls, {} below the floor", world.Time(), world.Balls().size(), world.Lost()); + for (const Basic::Ball& ball : world.Balls()) { + if (ball.lost) std::println(" ball {} fell through the floor", ball.id); + } } diff --git a/examples/Basic/interfaces/Basic.DebugCommands.cppm b/examples/Basic/interfaces/Basic.DebugCommands.cppm new file mode 100644 index 0000000..da9c73d --- /dev/null +++ b/examples/Basic/interfaces/Basic.DebugCommands.cppm @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// What an agent can call and read in a debug build. project.cpp compiles +// this module only when Debug::Use added Crafter.Debug, so the plain build +// has none of it. +export module Basic.DebugCommands; +import Basic.World; + +export namespace Basic { + // Registers the commands and watches on `world`, which must outlive the + // Session. + void RegisterDebug(World& world); +} diff --git a/examples/Basic/interfaces/Basic.DebugShim.cppm b/examples/Basic/interfaces/Basic.DebugShim.cppm new file mode 100644 index 0000000..ec79d73 --- /dev/null +++ b/examples/Basic/interfaces/Basic.DebugShim.cppm @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Crafter.Debug's frame-loop and timeline calls, in every build: the real +// ones under --debug-api, and in a plain build stand-ins that do nothing, so +// the simulation and main need no #ifdef around them. This is one of the +// app's own sources, so the define Debug::Use adds reaches it. +export module Basic.DebugShim; +import std; +#ifdef BASIC_DEBUG_API +export import Crafter.Debug; +#else +export namespace Crafter::Debug { + inline void Pump() {} + inline bool Advance() { return true; } + inline bool Paused() { return false; } + inline std::uint32_t TakeSteps() { return 0; } + inline bool QuitRequested() { return false; } + template void Log(A&&...) {} + template void LogWarn(A&&...) {} + template void LogError(A&&...) {} + template bool Check(bool ok, A&&...) { return ok; } + template void Emit(A&&...) {} + template void Sample(std::string_view, const T&) {} +} +#endif diff --git a/examples/Basic/interfaces/Basic.World.cppm b/examples/Basic/interfaces/Basic.World.cppm new file mode 100644 index 0000000..3ec2b06 --- /dev/null +++ b/examples/Basic/interfaces/Basic.World.cppm @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Balls dropped onto a floor slab, in two dimensions, at a fixed step. y is +// up, in metres; the slab's top is at floor.top and it is floor.thickness +// thick. +export module Basic.World; +import std; + +export namespace Basic { + struct Ball { + std::int32_t id = 0; + float x = 0; + float y = 0; + float vx = 0; + float vy = 0; + float radius = 0.1f; + // Below the floor, and so out of the scene for good. + bool lost = false; + }; + + struct Physics { + float gravity = -9.81f; + // The share of its speed a ball keeps when it bounces. + float restitution = 0.6f; + // Collision checks per tick. + std::int32_t substeps = 1; + }; + + struct Floor { + float top = 0; + float thickness = 0.1f; + }; + + // What `wait --event Bounce` waits for, sent for bounces faster than 1 m/s. + struct BounceEvent { + std::int32_t ball = 0; + float speed = 0; + }; + + class World { + public: + Physics physics; + Floor floor; + + // Removes every ball and drops the scene's five. + void Reset(); + void Clear(); + // Returns the new ball's id. + std::int32_t Spawn(float x, float y, float vy, float radius); + void Tick(float dt); + + const std::deque& Balls() const { return balls; } + const Ball* Find(std::int32_t id) const; + double Time() const { return time; } + std::int32_t Lost() const; + // The fastest ball's speed in the last tick, in m/s. + float Fastest() const { return fastest; } + + private: + std::deque balls; + std::int32_t nextId = 1; + double time = 0; + float fastest = 0; + }; +} diff --git a/examples/Basic/project.cpp b/examples/Basic/project.cpp index dba5ef5..c3fc25d 100644 --- a/examples/Basic/project.cpp +++ b/examples/Basic/project.cpp @@ -24,10 +24,15 @@ extern "C" Configuration CrafterBuildProject(std::span a for (const std::string_view arg : args) { if (arg != "--debug-api") depArgs.emplace_back(arg); } - Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", .define = "BASIC_DEBUG_API", .args = depArgs }); + const bool debugApi = Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", .define = "BASIC_DEBUG_API", .args = depArgs }); - std::array interfaces = {}; - std::array implementations = { "implementations/main" }; + std::vector interfaces = { "interfaces/Basic.DebugShim", "interfaces/Basic.World" }; + std::vector implementations = { "implementations/Basic.World", "implementations/main" }; + // The agent's commands and watches, in debug builds only. + if (debugApi) { + interfaces.emplace_back("interfaces/Basic.DebugCommands"); + implementations.emplace_back("implementations/Basic.DebugCommands"); + } cfg.GetInterfacesAndImplementations(interfaces, implementations); return cfg; }