Add identity-safe player moderation runtimes for T5 and T6
This commit is contained in:
+1
-1
@@ -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');
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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]}/"
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user