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
+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;
}
}