Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion inc/cvc/ariadne/avatar_physics.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;
Expand Down
28 changes: 27 additions & 1 deletion inc/cvc/ariadne/holon.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<avatar_world> _world; // physics (holon_config::physics + a Jolt build)
std::uint64_t _vetoes = 0; // SERVER: intents sanitize_intent changed
std::uint64_t _statics_rev = 0;
};

Expand Down
5 changes: 3 additions & 2 deletions src/arisrv/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<unsigned long long>(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<unsigned long long>(server_holon.tick()),
static_cast<unsigned long long>(server_holon.vetoes()));
for (const auto &p : roster) {
cvc::physics::body_state s;
std::uint64_t t = 0;
Expand Down
19 changes: 15 additions & 4 deletions src/cvc/ariadne/avatar_physics.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,15 @@ 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;
d.position = feet.position + ph::vec3{0.0, 0.0, 0.5 * _body.height};
d.rotation = feet.rotation;
d.linear_velocity = feet.linear_velocity;
d.material = static_cast<ph::material_id>(_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
Expand Down Expand Up @@ -103,14 +103,15 @@ void avatar_world::set_statics(const std::vector<avatar_static> &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;
}

Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 59 additions & 2 deletions src/cvc/ariadne/holon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cvc/ariadne/holon.h>
Expand Down Expand Up @@ -89,17 +90,70 @@ std::vector<std::string> 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())
return;
// 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;
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/cvc/ariadne/holon_sync.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
102 changes: 102 additions & 0 deletions src/cvc/tests/holon_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include <cvc/physics/body.h>
#include <cvc/physics/types.h>
#include <gtest/gtest.h>
#include <limits>
#include <vector>

using cvc::ariadne::avatar_intent;
Expand Down Expand Up @@ -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<double>::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";
}
Loading