Files
ReSkate/Server/server_activity.cpp
T
Robin MoellerandClaude Opus 5.5 dd8e610482 Plugins: a dedicated server's plugins hide a player from others, or mute them
Hide and Seek, tag, spectating and ghost runs need one player to be
gone for some others, which no plugin could do.

reskate.server.hide(target, viewers, {skater, name, voice, sound}) and
reskate.server.unhide(target, viewers?) do it per viewer; viewers is a
player, a list of players or "all" (everyone on now but the target).

Wholly (the default) the target leaves those viewers' rosters, so their
games take the skater, its name tag and its objects away as for a player
who left, and the server sends them nothing of the target: poses, sound,
voice, outfit, effects and objects. No protocol change: the roster per
viewer is the one the cards and polls use, and the server's introduce
already holds a player back until shown. Shown again, the target is
introduced anew (outfit, then whole poses, then all its objects).

Without the skater (skater = false), only the voice and/or the sound is
held back and the player stays. Name and skater go together: a game has
no skater without its player, and hiding one without the other would
need a protocol change.

Refused while the target shares a party or a throwdown with one of the
viewers, whose game would lose a party member or a player mid-way; while
hidden, the two cannot come into one party. A plugin that stops shows
everyone it hid; a player who leaves is dropped from every list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYabE7FCPgj3SaWeF92EML
2026-10-24 12:26:31 +02:00

258 lines
12 KiB
C++

#include "server_activity.h"
#include "Extension/Throwdowns/throwdown_wire.h"
#include <algorithm>
#include <format>
namespace dingosdk::server {
using multiplayer::ThrowdownMessage;
using Kind = ThrowdownMessage::Kind;
namespace {
constexpr std::string_view jam = "JamSession", spot_battle = "SpotBattle", skate = "ThrowdownSkate";
// A queue's leader repeats its offer every 10 s; one not heard from for this long is gone.
constexpr std::uint64_t queue_forget_us = 60'000'000;
// A running throwdown sends rows, turns or attempts at least every turn (at most a minute),
// and a Jam a score whenever anyone scores; after this long without any it has finished.
constexpr std::uint64_t jam_quiet_us = 60'000'000, turns_quiet_us = 90'000'000;
constexpr std::uint64_t lead_interval_us = 15'000'000;
// Chat about drops placed: at most one line per leader this often (offers are cheap to send).
constexpr std::uint64_t announce_interval_us = 30'000'000;
// More objects than this in one change are summed up instead of listed.
constexpr std::size_t listed_objects = 3;
std::string mode(std::string_view series) {
if (series == jam) return "Jam";
if (series == spot_battle) return "Spot Battle";
if (series == skate) return "S.K.A.T.E.";
return "throwdown"; // the series is peer text: never echo it into chat
}
std::string points(std::int64_t value) {
auto digits = std::to_string(value < 0 ? -value : value);
for (auto at = static_cast<std::ptrdiff_t>(digits.size()) - 3; at > 0; at -= 3) digits.insert(static_cast<std::size_t>(at), ",");
return value < 0 ? "-" + digits : digits;
}
std::string place(const std::array<float, 3> &at) {
return std::format("({:.0f}, {:.0f}, {:.0f})", at[0], at[1], at[2]);
}
std::string item_name(const NetworkObject &object) {
auto text = object.item.empty() ? std::string("an object") : object.item;
if (text.size() > 64) text.resize(64);
return text;
}
} // namespace
std::string ActivityLog::name(std::uint64_t player) {
if (auto current = name_ ? name_(player) : std::string{}; !current.empty()) return names_[player] = current;
const auto known = names_.find(player);
return known != names_.end() ? known->second : std::to_string(player);
}
std::string ActivityLog::title(const Key &key, const Throwdown &t) { return name(key.first) + "'s " + mode(t.series); }
void ActivityLog::throwdown(std::uint64_t sender, std::span<const std::uint8_t> bytes, const std::array<float, 3> *at,
std::uint64_t now) {
const auto decoded = multiplayer::decode_throwdown(bytes);
if (!decoded) return;
const auto &m = *decoded;
const Key key{m.leader, m.id};
auto found = throwdowns_.find(key);
if (m.kind == Kind::offer) {
if (sender != m.leader) return;
if (found != throwdowns_.end()) {
found->second.last = now;
if (found->second.series.empty()) found->second.series = m.series;
return;
}
// A leader has one drop at a time: one of theirs still here has finished.
for (auto it = throwdowns_.begin(); it != throwdowns_.end();) {
if (it->first.first != m.leader) { ++it; continue; }
if (it->second.started) finish(it->first, it->second);
it = throwdowns_.erase(it);
}
auto &t = throwdowns_[key];
t.series = m.series;
t.last = now;
for (const auto player : m.order)
if (player != m.leader) t.queue.push_back(player);
log_("[throwdown] " + name(m.leader) + " placed a " + mode(m.series) + " drop" + (at ? " near " + place(*at) : ""));
if (const auto last = announced_.find(m.leader);
announce_ && (last == announced_.end() || now - last->second >= announce_interval_us)) {
announced_[m.leader] = now;
announce_(name(m.leader) + " placed a " + mode(m.series) + " throwdown. Find it on the map to join.");
}
return;
}
if (found == throwdowns_.end()) {
// Started before the server saw its offer (it restarted, or the map changed back).
if (m.kind == Kind::close || m.kind == Kind::leave) return;
found = throwdowns_.emplace(key, Throwdown{}).first;
}
auto &t = found->second;
t.last = now;
const auto who = name(sender);
switch (m.kind) {
case Kind::offer: break;
case Kind::close:
if (sender != m.leader || t.started) return;
log_("[throwdown] " + title(key, t) + " drop was removed");
throwdowns_.erase(found);
return;
case Kind::join:
if (t.started || sender == m.leader || std::ranges::find(t.queue, sender) != t.queue.end()) return;
t.queue.push_back(sender);
log_(std::format("[throwdown] {} joined {} ({} in the queue)", who, title(key, t), t.queue.size() + 1));
return;
case Kind::leave:
if (t.started) {
if (!t.quit.insert(sender).second) return;
log_("[throwdown] " + who + " quit " + title(key, t));
if (std::ranges::all_of(t.players, [&](std::uint64_t p) { return t.quit.contains(p); })) throwdowns_.erase(found);
return;
}
if (const auto it = std::ranges::find(t.queue, sender); it != t.queue.end()) {
t.queue.erase(it);
log_("[throwdown] " + who + " left the queue for " + title(key, t));
}
return;
case Kind::start: {
if (sender != m.leader || t.started) return;
t.started = true;
t.players = m.order;
t.queue.clear();
std::string list;
for (const auto player : t.players) list += (list.empty() ? "" : ", ") + name(player);
log_(std::format("[throwdown] {} started with {} player{}: {}", title(key, t), t.players.size(),
t.players.size() == 1 ? "" : "s", list));
return;
}
case Kind::score: {
// Jam: the sender's running total.
t.total[sender] = m.value;
const auto top = std::ranges::max_element(t.total, [&](const auto &a, const auto &b) {
// Ties keep the player already in front.
return a.second < b.second || (a.second == b.second && b.first == t.leading);
});
if (top == t.total.end() || top->first == t.leading || top->second <= 0) return;
t.leading = top->first;
if (t.lead_logged && now - t.lead_logged < lead_interval_us) return;
t.lead_logged = now;
log_("[throwdown] " + name(top->first) + " takes the lead in " + title(key, t) + " with " + points(top->second));
return;
}
case Kind::row:
// Spot Battle: the round board adds each line; the overall board is set to the best turn.
if (m.add) t.turn[sender] += m.value;
else t.total[sender] = m.value;
return;
case Kind::turn_end: {
const auto scored = std::exchange(t.turn[sender], 0);
const auto best = t.total.find(sender);
log_(std::format("[throwdown] {}: {} scored {} in round {}{}", title(key, t), who, points(scored), m.value,
best != t.total.end() ? " (best " + points(best->second) + ")" : ""));
return;
}
case Kind::attempt:
++t.tries[sender][m.add ? 0 : 1];
log_(std::format("[throwdown] {}: {} {} (turn {})", title(key, t), who, m.add ? "landed" : "missed", m.value));
return;
}
}
void ActivityLog::finish(const Key &key, const Throwdown &t) {
std::vector<std::uint64_t> order = t.players;
for (const auto &[player, value] : t.total)
if (std::ranges::find(order, player) == order.end()) order.push_back(player);
const auto total = [&](std::uint64_t player) {
const auto it = t.total.find(player);
return it == t.total.end() ? std::int64_t{} : it->second;
};
// S.K.A.T.E. is won on letters, which the server does not see: its players stay in turn order.
const bool ranked = t.series != skate;
if (ranked) std::ranges::stable_sort(order, [&](std::uint64_t a, std::uint64_t b) { return total(a) > total(b); });
std::string results;
for (std::size_t i = 0; i < order.size(); ++i) {
const auto player = order[i];
std::string entry;
if (ranked) entry = std::format("{}. {} {}", i + 1, name(player), points(total(player)));
else {
const auto it = t.tries.find(player);
const auto tries = it == t.tries.end() ? std::array<unsigned, 2>{} : it->second;
entry = std::format("{} {} landed / {} missed", name(player), tries[0], tries[1]);
}
if (t.quit.contains(player)) entry += " (quit)";
results += (results.empty() ? "" : ", ") + entry;
}
log_("[throwdown] " + title(key, t) + " has finished" + (results.empty() ? "" : ": " + results));
}
void ActivityLog::tick(std::uint64_t now) {
for (auto it = throwdowns_.begin(); it != throwdowns_.end();) {
const auto &t = it->second;
const auto quiet = now > t.last ? now - t.last : 0;
if (!t.started && quiet > queue_forget_us) {
it = throwdowns_.erase(it); // its leader stopped offering it without saying so
continue;
}
if (t.started && quiet > (t.series == jam ? jam_quiet_us : turns_quiet_us)) {
finish(it->first, t);
it = throwdowns_.erase(it);
continue;
}
++it;
}
}
std::set<std::uint64_t> ActivityLog::playing() const {
std::set<std::uint64_t> players;
for (const auto &[key, t] : throwdowns_)
if (t.started)
for (const auto player : t.players)
if (!t.quit.contains(player)) players.insert(player);
return players;
}
bool ActivityLog::together(std::uint64_t a, std::uint64_t b) const {
for (const auto &[key, t] : throwdowns_) {
const auto in = [&](std::uint64_t player) {
return !t.quit.contains(player) && (key.first == player || std::ranges::find(t.queue, player) != t.queue.end() ||
std::ranges::find(t.players, player) != t.players.end());
};
if (in(a) && in(b)) return true;
}
return false;
}
void ActivityLog::left(std::uint64_t player) {
name(player); // keep the name for results after they left
for (auto it = throwdowns_.begin(); it != throwdowns_.end();) {
auto &t = it->second;
std::erase(t.queue, player);
// A queue goes with its leader; a running throwdown carries on for the others.
if (!t.started && it->first.first == player) it = throwdowns_.erase(it);
else ++it;
}
}
void ActivityLog::objects(std::uint64_t owner, const std::vector<NetworkObject> &before,
const std::vector<NetworkObject> &after) {
std::map<std::uint64_t, const NetworkObject *> old;
for (const auto &object : before) old.emplace(object.id, &object);
std::vector<const NetworkObject *> added, removed;
for (const auto &object : after)
if (!old.erase(object.id)) added.push_back(&object);
for (const auto &[id, object] : old) removed.push_back(object);
if (added.empty() && removed.empty()) return; // moved, turned or resized
const auto who = name(owner);
const auto total = std::format(" ({} in total)", after.size());
if (added.size() > listed_objects)
log_(std::format("[objects] {} {} {} objects{}", who, before.empty() ? "brought" : "placed", added.size(), total));
else
for (const auto *object : added)
log_("[objects] " + who + " placed " + item_name(*object) + " at " + place(object->position));
if (removed.size() > listed_objects)
log_(std::format("[objects] {} removed {} objects{}", who, removed.size(), total));
else
for (const auto *object : removed)
log_("[objects] " + who + " removed " + item_name(*object) + " at " + place(object->position));
}
} // namespace dingosdk::server