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
+24
View File
@@ -4,6 +4,30 @@ Changes are listed newest first, with an independent version for every individua
T5/T6 script and each native plugin. Current versions are recorded in
[versions.json](versions.json); see the [version policy](README.md#versioning).
## 2026-10-15 — Player moderation runtime
- **tws_t5_moderation.gsc 1.0.0 / tws_t6_moderation.gsc 1.0.0**: new optional
paired scripts register current GUIDs and use the existing shared connection
dispatcher. Require common 1.2.0, the matching native plugin 1.3.0, and Electric
Cherry's instance-authenticated moderation endpoint/private configuration.
- **t5-plugin 1.3.0 / t6-plugin 1.3.0**: add the native moderation component and
policy/journal utilities, connection-bound synchronous actions, durable cached
bans/mutes and expiry/revocation, and an opt-in bounded HTTP response limit.
Existing HTTP callers keep their previous limit behavior. Sources are each
plugin's component/moderation.cpp, utils/moderation.*, and utils/http.*.
Both games change for matching functionality; their chat opcodes remain distinct.
- Deploy the receiver first; build and install both matching DLL/script pairs,
supply separate instance credentials, and fully restart game-server processes.
Persistent tws-moderation directories must survive restart. No deployment or
live Windows/Plutonium verification is claimed. See [runtime contract and
validation](docs/moderation.md).
- Validation: paired native moderation policy/adapter checks passed with ASan and
UBSan, including separate-process journal and session-sequence recovery. The
GSC identity and retry/disable/resume checks, native HTTP cap/error checks,
existing HTTP component, bank and telemetry checks, metadata/link validation,
and `git diff --check` passed. Native adapter tests use engine shims; no DLL
build, GSC compilation or live game validation was performed.
## 2026-10-15
### Replace the dashboard panels with unboxed HUD overlays
+10 -1
View File
@@ -40,7 +40,7 @@ Versions use `MAJOR.MINOR.PATCH`: increase the patch for fixes and internal
changes, the minor for compatible features, and the major for breaking changes
to commands, configuration, native APIs, or data contracts. Reset lower fields
when increasing the minor or major. Bump once per logical change; documentation-only
and test-only changes are exempt. All 16 scripts and both plugins advance independently.
and test-only changes are exempt. All 18 scripts and both plugins advance independently.
The initial `1.0.0` versions document the existing source baseline. These TWS
versions are separate from upstream revisions in each plugin's `upstream.json`
@@ -591,3 +591,12 @@ powershell -File t6/scripts/tools/check.ps1 -CompilerPath C:\path\to\gsc-tool.ex
```
Requires PowerShell and `gsc-tool` (T6 PC).
## Player moderation (T5 and T6)
See [Moderation runtime](docs/moderation.md) for the instance credential contract,
policy persistence, command-result limits and deployment validation. New
`tws_t5_moderation.gsc` / `tws_t6_moderation.gsc` 1.0.0 require the corresponding
TWS plugin 1.3.0 and common script 1.2.0. Moderation is optional; without private
configuration it stays disabled. Existing bank and telemetry credentials are
never reused. Native plugin updates require a full game-server process restart.
+114
View File
@@ -0,0 +1,114 @@
# Moderation runtime
T5 and T6 use the same protocol and native policy core, with separate native
chat opcodes and existing engine symbols. Install the game's new moderation
script 1.0.0, common 1.2.0, and native plugin 1.3.0. The controller must implement
`POST /api/moderation/runtime` before enabling the scripts.
## Private configuration
Each instance receives its own non-shared credential. Keep this configuration
out of shared scripts, release artifacts, ordinary config exports, and logs:
```cfg
set tws_server_id "registered-server"
set tws_moderation_url "https://controller.example/api/moderation/runtime"
set tws_moderation_token "ec_moderation_REPLACE_WITH_INSTANCE_SECRET"
set tws_moderation_generation "1"
```
Set `tws_moderation_enabled "0"` to disable the runtime. Missing or invalid
configuration leaves it waiting; it retries every three seconds so private configuration can be installed without a map restart. Disabling stops synchronization and enforcement; it does not revoke controller sanctions or promise to restore existing engine mutes. HTTPS is mandatory; redirects are not followed.
The generation must match the controller's live instance. Only that instance's
credential authorizes its roster, receipts and policy; the bank and telemetry
credentials cannot authorize moderation.
## Synchronization
Native HTTP runs outside the game thread with 3-second connection and 5-second
transfer deadlines. Polls start at most once per 3 seconds; only one poll is in
flight. Responses are limited to 1 MiB at the transport (oversize fails without
truncation), 1000 sanctions and 100 actions. The native cache never crosses a GSC
string boundary. Requests include protocol 1, server, generation, runtime session, sessionSequence,
players `{guid,name,connection}`, capabilities, appliedRevision and up to 256
receipts. Sessions change on map/process startup. A positive safe-integer sessionSequence is incremented and atomically persisted before each new runtime starts; the controller rejects older sequences and conflicting sessions at the same sequence. Repeated unchanged configuration preserves the current sequence. Never share an instance credential or its cache directory between simultaneous game-server processes. Connections change on every
native connect/disconnect event, even when the same GUID reuses the same slot.
GUIDs remain strings; pure decimal IDs have leading zeroes removed without a numeric conversion; the T5 script reads getGuid and the T6 script reads the
stock persistent guid field. GUIDs are limited to 128 ASCII identifier bytes. Roster names are bounded to 255 UTF-8 bytes; malformed name bytes become question marks and ASCII controls become spaces. Blank names, including Unicode whitespace-only names, become Unknown player. Names never select an action target.
Responses contain `{ok,revision,serverTime,sanctions,actions,pollAfterMs}`.
Sanctions contain `{id,revision,guid,kind,reason,expiresAt}`, with `kind` ban/mute
and nullable expiry. Actions contain `{id,guid,connection,kind,reason,expiresAt}`;
kind is warn/message/kick/announce. Only announce uses null GUID and connection.
Deadlines and policy expiry are UTC ISO timestamps. Clock skew over two minutes
rejects an update. Received server time is anchored to monotonic elapsed time without moving backward between polls, conservatively including the full transfer age and a one-second timestamp rounding margin, so a slow or backward-jumping host clock cannot extend one-time deadlines. This may expire an action or timed policy slightly early. Deadlines are checked again after writing the action journal and before dispatch. Revision rollback, inconsistent policy at the same revision,
malformed data, duplicate IDs and changed action content reject an update.
Removing sanctions already expired according to the validated serverTime is permitted without a revision increment.
The current client uses its fixed 3-second poll interval after validating the
server's pollAfterMs bounds (1000–60000).
## Enforcement and recovery
Keep `tws-moderation/t5/` or `tws-moderation/t6/`, relative to the game process
working directory, on persistent writable storage. Files are scoped to server,
endpoint, instance generation and SHA-256 credential fingerprint and do not contain the credential. Complete
snapshots replace files atomically. Every write verifies ownership of the current session sequence so a superseded runtime cannot roll the counter or policy backward. Corrupt cache disables startup and preserves
the file; storage failures are logged and block new effectful actions. Cache loss must be recovered by rebuilding the controller instance generation and issuing a new private credential; resetting or deleting a cache under an existing credential cannot reset the controller's highest session sequence. Retired
instance generations cannot inherit a newer generation's policy cache.
Cached bans and mutes continue during controller outages and expire using the
host wall clock until synchronized, then the later of host time and the monotonic server-time estimate. After a restart without connectivity, host clock drift remains an operator responsibility. Ban enforcement is a kick after connection, checked through the
shared connected dispatcher and a 500 ms loop; it is not a pre-authentication
admission hook. Still-connected banned players are checked again every five
seconds. Removing the last applicable mute restores clients muted by this
runtime; unrelated manually applied mutes are not deliberately cleared. Managed
mute ownership survives process/map restart so revoked cached mutes can be
removed when the affected player returns. No native ban.txt is written.
One-time actions require the exact current connection and unexpired deadline.
The native game-thread adapter validates the target and executes immediately;
GSC's buffered executeCommand is never used. Only fixed kick/mute/unmute command
templates are available; untrusted text cannot add delimiters, quotes, line
breaks or backslash escapes. Visible text is bounded to 240 UTF-8 bytes without splitting codepoints. Messages use direct per-game server message calls.
Before one-time dispatch, the action and an unknown receipt are saved durably.
Duplicate IDs never execute again, including after restart. A crash between
journal write and dispatch may therefore lose a one-time action; its result
stays unknown instead of risking a duplicate kick. Journal entries expire from
storage only after receipt acknowledgment and at least a day past their action
deadline. The journal is bounded at 10000 entries; saturation stops new actions
and requires controller delivery/storage investigation.
A confirmed private-message receipt means the game server dispatched it, not
that the player read it. A kick is confirmed only if its synchronous dispatch
observes the native disconnect callback; otherwise the result is unknown.
Native mute commands have no readback, so appliedRevision means the policy is
cached and being enforced, not independently verified voice/text suppression.
Do not describe a cached policy revision as proven enforcement.
## Validation and rollout
Portable checks:
```sh
node tools/check_moderation.mjs
python3 tools/test_plugin_moderation.py
python3 tools/test_plugin_http.py
python3 tools/test_plugin_component.py
```
The native moderation runner accepts `--json-header '/path/{game}-json.hpp'`
when dependencies have not been restored. Use each plugin's pinned JSON revision.
It compiles the actual core and registration/HTTP bridge against minimal engine
shims with ASan/UBSan, including separate process persistence tests. These checks
do not validate Windows linking or the Plutonium ABI.
Build both x86 DLLs using the existing Windows build workflow. Deploy the new
plugins and scripts together, then fully restart each game-server process; a map
restart alone cannot load the native methods. Test in isolated T5 Zombies and T6
Zombies instances before fleet rollout: duplicate names, same-account reconnect,
slot reuse, kick command/reason behavior, text and voice mute/unmute, reconnect
and map/process restart, expiry, revocation, controller outage, credential
revocation, truncated/oversized responses and disk failure. Verify clientkick and
muteclient command support in the deployed Plutonium revisions. If unsupported,
keep moderation disabled; this change has no automatic production deployment.
@@ -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("h \"") + 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/t5", 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;
};
}
+11 -2
View File
@@ -1,7 +1,7 @@
# TWS T5 Zombies scripts
Custom HUD + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5
(Black Ops 1) zombies dedicated servers. Split across eight `.gsc` files,
(Black Ops 1) zombies dedicated servers. Split across nine `.gsc` files,
with `tws_t5_common` as the shared hub. Lobby lock also requires the HUD module.
Platform quirks and deploy commands: [NOTES.md](NOTES.md).
@@ -18,10 +18,13 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection |
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ |
## Install / upgrade
Copy `tws_t5_*.gsc` to `storage/t5/scripts/sp/`, then restart the map.
Copy the desired `tws_t5_*.gsc` modules to `storage/t5/scripts/sp/`, then restart the map.
Before adding the optional moderation module, install native plugin 1.3.0+ and
restart the full game-server process; see [moderation setup](../../docs/moderation.md).
When upgrading an existing installation, first remove these old scripts from
that directory:
@@ -595,3 +598,9 @@ and [score tracking](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Com
and leaderboard HTTP, JSON, type and string helpers. Install a build exposing
the natives listed in each module's documentation, then restart the server
process. No database is required.
## Moderation
Optional `tws_t5_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.
+63
View File
@@ -0,0 +1,63 @@
/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */
#include maps\_utility;
#include common_scripts\utility;
init()
{
if (GetDvar(#"zombiemode") != "1")
return;
scripts\sp\tws_t5_common::ensure_config();
level.moderation_ready = false;
scripts\sp\tws_t5_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 getGuid();
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;
}
}
@@ -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;
}
}
+1 -1
View File
@@ -502,7 +502,7 @@ for (const game of ['t5', 't6']) {
const functions = new Set([...eSource(game).matchAll(/^(\w+)\([^\n]*\)\s*\n\{/gm)].map(m => m[1]));
const folder = new URL(`${game}/scripts/`, root);
const scripts = readdirSync(folder).filter(file => file.endsWith('.gsc'));
assert.equal(scripts.length, 8);
assert.equal(scripts.length, 9);
let connected = 0;
for (const file of scripts) {
const source = readFileSync(new URL(file, folder), 'utf8');
+40
View File
@@ -0,0 +1,40 @@
// Exercise the real GSC identity adapter under a small VM shim; native policy tests are separate.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
for (const game of ['t5','t6']) {
const source=readFileSync(new URL(`../${game}/scripts/tws_${game}_moderation.gsc`,import.meta.url),'utf8');
assert.equal((source.match(/level waittill\("connected"/g)??[]).length,0,'must use the shared connection dispatcher');
assert(source.includes('register_connect_handler(::moderation_connected)'));
assert(!source.includes('executeCommand'),'all effects belong to the native synchronous adapter');
const start=source.indexOf('moderation_observe(player)\n{');
const end=source.indexOf('\nmoderation_loop()',start);
let body=source.slice(start,end).replace('moderation_observe(player)','function observe(player)');
body=body.replace(' guid =',' var guid =').replace(' name =',' var name =');
body=body.replace('player getGuid()','getGuid(player)').replaceAll('typeof(', 'kind(').replace('player moderationObserve(guid, name)', 'record(player,guid,name)');
const calls=[];
const c=vm.createContext({isDefined:v=>v!==null&&v!==undefined,kind:v=>typeof v==='number'?(Number.isInteger(v)?'int':'float'):typeof v,
getGuid:p=>p.identity,record:(...args)=>calls.push(args)});
vm.runInContext(body,c);
const player=identity=>({identity,guid:identity,name:'same name'});
c.observe(player('18446744073709551615')); assert.equal(calls.at(-1)[1],'18446744073709551615');
c.observe(player(2741626)); assert.equal(calls.at(-1)[1],'2741626');
c.observe(player('0000123')); assert.equal(calls.at(-1)[1],'0000123');
const count=calls.length; for(const value of [undefined,null,1.5,{},[]])c.observe(player(value)); assert.equal(calls.length,count);
c.observe(undefined); assert.equal(calls.length,count);
c.observe({...player('abc'),name:undefined}); assert.equal(calls.at(-1)[2],'Unknown player');
let loop=source.slice(source.indexOf('moderation_loop()\n{'))
.replace('moderation_loop()', 'function* loop()').replaceAll(/wait ([0-9.]+);/g, 'yield $1;')
.replace('players = getPlayers();', 'var players = getPlayers();').replace('for (i = 0;', 'for (var i = 0;').replace('players.size', 'players.length');
let enabled=true, configured=false, stopped=0, pumped=0, observed=0;
const level={moderation_ready:false};
const runtime=vm.createContext({level,GetDvar:key=>key==='tws_moderation_enabled'?(enabled?'1':'0'):'configured',GetDvarInt:()=>1,
moderationConfigure:()=>configured,moderationStop:()=>++stopped,moderationPump:()=>++pumped,getPlayers:()=>[{guid:'123'}],moderation_observe:()=>++observed});
vm.runInContext(loop,runtime); const poll=runtime.loop();
assert.equal(poll.next().value,3); assert.equal(level.moderation_ready,false);
configured=true; assert.equal(poll.next().value,0.5); assert.equal(level.moderation_ready,true);
assert.equal(pumped,1); assert.equal(observed,1);
enabled=false; assert.equal(poll.next().value,3); assert.equal(level.moderation_ready,false); assert.equal(stopped,1);
enabled=true; assert.equal(poll.next().value,0.5); assert.equal(pumped,2);
console.log(`PASS ${game}: actual moderation identity, retry/disable/resume loop and shared dispatcher integration`);
}
+7 -2
View File
@@ -66,9 +66,9 @@ def main():
base = f"http://127.0.0.1:{http.server_address[1]}"
env = {**os.environ, "NO_PROXY": "*", "no_proxy": "*"}
def probe(path, connect="1000", total="1000"):
def probe(path, connect="1000", total="1000", cap=None):
url = path if "://" in path else base + path
completed = subprocess.run([str(executable), url, connect, total], env=env, capture_output=True, text=True, check=True, timeout=8)
completed = subprocess.run([str(executable), url, connect, total] + ([] if cap is None else [str(cap)]), env=env, capture_output=True, text=True, check=True, timeout=8)
return json.loads(completed.stdout)
normal = probe("/200", "-", "-")
@@ -84,6 +84,11 @@ def main():
assert timed_out["body_size"] == 0, timed_out
if status: assert timed_out["first_byte_ms"] > 0, timed_out
assert probe("/200")["curl_code"] == 0 # The next request completes after the stall.
exact = probe("/200", cap=11)
assert exact["curl_code"] == 0 and exact["body_size"] == 11, exact
bounded = probe("/200", cap=10)
assert bounded["curl_code"] == 23 and bounded["body_size"] == 0, bounded
assert probe("/200")["body_size"] == 11
slow = probe("/slow")
assert slow["curl_code"] == 0 and slow["first_byte_ms"] >= 90, slow
tls_url = f"https://127.0.0.1:{tls.server_address[1]}/"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Compile both real native moderation cores with ASan/UBSan (no Windows/VM emulation)."""
import argparse
import os
import re
from pathlib import Path
import shutil
import subprocess
import tempfile
ROOT=Path(__file__).resolve().parents[1]
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--json-header', help='Pinned single header; {game} expands to t5/t6')
a=p.parse_args()
with tempfile.TemporaryDirectory(prefix='tws-moderation-tests-') as tmp:
temp=Path(tmp)
for game in ('t5','t6'):
plugin=ROOT/game/'plugins'/f'tws-{game}-gsc-utils'
header=Path(a.json_header.format(game=game)) if a.json_header else plugin/'deps/json/single_include/nlohmann/json.hpp'
if not header.is_file(): p.error(f'Missing {header}; restore pinned dependencies or pass --json-header')
shutil.copyfile(header,temp/'json.hpp')
(temp/'stdinc.hpp').write_text('#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n')
exe=temp/game
subprocess.run([os.environ.get('CXX','c++'),'-std=c++20','-Wall','-Wextra','-Werror','-fsanitize=address,undefined','-I',str(temp),'-I',str(plugin/'src/utils'),str(plugin/'src/utils/moderation.cpp'),str(ROOT/'tools/tests/moderation_cases.cpp'),'-o',str(exe)],check=True)
subprocess.run([str(exe),str(temp/f'{game}-data')],check=True)
subprocess.run([str(exe),str(temp/f'{game}-restart'),'write'],check=True)
subprocess.run([str(exe),str(temp/f'{game}-restart'),'read'],check=True)
component=re.sub(r'^#include[^\n]*\n','',(plugin/'src/component/moderation.cpp').read_text(),flags=re.MULTILINE)
(temp/'component.cpp').write_text('#include "moderation_component_stub.hpp"\n#include "moderation.hpp"\n#include <iomanip>\n'+component+(ROOT/'tools/tests/moderation_component_cases.cpp').read_text())
shutil.copyfile(ROOT/'tools/tests/moderation_component_stub.hpp',temp/'moderation_component_stub.hpp')
subprocess.run([os.environ.get('CXX','c++'),'-std=c++20','-Wall','-Wextra','-Werror','-fsanitize=address,undefined','-I',str(temp),'-I',str(plugin/'src/utils'),str(plugin/'src/utils/moderation.cpp'),str(temp/'component.cpp'),'-o',str(temp/'component')],check=True)
component_dir=temp/f'{game}-component'; component_dir.mkdir()
subprocess.run([str(temp/'component')],cwd=component_dir,check=True)
print(f'PASS {game}: moderation native policy, journal and process restart',flush=True)
+2 -1
View File
@@ -6,12 +6,13 @@
int main(int argc, char** argv)
{
if (argc != 4 || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
if ((argc != 4 && argc != 5) || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
return 2;
utils::http::timeouts limits;
// A dash exercises the defaults compiled into the actual plugin source.
if (std::string(argv[2]) != "-") limits.connect_timeout_ms = std::stol(argv[2]);
if (std::string(argv[3]) != "-") limits.timeout_ms = std::stol(argv[3]);
if (argc == 5) limits.max_response_bytes = std::stoul(argv[4]);
const auto started = std::chrono::steady_clock::now();
const auto result = utils::http::get_data(argv[1], "{\"event\":\"heartbeat\"}",
{{"Content-Type", "application/json"}, {"Authorization", "Bearer test-only"}}, "POST", limits);
+160
View File
@@ -0,0 +1,160 @@
#include "moderation.hpp"
#include <cassert>
#include <fstream>
#include <iostream>
using namespace utils::moderation;
static const long long NOW = timestamp("2026-10-15T12:00:00.000Z");
static const std::string END = "2026-10-15T12:00:30.000Z";
static json reply(int revision, json sanctions = json::array(), json actions = json::array())
{
if (sanctions.is_null()) sanctions = json::array();
if (sanctions.is_object()) sanctions = json::array({sanctions});
if (actions.is_object()) actions = json::array({actions});
return {{"ok", true}, {"revision", revision}, {"serverTime", "2026-10-15T12:00:00.000Z"}, {"sanctions", sanctions}, {"actions", actions}, {"pollAfterMs", 3000}};
}
static json policy(std::string kind, std::string expiry = END)
{
return {{"id", "policy"}, {"revision", 1}, {"guid", "18446744073709551615"}, {"kind", kind}, {"reason", "rule"}, {"expiresAt", expiry.empty() ? json(nullptr) : json(expiry)}};
}
static json act(std::string id, std::string connection, std::string kind = "kick")
{
return {{"id", id}, {"guid", "18446744073709551615"}, {"connection", connection}, {"kind", kind}, {"reason", "rule; say bad\n\"bad\\^1"}, {"expiresAt", END}};
}
template<class F> void rejects(F f) { bool threw = false; try { f(); } catch (...) { threw = true; } assert(threw); }
int main(int argc, char** argv)
{
assert(argc >= 2); auto root = std::filesystem::path(argv[1]);
runtime game(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a");
if (argc == 3)
{
if (std::string(argv[2]) == "write")
{
assert(game.request()["sessionSequence"] == 1);
game.observe(0, "18446744073709551615", "one"); auto id = game.players.at(0).connection;
game.accept(reply(1, {policy("ban", "")}, {act("crash", id)}).dump(), NOW);
game.enforce(NOW, [](const auto&, int, const auto&) -> std::string { return "unknown"; });
}
else
{
assert(game.request()["sessionSequence"] == 2);
assert(game.revision() == 1); assert(game.request()["receipts"][0]["id"] == "crash");
assert(game.request()["receipts"][0]["state"] == "unknown");
int kicks = 0; game.observe(0, "18446744073709551615", "one");
game.enforce(NOW + 10, [&](const auto& kind, int, const auto&) { if (kind == "kick") ++kicks; return "confirmed"; });
assert(kicks == 1); // cached permanent policy enforces without a network response
}
return 0;
}
assert(canonical_guid("000123") == "123" && canonical_guid("000") == "0");
assert(canonical_guid("18446744073709551615") == "18446744073709551615" && canonical_guid("abc001") == "abc001");
assert(timestamp("2026-10-15T12:00:00Z") == NOW);
for (const auto* invalid : {"garbage", "2026-02-31T12:00:00Z", "2026-10-15T12:00:00+01:00", "2026-10-15T12:00:00.abcZ"}) rejects([&] { timestamp(invalid); });
assert(command_text("a\";\\\n\r^1b") == "a 1b");
assert(command_text(std::string(239, 'a') + "é") == std::string(239, 'a'));
assert(command_text(std::string(238, 'a') + "é") == std::string(238, 'a') + "é");
game.observe(1, "000123", std::string(254, 'a') + "é");
assert(game.players.at(1).guid == "123" && game.players.at(1).name == std::string(254, 'a'));
game.observe(1, "123", std::string("bad") + char(0xff)); assert(game.players.at(1).name == "bad?");
game.observe(1, "123", std::string("bad") + char(1) + "name"); assert(game.players.at(1).name == "bad name");
for (const std::string blank : {"", " ", "\t\r\n\x01\x7f", "\xc2\xa0\xe2\x80\x83\xef\xbb\xbf"})
{
game.observe(1, "123", blank); assert(game.players.at(1).name == "Unknown player");
assert(game.request()["players"][0]["name"] == "Unknown player");
}
rejects([&] { game.observe(1, "abc.001", "name"); });
game.request().dump(); game.disconnect(1);
game.observe(0, "18446744073709551615", "same name"); auto first = game.players.at(0).connection;
game.observe(0, "18446744073709551615", "new name"); assert(game.players.at(0).connection == first);
game.disconnect(0); game.observe(0, "18446744073709551615", "same name"); assert(game.players.at(0).connection != first);
int executed = 0;
auto execute = [&](const std::string&, int, const std::string& text) { ++executed; assert(text.find_first_of("\r\n;\"\\^") == std::string::npos); return "confirmed"; };
game.accept(reply(1, {}, {act("stale", first)}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 0);
assert(game.request()["receipts"][0]["state"] == "failed");
const auto valid = act("once", game.players.at(0).connection);
game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1);
game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1);
auto changed = valid; changed["reason"] = "different action";
rejects([&] { game.accept(reply(1, {}, {changed}).dump(), NOW); });
auto expired = act("expired", game.players.at(0).connection); game.accept(reply(1, {}, {expired}).dump(), NOW + 31); game.enforce(NOW + 31, execute); assert(executed == 1);
auto future = reply(2); future["serverTime"] = "2026-10-16T12:00:00Z"; rejects([&] { game.accept(future.dump(), NOW); });
rejects([&] { game.accept(reply(0).dump(), NOW); });
auto invalid = reply(2, {policy("mute")}); invalid["sanctions"][0]["guid"] = "bad guid"; rejects([&] { game.accept(invalid.dump(), NOW); }); assert(game.revision() == 1);
for (const auto* malformed : {"{}", "[]", "not json", "{\"ok\":false}"}) rejects([&] { game.accept(malformed, NOW); });
auto too_many = reply(2); for (int i = 0; i < 1001; ++i) too_many["sanctions"].push_back(policy("mute")); rejects([&] { game.accept(too_many.dump(), NOW); });
rejects([&] { game.accept(std::string(1024 * 1024 + 1, ' '), NOW); });
rejects([&] { game.accept(reply(1, {policy("mute")}).dump(), NOW); });
std::vector<std::string> effects;
auto record = [&](const auto& kind, int, const auto&) { effects.push_back(kind); return "unknown"; };
game.accept(reply(2, {policy("mute")}).dump(), NOW); game.enforce(NOW, record); game.enforce(NOW + 1, record); assert(effects == std::vector<std::string>{"mute"});
game.enforce(NOW + 31, record); assert(effects == (std::vector<std::string>{"mute", "unmute"}));
auto expired_snapshot = reply(2); expired_snapshot["serverTime"] = "2026-10-15T12:00:31.000Z";
game.accept(expired_snapshot.dump(), NOW); // server expiry wins even when local clock lags within allowed skew
game.accept(reply(3, {policy("mute", "")}).dump(), NOW); game.enforce(NOW, record);
game.accept(reply(4).dump(), NOW); game.enforce(NOW, record); assert(effects.back() == "unmute");
game.accept(reply(5, {policy("ban", "")}).dump(), NOW);
auto disconnecting = [&](const auto& kind, int slot, const auto&) { assert(kind == "kick"); game.disconnect(slot); return "confirmed"; };
game.enforce(NOW, disconnecting); assert(game.players.empty());
// Slow host time and buffered responses cannot extend a one-time deadline.
{
runtime slow(root / "slow-clock", "server", "endpoint", 1, "credential-a");
slow.observe(0, "18446744073709551615", "one");
slow.accept(reply(1, {}, {act("slow", slow.players.at(0).connection)}).dump(), NOW - 60, 31000);
const auto count = executed; slow.enforce(NOW - 60, execute); assert(executed == count);
assert(slow.request()["receipts"][0]["state"] == "expired");
}
// A faster next response cannot roll the conservative server-time anchor back.
{
runtime monotonic(root / "monotonic-clock", "server", "endpoint", 1, "credential-a");
monotonic.observe(0, "18446744073709551615", "one");
auto timed = reply(1, {policy("mute", "2026-10-15T12:00:07.000Z")});
const auto count = effects.size();
monotonic.accept(timed.dump(), NOW, 8000); monotonic.enforce(NOW, record);
monotonic.accept(timed.dump(), NOW, 0); monotonic.enforce(NOW, record);
assert(effects.size() == count);
}
// A superseded runtime cannot overwrite a newer session counter or execute.
{
const auto replacement_root = root / "replacement";
runtime older(replacement_root, "server", "endpoint", 1, "credential-a");
older.observe(0, "18446744073709551615", "one");
older.accept(reply(1, {}, {act("superseded", older.players.at(0).connection)}).dump(), NOW);
runtime newer(replacement_root, "server", "endpoint", 1, "credential-a");
const auto count = executed; rejects([&] { older.enforce(NOW, execute); }); assert(executed == count);
newer.accept(reply(2).dump(), NOW); assert(newer.request()["sessionSequence"] == 2);
runtime recovered(replacement_root, "server", "endpoint", 1, "credential-a");
assert(recovered.request()["sessionSequence"] == 3 && recovered.revision() == 2);
for (const auto& file : std::filesystem::directory_iterator(replacement_root)) std::filesystem::remove(file.path());
rejects([&] { recovered.accept(reply(3).dump(), NOW); }); // cache deletion cannot silently reset its sequence
}
// Managed mute ownership survives restart and allows revocation without clearing unrelated mutes.
const auto mute_root = root / "mute-restart";
{
runtime original(mute_root, "server", "endpoint", 1, "credential-a");
original.observe(0, "18446744073709551615", "one");
original.accept(reply(1, {policy("mute", "")}).dump(), NOW);
original.enforce(NOW, record);
}
{
runtime recovered(mute_root, "server", "endpoint", 1, "credential-a");
recovered.accept(reply(2).dump(), NOW);
recovered.observe(1, "18446744073709551615", "one");
const auto before = effects.size(); recovered.enforce(NOW, record);
assert(effects.size() == before + 1 && effects.back() == "unmute");
recovered.observe(2, "someone-else", "two"); recovered.enforce(NOW, record);
assert(effects.size() == before + 1);
}
// Pre-dispatch disk failure must prevent the irreversible effect.
game.observe(0, "18446744073709551615", "one");
game.accept(reply(6, {}, {act("disk", game.players.at(0).connection)}).dump(), NOW);
std::filesystem::path journal;
for (const auto& file : std::filesystem::directory_iterator(root)) if (file.path().extension() == ".json") journal = file.path();
auto temporary = journal; temporary += ".tmp"; std::filesystem::create_directory(temporary);
const auto before_disk = executed; rejects([&] { game.enforce(NOW, execute); }); assert(executed == before_disk);
std::filesystem::remove(temporary);
runtime different_credential(root, "server", "https://controller/api/moderation/runtime", 1, "credential-b");
assert(different_credential.revision() == 0 && different_credential.request()["receipts"].empty());
// Corruption is visible: runtime never replaces a bad persistent cache with empty policy.
{ std::ofstream file(journal); file << "corrupt"; }
rejects([&] { runtime broken(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a"); });
std::cout << "moderation policy, identity, expiry, revocation, journal and schema checks passed\n";
}
@@ -0,0 +1,49 @@
int main()
{
plugin::plugin plugin; moderation::component component; component.on_startup(&plugin);
const std::string url="https://controller/api/moderation/runtime", token="ec_moderation_"+std::string(43,'a');
assert(!gsc::function::configure("server","http://controller/api/moderation/runtime",token,1));
assert(!gsc::function::configure("server","https://controller@evil/api/moderation/runtime",token,1));
assert(!gsc::function::configure("server",url,token+"\r\nX: leak",1));
assert(gsc::function::configure("server",url,token,1));
// A failed replacement config must not permanently poison the valid old config.
assert(!gsc::function::configure("server",url,token,0));
assert(gsc::function::configure("server",url,token,1));
assert(!moderation::fault);
const auto initial_session=moderation::current->session;
const auto initial_sequence=moderation::current->request()["sessionSequence"];
assert(gsc::function::configure("server",url,token,1));
assert(moderation::current->session==initial_session && moderation::current->request()["sessionSequence"] == initial_sequence); // periodic configuration cannot invalidate connections
scripting::entity player{{0,0}}, wrong{{0,1}};
assert(!gsc::method::observe(wrong,"123","one"));
assert(!gsc::method::observe(player,"123; kick all","one"));
assert(gsc::method::observe(player,"123","one"));
const auto connection=moderation::current->players.at(0).connection;
plugin.i.c.disconnect(0); assert(gsc::method::observe(player,"123","two"));
assert(moderation::current->players.at(0).connection!=connection);
auto now=moderation::now();
auto iso=[](long long seconds) {time_t time=seconds; auto tm=*gmtime(&time); std::ostringstream out; out<<std::put_time(&tm,"%Y-%m-%dT%H:%M:%SZ"); return out.str();};
utils::moderation::json reply={{"ok",true},{"revision",1},{"serverTime",iso(now)},{"sanctions",utils::moderation::json::array()},
{"actions",{{{"id","stale"},{"guid","123"},{"connection",connection},{"kind","kick"},{"reason","reason"},{"expiresAt",iso(now+30)}}}},{"pollAfterMs",3000}};
if (reply["actions"].is_object()) reply["actions"] = utils::moderation::json::array({reply["actions"]});
utils::http::response=reply.dump(); gsc::function::pump(); scheduler::thread_pool.flush(); gsc::function::pump();
assert(command::commands.empty()); assert(moderation::current->request()["receipts"][0]["state"]=="failed");
reply["actions"][0]["id"]="valid";reply["actions"][0]["connection"]=moderation::current->players.at(0).connection;
reply["actions"][0]["kind"]="message";reply["actions"][0]["reason"]="a\";\\\r\nsay";
moderation::current->accept(reply.dump(),now); gsc::function::pump();
assert(game::messages.size()==1); assert(game::messages[0].first==0); assert(game::messages[0].second.find(';')==std::string::npos);
assert(game::messages[0].second.find('\n')==std::string::npos);
moderation::current->accept(reply.dump(),now); gsc::function::pump(); assert(game::messages.size()==1);
reply["actions"][0]["id"]="shutdown"; reply["actions"][0]["kind"]="kick";
moderation::current->accept(reply.dump(),now);
command::on_execute=[] { scripting::shutdown(); };
gsc::function::pump(); assert(!moderation::current); // callback cannot free the active frame
command::on_execute={};
assert(gsc::function::configure("server",url,token,1));
assert(moderation::current->request()["receipts"].size()>=1);
const auto old_session=moderation::current->session;
assert(gsc::function::configure("server",url,"ec_moderation_"+std::string(43,'b'),1));
assert(moderation::current->session!=old_session && moderation::current->revision()==0);
assert(moderation::current->request()["receipts"].empty());
scripting::shutdown();
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <cassert>
#include <chrono>
#include <cstdio>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include <json.hpp>
#include "http.hpp"
namespace game {
struct scr_entref_t { unsigned short entnum = 0, classnum = 0; };
inline std::vector<std::pair<int,std::string>> messages;
inline void SV_GameSendServerCommand(int slot, int, const char* message) { messages.emplace_back(slot,message); }
}
namespace scripting {
struct entity { game::scr_entref_t ref; game::scr_entref_t get_entity_reference() const { return ref; } };
struct array {};
inline std::function<void()> shutdown;
inline void on_shutdown(std::function<void()> callback) { shutdown=callback; }
}
namespace plugin {
struct callbacks { std::function<void(unsigned)> connect, disconnect; void on_player_connect(std::function<void(unsigned)> fn) {connect=fn;} void on_player_disconnect(std::function<void(unsigned)> fn) {disconnect=fn;} };
struct iface { plugin::callbacks c; plugin::callbacks* callbacks() {return &c;} };
struct plugin { iface i; iface* get_interface() {return &i;} };
}
struct component_interface { virtual ~component_interface()=default; virtual void on_startup(plugin::plugin*) {} };
#define REGISTER_COMPONENT(x)
namespace command { inline std::function<void()> on_execute; inline std::vector<std::string> commands; inline void execute(std::string command,bool sync) {assert(sync); commands.push_back(command); if(on_execute)on_execute();} }
namespace scheduler { struct pool { std::vector<std::function<void()>> tasks; void push(std::function<void()> fn) {tasks.push_back(fn);} void flush() {auto copy=tasks; tasks.clear(); for(auto& fn:copy)fn();} }; inline pool thread_pool; }
namespace gsc::function {
inline std::function<bool(std::string,std::string,std::string,int)> configure;
inline std::function<void()> pump;
template<typename F> void add(const char*,F fn) {
if constexpr(std::is_invocable_r_v<bool,F,std::string,std::string,std::string,int>)configure=fn;
else if constexpr(std::is_invocable_r_v<void,F>) { if constexpr(std::is_same_v<std::invoke_result_t<F>,void>)pump=fn; }
}
}
namespace gsc::method { inline std::function<bool(scripting::entity,std::string,std::string)> observe; template<typename F> void add(const char*,F fn) {observe=fn;} }
namespace utils::cryptography::sha256 { inline std::string compute(const std::string& input, bool hex) { assert(hex); return std::to_string(std::hash<std::string>{}(input)); } }
namespace utils::http {
inline std::string response, request;
inline std::optional<result> get_data(const std::string& url,const std::string& fields,const headers& hdrs,const std::string& method,const timeouts& limits) {
assert(url=="https://controller/api/moderation/runtime"); assert(method=="POST"); assert(hdrs.at("Authorization").starts_with("Bearer ec_moderation_")); assert(limits.max_response_bytes==1024*1024);
request=fields; result out; out.status=200; out.code=CURLE_OK; out.buffer=response; return out;
}
}
+4 -2
View File
@@ -15,6 +15,8 @@
"tws_t6_leaderboard.gsc": "1.1.0",
"tws_t6_lock.gsc": "1.0.0",
"tws_t6_telemetry.gsc": "1.0.0",
"t5-plugin": "1.2.0",
"t6-plugin": "1.2.0"
"t5-plugin": "1.3.0",
"t6-plugin": "1.3.0",
"tws_t5_moderation.gsc": "1.0.0",
"tws_t6_moderation.gsc": "1.0.0"
}