Add identity-safe player moderation runtimes for T5 and T6

This commit is contained in:
2026-10-15 21:35:19 -04:00
parent 2b5b1955bc
commit d7bf16e9c2
27 changed files with 1784 additions and 25 deletions
@@ -0,0 +1,161 @@
#include <stdinc.hpp>
#include "loader/component_loader.hpp"
#include "gsc.hpp"
#include "scripting.hpp"
#include "scheduler.hpp"
#include "command.hpp"
#include "game/game.hpp"
#include "game/scripting/array.hpp"
#include <utils/moderation.hpp>
#include <utils/http.hpp>
#include <utils/cryptography.hpp>
#include <atomic>
#include <chrono>
#include <memory>
namespace moderation
{
namespace
{
using runtime = utils::moderation::runtime;
struct transfer
{
std::string session, response;
std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now();
std::atomic<bool> done{false};
bool success = false;
};
std::shared_ptr<runtime> current;
std::shared_ptr<transfer> pending;
std::string endpoint, credential, configured_server;
int configured_generation = 0;
std::chrono::steady_clock::time_point next_poll{}, next_log{};
bool fault = false;
long long now() { return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(); }
void failure()
{
if (std::chrono::steady_clock::now() >= next_log)
{
printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n");
next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30);
}
}
void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast<int>(slot)); }
std::string execute(const std::string& kind, int slot, const std::string& reason)
{
auto active = current;
if (!active) return "failed";
// This function is called only by the server-thread GSC pump. No queue/yield
// separates runtime target validation from synchronous engine dispatch.
if (kind == "announce" || kind == "warn" || kind == "message")
{
const auto text = std::string("j \"") + reason + "\"";
game::SV_GameSendServerCommand(slot, 0, text.c_str());
return "confirmed";
}
if (slot < 0 || slot >= 64) return "failed";
if (kind == "mute" || kind == "unmute")
{
command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true);
return "unknown"; // Engine has no mute-state readback in this adapter.
}
if (kind != "kick") return "failed";
const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string();
command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true);
return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown";
}
void pump()
{
if (!current || fault) return;
try
{
if (pending && pending->done.load())
{
auto completed = pending; pending.reset();
if (completed->session == current->session)
{
if (completed->success)
{
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - completed->started).count();
current->accept(completed->response, now(), age);
}
else failure();
}
}
auto active = current;
active->enforce(now(), [active](const auto& kind, int slot, const auto& reason)
{
return current == active ? execute(kind, slot, reason) : std::string("failed");
});
if (current != active) return;
if (!pending && std::chrono::steady_clock::now() >= next_poll)
{
auto request = current->request().dump(); auto target = endpoint; auto token = credential;
pending = std::make_shared<transfer>(); pending->session = current->session; auto work = pending;
next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3);
scheduler::thread_pool.push([work, request, target, token]
{
try
{
utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000;
limits.max_response_bytes = 1024 * 1024;
const auto response = utils::http::get_data(target, request,
{{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits);
if (response && response->code == CURLE_OK && response->status == 200)
{ work->response = response->buffer; work->success = true; }
}
catch (...) { work->success = false; }
work->done.store(true);
});
}
}
catch (...) { failure(); }
}
}
class component final : public component_interface
{
public:
void on_startup(plugin::plugin* plugin) override
{
plugin->get_interface()->callbacks()->on_player_connect(disconnected);
plugin->get_interface()->callbacks()->on_player_disconnect(disconnected);
scripting::on_shutdown([]
{
// In-flight requests own only native data. Results from the old VM are abandoned.
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
});
gsc::function::add("moderationversion", [] { return std::string("1.0.0"); });
gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation)
{
if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true;
// Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects.
if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos ||
url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 ||
token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 ||
token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false;
try
{
auto configured = std::make_shared<runtime>("tws-moderation/t6", server, url, generation,
utils::cryptography::sha256::compute(token, true));
current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation;
endpoint = url; credential = token; next_poll = {}; fault = false; return true;
}
catch (...) { fault = true; failure(); return false; }
});
gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name)
{
if (!current || fault) return false;
const auto ref = entity.get_entity_reference();
if (ref.classnum != 0 || ref.entnum >= 64) return false;
try { current->observe(ref.entnum, guid, name); return true; }
catch (...) { failure(); return false; }
});
gsc::function::add("moderationstop", []
{
current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false;
});
gsc::function::add("moderationpump", pump);
}
};
}
REGISTER_COMPONENT(moderation::component)
@@ -14,13 +14,17 @@ namespace utils::http
{
namespace
{
struct response_buffer { std::string bytes; size_t limit; };
size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept
{
const auto buffer = static_cast<std::string*>(userp);
const auto buffer = static_cast<response_buffer*>(userp);
if (size && nmemb > static_cast<size_t>(-1) / size) return 0;
const auto total_size = size * nmemb;
if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0;
try
{
buffer->append(static_cast<char*>(contents), total_size);
buffer->bytes.append(static_cast<char*>(contents), total_size);
}
catch (...)
{
@@ -68,7 +72,7 @@ namespace utils::http
header_list.reset(appended);
}
std::string buffer{};
response_buffer buffer{{}, limits.max_response_bytes};
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get());
curl_easy_setopt(curl, CURLOPT_URL, url.data());
@@ -106,7 +110,7 @@ namespace utils::http
response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME);
response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME);
if (response.code == CURLE_OK)
response.buffer = std::move(buffer);
response.buffer = std::move(buffer.bytes);
return response;
}
}
@@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <cstddef>
#include <optional>
#include <unordered_map>
@@ -25,6 +26,8 @@ namespace utils::http
{
long connect_timeout_ms = 5000;
long timeout_ms = 15000;
// Opt-in transport cap. Zero preserves existing callers; oversized transfers fail, never truncate.
size_t max_response_bytes = 0;
};
using headers = std::unordered_map<std::string, std::string>;
@@ -0,0 +1,349 @@
#include <stdinc.hpp>
#include "moderation.hpp"
#include <chrono>
#include <cctype>
#include <ctime>
#include <iomanip>
#include <random>
#include <set>
namespace utils::moderation
{
namespace
{
constexpr size_t max_file = 16 * 1024 * 1024;
constexpr size_t max_response = 1024 * 1024;
bool identifier(const std::string& value, size_t limit = 128)
{
return !value.empty() && value.size() <= limit &&
std::all_of(value.begin(), value.end(), [](unsigned char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' || ch == ':';
});
}
std::string bounded_text(const std::string& value, size_t limit)
{
// Engine names may contain malformed bytes. Preserve valid UTF-8 and never
// split a codepoint or let one bad name poison the complete JSON roster.
std::string result;
for (size_t i = 0; i < value.size() && result.size() < limit;)
{
const auto ch = static_cast<unsigned char>(value[i]);
size_t width = ch < 0x80 ? 1 : ch >= 0xc2 && ch <= 0xdf ? 2 :
ch >= 0xe0 && ch <= 0xef ? 3 : ch >= 0xf0 && ch <= 0xf4 ? 4 : 0;
bool valid = width && i + width <= value.size();
for (size_t j = 1; valid && j < width; ++j)
valid = (static_cast<unsigned char>(value[i + j]) & 0xc0) == 0x80;
if (valid && width >= 3)
{
const auto second = static_cast<unsigned char>(value[i + 1]);
valid = !(ch == 0xe0 && second < 0xa0) && !(ch == 0xed && second >= 0xa0) &&
!(ch == 0xf0 && second < 0x90) && !(ch == 0xf4 && second >= 0x90);
}
if (!valid) { result += '?'; ++i; continue; }
if (result.size() + width > limit) break;
result.append(value, i, width); i += width;
}
return result;
}
std::string player_name(const std::string& value)
{
auto name = bounded_text(value, 255);
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == 127) ch = ' ';
auto visible = name;
// Match ECMAScript trim whitespace so the controller cannot reject an
// otherwise complete roster because one display name is blank.
for (const std::string space : {" ", "\xc2\xa0", "\xe1\x9a\x80", "\xe2\x80\x80", "\xe2\x80\x81",
"\xe2\x80\x82", "\xe2\x80\x83", "\xe2\x80\x84", "\xe2\x80\x85", "\xe2\x80\x86", "\xe2\x80\x87",
"\xe2\x80\x88", "\xe2\x80\x89", "\xe2\x80\x8a", "\xe2\x80\xa8", "\xe2\x80\xa9", "\xe2\x80\xaf",
"\xe2\x81\x9f", "\xe3\x80\x80", "\xef\xbb\xbf"})
for (auto pos = visible.find(space); pos != std::string::npos; pos = visible.find(space)) visible.erase(pos, space.size());
return visible.empty() ? "Unknown player" : name;
}
bool guid_identifier(const std::string& value) { return identifier(value, 128) && value.find('.') == std::string::npos; }
std::string hash_scope(const std::string& text)
{
unsigned long long hash = 14695981039346656037ull;
for (unsigned char ch : text) hash = (hash ^ ch) * 1099511628211ull;
std::ostringstream out; out << std::hex << hash; return out.str();
}
void require(bool condition) { if (!condition) throw std::runtime_error("invalid moderation data"); }
std::string field(const json& value, const char* key, size_t max, bool empty = false)
{
require(value.contains(key) && value.at(key).is_string());
auto result = value.at(key).get<std::string>();
require((empty || !result.empty()) && result.size() <= max);
return result;
}
void reason(const json& value) { field(value, "reason", 500, true); }
void sanction(const json& value, long long revision)
{
require(value.is_object());
require(identifier(field(value, "id", 128)) && guid_identifier(field(value, "guid", 128)));
require(value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= revision);
auto kind = field(value, "kind", 16); require(kind == "ban" || kind == "mute"); reason(value);
if (!value.at("expiresAt").is_null()) timestamp(field(value, "expiresAt", 32));
}
void action(const json& value)
{
require(value.is_object() && identifier(field(value, "id", 128)));
auto kind = field(value, "kind", 16);
require(kind == "kick" || kind == "warn" || kind == "message" || kind == "announce");
if (kind == "announce") require(value.at("guid").is_null() && value.at("connection").is_null());
else require(guid_identifier(field(value, "guid", 128)) && identifier(field(value, "connection", 128)));
reason(value); timestamp(field(value, "expiresAt", 32));
}
}
long long timestamp(const std::string& value)
{
require(value.size() == 20 || value.size() == 24);
require(value.back() == 'Z' && value[10] == 'T');
if (value.size() == 24) require(value[19] == '.' && std::isdigit(value[20]) && std::isdigit(value[21]) && std::isdigit(value[22]));
std::tm tm{}; std::istringstream input(value.substr(0, 19)); input >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
require(!input.fail() && input.peek() == EOF && tm.tm_year >= 100 && tm.tm_year <= 200);
const auto original = tm;
#ifdef _WIN32
auto result = _mkgmtime64(&tm);
#else
auto result = timegm(&tm);
#endif
require(result >= 0 && original.tm_year == tm.tm_year && original.tm_mon == tm.tm_mon && original.tm_mday == tm.tm_mday &&
original.tm_hour == tm.tm_hour && original.tm_min == tm.tm_min && original.tm_sec == tm.tm_sec);
return result;
}
std::string command_text(const std::string& value)
{
// Untrusted text is never a console delimiter or a quoted command escape.
std::string result;
for (unsigned char ch : bounded_text(value, 240))
{
result += (ch < 32 || ch == 127 || ch == '"' || ch == '\\' || ch == ';' || ch == '^') ? ' ' : static_cast<char>(ch);
}
return result;
}
std::string canonical_guid(const std::string& value)
{
if (!value.empty() && std::all_of(value.begin(), value.end(), [](char ch) { return ch >= '0' && ch <= '9'; }))
{
const auto first = value.find_first_not_of('0');
return first == std::string::npos ? "0" : value.substr(first);
}
return value;
}
std::string new_session()
{
std::random_device random; std::ostringstream out; out << std::hex << std::setfill('0');
for (int i = 0; i < 4; ++i) out << std::setw(8) << static_cast<unsigned>(random());
return out.str();
}
runtime::runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope)
: server_(std::move(server)), endpoint_(std::move(endpoint)), credential_scope_(std::move(credential_scope)), generation_(generation)
{
require(!server_.empty() && server_.size() <= 255 && generation_ > 0 && identifier(credential_scope_));
file_ = root / (hash_scope(server_ + '\0' + endpoint_ + '\0' + std::to_string(generation_) + '\0' + credential_scope_) + ".json");
saved_ = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"generation", generation_}, {"credentialScope", credential_scope_},
{"revision", 0}, {"sessionSequence", 0}, {"sanctions", json::array()}, {"journal", json::object()}, {"ownedMutes", json::array()}};
if (std::filesystem::exists(file_))
{
require(std::filesystem::file_size(file_) < max_file);
std::ifstream stream(file_, std::ios::binary); json value; stream >> value;
require(!stream.bad() && value.at("version") == 1 && value.at("server") == server_ && value.at("endpoint") == endpoint_ &&
value.at("generation") == generation_ && value.at("credentialScope") == credential_scope_ && value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= 9007199254740991LL &&
value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && value.at("journal").is_object() && value.at("journal").size() <= 10000);
for (const auto& policy : value.at("sanctions")) sanction(policy, value.at("revision").get<long long>());
for (auto it = value.at("journal").begin(); it != value.at("journal").end(); ++it)
{
require(identifier(it.key())); action(it.value().at("action"));
auto state = field(it.value(), "state", 16);
require(state == "confirmed" || state == "failed" || state == "unknown" || state == "expired");
require(it.value().at("reported").is_boolean() && it.value().at("action").at("id") == it.key());
}
require(value.at("ownedMutes").is_array() && value.at("ownedMutes").size() <= 10000);
for (const auto& guid : value.at("ownedMutes")) require(guid.is_string() && guid_identifier(guid.get<std::string>()));
saved_ = std::move(value);
}
require(saved_.at("sessionSequence").is_number_integer() && saved_.at("sessionSequence") >= 0 && saved_.at("sessionSequence") < 9007199254740991LL);
auto next = saved_; next["sessionSequence"] = saved_.at("sessionSequence").get<long long>() + 1;
save(next); // Allocate this boot/map sequence durably before exposing its session.
}
void runtime::save(const json& next)
{
const auto bytes = next.dump(); require(bytes.size() < max_file);
const auto exists = std::filesystem::exists(file_);
require(exists || !allocated_);
if (exists)
{
// A synchronous engine command may replace the map/runtime before the
// older call frame returns. It must never overwrite the new sequence.
require(std::filesystem::file_size(file_) < max_file);
std::ifstream source(file_, std::ios::binary); json checkpoint; source >> checkpoint;
require(!source.bad() && checkpoint.at("version") == 1 && checkpoint.at("server") == server_ &&
checkpoint.at("endpoint") == endpoint_ && checkpoint.at("generation") == generation_ &&
checkpoint.at("credentialScope") == credential_scope_ && checkpoint.at("sessionSequence") == saved_.at("sessionSequence"));
}
std::filesystem::create_directories(file_.parent_path()); auto temp = file_; temp += ".tmp";
std::ofstream stream(temp, std::ios::binary | std::ios::trunc); stream.write(bytes.data(), bytes.size()); stream.flush();
require(stream.good()); stream.close(); require(!stream.fail());
#ifdef _WIN32
require(MoveFileExW(temp.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0);
#else
std::filesystem::rename(temp, file_);
#endif
saved_ = next; allocated_ = true;
}
void runtime::observe(int slot, const std::string& guid, const std::string& name)
{
require(slot >= 0 && slot < 64 && guid_identifier(guid));
auto found = players.find(slot);
if (found == players.end() || found->second.guid != canonical_guid(guid))
{
disconnect(slot);
players.emplace(slot, player{slot, canonical_guid(guid), player_name(name), session + ":" + std::to_string(++serial_)});
}
else found->second.name = player_name(name);
}
void runtime::disconnect(int slot)
{
auto found = players.find(slot);
if (found != players.end()) { muted_.erase(found->second.connection); kicked_.erase(found->second.connection); players.erase(found); }
}
json runtime::request()
{
json roster = json::array(), receipts = json::array(); sent_.clear();
for (const auto& [slot, value] : players) roster.push_back({{"guid", value.guid}, {"name", value.name}, {"connection", value.connection}});
for (auto it = saved_["journal"].begin(); it != saved_["journal"].end(); ++it)
if (!it.value().at("reported").get<bool>() && receipts.size() < 256)
{
receipts.push_back({{"id", it.key()}, {"state", it.value().at("state")}, {"detail", it.value().value("detail", std::string())}});
sent_.push_back(it.key());
}
return {{"protocol", 1}, {"server", server_}, {"generation", generation_}, {"session", session}, {"sessionSequence", saved_.at("sessionSequence")}, {"players", roster},
{"capabilities", {"warn", "message", "kick", "announce", "ban", "mute"}}, {"appliedRevision", revision()}, {"receipts", receipts}};
}
void runtime::accept(const std::string& response, long long now, long long transfer_age_ms)
{
const auto received = std::chrono::steady_clock::now();
require(!response.empty() && response.size() <= max_response && transfer_age_ms >= 0 && transfer_age_ms <= 86400000);
auto value = json::parse(response); require(value.is_object() && value.at("ok").is_boolean() && value.at("ok").get<bool>());
require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL);
auto revision = value.at("revision").get<long long>();
auto server_now = timestamp(field(value, "serverTime", 32)); require(std::abs(now - server_now) <= 120);
require(value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 &&
value.at("actions").is_array() && value.at("actions").size() <= 100 &&
value.at("pollAfterMs").is_number_integer() && value.at("pollAfterMs") >= 1000 && value.at("pollAfterMs") <= 60000);
std::set<std::string> ids;
for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get<std::string>()).second); }
ids.clear();
for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get<std::string>(); require(ids.emplace(id).second);
if (saved_["journal"].contains(id)) require(saved_["journal"][id].at("action") == entry); }
if (revision == this->revision() && revision != 0)
{
auto previous = saved_.at("sanctions");
for (auto it = previous.begin(); it != previous.end(); )
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = previous.erase(it); else ++it;
auto incoming = value.at("sanctions");
for (auto it = incoming.begin(); it != incoming.end(); )
if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get<std::string>()) <= server_now) it = incoming.erase(it); else ++it;
auto by_id = [](const json& a, const json& b) { return a.at("id").get<std::string>() < b.at("id").get<std::string>(); };
std::sort(previous.begin(), previous.end(), by_id); std::sort(incoming.begin(), incoming.end(), by_id);
require(incoming == previous);
}
auto next = saved_; next["revision"] = revision; next["sanctions"] = value.at("sanctions");
for (const auto& id : sent_) if (next["journal"].contains(id)) next["journal"][id]["reported"] = true;
// Expired entries are safe to prune only after acknowledgment. An old action still fails its deadline.
for (auto it = next["journal"].begin(); it != next["journal"].end(); )
if (it.value().at("reported").get<bool>() && timestamp(it.value().at("action").at("expiresAt").get<std::string>()) < now - 86400) it = next["journal"].erase(it); else ++it;
save(next); pending_ = value.at("actions");
const auto accepted = std::chrono::steady_clock::now();
const auto processing = std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - received);
const auto estimate = std::chrono::system_clock::from_time_t(server_now) + std::chrono::seconds(1) +
std::chrono::milliseconds(transfer_age_ms) + processing;
const auto previous = server_anchor_ + std::chrono::duration_cast<std::chrono::system_clock::duration>(accepted - clock_anchor_);
server_anchor_ = server_anchor_.time_since_epoch().count() ? std::max(previous, estimate) : estimate;
clock_anchor_ = accepted;
}
long long runtime::effective_time(long long local) const
{
if (!server_anchor_.time_since_epoch().count()) return local;
// Never extend deadlines when the host clock lags or jumps backward. The
// full request age is conservative because serverTime is generated during it.
const auto elapsed = std::chrono::duration_cast<std::chrono::system_clock::duration>(std::chrono::steady_clock::now() - clock_anchor_);
const auto estimate = std::chrono::duration_cast<std::chrono::seconds>((server_anchor_ + elapsed).time_since_epoch()).count();
return std::max(local, static_cast<long long>(estimate));
}
void runtime::enforce(long long now, const executor& execute)
{
now = effective_time(now);
auto pending = pending_; pending_ = json::array();
for (const auto& item : pending)
{
auto id = item.at("id").get<std::string>();
if (saved_["journal"].contains(id))
{
// A duplicate immutable action is acknowledged, never executed twice.
require(saved_["journal"][id].at("action") == item);
auto next = saved_; next["journal"][id]["reported"] = false; save(next); continue;
}
require(saved_["journal"].size() < 10000);
auto kind = item.at("kind").get<std::string>(); int slot = -1;
auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain.");
if (timestamp(item.at("expiresAt").get<std::string>()) <= now) { state = "expired"; detail = "Action deadline elapsed."; }
else if (kind != "announce")
{
for (const auto& [number, target] : players)
if (item.at("guid") == target.guid && item.at("connection") == target.connection) slot = number;
if (slot < 0) { state = "failed"; detail = "Player connection no longer matches."; }
}
auto next = saved_; next["journal"][id] = {{"action", item}, {"state", state}, {"detail", detail}, {"reported", false}}; save(next);
if (state == "unknown")
{
// All target validation and dispatch occur on the calling game thread, without yielding.
if (timestamp(item.at("expiresAt").get<std::string>()) <= effective_time(now)) state = "expired";
else
{
try { state = execute(kind, slot, command_text(item.at("reason").get<std::string>())); }
catch (...) { state = "unknown"; }
if (state != "confirmed" && state != "failed") state = "unknown";
}
next = saved_; next["journal"][id]["state"] = state;
next["journal"][id]["detail"] = state == "confirmed" ? "Game dispatch completed." : state == "expired" ? "Action deadline elapsed before dispatch." : "Game dispatch outcome could not be verified."; save(next);
}
}
// Snapshot is copied because kicking can synchronously invoke a disconnect callback.
const auto roster = players;
for (const auto& [slot, target] : roster)
{
now = effective_time(now);
if (!players.contains(slot) || players.at(slot).connection != target.connection) continue;
bool ban = false, mute = false; std::string why;
for (const auto& policy : saved_.at("sanctions"))
{
if (policy.at("guid") != target.guid || (!policy.at("expiresAt").is_null() && timestamp(policy.at("expiresAt").get<std::string>()) <= now)) continue;
if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get<std::string>(); }
if (policy.at("kind") == "mute") mute = true;
}
if (ban)
{
if (!kicked_.contains(target.connection) || kicked_[target.connection] + 5 <= now)
{ kicked_[target.connection] = now; execute("kick", slot, command_text(why)); }
continue;
}
bool was_muted = muted_.contains(target.connection) && muted_.at(target.connection);
auto owned = saved_.at("ownedMutes");
bool managed = std::find(owned.begin(), owned.end(), target.guid) != owned.end();
if (mute != was_muted || (!mute && managed))
{
if (mute && !managed)
{
require(owned.size() < 10000); owned.push_back(target.guid);
auto next = saved_; next["ownedMutes"] = owned; save(next);
}
execute(mute ? "mute" : "unmute", slot, ""); muted_[target.connection] = mute;
if (!mute && managed)
{
owned.erase(std::remove(owned.begin(), owned.end(), json(target.guid)), owned.end());
auto next = saved_; next["ownedMutes"] = owned; save(next);
}
}
}
}
}
@@ -0,0 +1,46 @@
#pragma once
#include <filesystem>
#include <chrono>
#include <functional>
#include <map>
#include <string>
#include <vector>
#include <json.hpp>
namespace utils::moderation
{
using json = nlohmann::json;
struct player { int slot; std::string guid, name, connection; };
using executor = std::function<std::string(const std::string&, int, const std::string&)>;
long long timestamp(const std::string& value);
std::string command_text(const std::string& value);
std::string new_session();
std::string canonical_guid(const std::string& value);
class runtime
{
public:
runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope);
std::string session = new_session();
std::map<int, player> players;
void observe(int slot, const std::string& guid, const std::string& name);
void disconnect(int slot);
json request();
void accept(const std::string& response, long long now, long long transfer_age_ms = 0);
void enforce(long long now, const executor& execute);
long long revision() const { return saved_.at("revision").get<long long>(); }
private:
std::filesystem::path file_;
std::string server_, endpoint_, credential_scope_;
int generation_;
unsigned long long serial_ = 0;
bool allocated_ = false;
std::chrono::system_clock::time_point server_anchor_{};
std::chrono::steady_clock::time_point clock_anchor_{};
json saved_, pending_ = json::array();
std::vector<std::string> sent_;
std::map<std::string, bool> muted_;
std::map<std::string, long long> kicked_;
void save(const json& next);
long long effective_time(long long local) const;
};
}
+13 -5
View File
@@ -2,7 +2,7 @@
Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts,
just like T5. The telemetry token is embedded in the reporter script; no
separate telemetry token config is required. The optional bank needs its own
separate telemetry token config is required. The optional bank and moderation modules each need their own
private server token.
| File | Role | Dependencies |
@@ -15,14 +15,16 @@ private server token.
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common 1.2.0+, tws-t6-gsc-utils |
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 |
| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
## Install
1. Copy all eight `tws_t6_*.gsc` files into
1. Copy the desired modules from the nine `tws_t6_*.gsc` files into
`Plutonium/storage/t6/scripts/zm/`. Update common along with HUD, leaderboard
and AFK when those modules are installed.
2. For telemetry, leaderboard or bank, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
in `Plutonium/plugins/` and restart the server process. The other five scripts
2. For telemetry, leaderboard, bank or moderation, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1,
in `Plutonium/plugins/` and restart the server process. Moderation requires
plugin 1.3.0+ before its script is installed; see [moderation setup](../../docs/moderation.md). The other five scripts
do not require that plugin.
3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in
the reporter; see [authentication setup](../../README.md#telemetry-authentication-t5-and-t6).
@@ -260,7 +262,7 @@ another mod installed one first.
Run the shared [portable and live HUD checks](../../README.md#hud-validation)
for core placement, service pagination, record interruptions and command regressions.
To compile all eight scripts for T6 PC with gsc-tool:
To compile all nine scripts for T6 PC with gsc-tool:
```powershell
.\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe
@@ -268,3 +270,9 @@ To compile all eight scripts for T6 PC with gsc-tool:
Compilation does not verify engine/plugin linking or gameplay. T6 differences,
research sources and live checks are in [NOTES.md](NOTES.md).
## Moderation
Optional `tws_t6_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching
plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private
configuration, persistence, action results and deployment checks.
+3 -3
View File
@@ -6,8 +6,8 @@ $ErrorActionPreference = 'Stop'
$compiler = (Resolve-Path -LiteralPath $CompilerPath).Path
$repo = Split-Path -Parent $PSScriptRoot
$files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name)
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc', 'tws_t6_bank.gsc')
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the eight TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank.' }
$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc', 'tws_t6_bank.gsc', 'tws_t6_moderation.gsc')
if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the nine TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank, moderation.' }
$common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw
$functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value })
foreach ($file in $files) {
@@ -32,4 +32,4 @@ foreach ($file in $files) {
}
Write-Output "PASS $($file.Name)"
}
Write-Output 'All eight T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
Write-Output 'All nine T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.'
+66
View File
@@ -0,0 +1,66 @@
/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */
#include maps\mp\_utility;
#include common_scripts\utility;
// T6 loader probes main and init; initialize only once.
main()
{
}
init()
{
scripts\zm\tws_t6_common::ensure_config();
level.moderation_ready = false;
scripts\zm\tws_t6_common::register_connect_handler(::moderation_connected);
level thread moderation_loop();
}
moderation_connected()
{
self endon("disconnect");
if (!level.moderation_ready)
return;
moderation_observe(self);
moderationPump();
}
moderation_observe(player)
{
if (!isDefined(player))
return;
guid = player.guid;
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return;
// Preserve large identifiers as strings; native validation rejects ambiguous inputs.
guid = "" + guid;
name = player.name;
if (!isDefined(name))
name = "Unknown player";
player moderationObserve(guid, name);
}
moderation_loop()
{
for (;;)
{
if (GetDvar("tws_moderation_enabled") == "0")
{
moderationStop();
level.moderation_ready = false;
wait 3;
continue;
}
level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"),
GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation"));
if (!level.moderation_ready)
{
wait 3;
continue;
}
players = getPlayers();
for (i = 0; i < players.size; i++)
moderation_observe(players[i]);
moderationPump();
wait 0.5;
}
}