#include "Gameplay.hpp" #include #include #include #include #include #include #include using namespace faset::runtime; using Json = nlohmann::json; namespace { void check(bool result, const char* text) { if (!result) throw std::runtime_error(text); } void near(float actual, float expected, float tolerance, const char* text) { check(std::abs(actual - expected) < tolerance, text); } template void rejects(F&& fn, const char* message) { bool caught = false; try { fn(); } catch (const std::exception&) { caught = true; } check(caught, message); } Json component(std::string type, Json fields = Json::object()) { return {{"id", type + "-id"}, {"type", type}, {"version", 1}, {"fields", fields}}; } Json entity(std::string id, float y = 0) { return {{"id", id}, {"name", id}, {"parent", nullptr}, {"components", Json::array({component("faset.transform", {{"position", {0, y, 0}}})})}}; } Json scene(int dim = 2) { return {{"format", "faset.scene"}, {"version", 1}, {"id", "test-scene"}, {"name", "Test"}, {"dimension", dim}, {"entities", Json::array()}, {"instances", Json::array()}}; } void physics(int dimension) { Runtime world; auto doc = scene(dimension); auto floor = entity("ground", -0.5f); auto falling = entity("falling", 4); auto type = dimension == 2 ? "faset.rigid_body_2d" : "faset.rigid_body_3d"; Json extents = dimension == 2 ? Json{10, 0.5} : Json{10, 0.5, 10}; floor["components"].push_back( component(type, {{"body_type", "static"}, {"half_extents", extents}})); falling["components"].push_back(component(type)); doc["entities"] = Json::array({floor, falling}); world.load(doc); auto h = world.find("falling"); bool contact = false; for (int i = 0; i < 240; ++i) { world.advance(1.0 / 60); for (const auto& event : world.collisions()) contact = contact || event.began; } near(world.transform(h).position[1], 0.5f, 0.09f, "body must fall and settle on actual solver floor"); check(contact, "native contact event must be delivered"); check(world.grounded(h), "settled body must have a supporting native contact"); auto pose = world.transform(h); pose.position[1] = 6; world.teleport(h, pose); near(world.presentation(h).position[1], 6, 0.0001f, "teleport resets interpolation"); check(!world.grounded(h), "teleport off the floor removes grounded state"); rejects([&] { world.setTransform(h, pose); }, "physics transform cannot be casually overwritten"); world.applyImpulse(h, {0, 2, 0}); check(world.velocity(h)[1] > 0, "impulse changes solver velocity"); } void lifecycle() { Runtime world; std::vector events; bool spawned = false; float presented = -1; int pressedTicks = 0; Behavior behavior; behavior.onStart = [&](Runtime&, EntityHandle, double) { events.push_back("start"); }; behavior.fixedUpdate = [&](Runtime& r, EntityHandle h, double) { events.push_back("fixed"); if (r.input().jumpPressed) ++pressedTicks; auto t = r.transform(h); t.position[0] += 1; r.setTransform(h, t); if (!spawned) { r.spawn(entity("spawned")); spawned = true; } }; behavior.update = [&](Runtime&, EntityHandle, double) { events.push_back("update"); }; behavior.lateUpdate = [&](Runtime& r, EntityHandle h, double) { events.push_back("late"); presented = r.presentation(h).position[0]; }; behavior.onDestroy = [&](Runtime& r, EntityHandle h, double) { check(r.valid(h), "OnDestroy still sees a valid handle"); events.push_back("destroy"); }; world.registerBehavior("test.behavior", behavior); auto doc = scene(); auto object = entity("main"); object["components"].push_back(component("test.behavior")); doc["entities"].push_back(object); world.load(doc); check(events == std::vector{"start"}, "load runs OnStart once"); world.advance(1.0 / 120, {0, 0, true, false}); check(!world.find("spawned"), "zero-tick frame applies no structural commands"); world.advance(1.0 / 60); check(!world.find("spawned"), "FixedUpdate spawn must wait until next tick"); near(presented, 0.5f, 0.001f, "LateUpdate receives interpolated transform"); check(pressedTicks == 1, "input edge preserved across zero-tick frame"); world.advance(3.0 / 60); check(bool(world.find("spawned")), "spawn appears next tick"); check(pressedTicks == 1, "edge not repeated in catchup ticks"); auto old = world.find("main"); world.destroy(old); check(world.valid(old), "destroy deferred"); world.singleStep(); check(!world.valid(old), "handle invalid after removal"); check(events.back() == "destroy", "destroy lifecycle runs exactly at barrier"); world.spawn(entity("replacement")); world.singleStep(); check(!world.valid(old), "reused slot never revives a stale handle"); auto replacement = world.find("replacement"); world.load(doc); check(!world.valid(replacement), "load creates a new session"); // Ensure captured state remains alive while Runtime's destructor calls OnDestroy. world.clear(); } void clockAndValidation() { Runtime world; auto doc = scene(); doc["entities"].push_back(entity("object")); world.load(doc); auto stats = world.advance(1.0); check(stats.fixedTicks == 4, "catchup bounded to four ticks"); check(stats.droppedTime > 0.9, "excess time reported"); check(stats.interpolationAlpha >= 0 && stats.interpolationAlpha < 1, "interpolation fraction bounded"); auto tick = stats.tick; world.setPaused(true); world.advance(100); check(world.snapshot().tick == tick, "pause does not accumulate"); world.singleStep(); check(world.snapshot().tick == tick + 1, "single-step advances exactly once"); world.setPaused(false); check(world.advance(0).fixedTicks == 0, "resume does not catch up pause"); auto old = world.find("object"); auto invalid = doc; invalid["entities"][0]["parent"] = "object"; rejects([&] { world.load(invalid); }, "reject hierarchy cycle"); check(world.valid(old), "invalid load preserves old world"); invalid = doc; invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d")); rejects([&] { world.load(invalid); }, "reject physics dimension mismatch"); rejects([&] { world.advance(-1); }, "reject negative time"); world.addComponent(old, component("faset.sprite")); check(!world.snapshot().entities[0].sprite, "component addition deferred"); world.singleStep(); check(world.snapshot().entities[0].sprite.has_value(), "component added at barrier"); world.removeComponent(old, "faset.sprite"); world.singleStep(); check(!world.snapshot().entities[0].sprite, "component removed at barrier"); Runtime other; other.load(doc); check(!other.valid(old), "handle cannot cross worlds"); auto schema = faset::gameplay::schema(); check(schema.size() == 2, "sample has explicit metadata without world"); } void structuralFailuresAndCallbacks() { Runtime world; auto doc = scene(); doc["entities"].push_back(entity("object")); world.load(doc); auto h = world.find("object"); world.addComponent(h, component("faset.sprite", {{"size", {-1, 2}}})); world.singleStep(); check(!world.snapshot().entities[0].sprite, "invalid deferred component leaves entity unchanged"); check(!world.diagnostics().empty(), "invalid deferred command reports diagnostic"); auto zero = world.transform(h); zero.scale[0] = 0; world.setTransform(h, zero); world.addComponent(h, component("faset.rigid_body_2d")); world.singleStep(); rejects([&] { world.fields(h, "faset.rigid_body_2d"); }, "invalid runtime collider scale must not half-add component"); zero.scale[0] = 1; world.setTransform(h, zero); world.addComponent(h, component("faset.rigid_body_2d")); world.singleStep(); check(world.velocity(h)[1] < 0, "deferred body runs actual physics"); world.removeComponent(h, "faset.rigid_body_2d"); world.singleStep(); rejects([&] { world.velocity(h); }, "removed physics adapter no longer accessible"); world.destroy(h); world.destroy(h); world.singleStep(); check(!world.valid(h), "repeated deferred destroy is safe"); rejects([&] { world.advance(std::numeric_limits::quiet_NaN()); }, "nonfinite time rejected"); rejects([&] { world.advance(0, {std::numeric_limits::infinity(), 0, false, false}); }, "nonfinite input rejected"); Runtime callbacks; std::vector order; Behavior b; b.onStart = [&](Runtime& r, EntityHandle, double) { order.push_back("start"); rejects([&] { r.singleStep(); }, "OnStart cannot recursively advance"); }; b.fixedUpdate = [&](Runtime&, EntityHandle, double) { order.push_back("fixed"); throw std::runtime_error("intentional callback failure"); }; b.update = [&](Runtime&, EntityHandle, double) { order.push_back("update"); }; b.lateUpdate = [&](Runtime& r, EntityHandle h, double) { order.push_back("late"); auto p = r.presentation(h); p.position[2] = 9; r.setPresentation(h, p); }; callbacks.registerBehavior("test", b); auto object = entity("callbacks"); object["components"].push_back(component("test")); doc["entities"] = Json::array({object}); callbacks.load(doc); callbacks.singleStep(); check(order == std::vector{"start", "fixed", "update", "late"}, "callback failure does not skip remaining phases"); check(callbacks.diagnostics().size() == 1, "callback exception diagnostic"); near(callbacks.snapshot().entities[0].transform.position[2], 9, 0.001f, "LateUpdate changes final presentation only"); near(callbacks.transform(callbacks.find("callbacks")).position[2], 0, 0.001f, "presentation does not overwrite simulation"); callbacks.clear(); } void sampleGameplay() { Runtime world; faset::gameplay::registerGameplay(world); auto doc = scene(3); auto door = entity("door"); door["components"].push_back(component("gameplay.door", {{"speed", 2.0}})); doc["entities"] = Json::array({door}); world.load(doc); world.advance(1.0 / 60, {0, 0, false, true}); for (int i = 0; i < 59; ++i) world.advance(1.0 / 60); near(world.transform(world.find("door")).rotation[1], 1.5707963f, 0.001f, "sample door opens through real static gameplay callback"); world.advance(1.0 / 60, {0, 0, false, true}); for (int i = 0; i < 59; ++i) world.advance(1.0 / 60); near(world.transform(world.find("door")).rotation[1], 0, 0.001f, "sample door toggles closed"); } void groundedJump() { Runtime world; faset::gameplay::registerGameplay(world); auto doc = scene(2); auto ground = entity("ground", -0.5f); ground["components"].push_back( component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {10, .5}}})); auto character = entity("character", 0.5f); character["components"].push_back(component("faset.rigid_body_2d")); character["components"].push_back(component("gameplay.character")); doc["entities"] = Json::array({ground, character}); world.load(doc); auto h = world.find("character"); for (int i = 0; i < 10; ++i) world.advance(1.0 / 60); check(world.grounded(h), "character starts supported"); world.advance(1.0 / 60, {0, 0, true, false}); check(world.velocity(h)[1] > 4, "grounded jump sets upward velocity"); for (int i = 0; i < 90 && world.velocity(h)[1] > 0.1f; ++i) world.advance(1.0 / 60); check(!world.grounded(h), "apex is not grounded"); auto before = world.velocity(h)[1]; world.advance(1.0 / 60, {0, 0, true, false}); check(world.velocity(h)[1] < before, "jump at apex must not create a second impulse"); for (int i = 0; i < 180; ++i) world.advance(1.0 / 60); check(world.grounded(h), "character regains ground after landing"); Runtime wall; auto wallScene = scene(2); auto obstacle = entity("wall"); obstacle["components"].push_back( component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {.5, 5}}})); auto body = entity("side"); body["components"][0]["fields"]["position"] = {1, 0, 0}; body["components"].push_back(component("faset.rigid_body_2d", {{"gravity_scale", 0}})); wallScene["entities"] = Json::array({obstacle, body}); wall.load(wallScene); for (int i = 0; i < 5; ++i) wall.advance(1.0 / 60); check(!wall.grounded(wall.find("side")), "wall contact is not a supporting floor contact"); } } // namespace int main() { try { auto run = [](const char* name, auto fn) { try { fn(); std::cout << name << " passed\n"; } catch (const std::exception& error) { throw std::runtime_error(std::string(name) + ": " + error.what()); } }; run("Box2D", [] { physics(2); }); run("Box3D", [] { physics(3); }); run("Lifecycle", lifecycle); run("Clock/validation", clockAndValidation); run("Structural failures", structuralFailuresAndCallbacks); run("Sample gameplay", sampleGameplay); run("Grounded jump", groundedJump); std::cout << "runtime contracts passed: actual Box2D/Box3D collisions, lifecycle, handles, " "interpolation, deferred mutation, catchup, pause, validation, gameplay\n"; return 0; } catch (const std::exception& ex) { std::cerr << ex.what() << '\n'; return 1; } }