diff --git a/inc/cvc/ariadne/avatar_physics.h b/inc/cvc/ariadne/avatar_physics.h index 764549feb..76577411c 100644 --- a/inc/cvc/ariadne/avatar_physics.h +++ b/inc/cvc/ariadne/avatar_physics.h @@ -96,6 +96,9 @@ class avatar_world { void place(const std::string &pid, const cvc::physics::body_state &feet); // A kinematic avatar: arrive at this feet pose by the end of the next step. void move_kinematic(const std::string &pid, const cvc::physics::pose &feet); + // A dynamic avatar's mass (kg, > 0) from the next step on; it survives set_statics. A kinematic + // avatar has no mass (a no-op). + void set_mass(const std::string &pid, double kg); void step(); @@ -108,8 +111,9 @@ class avatar_world { struct avatar { std::uint32_t id = 0; // cvc::physics::body_id value bool dynamic = true; + double mass = 0.0; // kg; avatar_body::mass until set_mass }; - std::uint32_t create_avatar(const cvc::physics::body_state &feet, bool dynamic); + std::uint32_t create_avatar(const cvc::physics::body_state &feet, bool dynamic, double mass); avatar_body _body; double _dt; diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h index 7a60be1f8..f42ed1e2a 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -95,6 +95,11 @@ struct holon_config { // SERVER with physics: an avatar that fell off the world (below this z) respawns where it // joined. double respawn_below_z = -10.0; + // The VETO (sanitize_intent): the fastest an intent may ask an avatar to move horizontally (m/s; + // a faster ask is scaled down to it), and whether it may ask to move vertically at all (avatars + // walk; gravity, not the client, owns z). + double max_speed = 4.0; + bool allow_vertical = false; }; class holon { @@ -130,9 +135,29 @@ class holon { // Set a participant's latest intent. Server: the validated input it integrates // into the authoritative pose, from the next quantum on (any queued stamped // intents are dropped). Client: only the LOCAL avatar's own input (a non-local - // pid is ignored -- a client cannot drive a peer). + // pid is ignored -- a client cannot drive a peer). Either way the intent passes + // sanitize_intent first. void set_intent(const std::string &pid, const avatar_intent &in); + // The server's VETO over a proposed intent (§5h propose -> veto -> override): a + // non-finite move is no move, a non-finite yaw faces +X, the vertical is dropped + // unless allow_vertical, the horizontal speed is capped at max_speed and the yaw + // is wrapped to [-pi, pi]. An honest client applies the same rule to its own + // prediction, so it agrees with what the server will integrate (no correction); + // a client that asks for more is overruled on reconcile. + avatar_intent sanitize_intent(const avatar_intent &in) const; + // SERVER: how many intents the veto has changed. + std::uint64_t vetoes() const noexcept { return _vetoes; } + + // SERVER OVERRIDES (§5h): the authority acts on an avatar at will, and the result + // replicates like any authoritative pose (the owning client reconciles to it). + // Teleport `pid` to `feet` (pose + velocities). False for an unknown pid or on a + // client holon. + bool override_state(const std::string &pid, const cvc::physics::body_state &feet); + // Re-mass `pid` (kg, finite and > 0). Physics only: false without a world, for an + // unknown pid, a bad mass, or on a client holon. + bool set_mass(const std::string &pid, double kg); + // SERVER: a participant's intent STAMPED with the tick the client applied it from // in its prediction. It takes effect at that tick -- so the authoritative pose // matches the prediction exactly when it arrives in time (the client runs ahead by @@ -258,6 +283,7 @@ class holon { double _alpha = 0.0; // the last frame's interpolation alpha, for display_pose std::uint64_t _lead = 0; // CLIENT: ticks ahead of the server (set_lead_ticks) std::unique_ptr _world; // physics (holon_config::physics + a Jolt build) + std::uint64_t _vetoes = 0; // SERVER: intents sanitize_intent changed std::uint64_t _statics_rev = 0; }; diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index f267ab77c..4d868ad35 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -331,8 +331,9 @@ int main(int argc, char **argv) { } if (room_debug && ++room_debug_ticks % 25 == 0) { const auto roster = cvc::ariadne::conference::room_roster(app, room); - std::printf("[arisrv] room '%s': %zu present, holon tick %llu\n", room.c_str(), roster.size(), - static_cast(server_holon.tick())); + std::printf("[arisrv] room '%s': %zu present, holon tick %llu, %llu intent(s) vetoed\n", + room.c_str(), roster.size(), static_cast(server_holon.tick()), + static_cast(server_holon.vetoes())); for (const auto &p : roster) { cvc::physics::body_state s; std::uint64_t t = 0; diff --git a/src/cvc/ariadne/avatar_physics.cpp b/src/cvc/ariadne/avatar_physics.cpp index e635f959f..528c4fe36 100644 --- a/src/cvc/ariadne/avatar_physics.cpp +++ b/src/cvc/ariadne/avatar_physics.cpp @@ -65,7 +65,7 @@ avatar_world::avatar_world(double fixed_dt, const avatar_body &body) : _body(bod avatar_world::~avatar_world() = default; -std::uint32_t avatar_world::create_avatar(const ph::body_state &feet, bool dynamic) { +std::uint32_t avatar_world::create_avatar(const ph::body_state &feet, bool dynamic, double mass) { ph::body_desc d; d.shape = ph::box{ph::vec3{0.5 * _body.width, 0.5 * _body.width, 0.5 * _body.height}}; d.motion = dynamic ? ph::motion_type::dynamic : ph::motion_type::kinematic; @@ -73,7 +73,7 @@ std::uint32_t avatar_world::create_avatar(const ph::body_state &feet, bool dynam d.rotation = feet.rotation; d.linear_velocity = feet.linear_velocity; d.material = static_cast(_avatar_material); - d.mass = dynamic ? _body.mass : 0.0; + d.mass = dynamic ? mass : 0.0; d.linear_damping = 0.0; d.angular_damping = 0.0; d.allow_sleeping = false; // an idle avatar must still be pushable and respond to its intent @@ -103,14 +103,15 @@ void avatar_world::set_statics(const std::vector &statics) { _statics.push_back(_world->add_body(d).value); } for (auto &kv : _avatars) - kv.second.id = create_avatar(kept[kv.first], kv.second.dynamic); + kv.second.id = create_avatar(kept[kv.first], kv.second.dynamic, kv.second.mass); } void avatar_world::add_avatar(const std::string &pid, const ph::body_state &feet, bool dynamic) { remove_avatar(pid); avatar a; a.dynamic = dynamic; - a.id = create_avatar(feet, dynamic); + a.mass = _body.mass; + a.id = create_avatar(feet, dynamic, a.mass); _avatars[pid] = a; } @@ -162,6 +163,16 @@ void avatar_world::move_kinematic(const std::string &pid, const ph::pose &feet) feet.rotation); } +void avatar_world::set_mass(const std::string &pid, double kg) { + avatar &a = _avatars.at(pid); + if (!a.dynamic) + return; + ph::body_settings s = _world->get_body(bid(a.id)); + s.mass = kg; + _world->set_body(bid(a.id), s); + a.mass = kg; +} + void avatar_world::step() { _world->step(); } ph::body_state avatar_world::state(const std::string &pid) const { diff --git a/src/cvc/ariadne/holon.cpp b/src/cvc/ariadne/holon.cpp index 0e0c60fb8..59c78a342 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -14,6 +14,7 @@ */ #include +#include #include #include #include @@ -89,6 +90,34 @@ std::vector holon::participants() const { return out; } +avatar_intent holon::sanitize_intent(const avatar_intent &in) const { + avatar_intent out = in; + const cvc::physics::vec3 &m = in.move; + if (!std::isfinite(m.x) || !std::isfinite(m.y) || !std::isfinite(m.z)) + out.move = cvc::physics::vec3{0.0, 0.0, 0.0}; + if (!_cfg.allow_vertical) + out.move.z = 0.0; + const double h2 = out.move.x * out.move.x + out.move.y * out.move.y; + if (h2 > _cfg.max_speed * _cfg.max_speed) { + const double k = _cfg.max_speed / std::sqrt(h2); + out.move.x *= k; + out.move.y *= k; + } + constexpr double kPi = 3.14159265358979323846; + if (!std::isfinite(out.yaw)) + out.yaw = 0.0; + else if (out.yaw > kPi || out.yaw < -kPi) + out.yaw = std::remainder(out.yaw, 2.0 * kPi); + return out; +} + +namespace { +bool same_intent(const avatar_intent &a, const avatar_intent &b) { + // Bitwise-ish: a NaN in `a` differs from whatever sanitize_intent made of it. + return a.move.x == b.move.x && a.move.y == b.move.y && a.move.z == b.move.z && a.yaw == b.yaw; +} +} // namespace + void holon::set_intent(const std::string &pid, const avatar_intent &in) { auto it = _participants.find(pid); if (it == _participants.end()) @@ -96,10 +125,35 @@ void holon::set_intent(const std::string &pid, const avatar_intent &in) { // A client may drive only its OWN (local) avatar; it cannot move a peer. if (_role == holon_role::client && !it->second.local) return; - it->second.intent = in; + const avatar_intent ok = sanitize_intent(in); + if (_role == holon_role::server && !same_intent(in, ok)) + ++_vetoes; + it->second.intent = ok; it->second.queued.clear(); } +bool holon::override_state(const std::string &pid, const cvc::physics::body_state &feet) { + if (_role != holon_role::server) + return false; + auto it = _participants.find(pid); + if (it == _participants.end()) + return false; + if (_world) + _world->place(pid, feet); + else + it->second.sim.set_state(feet); + return true; +} + +bool holon::set_mass(const std::string &pid, double kg) { + if (_role != holon_role::server || !_world || !(kg > 0.0) || !std::isfinite(kg)) + return false; + if (!_participants.count(pid)) + return false; + _world->set_mass(pid, kg); + return true; +} + void holon::queue_intent(const std::string &pid, const avatar_intent &in, std::uint64_t tick) { if (_role != holon_role::server) return; @@ -120,7 +174,10 @@ void holon::queue_intent(const std::string &pid, const avatar_intent &in, std::u at = next; if (!p.queued.empty() && at < p.queued.back().first) at = p.queued.back().first; - p.queued.emplace_back(at, in); + const avatar_intent ok = sanitize_intent(in); + if (!same_intent(in, ok)) + ++_vetoes; + p.queued.emplace_back(at, ok); } void holon::apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index 89214f6db..5413c770b 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -172,6 +172,7 @@ bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, holon_config room_holon_config() { holon_config cfg; cfg.physics = true; + cfg.max_speed = 4.0; // twice the walk; room to add a run without loosening the veto return cfg; } diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp index 873862763..1d7b26498 100644 --- a/src/cvc/tests/holon_test.cpp +++ b/src/cvc/tests/holon_test.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include using cvc::ariadne::avatar_intent; @@ -617,3 +618,104 @@ TEST(HolonPhysicsTest, ClientPredictionMatchesTheServersPhysics) { EXPECT_LT(corrected, 1e-3) << "and the client predicted the same"; EXPECT_NEAR(client.state("A").position.x, server.state("A").position.x, 0.05); } + +// ── authority: the server's veto and overrides (§5h propose -> veto -> override) ── + +TEST(HolonAuthorityTest, TheVetoCapsSpeedDropsTheVerticalAndRejectsNonFinite) { + holon_config cfg; + cfg.max_speed = 3.0; + holon server(holon_role::server, cfg); + const double nan = std::nan(""); + avatar_intent fast{vec3{30.0, 40.0, 5.0}}; + fast.yaw = 7.0; // more than a turn + const avatar_intent a = server.sanitize_intent(fast); + EXPECT_NEAR(a.move.x, 1.8, 1e-12) << "scaled down to max_speed, direction kept"; + EXPECT_NEAR(a.move.y, 2.4, 1e-12); + EXPECT_EQ(a.move.z, 0.0) << "avatars walk; gravity owns z"; + EXPECT_NEAR(a.yaw, 7.0 - 2.0 * 3.14159265358979323846, 1e-12) << "wrapped to [-pi, pi]"; + avatar_intent bad{vec3{nan, 1.0, 0.0}}; + bad.yaw = std::numeric_limits::infinity(); + const avatar_intent b = server.sanitize_intent(bad); + EXPECT_EQ(b.move.x, 0.0); + EXPECT_EQ(b.move.y, 0.0) << "a non-finite move is no move at all"; + EXPECT_EQ(b.yaw, 0.0); + const avatar_intent ok{vec3{1.0, 1.0, 0.0}}; + const avatar_intent c = server.sanitize_intent(ok); + EXPECT_TRUE(c.move.x == 1.0 && c.move.y == 1.0 && c.yaw == 0.0) + << "an honest intent passes as is"; + + // The server integrates the vetoed intent, and counts the veto. + server.add_participant("A"); + const double dt = server.clock().fixed_dt(); + server.set_intent("A", avatar_intent{vec3{100.0, 0.0, 0.0}}); + for (int i = 0; i < 120; ++i) + server.advance(dt); + EXPECT_NEAR(server.state("A").position.x, 3.0, 1e-6) << "1 s at the 3 m/s cap, not 100"; + server.queue_intent("A", bad, server.tick() + 1); + EXPECT_EQ(server.vetoes(), 2u); +} + +// A client that asks for more than the cap predicts with the vetoed intent too, so it still agrees +// with the server: the veto costs an honest client nothing, and a client running a modified +// prediction is simply overruled on reconcile. +TEST(HolonAuthorityTest, AClientPredictsTheVetoedIntent) { + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); + client.add_participant("A", true); + const double dt = server.clock().fixed_dt(); + const avatar_intent greedy{vec3{50.0, 0.0, 0.0}}; + server.set_intent("A", greedy); + client.set_intent("A", greedy); + for (int f = 1; f <= 60; ++f) { + server.advance(dt); + client.advance(dt); + client.apply_authoritative("A", server.state("A"), server.tick()); + } + const vec3 c = client.take_correction("A"); + EXPECT_EQ(c.x, 0.0); + EXPECT_DOUBLE_EQ(client.state("A").position.x, server.state("A").position.x); + EXPECT_NEAR(server.state("A").position.x, 0.5 * 4.0, 1e-6) << "0.5 s at the default 4 m/s cap"; +} + +TEST(HolonAuthorityTest, TheServerTeleportsAnAvatarAndTheClientFollows) { + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); + client.add_participant("A", true); + const double dt = server.clock().fixed_dt(); + server.advance(dt); + client.advance(dt); + EXPECT_FALSE(client.override_state("A", at(9.0, 9.0))) << "only the authority overrides"; + ASSERT_TRUE(server.override_state("A", at(6.0, -2.0))); + EXPECT_FALSE(server.override_state("nobody", at(0.0, 0.0))); + server.advance(dt); + client.advance(dt); + client.apply_authoritative("A", server.state("A"), server.tick()); + EXPECT_DOUBLE_EQ(client.state("A").position.x, 6.0) << "the owner reconciles to the override"; + EXPECT_DOUBLE_EQ(client.state("A").position.y, -2.0); +} + +TEST(HolonAuthorityTest, AReMassedAvatarIsHarderToPush) { + // How far a 70 kg walker shoves a still avatar of mass `kg` in 2 s. + const auto shove = [](double kg) { + holon server(holon_role::server, physics_config()); + if (!server.physics()) + return -1.0; + server.set_statics(room_statics(), 1); + server.add_participant("A", false, at(-6.0, 5.0)); + server.add_participant("B", false, at(-4.0, 5.0)); + EXPECT_TRUE(server.set_mass("B", kg)); + server.set_intent("A", avatar_intent{vec3{2.0, 0.0, 0.0}}); + run(server, 240); + return server.state("B").position.x - (-4.0); + }; + const double light = shove(70.0), heavy = shove(2000.0); + if (light < 0.0) + GTEST_SKIP() << "no Jolt backend in this build"; + EXPECT_GT(light, 0.5) << "an equal mass is shoved aside"; + EXPECT_LT(heavy, 0.5 * light) << "a heavy one barely gives way"; + holon client(holon_role::client, physics_config()); + client.add_participant("A", true); + EXPECT_FALSE(client.set_mass("A", 10.0)) << "only the authority re-masses"; +}