feat(examples): Basic, a ball simulation with a bug to find
The example was a placeholder that printed which build it was. It is now a headless simulation of five balls dropped onto a 10 cm floor slab at 60 ticks a second, with a bug planted the way such bugs are written: collision is checked only where each step lands, so the ball dropped from 60 m, at 34 m/s, steps over the slab and falls forever. It shows the consumer side end to end: - Basic.DebugShim forwards Pump, Advance, QuitRequested, Log, Check, Emit and Sample to Crafter.Debug in debug builds and does nothing in plain ones, so the simulation and main have no #ifdef but the session's. - Basic.DebugCommands, compiled only when Debug::Use returns true, registers World_Spawn, World_Reset, World_Clear and Ball_Get, and watches for the physics, the floor and the balls. - The simulation checks each ball stays above the floor, emits Bounce and samples the fastest ball, so `pause --on-check`, `wait --event` and `trace --history` have something to show. Driven over the real transport, it shows the whole loop: `log --level warn` names the ball, `pause --on-check` stops the frame it fails, `set physics.substeps 4` and `call World_Reset` show the fix holds before any code changes. The plain build simulates ten seconds and prints that one ball fell through. Its agent notes describe the app without the bug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6da3b274a6
commit
4a0d911e69
8 changed files with 292 additions and 9 deletions
51
examples/Basic/implementations/Basic.DebugCommands.cpp
Normal file
51
examples/Basic/implementations/Basic.DebugCommands.cpp
Normal file
|
|
@ -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<float> y;
|
||||
float x = 0;
|
||||
float vy = 0;
|
||||
float radius = 0.1f;
|
||||
};
|
||||
|
||||
struct BallArgs {
|
||||
Debug::Required<std::int32_t> 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<std::int32_t> {
|
||||
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<Ball> {
|
||||
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<Ball>(world.Balls().begin(), world.Balls().end()); });
|
||||
}
|
||||
65
examples/Basic/implementations/Basic.World.cpp
Normal file
65
examples/Basic/implementations/Basic.World.cpp
Normal file
|
|
@ -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<float, 5> heights = { 1, 3, 8, 20, 60 };
|
||||
for (std::size_t i = 0; i < heights.size(); ++i) {
|
||||
Spawn(static_cast<float>(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<float>(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::int32_t>(std::ranges::count(balls, true, &Ball::lost));
|
||||
}
|
||||
|
|
@ -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<Clock::duration>(std::chrono::duration<float>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue