Compare commits
12
Commits
4501eba873
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f1c65d17 | ||
|
|
48653af3dc | ||
|
|
870f13b150 | ||
|
|
b678de2f6f | ||
|
|
90b94911c5 | ||
|
|
428aefdf24 | ||
|
|
aca728b557 | ||
|
|
ef96a2c308 | ||
|
|
83f1c91877 | ||
|
|
59f88ac33d | ||
|
|
d723f23d67 | ||
|
|
7f2205e9c4 |
@@ -1,5 +1,18 @@
|
|||||||
# Agent changelog
|
# Agent changelog
|
||||||
|
|
||||||
|
## 2026-10-17
|
||||||
|
|
||||||
|
### 1.12.0 — Recover AFK reservations before teardown (EC #151)
|
||||||
|
|
||||||
|
- Advertise `afk.settlement` and drain durable AFK decisions only after confirming
|
||||||
|
the game process has stopped. Removal, rebuild and creation cancellation retain
|
||||||
|
runtime storage until the controller acknowledges every recovery decision.
|
||||||
|
- Authenticate recovery with the assigned host and unfinished teardown operation;
|
||||||
|
never follow a journal URL or use a retired game credential. Keep uncertain
|
||||||
|
crash durations distinct from confirmed consumption.
|
||||||
|
- Deploy before enabling the matching AFK scripts/plugins. Package and offline-sign
|
||||||
|
the release before publication; no deployment or signing is performed here.
|
||||||
|
|
||||||
## 2026-10-15
|
## 2026-10-15
|
||||||
|
|
||||||
### 1.11.0 — Private player moderation configuration
|
### 1.11.0 — Private player moderation configuration
|
||||||
|
|||||||
+129
-2
@@ -35,7 +35,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.11.0"
|
VERSION = "1.12.0"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -49,6 +49,7 @@ PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
|
|||||||
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
|
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
|
||||||
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
|
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
|
||||||
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
|
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
|
||||||
|
AFK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/afk"
|
||||||
MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg"
|
MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg"
|
||||||
MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n"
|
MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n"
|
||||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||||||
@@ -1053,6 +1054,86 @@ class LibraryTransfer:
|
|||||||
self.persist(force=True)
|
self.persist(force=True)
|
||||||
|
|
||||||
|
|
||||||
|
def afk_recovery_requests(journal):
|
||||||
|
"""Mirror the native journal's deterministic recovery after a proven stop.
|
||||||
|
|
||||||
|
Active crashes never invent an elapsed duration. Their checkpoint is only
|
||||||
|
a lower bound; EC retains the hold for an audited correction. Requests that
|
||||||
|
may already have reached EC preserve their original identities and bodies.
|
||||||
|
"""
|
||||||
|
def identifier(value):
|
||||||
|
return isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", value)
|
||||||
|
|
||||||
|
def seconds(value):
|
||||||
|
return type(value) is int and 0 <= value <= 604800
|
||||||
|
|
||||||
|
def decode(raw):
|
||||||
|
if not isinstance(raw, str) or not 0 < len(raw.encode()) < 0x5000:
|
||||||
|
raise ValueError()
|
||||||
|
value = json.loads(raw)
|
||||||
|
if (not isinstance(value, dict) or value.get("server") != journal["server"]
|
||||||
|
or not all(identifier(value.get(key)) for key in ("guid", "match", "request_id"))
|
||||||
|
or value.get("action") not in ("event", "purchase", "reserve", "start", "settle")):
|
||||||
|
raise ValueError()
|
||||||
|
if value["action"] in ("reserve", "start", "settle") and not identifier(value.get("session_id")):
|
||||||
|
raise ValueError()
|
||||||
|
if value["action"] == "reserve" and (not seconds(value.get("seconds")) or value["seconds"] == 0):
|
||||||
|
raise ValueError()
|
||||||
|
return value
|
||||||
|
|
||||||
|
try:
|
||||||
|
if (not isinstance(journal, dict) or type(journal.get("version")) is not int
|
||||||
|
or journal["version"] != 1 or not isinstance(journal.get("server"), str)
|
||||||
|
or not 0 < len(journal["server"]) <= 128
|
||||||
|
or journal.get("endpoint") != AFK_GAME_ENDPOINT
|
||||||
|
or not isinstance(journal.get("requests"), list)
|
||||||
|
or not isinstance(journal.get("sessions"), list)
|
||||||
|
or len(journal["requests"]) + len(journal["sessions"]) > 4096):
|
||||||
|
raise ValueError()
|
||||||
|
requests = {}
|
||||||
|
for raw in journal["requests"]:
|
||||||
|
value = decode(raw)
|
||||||
|
if value["request_id"] in requests:
|
||||||
|
raise ValueError()
|
||||||
|
requests[value["request_id"]] = value
|
||||||
|
sessions = set()
|
||||||
|
for session in journal["sessions"]:
|
||||||
|
data = decode(session["body"])
|
||||||
|
session_id = data.get("session_id")
|
||||||
|
phase = session["phase"]
|
||||||
|
authorized, consumed = session["authorized_seconds"], session["consumed_seconds"]
|
||||||
|
if (data["action"] != "reserve" or session_id in sessions
|
||||||
|
or phase not in ("prepared", "authorized", "active")
|
||||||
|
or not seconds(authorized) or not seconds(consumed)
|
||||||
|
or consumed > authorized or authorized > data["seconds"]
|
||||||
|
or (phase == "prepared" and authorized != 0)
|
||||||
|
or (phase != "prepared" and authorized == 0)
|
||||||
|
or (phase != "active" and consumed != 0)):
|
||||||
|
raise ValueError()
|
||||||
|
sessions.add(session_id)
|
||||||
|
requests = {key: value for key, value in requests.items()
|
||||||
|
if not (value["action"] in ("reserve", "start") and value["session_id"] == session_id)}
|
||||||
|
hashed = 14695981039346656037
|
||||||
|
scope = journal["server"] + "\0" + journal["endpoint"] + "\0" + session_id
|
||||||
|
for byte in scope.encode():
|
||||||
|
hashed = ((hashed ^ byte) * 1099511628211) & 0xffffffffffffffff
|
||||||
|
transfer = {"server": journal["server"], "guid": data["guid"], "match": data["match"],
|
||||||
|
"session_id": session_id, "request_id": f"afk-stop-{hashed:016x}",
|
||||||
|
"action": "settle", "end_reason": "crash" if phase == "active" else "never_started",
|
||||||
|
"consumed_seconds": None if phase == "active" else 0}
|
||||||
|
if phase == "active":
|
||||||
|
transfer["confirmed_seconds"] = consumed
|
||||||
|
previous = requests.get(transfer["request_id"])
|
||||||
|
if previous is not None and previous != transfer:
|
||||||
|
raise ValueError()
|
||||||
|
requests[transfer["request_id"]] = transfer
|
||||||
|
if any(value["action"] in ("reserve", "start") for value in requests.values()):
|
||||||
|
raise ValueError()
|
||||||
|
return list(requests.values())
|
||||||
|
except (ValueError, TypeError, KeyError, AttributeError):
|
||||||
|
raise AgentError("AFK journal is invalid; runtime storage retained") from None
|
||||||
|
|
||||||
|
|
||||||
class Agent:
|
class Agent:
|
||||||
def __init__(self, config, docker=None):
|
def __init__(self, config, docker=None):
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -1574,7 +1655,7 @@ class Agent:
|
|||||||
if line.startswith("PRETTY_NAME="):
|
if line.startswith("PRETTY_NAME="):
|
||||||
os_name = line.split("=", 1)[1].strip('"')
|
os_name = line.split("=", 1)[1].strip('"')
|
||||||
update = self.update_inventory()
|
update = self.update_inventory()
|
||||||
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement", "afk.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||||
plutonium_cache = self.plutonium_cache_inventory()
|
plutonium_cache = self.plutonium_cache_inventory()
|
||||||
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
||||||
|
|
||||||
@@ -2237,6 +2318,52 @@ class Agent:
|
|||||||
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
|
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
|
||||||
except (ValueError, TypeError, KeyError, AttributeError):
|
except (ValueError, TypeError, KeyError, AttributeError):
|
||||||
raise AgentError("Bank journal is invalid; runtime storage retained") from None
|
raise AgentError("Bank journal is invalid; runtime storage retained") from None
|
||||||
|
self.settle_afk(instance_id, generation, operation_id)
|
||||||
|
|
||||||
|
def settle_afk(self, instance_id, generation, operation_id=None):
|
||||||
|
"""Retain AFK evidence until stopped-process reconciliation is acknowledged."""
|
||||||
|
app = scoped(self.instance_dir(instance_id, generation), "app")
|
||||||
|
if not app.exists():
|
||||||
|
return
|
||||||
|
if self.inspect(instance_id, generation)["running"]:
|
||||||
|
raise AgentError("AFK settlement requires a stopped instance")
|
||||||
|
operation_id = operation_id or self.executing_id
|
||||||
|
visited, journals = 0, []
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
|
||||||
|
def unreadable(_error):
|
||||||
|
raise AgentError("AFK journal discovery failed; runtime storage retained")
|
||||||
|
|
||||||
|
for directory, directories, files in os.walk(app, followlinks=False, onerror=unreadable):
|
||||||
|
relative = Path(directory).relative_to(app)
|
||||||
|
inside = "tws-afk" in relative.parts
|
||||||
|
visited += 1
|
||||||
|
if visited > 100000 or time.monotonic() > deadline:
|
||||||
|
raise AgentError("AFK journal discovery limit exceeded; runtime storage retained")
|
||||||
|
for name in directories + files:
|
||||||
|
if (inside or name == "tws-afk") and (Path(directory) / name).is_symlink():
|
||||||
|
raise AgentError("AFK journal paths must not be symlinks")
|
||||||
|
if inside:
|
||||||
|
journals.extend((relative / name).as_posix() for name in files if name.endswith(".json"))
|
||||||
|
if len(journals) > 4096:
|
||||||
|
raise AgentError("Too many AFK journals; runtime storage retained")
|
||||||
|
for path in sorted(journals):
|
||||||
|
try:
|
||||||
|
raw = managed_read(app, path)
|
||||||
|
if raw is None or len(raw) > 16 * 1024 * 1024:
|
||||||
|
raise ValueError()
|
||||||
|
transfers = afk_recovery_requests(json.loads(raw))
|
||||||
|
except (Rejected, ValueError, UnicodeError):
|
||||||
|
raise AgentError("AFK journal cannot be read; runtime storage retained") from None
|
||||||
|
if transfers and not operation_id:
|
||||||
|
raise AgentError("AFK settlement requires a teardown operation")
|
||||||
|
for transfer in transfers:
|
||||||
|
response = self.request("POST", "/api/agent/afk/settle", {
|
||||||
|
"operationId": operation_id, "action": transfer["action"], "transfer": transfer})
|
||||||
|
expected = ("settled", "unresolved", "corrected") if transfer["action"] == "settle" else ("rewarded", "purchased", "rejected")
|
||||||
|
if (not isinstance(response, dict) or response.get("request_id") != transfer["request_id"]
|
||||||
|
or response.get("state") not in expected):
|
||||||
|
raise AgentError("AFK recovery was not acknowledged; runtime storage retained")
|
||||||
|
|
||||||
def lifecycle(self, kind, instance_id, generation, payload):
|
def lifecycle(self, kind, instance_id, generation, payload):
|
||||||
unhealthy_recovery = payload.get("unhealthyRecovery") is True
|
unhealthy_recovery = payload.get("unhealthyRecovery") is True
|
||||||
|
|||||||
@@ -1832,6 +1832,118 @@ class AgentTests(unittest.TestCase):
|
|||||||
self.assertNotIn(saved["moderation"]["token"], row["result"])
|
self.assertNotIn(saved["moderation"]["token"], row["result"])
|
||||||
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
|
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
|
||||||
|
|
||||||
|
def afk_journal(self, game="t5", phase="active"):
|
||||||
|
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-afk/{game}/scope.json"
|
||||||
|
reserve = {"action": "reserve", "server": "t5_zombie_test_1", "guid": "ace",
|
||||||
|
"match": "match-one", "request_id": "reserve-one", "session_id": "session-one", "seconds": 1800}
|
||||||
|
module.atomic_json(path, {"version": 1, "server": reserve["server"], "endpoint": module.AFK_GAME_ENDPOINT,
|
||||||
|
"requests": [json.dumps(reserve)], "sessions": [{"body": json.dumps(reserve), "phase": phase,
|
||||||
|
"authorized_seconds": 0 if phase == "prepared" else 1800,
|
||||||
|
"consumed_seconds": 42 if phase == "active" else 0}]})
|
||||||
|
return path
|
||||||
|
|
||||||
|
def afk_acknowledgment(self, method, path, payload):
|
||||||
|
self.assertEqual((method, path), ("POST", "/api/agent/afk/settle"))
|
||||||
|
self.assertFalse(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
transfer = payload["transfer"]
|
||||||
|
self.assertEqual(transfer["action"], "settle")
|
||||||
|
return {"state": "unresolved" if transfer["consumed_seconds"] is None else "settled",
|
||||||
|
"request_id": transfer["request_id"]}
|
||||||
|
|
||||||
|
def test_afk_remove_recovers_both_engines_without_fabricating_crash_duration(self):
|
||||||
|
self.create()
|
||||||
|
paths = [self.afk_journal(game) for game in ("t5", "t6")]
|
||||||
|
def acknowledge(*args):
|
||||||
|
transfer = args[2]["transfer"]
|
||||||
|
self.assertTrue(all(path.exists() for path in paths))
|
||||||
|
self.assertEqual(transfer["end_reason"], "crash")
|
||||||
|
self.assertIsNone(transfer["consumed_seconds"])
|
||||||
|
self.assertEqual(transfer["confirmed_seconds"], 42)
|
||||||
|
self.assertRegex(transfer["request_id"], r"^afk-stop-[0-9a-f]{16}$")
|
||||||
|
return self.afk_acknowledgment(*args)
|
||||||
|
with patch.object(self.agent, "request", side_effect=acknowledge) as request:
|
||||||
|
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertEqual(request.call_count, 2)
|
||||||
|
self.assertTrue(all(not path.exists() for path in paths))
|
||||||
|
|
||||||
|
def test_afk_recovery_retains_storage_and_reuses_decisions_after_lost_reply(self):
|
||||||
|
self.create()
|
||||||
|
path = self.afk_journal(phase="authorized")
|
||||||
|
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
|
||||||
|
original = path.read_bytes()
|
||||||
|
observed = []
|
||||||
|
def lost(method, endpoint, payload):
|
||||||
|
observed.append(payload["transfer"])
|
||||||
|
raise AgentError("Lost reply")
|
||||||
|
with patch.object(self.agent, "request", side_effect=lost):
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
|
||||||
|
self.assertEqual(path.read_bytes(), original)
|
||||||
|
self.agent.journal.close()
|
||||||
|
self.agent = Agent(self.config, self.docker)
|
||||||
|
def acknowledge(method, endpoint, payload):
|
||||||
|
self.assertEqual(payload["transfer"], observed[0])
|
||||||
|
self.assertEqual(payload["transfer"]["consumed_seconds"], 0)
|
||||||
|
self.assertEqual(payload["transfer"]["end_reason"], "never_started")
|
||||||
|
return self.afk_acknowledgment(method, endpoint, payload)
|
||||||
|
with patch.object(self.agent, "request", side_effect=acknowledge):
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "completed")
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
def test_afk_remove_accepts_an_already_corrected_session(self):
|
||||||
|
self.create()
|
||||||
|
path = self.afk_journal()
|
||||||
|
def acknowledge(method, endpoint, payload):
|
||||||
|
response = self.afk_acknowledgment(method, endpoint, payload)
|
||||||
|
return {**response, "state": "corrected"}
|
||||||
|
with patch.object(self.agent, "request", side_effect=acknowledge):
|
||||||
|
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
def test_afk_recovery_rejects_running_process_corruption_and_untrusted_paths(self):
|
||||||
|
self.create()
|
||||||
|
path = self.afk_journal()
|
||||||
|
with self.assertRaisesRegex(AgentError, "stopped instance"):
|
||||||
|
self.agent.settle_afk(INSTANCE, 1, "remove")
|
||||||
|
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
|
||||||
|
original = json.loads(path.read_text())
|
||||||
|
for invalid in ("{broken", json.dumps({**original, "endpoint": "https://untrusted.example"}),
|
||||||
|
json.dumps({**original, "sessions": []}),
|
||||||
|
json.dumps({**original, "requests": original["requests"] * 2})):
|
||||||
|
path.write_text(invalid)
|
||||||
|
with patch.object(self.agent, "request") as request:
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
|
||||||
|
request.assert_not_called()
|
||||||
|
self.assertTrue(path.exists())
|
||||||
|
outside = Path(self.temp.name) / "afk-outside.json"
|
||||||
|
outside.write_text(json.dumps(original))
|
||||||
|
path.unlink()
|
||||||
|
path.symlink_to(outside)
|
||||||
|
with patch.object(self.agent, "request") as request:
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
|
||||||
|
request.assert_not_called()
|
||||||
|
self.assertTrue(outside.exists())
|
||||||
|
|
||||||
|
def test_afk_recovery_preserves_purchase_and_event_receipt_identity(self):
|
||||||
|
self.create()
|
||||||
|
path = self.afk_journal(phase="prepared")
|
||||||
|
value = json.loads(path.read_text())
|
||||||
|
for action in ("event", "purchase"):
|
||||||
|
value["requests"].append(json.dumps({"action": action, "server": value["server"], "guid": "ace",
|
||||||
|
"match": "match-one", "request_id": action + "-one", "confirmed": True}))
|
||||||
|
module.atomic_json(path, value)
|
||||||
|
expected = module.afk_recovery_requests(value)
|
||||||
|
def acknowledge(method, endpoint, payload):
|
||||||
|
self.assertEqual(endpoint, "/api/agent/afk/settle")
|
||||||
|
self.assertIn(payload["transfer"], expected)
|
||||||
|
return {"request_id": payload["transfer"]["request_id"],
|
||||||
|
"state": "settled" if payload["action"] == "settle" else "rejected"}
|
||||||
|
with patch.object(self.agent, "request", side_effect=acknowledge) as request:
|
||||||
|
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertEqual(request.call_count, 3)
|
||||||
|
|
||||||
def bank_journal(self, decisions=("commit", "cancel"), game="t5", endpoint=module.BANK_GAME_ENDPOINT):
|
def bank_journal(self, decisions=("commit", "cancel"), game="t5", endpoint=module.BANK_GAME_ENDPOINT):
|
||||||
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/{module.digest(endpoint.encode())}.json"
|
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/{module.digest(endpoint.encode())}.json"
|
||||||
reports = [json.dumps({"server": "t5_zombie_test_1", "guid": "ace", "match": "match-one", "request_id": f"{game}-{i}", "operation": "deposit", "amount": 2000, "capacity": 1000000, "decision": decision}) for i, decision in enumerate(decisions)]
|
reports = [json.dumps({"server": "t5_zombie_test_1", "guid": "ace", "match": "match-one", "request_id": f"{game}-{i}", "operation": "deposit", "amount": 2000, "capacity": 1000000, "decision": decision}) for i, decision in enumerate(decisions)]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ and launch your first server.
|
|||||||
- [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports.
|
- [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports.
|
||||||
- [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments.
|
- [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments.
|
||||||
- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery.
|
- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery.
|
||||||
|
- [AFK reserve](guides/afk.md) — persistent break time, gameplay rewards, purchases and session recovery.
|
||||||
- [Player moderation](guides/moderation.md) — restrictions, live actions, cases, evidence and game integration.
|
- [Player moderation](guides/moderation.md) — restrictions, live actions, cases, evidence and game integration.
|
||||||
- [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API.
|
- [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Persistent AFK time
|
||||||
|
|
||||||
|
AFK time is a GUID account shared by T5 and T6, separate from the points bank. The scripts own `.afk`, `.back`, explicit purchase confirmation, actual gameplay contribution detection, protection and controls, the center-screen countdown, and all-player-AFK / match-ending behavior. Electric Cherry owns policy, durable receipts, atomic points purchases, reservations, and reporting. The immutable native instance's existing bank credential authenticates both APIs; the shared telemetry token and browser sessions cannot authorize game requests.
|
||||||
|
|
||||||
|
## Policy and accounting
|
||||||
|
|
||||||
|
All balances are integer seconds. Defaults:
|
||||||
|
|
||||||
|
| Setting | Environment variable | Default |
|
||||||
|
| ---------------------------------------------- | ---------------------------------------------------------------- | ----------- |
|
||||||
|
| Free rolling allowance | `EC_AFK_FREE_SECONDS` | 1800 |
|
||||||
|
| Combined earned/purchased capacity | `EC_AFK_CAPACITY_SECONDS` | 21600 |
|
||||||
|
| Active play interval / reward | `EC_AFK_ACTIVE_INTERVAL_SECONDS`, `EC_AFK_ACTIVE_REWARD_SECONDS` | 300 / 60 |
|
||||||
|
| Participating round reward | `EC_AFK_ROUND_REWARD_SECONDS` | 15 |
|
||||||
|
| Kill interval / reward | `EC_AFK_KILL_INTERVAL`, `EC_AFK_KILL_REWARD_SECONDS` | 100 / 30 |
|
||||||
|
| Headshot interval / reward | `EC_AFK_HEADSHOT_INTERVAL`, `EC_AFK_HEADSHOT_REWARD_SECONDS` | 25 / 15 |
|
||||||
|
| Successful teammate revive | `EC_AFK_REVIVE_REWARD_SECONDS` | 30 |
|
||||||
|
| Performance reward cap per rolling active hour | `EC_AFK_BONUS_HOURLY_SECONDS` | 480 |
|
||||||
|
| Points / seconds per purchase pack | `EC_AFK_PURCHASE_POINTS`, `EC_AFK_PURCHASE_SECONDS` | 10000 / 600 |
|
||||||
|
|
||||||
|
Free time is spent first. Its available amount is the allowance minus outstanding free reservations and confirmed free consumption charged within the preceding 24 hours. Consumption is charged at the controller's receipt of a durable stop, so free time replenishes 24 hours after that receipt. This deliberately conservative rolling window cannot grant more than the allowance in a rolling day, even with delayed delivery or inaccurate server clocks. Each charge expires independently; unused days never accumulate. Reads, reconnects, map changes, and changing game/server do not reset anything. An unclosed reservation never expires, even after 24 hours. Profile `freeRenewsAt` is the next known charge expiration, not a promise to release unresolved authorization.
|
||||||
|
|
||||||
|
Active seconds and partial kill/headshot thresholds persist by GUID across matches and servers. Cumulative snapshots contribute only their increase over the last accepted snapshot for that originating immutable instance, match key, and GUID. Active play rewards use every completed interval. Participating rounds, kill thresholds, and headshot thresholds share one rolling 3600-_active-second_ budget: each performance grant occupies budget until another 3600 verified active seconds elapses. Kill and headshot rewards can stack. Spectating, AFK, idle connections, wall-clock waiting, and raw score increases do not advance this clock. Each legitimate successful teammate revive earns the full configured revive reward, including repeated revives of the same teammate; there is no revive count limit or performance cap. Only overall saved capacity applies.
|
||||||
|
|
||||||
|
Capacity includes saved seconds currently reserved by sessions. Overflow rewards and performance bonuses above the rolling budget are discarded, while partial threshold progress persists; capacity does not bank deferred whole rewards. The scripts report only actual gameplay events, eligible active seconds, and rounds with participation. The backend requires `activity:"playing"`, rejects current reward submissions while a session is reserved or started (an unresolved, stopped crash may resume gameplay), enforces monotonic counters, and rejects cumulative active time above the match's wall duration plus a 60-second telemetry tolerance. This scoped trusted-server API cannot independently observe a game engine's successful revive or verify its eligibility. The native/GSC collection paths are part of the trust boundary.
|
||||||
|
|
||||||
|
## Machine protocol
|
||||||
|
|
||||||
|
Use `POST /api/afk/{balance,event,purchase,reserve,start,settle}` with `Content-Type: application/json` and `Authorization: Bearer <instance bank credential>`. The body limit is 4096 bytes; responses have `Cache-Control: no-store`. Purchases, reservations, start acknowledgments, and balance reads require the exact current active telemetry match and a present GUID on the authenticated server. Gameplay backlog can additionally reference a retained historical match containing that GUID when the immutable instance lifetime proves origin. Historical active time is bounded to the recorded match end or last received telemetry, and its grants still respect held saved capacity. Gameplay whose historical membership evidence has been removed is rejected without granting time. Settlement remains valid for the original instance after disconnect, match completion, or match/server history deletion.
|
||||||
|
|
||||||
|
All requests contain `server`, `guid`, and `match` (the existing telemetry `reporter_match_key`, not a separate AFK match identifier). Mutations additionally require `request_id`. GUID, match, request and session IDs are 1–64 characters from `[A-Za-z0-9_.:-]`. Optional `event_time` is a parseable timestamp no more than five minutes in the future; it records the reported event time, while `created_at` records controller receipt time. Keep the complete body immutable across retries, including `event_time`.
|
||||||
|
|
||||||
|
| Action | Additional request fields |
|
||||||
|
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `balance` | None |
|
||||||
|
| `event` | `seq` positive integer; `active_seconds`, `kills`, `headshots`, `rounds`, `revives` nonnegative cumulative integer counters (maximum 2147483647); `activity:"playing"` |
|
||||||
|
| `purchase` | `packs` integer 1–1000; `confirmed:true`; `expected_purchase_points` and `expected_purchase_seconds`, copied from the displayed quote, per pack |
|
||||||
|
| `reserve` | `session_id`; `seconds` integer 1–604800, the requested maximum authorization |
|
||||||
|
| `start` | `session_id` |
|
||||||
|
| `settle` | `session_id`; `consumed_seconds` integer 0–604800 or `null`; `end_reason`; optional `confirmed_seconds` checkpoint |
|
||||||
|
|
||||||
|
`end_reason` is one of `manual`, `disconnect`, `death`, `match_end`, `expired`, `never_started`, or `crash`. `expired` requires exact consumption of the authorization. `never_started` requires zero. An unknown crash duration uses `consumed_seconds:null,end_reason:"crash",confirmed_seconds:<last durable elapsed lower bound>`. Confirmed consumption must fit the original authorization and cannot fall below an already-recorded checkpoint.
|
||||||
|
|
||||||
|
Every application result has:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"state": "reserved",
|
||||||
|
"code": "ok",
|
||||||
|
"request_id": "request-123",
|
||||||
|
"free_seconds": 0,
|
||||||
|
"saved_seconds": 600,
|
||||||
|
"available_seconds": 600,
|
||||||
|
"reserved_seconds": 1800,
|
||||||
|
"capacity_seconds": 21600,
|
||||||
|
"authorized_seconds": 1800,
|
||||||
|
"bank_balance": 20000,
|
||||||
|
"purchase_points": 10000,
|
||||||
|
"purchase_seconds": 600,
|
||||||
|
"max_purchase_packs": 2,
|
||||||
|
"session_id": "session-123",
|
||||||
|
"consumed_seconds": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`available_seconds` excludes held authorizations; `reserved_seconds` is held time for this GUID across all servers. `authorized_seconds` is meaningful for session results and is the original authorization, never an extension. `session_id` and `consumed_seconds` appear on session results; `awarded_seconds` appears on reward and purchase results. `max_purchase_packs` is the smaller of affordable packs and capacity, or zero when a bank transfer is prepared. Recheck the quote through `balance` before confirmation; changed pricing rejects with `price_changed` without spending points. There is no automatic purchase.
|
||||||
|
|
||||||
|
States are `balance`, `rewarded`, `purchased`, `reserved`, `started`, `settled`, `unresolved`, `corrected`, and `rejected`. HTTP 200 can contain an application rejection (`ok:false`), such as `empty`, `session_pending`, `bank_pending`, `insufficient_balance`, `reserve_capacity`, `price_changed`, `invalid_active_time`, `invalid_consumption`, `progress_regression`, or `progress_conflict`. Authentication failures are 401/403; invalid syntax/amounts are 400. `not_in_match` is a nonreceipted HTTP 409 for unknown/current membership publication lag on new work, allowing the same immutable request to retry after telemetry catches up. Known completed/obsolete matches and recorded players who have departed (`present=0`) are terminal HTTP 200 rejections for new work. Only never-observed membership remains eligible for the publication-lag retry. Gameplay missing historical membership/origin proof is also a terminal HTTP 200 rejection so an unrecoverable old snapshot cannot block the durable queue; subsequent cumulative snapshots can still include its counters if membership is established. Changed request identity or ownership is HTTP 409 `request_conflict`/`session_conflict`. Transaction failures are 503 and can retry unchanged. Early authentication/body failures may contain only `ok` and `code`.
|
||||||
|
|
||||||
|
Purchase debits the existing bank account and grants saved time in the same immediate SQLite transaction, with linked point/time ledger entries. A prepared bank transfer blocks purchase until its own existing commit/cancel workflow completes. No second points bank is created.
|
||||||
|
|
||||||
|
## Ordering, authorization, and outages
|
||||||
|
|
||||||
|
1. Native code durably writes the immutable request before sending it. Use one stable request ID per operation, with separate IDs for reserve, start, and settle. Stable session IDs bind all session operations.
|
||||||
|
2. `reserve` atomically takes free time first and saved time second, authorizing `min(requested,available)` seconds. Only one outstanding session can hold a GUID's funds. An identical retry returns the saved result. A new operation referring to an already-known session cannot reset or extend it.
|
||||||
|
3. Durable native state records the reservation and the start intent. `start` must return the `started` acknowledgment before protection is enabled. Scripts additionally recheck current player/map eligibility and activate only once. A delayed acknowledgment cannot reactivate an ended local session. The countdown uses the locally recorded authorization and elapsed clock; it does not require heartbeat renewal.
|
||||||
|
4. Manual return, disconnect, death, match end, or zero countdown removes protection and restores controls before producing a durable stop. Native code records the confirmed elapsed seconds and immutable `settle` request, then retries delivery until acknowledged. Round elapsed time up to whole seconds and cap it to authorization. At zero the scripts immediately restore normal vulnerability and controls; there is no grace, automatic buy, or spectator transition. Manual `.back` retains its separate gameplay behavior.
|
||||||
|
5. `settle` records confirmed consumption and releases unused reserved seconds exactly once. It accepts legitimate delayed reports after membership disappears. `never_started` settlement can arrive before its reserve and installs a tombstone; delayed reserve/start cannot reopen that session. Completed/corrected sessions remain terminal. A retry of an earlier operation returns that operation's original receipt, which may predate settlement; native terminal journal state must win over such an old response.
|
||||||
|
6. If the controller is unavailable, an already-started session keeps its locally authorized countdown to zero. If reserve/start was never acknowledged, do not enable protection. A crashed process must first be proven stopped before recovery. A checkpoint is only a lower bound on elapsed time; an absent heartbeat or old checkpoint is never evidence for a refund. Recover unknown elapsed time as `unresolved`, retaining authorization until reliable stop evidence or an explicit administrator correction resolves it.
|
||||||
|
|
||||||
|
Receipts bind the complete body and action to server, immutable instance, and request ID. Reusing an ID with any changed parameter is a conflict. Accepted same/lower-sequence gameplay counters award no additional time; same sequence with changed counters conflicts, a later sequence with a decreased total is rejected, and a lower sequence cannot introduce a higher counter. Native/GSC code must preserve cumulative per-GUID totals across reconnects within the same match. A distinct revive increments `revives` even for the same teammate; retrying a snapshot never increments it.
|
||||||
|
|
||||||
|
For host teardown, `POST /api/agent/afk/settle` uses the assigned host credential and `{operationId,action,transfer}`. Only an unfinished `instance.remove`, `instance.rebuild`, or `instance.cancel-create` for that host/immutable instance is accepted. `action:"settle"` reconciles session evidence; `event` and `purchase` are receipt-only recovery and return `not_applied` if the original request never committed. Teardown cannot create a new purchase or reward. Instance retirement revokes normal game API credentials, including reads; the assigned host recovery path permits settlement after retirement without granting a replacement instance ownership of old sessions.
|
||||||
|
|
||||||
|
## Crash correction and reporting
|
||||||
|
|
||||||
|
An administrator can reconcile an explicitly stopped `unresolved` session with `POST /api/afk/corrections` using their browser session and same-origin protections:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "session-123",
|
||||||
|
"request_id": "admin-case-456",
|
||||||
|
"consumed_seconds": 120,
|
||||||
|
"reason": "Confirmed process stopped; reviewed native checkpoint and host logs.",
|
||||||
|
"stopped": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The selected charge must be at least the confirmed checkpoint and no more than authorization. Correction cannot release a live reserved/started session, and does not convert an uncertain duration into an exact one: `state` becomes `corrected`, `consumedSeconds` remains null, `chargedSeconds` records the accounting decision, and `confirmedSeconds` retains the evidence lower bound. The audit ledger records the actor, reason, charge, and release. The correction ID is idempotent and rejects changed parameters. A true confirmed stop report can instead settle an unresolved session with its verified exact duration.
|
||||||
|
|
||||||
|
Open **Players → AFK** (`/afk`) to browse all AFK accounts, sessions, and time-ledger entries. The accounts view supports player-name and exact-GUID searches and links each account to its player profile. Session history includes unresolved reservations; session and ledger history can be filtered by game, server, and date. Filters and pagination remain in the URL when opening a player or retained match, so the back link restores the same overview. Refresh reads current balances without issuing grants, releasing reservations, or otherwise changing accounts. This reporting page has no purchase or correction controls.
|
||||||
|
|
||||||
|
The AFK overview and player profiles use the same signed-in viewer access as Bank. Player profiles also retain balances and recent time-ledger entries alongside bank information. Reports distinguish cumulative verified active seconds, confirmed AFK duration, unresolved sessions, expiration count, manual returns, purchases, and corrected charges. Session starts, end reasons, operation/session IDs, server, match when retained, reported event time, receipt time, resulting balances, free grants, rewards, point-linked purchases, reservations, consumption, releases, and corrections survive match retention and server deletion.
|
||||||
|
|
||||||
|
## Coordinated rollout and validation
|
||||||
|
|
||||||
|
Back up the controller database and persistent game journals. Deploy EC first;
|
||||||
|
migration 30 creates the AFK accounts, operations, progress, sessions, bonus budget
|
||||||
|
and ledger tables without match/server cascading deletion. Install host agent
|
||||||
|
**1.12.0+**, including its `afk.settlement` capability, before activating the game
|
||||||
|
scripts. Package and offline-sign the agent release using the normal host update
|
||||||
|
workflow; a local build does not sign or deploy it.
|
||||||
|
|
||||||
|
Install TWS AFK scripts **2.0.0** with matching native plugins **1.4.0+** for both
|
||||||
|
engines, common **1.4.0+**, telemetry **1.0.0+**, and Plutonium **r5334+**. Fully
|
||||||
|
restart each game-server process for the plugin update. Keep the existing private
|
||||||
|
bank credential configuration and persistent `tws-afk/t5/` / `tws-afk/t6/` storage.
|
||||||
|
See the [scripts AFK guide](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/src/branch/main/docs/afk.md)
|
||||||
|
for commands, active-play criteria, protection ownership, game dvars and the
|
||||||
|
in-game validation matrix.
|
||||||
|
|
||||||
|
Run `npx vitest run tests/unit/afk.test.ts tests/unit/bank.test.ts tests/unit/ui-afk-profile.test.ts`,
|
||||||
|
the normal EC checks/build, and `python3 -m unittest discover -s agent/tests -v`.
|
||||||
|
The scripts repository supplies `node tools/check_afk.mjs` and
|
||||||
|
`python3 tools/test_plugin_afk.py` with the pinned native JSON dependency headers.
|
||||||
|
Portable native tests use sanitizers and engine adapters; script tests use engine
|
||||||
|
shims. Neither proves Windows DLL linking or live game behavior.
|
||||||
|
|
||||||
|
Before production rollout, exercise both T5 and T6 with a short test allowance:
|
||||||
|
manual return, immediate zero-time expiry, downed/dead/spectator states, `.hud`
|
||||||
|
hidden, repeated revives, partial reward thresholds across reconnects, explicit
|
||||||
|
purchase confirmation and capacity rejection. Compare the center countdown to
|
||||||
|
the authorized seconds, ensure controls/weapons and normal vulnerability return
|
||||||
|
at zero, and confirm active-play metrics exclude AFK time (manual `.back` restores reward eligibility immediately, including during its separate protection grace). Test last-active
|
||||||
|
death/disconnect and stock afterlife/Grief recovery on deployed maps.
|
||||||
|
|
||||||
|
Drop reserve/start/purchase/settle responses, reorder deliveries, disconnect EC
|
||||||
|
after authorization and restart game/controller/host processes. Confirm the local
|
||||||
|
deadline survives the outage, durable stops release unused time once, and ambiguous
|
||||||
|
crashes remain unconfirmed until an audited correction. Rebuild/remove an instance
|
||||||
|
with both games' journals and a retired game credential; host recovery must retain
|
||||||
|
files until acknowledged and must never create a new purchase or gameplay reward.
|
||||||
|
Live T5/T6 gameplay and deployment validation require the actual game servers.
|
||||||
+107
-11
@@ -125,18 +125,114 @@ resetting its cache or credentials.
|
|||||||
|
|
||||||
## Cases, evidence and review
|
## Cases, evidence and review
|
||||||
|
|
||||||
Create a case for a report, investigation or appeal. Staff notes are explicitly
|
Create a case for a report, investigation or appeal. Each case has a dedicated,
|
||||||
labeled as notes. Selecting archived chat verifies the target GUID and original
|
shareable page with the player identity, evidence, staff notes, linked restrictions,
|
||||||
message against the recorded match and saves an independent snapshot, including
|
and an activity timeline. These pages require moderation access. Give a case a
|
||||||
the original timing context. Unknown or estimated timestamps remain such; the
|
priority and assign it to an eligible administrator or moderator. Filter the case
|
||||||
snapshot does not turn them into exact observations. Unarchived or already
|
queue by status, priority, or assignment; active cases appear first, ordered by
|
||||||
deleted chat cannot be attached as verified evidence.
|
priority and most recent update.
|
||||||
|
|
||||||
Cases can be resolved or dismissed with a recorded reason. Evidence, restrictions
|
Use **Change status** to move a case between **Open**, **In progress**, and
|
||||||
and append-only moderation events survive source-match cleanup and server
|
**Closed**, recording a reason for each transition. Assignment is separate:
|
||||||
deletion. They currently have no automatic retention cleanup. Authentication
|
a case can have an owner at any stage. To resume a closed case, change its status
|
||||||
audit cleanup does not remove moderation history. Correct decisions by adding
|
to Open or In progress. Evidence, assessments, actions, and earlier decisions
|
||||||
notes or revoking restrictions; there is no history-erasing delete endpoint.
|
remain in its history. Editing case details does not change its status.
|
||||||
|
|
||||||
|
Existing closed outcomes are migrated to Closed. Previously escalated cases
|
||||||
|
become In progress. Their previous statuses are recorded in history; existing
|
||||||
|
decision reasons, action references, assignments, and follow-ups are preserved.
|
||||||
|
|
||||||
|
Use **Edit assessment** to keep a concise current summary, list the applicable
|
||||||
|
rules, and record a finding for each concern: hate speech, harassment, threats,
|
||||||
|
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
|
||||||
|
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
|
||||||
|
the evidence; case status tracks whether work remains. Record the decision and
|
||||||
|
its rationale in the assessment summary.
|
||||||
|
Every saved assessment retains its author, timestamp, and complete previous
|
||||||
|
revision. Assessments can be revised after closure without reopening the case.
|
||||||
|
|
||||||
|
Before opening a case, review existing cases for the same game and GUID. A case
|
||||||
|
also shows same-player candidates; a shared archived match is a useful potential
|
||||||
|
duplicate signal, not a finding that two reports are identical. Link cases as
|
||||||
|
**Same incident**, **Appeal of**, or **Duplicate of**, recording why they belong
|
||||||
|
together. Same-incident links may include different players; appeals and duplicates
|
||||||
|
must concern the same game and player. Links appear from both cases with their
|
||||||
|
direction made explicit. Self-links and circular appeal/duplicate chains are
|
||||||
|
rejected. Removing a mistaken relationship requires a reason and preserves its
|
||||||
|
history. Relationship changes do not close or reopen either case.
|
||||||
|
|
||||||
|
Schedule a follow-up using a date and time, or **Review when a linked mute expires**.
|
||||||
|
Custom date entry uses your browser's local time; displayed dates follow your time
|
||||||
|
zone preference. Reviews remain scheduled after a case closes or reopens. Use
|
||||||
|
**Overdue reviews** to find due work across open and closed cases, and clear the
|
||||||
|
follow-up with a reason when the review is complete.
|
||||||
|
|
||||||
|
In-app case updates notify the assignee and case creator when another staff member
|
||||||
|
adds evidence or a staff note. Assignment changes notify the previous and new assignees
|
||||||
|
and the creator, excluding the person making the change. **Your unread updates**
|
||||||
|
is personal to your account. Open a case to review and mark existing updates
|
||||||
|
read; notifications arriving afterward stay unread. Queue and case pages refresh
|
||||||
|
every 15 seconds while visible. These are in-app notifications, with no email or
|
||||||
|
external messaging delivery.
|
||||||
|
|
||||||
|
Apply a ban or mute directly from an active case. The case fixes the target game
|
||||||
|
and GUID and records the resulting restriction alongside the evidence. The same
|
||||||
|
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
|
||||||
|
restriction leaves the case active so staff can finish reviewing it. Changing case status
|
||||||
|
records workflow progress; it does not send a warning, create a restriction, or
|
||||||
|
revoke one. Linked restrictions can be revoked separately with
|
||||||
|
the required permission and reason, including after a case closes. Reopening a
|
||||||
|
case also leaves its restrictions in place. Check enforcement coverage after
|
||||||
|
applying or revoking a restriction.
|
||||||
|
|
||||||
|
Use **Link existing action** to associate a warning, kick, private message, ban, or
|
||||||
|
mute already recorded for this player with the case. This preserves the original
|
||||||
|
action, author, time, delivery state, and expiry; it does not issue another action
|
||||||
|
or extend a restriction. The same action can support more than one related case.
|
||||||
|
Linking requires the current case revision and a reason, and appears in its timeline.
|
||||||
|
Linked records show what actually happened, including failed or uncertain delivery.
|
||||||
|
They remain visible when a case closes; status changes do not require choosing
|
||||||
|
or re-entering an action.
|
||||||
|
|
||||||
|
Staff notes are explicitly labeled as notes. In match chat, select up to 100
|
||||||
|
messages and choose **Attach to case** to open a case or add them to an existing
|
||||||
|
case for the selected subject. Select at least one message from that subject;
|
||||||
|
messages from other participants can be selected alongside it. Each attachment
|
||||||
|
preserves selected messages and, by default, three surrounding messages on either side, with
|
||||||
|
names, GUIDs, original timing, and highlighted subject messages. The selection
|
||||||
|
preview shows the excerpt before saving. Server verification uses stable archived
|
||||||
|
message identifiers; repeated text remains separate occurrences, and attaching
|
||||||
|
the same selected messages again does not duplicate evidence. Exact-text
|
||||||
|
attachments remain available and include every matching occurrence, without the
|
||||||
|
former ten-occurrence limit.
|
||||||
|
|
||||||
|
Snapshots remain readable after source-match cleanup. Unknown or estimated
|
||||||
|
timestamps remain such; the snapshot does not turn them into exact observations.
|
||||||
|
Unarchived or already deleted chat cannot be attached as verified evidence.
|
||||||
|
|
||||||
|
Open **Chat archive** from Moderation to include all recorded matches, including
|
||||||
|
round-one games, interrupted games, and games missing an end report. Archive
|
||||||
|
access requires moderation read permission. Filter by server, game, map, status,
|
||||||
|
or date and open a match to select its preserved messages. Incomplete matches
|
||||||
|
show their observed state rather than final results. Ordinary match history and
|
||||||
|
leaderboard qualification are unchanged.
|
||||||
|
|
||||||
|
Both public (`say`) and team (`sayteam`) log messages are captured. Team messages
|
||||||
|
carry a **Team chat** label in chat and saved excerpts. Collection runs without a live
|
||||||
|
viewer and checks surviving logs once after startup, including idle servers.
|
||||||
|
Archive coverage still depends on the source log and accepted match telemetry:
|
||||||
|
logs that were deleted or rotated before collection, or old clock segments that
|
||||||
|
cannot safely be associated with a match, cannot be reconstructed. The collector
|
||||||
|
retains a bounded log tail (up to 2,000 lines / 256 KiB); it is not a full historical
|
||||||
|
log import. Already saved case excerpts survive later source cleanup.
|
||||||
|
|
||||||
|
Case decisions, edits, and restrictions check the current revision; refresh before
|
||||||
|
retrying a decision if another staff member has changed the case. Evidence,
|
||||||
|
restrictions and append-only moderation events survive source-match cleanup and
|
||||||
|
server deletion. They currently have no automatic retention cleanup.
|
||||||
|
Authentication audit cleanup does not remove moderation history. Correct decisions
|
||||||
|
by reopening, adding notes, or revoking restrictions; there is no history-erasing
|
||||||
|
delete endpoint.
|
||||||
|
|
||||||
Leaderboard disqualification, bank balance changes, automatic escalation and a
|
Leaderboard disqualification, bank balance changes, automatic escalation and a
|
||||||
public report/appeal portal are separate features. This workflow supports staff
|
public report/appeal portal are separate features. This workflow supports staff
|
||||||
|
|||||||
@@ -1101,3 +1101,22 @@ host credential; journal URLs never determine request destinations. Upgrade
|
|||||||
agents first, then [rebuild servers changing bank endpoints](bank.md#upgrading-from-a-controller-derived-game-endpoint)
|
agents first, then [rebuild servers changing bank endpoints](bank.md#upgrading-from-a-controller-derived-game-endpoint)
|
||||||
to drain pending decisions before the new scripts start. See the
|
to drain pending decisions before the new scripts start. See the
|
||||||
[agent changelog](../../agent/CHANGELOG.md) for release and validation details.
|
[agent changelog](../../agent/CHANGELOG.md) for release and validation details.
|
||||||
|
|
||||||
|
### AFK journals during server teardown
|
||||||
|
|
||||||
|
Install agent **1.12.0+** before enabling [persistent AFK reserves](afk.md).
|
||||||
|
The `afk.settlement` capability adds stopped-process recovery for
|
||||||
|
`tws-afk/t5/` and `tws-afk/t6/` journals. The agent uses its enrolled controller
|
||||||
|
and assigned unfinished teardown operation at `/api/agent/afk/settle`.
|
||||||
|
Journal endpoints must match the fixed public AFK endpoint; they never control
|
||||||
|
the destination of an agent request.
|
||||||
|
|
||||||
|
Removal, rebuild and creation cancellation preserve runtime files until every
|
||||||
|
decision is acknowledged. Prepared sessions that never activated return their
|
||||||
|
reservation; confirmed stops retain their original elapsed seconds and IDs.
|
||||||
|
Active sessions interrupted by a crash become unresolved, retaining their
|
||||||
|
checkpoint and balance hold for administrator reconciliation. The agent does
|
||||||
|
not invent a duration from the time of its next heartbeat. Pending purchase or
|
||||||
|
reward requests only retrieve existing receipts during teardown; they cannot
|
||||||
|
create new spending after the game stopped. Corrupt, oversized, symlinked or
|
||||||
|
unacknowledged journals block runtime deletion.
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
export const afkPageSize = 25;
|
||||||
|
export const afkLedgerKinds = [
|
||||||
|
'free_grant',
|
||||||
|
'purchase',
|
||||||
|
'reward',
|
||||||
|
'reserve',
|
||||||
|
'start',
|
||||||
|
'release',
|
||||||
|
'unresolved',
|
||||||
|
'consume',
|
||||||
|
'correction'
|
||||||
|
] as const;
|
||||||
|
export type AfkLedgerKind = (typeof afkLedgerKinds)[number];
|
||||||
|
export type AfkSessionState = 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected';
|
||||||
|
|
||||||
|
export interface AfkQuery {
|
||||||
|
view: 'accounts' | 'sessions' | 'ledger';
|
||||||
|
q: string;
|
||||||
|
balance: '' | 'saved' | 'reserved' | 'unresolved';
|
||||||
|
sort: 'saved' | 'available' | 'recent';
|
||||||
|
game: '' | 't5' | 't6';
|
||||||
|
server: string;
|
||||||
|
state: '' | AfkSessionState;
|
||||||
|
kind: '' | AfkLedgerKind;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
offset: number;
|
||||||
|
player: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAfkQuery(params: URLSearchParams): AfkQuery {
|
||||||
|
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
|
||||||
|
const value = params.get(key) || fallback;
|
||||||
|
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
function text(key: string, max: number) {
|
||||||
|
const value = (params.get(key) ?? '').trim();
|
||||||
|
if (value.length > max) throw new Error(`${key} is too long.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function date(key: string) {
|
||||||
|
const value = text(key, 10);
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
||||||
|
!Number.isFinite(Date.parse(value)) ||
|
||||||
|
new Date(value).toISOString().slice(0, 10) !== value)
|
||||||
|
)
|
||||||
|
throw new Error('Use a valid date in YYYY-MM-DD format.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const offset = Number(params.get('offset') || 0);
|
||||||
|
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid page offset.');
|
||||||
|
const from = date('from');
|
||||||
|
const to = date('to');
|
||||||
|
if (from && to && from > to) throw new Error('The end date must follow the start date.');
|
||||||
|
return {
|
||||||
|
view: choice('view', ['accounts', 'sessions', 'ledger'], 'accounts'),
|
||||||
|
q: text('q', 100),
|
||||||
|
balance: choice('balance', ['', 'saved', 'reserved', 'unresolved'], ''),
|
||||||
|
sort: choice('sort', ['saved', 'available', 'recent'], 'saved'),
|
||||||
|
game: choice('game', ['', 't5', 't6'], ''),
|
||||||
|
server: text('server', 256),
|
||||||
|
state: choice(
|
||||||
|
'state',
|
||||||
|
['', 'reserved', 'started', 'settled', 'unresolved', 'corrected'],
|
||||||
|
''
|
||||||
|
),
|
||||||
|
kind: choice('kind', ['', ...afkLedgerKinds], ''),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
offset: Math.floor(offset / afkPageSize) * afkPageSize,
|
||||||
|
player: text('player', 128)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AfkIdentity {
|
||||||
|
guid: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
export interface AfkAccount extends AfkIdentity {
|
||||||
|
freeSeconds: number;
|
||||||
|
savedSeconds: number;
|
||||||
|
availableSeconds: number;
|
||||||
|
reservedSeconds: number;
|
||||||
|
reservedSavedSeconds: number;
|
||||||
|
unresolvedSessions: number;
|
||||||
|
lastActivity: string | null;
|
||||||
|
freeRenewsAt: string | null;
|
||||||
|
}
|
||||||
|
export interface AfkSession extends AfkIdentity {
|
||||||
|
sessionId: string;
|
||||||
|
server: string;
|
||||||
|
game: string | null;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
state: AfkSessionState;
|
||||||
|
authorizedSeconds: number;
|
||||||
|
consumedSeconds: number | null;
|
||||||
|
chargedSeconds: number | null;
|
||||||
|
confirmedSeconds: number;
|
||||||
|
endReason: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
startedAt: string | null;
|
||||||
|
endedAt: string | null;
|
||||||
|
}
|
||||||
|
export interface AfkLedgerEntry extends AfkIdentity {
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
game: string | null;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
kind: AfkLedgerKind;
|
||||||
|
freeDelta: number;
|
||||||
|
savedDelta: number;
|
||||||
|
freeSeconds: number;
|
||||||
|
savedSeconds: number;
|
||||||
|
operationId: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
eventAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface AfkPage<T> {
|
||||||
|
entries: T[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
export interface AfkOverview {
|
||||||
|
query: AfkQuery;
|
||||||
|
checkedAt: string;
|
||||||
|
capacitySeconds: number;
|
||||||
|
summary: {
|
||||||
|
accounts: number;
|
||||||
|
savedSeconds: number;
|
||||||
|
reservedSeconds: number;
|
||||||
|
unresolvedSessions: number;
|
||||||
|
};
|
||||||
|
servers: string[];
|
||||||
|
accounts: AfkPage<AfkAccount>;
|
||||||
|
sessions: AfkPage<AfkSession>;
|
||||||
|
ledger: AfkPage<AfkLedgerEntry>;
|
||||||
|
}
|
||||||
+15
-7
@@ -4,6 +4,8 @@ export interface ChatMessage {
|
|||||||
slot: number;
|
slot: number;
|
||||||
player: string;
|
player: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
/** Omitted for public chat to preserve existing archived message identities. */
|
||||||
|
channel?: 'team';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatEntry =
|
export type ChatEntry =
|
||||||
@@ -17,7 +19,12 @@ export interface ChatContext {
|
|||||||
round: number | null;
|
round: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContextualChatEntry = ChatEntry & { context: ChatContext; matchId?: number };
|
export type ContextualChatEntry = ChatEntry & {
|
||||||
|
context: ChatContext;
|
||||||
|
matchId?: number;
|
||||||
|
/** Stable archive identity, independent of estimated wall-clock timestamps. */
|
||||||
|
messageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ChatMatch {
|
export interface ChatMatch {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -93,18 +100,19 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
|
|||||||
previousSeconds = seconds;
|
previousSeconds = seconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
|
const match = /^\s*(\d+:[0-5]\d)\s+(say|sayteam);([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
const message = plainText(match[5]);
|
const message = plainText(match[6]);
|
||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
push({
|
push({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
message: {
|
message: {
|
||||||
time: match[1],
|
time: match[1],
|
||||||
guid: match[2],
|
guid: match[3],
|
||||||
slot: Number(match[3]),
|
slot: Number(match[4]),
|
||||||
player: plainText(match[4]) || 'Unknown player',
|
player: plainText(match[5]) || 'Unknown player',
|
||||||
message
|
message,
|
||||||
|
...(match[2] === 'sayteam' ? { channel: 'team' as const } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface MatchHistoryQuery {
|
|||||||
sort: MatchSort;
|
sort: MatchSort;
|
||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
page: number;
|
page: number;
|
||||||
|
archive: boolean;
|
||||||
}
|
}
|
||||||
export const matchQueryKeys = [
|
export const matchQueryKeys = [
|
||||||
'game',
|
'game',
|
||||||
@@ -19,7 +20,8 @@ export const matchQueryKeys = [
|
|||||||
'status',
|
'status',
|
||||||
'sort',
|
'sort',
|
||||||
'direction',
|
'direction',
|
||||||
'page'
|
'page',
|
||||||
|
'archive'
|
||||||
] as const;
|
] as const;
|
||||||
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
||||||
const game = params.get('game') ?? '';
|
const game = params.get('game') ?? '';
|
||||||
@@ -30,6 +32,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
|||||||
const sort = params.get('sort') || 'started';
|
const sort = params.get('sort') || 'started';
|
||||||
const direction = params.get('direction') || 'desc';
|
const direction = params.get('direction') || 'desc';
|
||||||
const page = Number(params.get('page') || 1);
|
const page = Number(params.get('page') || 1);
|
||||||
|
const archive = params.get('archive') ?? '';
|
||||||
if (
|
if (
|
||||||
!['', 't5', 't6'].includes(game) ||
|
!['', 't5', 't6'].includes(game) ||
|
||||||
map.length > 1024 ||
|
map.length > 1024 ||
|
||||||
@@ -40,7 +43,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
|||||||
!['asc', 'desc'].includes(direction) ||
|
!['asc', 'desc'].includes(direction) ||
|
||||||
!Number.isSafeInteger(page) ||
|
!Number.isSafeInteger(page) ||
|
||||||
page < 1 ||
|
page < 1 ||
|
||||||
page > 1000000
|
page > 1000000 ||
|
||||||
|
!['', '0', '1'].includes(archive)
|
||||||
)
|
)
|
||||||
throw new Error('Invalid match history filters.');
|
throw new Error('Invalid match history filters.');
|
||||||
return {
|
return {
|
||||||
@@ -51,7 +55,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
|||||||
status: status as MatchHistoryQuery['status'],
|
status: status as MatchHistoryQuery['status'],
|
||||||
sort: sort as MatchSort,
|
sort: sort as MatchSort,
|
||||||
direction: direction as 'asc' | 'desc',
|
direction: direction as 'asc' | 'desc',
|
||||||
page
|
page,
|
||||||
|
archive: archive === '1'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/** Namespaced filters also work inside an instance's Matches tab. */
|
/** Namespaced filters also work inside an instance's Matches tab. */
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export interface MatchPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchDetail extends ServerMatch {
|
export interface MatchDetail extends ServerMatch {
|
||||||
status: 'completed';
|
|
||||||
game: string;
|
game: string;
|
||||||
startObserved: boolean;
|
startObserved: boolean;
|
||||||
players: MatchPlayer[];
|
players: MatchPlayer[];
|
||||||
|
|||||||
+158
-2
@@ -6,6 +6,91 @@ export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
|
|||||||
export type ModerationState =
|
export type ModerationState =
|
||||||
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
|
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
|
||||||
|
|
||||||
|
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
|
||||||
|
export const moderationCaseOutcomes = [
|
||||||
|
'resolved',
|
||||||
|
'dismissed',
|
||||||
|
'warning_issued',
|
||||||
|
'restriction_applied',
|
||||||
|
'no_action',
|
||||||
|
'duplicate',
|
||||||
|
'appeal_upheld',
|
||||||
|
'appeal_denied'
|
||||||
|
] as const;
|
||||||
|
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
|
||||||
|
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
|
||||||
|
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
|
||||||
|
export type ModerationCaseRelation = (typeof moderationCaseRelations)[number];
|
||||||
|
export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string> = {
|
||||||
|
same_incident: 'Same incident',
|
||||||
|
appeal_of: 'Appeal of',
|
||||||
|
duplicate_of: 'Duplicate of'
|
||||||
|
};
|
||||||
|
export const moderationCaseStatusLabels: Record<
|
||||||
|
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
open: 'Open',
|
||||||
|
in_review: 'In progress',
|
||||||
|
closed: 'Closed',
|
||||||
|
escalated: 'Escalated',
|
||||||
|
resolved: 'Resolved',
|
||||||
|
dismissed: 'Dismissed',
|
||||||
|
warning_issued: 'Warning issued',
|
||||||
|
restriction_applied: 'Restriction applied',
|
||||||
|
no_action: 'No action needed',
|
||||||
|
duplicate: 'Duplicate',
|
||||||
|
appeal_upheld: 'Appeal upheld',
|
||||||
|
appeal_denied: 'Appeal denied'
|
||||||
|
};
|
||||||
|
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
|
||||||
|
return status === 'open' || status === 'in_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationConcernCategories = [
|
||||||
|
'hate_speech',
|
||||||
|
'harassment',
|
||||||
|
'threats',
|
||||||
|
'safeguarding',
|
||||||
|
'spam',
|
||||||
|
'cheating',
|
||||||
|
'other'
|
||||||
|
] as const;
|
||||||
|
export type ModerationConcernCategory = (typeof moderationConcernCategories)[number];
|
||||||
|
export const moderationConcernLabels: Record<ModerationConcernCategory, string> = {
|
||||||
|
hate_speech: 'Hate speech',
|
||||||
|
harassment: 'Harassment',
|
||||||
|
threats: 'Threats',
|
||||||
|
safeguarding: 'Safeguarding',
|
||||||
|
spam: 'Spam',
|
||||||
|
cheating: 'Cheating',
|
||||||
|
other: 'Other'
|
||||||
|
};
|
||||||
|
export const moderationFindingOutcomes = [
|
||||||
|
'substantiated',
|
||||||
|
'inconclusive',
|
||||||
|
'unsubstantiated'
|
||||||
|
] as const;
|
||||||
|
export type ModerationFindingOutcome = (typeof moderationFindingOutcomes)[number];
|
||||||
|
export const moderationFindingLabels: Record<ModerationFindingOutcome, string> = {
|
||||||
|
substantiated: 'Substantiated',
|
||||||
|
inconclusive: 'Inconclusive',
|
||||||
|
unsubstantiated: 'Unsubstantiated'
|
||||||
|
};
|
||||||
|
export interface ModerationCaseAssessment {
|
||||||
|
revision: number;
|
||||||
|
summary: string;
|
||||||
|
rules: string[];
|
||||||
|
findings: {
|
||||||
|
category: ModerationConcernCategory;
|
||||||
|
finding: ModerationFindingOutcome;
|
||||||
|
detail: string;
|
||||||
|
}[];
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModerationPlayer {
|
export interface ModerationPlayer {
|
||||||
game: ModerationGame;
|
game: ModerationGame;
|
||||||
guid: string;
|
guid: string;
|
||||||
@@ -18,6 +103,7 @@ export interface ModerationPlayer {
|
|||||||
}
|
}
|
||||||
export interface ModerationSanction {
|
export interface ModerationSanction {
|
||||||
id: string;
|
id: string;
|
||||||
|
caseId?: string | null;
|
||||||
game: ModerationGame;
|
game: ModerationGame;
|
||||||
guid: string;
|
guid: string;
|
||||||
kind: 'ban' | 'mute';
|
kind: 'ban' | 'mute';
|
||||||
@@ -68,12 +154,76 @@ export interface ModerationCase {
|
|||||||
game: ModerationGame;
|
game: ModerationGame;
|
||||||
guid: string;
|
guid: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: 'open' | 'resolved' | 'dismissed';
|
status: ModerationCaseStatus;
|
||||||
|
priority: ModerationCasePriority;
|
||||||
|
assigneeId: string | null;
|
||||||
|
assignee: string | null;
|
||||||
actor: string;
|
actor: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
evidence: { id: string; text: string; source: string; createdAt: string; actor: string }[];
|
assessment?: ModerationCaseAssessment | null;
|
||||||
|
reviewAt?: string | null;
|
||||||
|
reviewReason?: string;
|
||||||
|
reviewSanctionId?: string | null;
|
||||||
|
unreadUpdates?: number;
|
||||||
|
evidence: {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
source: string;
|
||||||
|
snapshot?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
actor: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
export interface ModerationCaseDetail {
|
||||||
|
case: ModerationCase;
|
||||||
|
player: ModerationPlayer;
|
||||||
|
sanctions: ModerationSanction[];
|
||||||
|
linkedActions?: ModerationCaseAction[];
|
||||||
|
availableActions?: ModerationCaseAction[];
|
||||||
|
assessment?: ModerationCaseAssessment | null;
|
||||||
|
assessmentHistory?: ModerationCaseAssessment[];
|
||||||
|
relatedCases?: ModerationCaseRelationship[];
|
||||||
|
candidateCases?: ModerationCaseCandidate[];
|
||||||
|
notifications?: ModerationCaseNotification[];
|
||||||
|
events: ModerationEvent[];
|
||||||
|
coverage: ModerationCoverage[];
|
||||||
|
assignees: { id: string; username: string }[];
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
export type ModerationCaseReference = Pick<
|
||||||
|
ModerationCase,
|
||||||
|
'id' | 'title' | 'status' | 'game' | 'guid'
|
||||||
|
>;
|
||||||
|
export interface ModerationCaseRelationship {
|
||||||
|
id: string;
|
||||||
|
relation: ModerationCaseRelation;
|
||||||
|
direction: 'outgoing' | 'incoming';
|
||||||
|
case: ModerationCaseReference;
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface ModerationCaseCandidate extends ModerationCaseReference {
|
||||||
|
sharedMatch: boolean;
|
||||||
|
}
|
||||||
|
export interface ModerationCaseNotification {
|
||||||
|
id: number;
|
||||||
|
caseId: string;
|
||||||
|
caseTitle: string;
|
||||||
|
kind: 'evidence' | 'assignment';
|
||||||
|
createdAt: string;
|
||||||
|
readAt: string | null;
|
||||||
|
}
|
||||||
|
export interface ModerationCaseAction {
|
||||||
|
id: string;
|
||||||
|
type: 'restriction' | 'action';
|
||||||
|
kind: string;
|
||||||
|
reason: string;
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
state: string;
|
||||||
|
expiresAt: string | null;
|
||||||
}
|
}
|
||||||
export interface ModerationEvent {
|
export interface ModerationEvent {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -100,6 +250,10 @@ export interface ModerationQuery {
|
|||||||
game: '' | ModerationGame;
|
game: '' | ModerationGame;
|
||||||
guid: string;
|
guid: string;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
|
||||||
|
priority?: '' | ModerationCasePriority;
|
||||||
|
assignedTo?: string;
|
||||||
|
review?: '' | 'due' | 'scheduled' | 'updates';
|
||||||
}
|
}
|
||||||
export interface ModerationOverview {
|
export interface ModerationOverview {
|
||||||
query: ModerationQuery;
|
query: ModerationQuery;
|
||||||
@@ -112,6 +266,8 @@ export interface ModerationOverview {
|
|||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
|
notifications?: ModerationCaseNotification[];
|
||||||
|
unreadCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
|
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { activityRedirectHref } from './activity-redirect.js';
|
|||||||
|
|
||||||
const origin = 'https://electric-cherry.invalid';
|
const origin = 'https://electric-cherry.invalid';
|
||||||
const pagePath =
|
const pagePath =
|
||||||
/^\/(?:$|activity|operations|autoscaling|account|bank|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
/^\/(?:$|activity|operations|autoscaling|account|bank|afk|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
||||||
|
|
||||||
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
||||||
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
||||||
@@ -63,6 +63,7 @@ export function returnLabel(href: string): string {
|
|||||||
provisioning: 'Server queues',
|
provisioning: 'Server queues',
|
||||||
leaderboard: 'Leaderboard',
|
leaderboard: 'Leaderboard',
|
||||||
bank: 'Bank',
|
bank: 'Bank',
|
||||||
|
afk: 'AFK',
|
||||||
matches: 'Match History',
|
matches: 'Match History',
|
||||||
metrics: 'Metrics'
|
metrics: 'Metrics'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const navigationGroups: NavigationGroup[] = [
|
|||||||
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
||||||
{ label: 'Matches', href: '/matches' },
|
{ label: 'Matches', href: '/matches' },
|
||||||
{ label: 'Bank', href: '/bank' },
|
{ label: 'Bank', href: '/bank' },
|
||||||
|
{ label: 'AFK', href: '/afk' },
|
||||||
{ label: 'Moderation', href: '/moderation', moderation: true },
|
{ label: 'Moderation', href: '/moderation', moderation: true },
|
||||||
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
return 'admin';
|
return 'admin';
|
||||||
|
|
||||||
if (within(routeId, '/api/agent')) return 'machine';
|
if (within(routeId, '/api/agent')) return 'machine';
|
||||||
if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine';
|
if (routeId === '/api/afk/corrections') return 'admin';
|
||||||
|
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
|
||||||
|
return 'machine';
|
||||||
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
||||||
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
|
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
|
||||||
if (routeId === '/api/leaderboard' && reading) return 'public';
|
if (routeId === '/api/leaderboard' && reading) return 'public';
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
/** Accounts, receipts and audit evidence deliberately outlive matches and servers. */
|
||||||
|
export function migrateAfk(handle: Database.Database): void {
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS afk_accounts (
|
||||||
|
guid TEXT PRIMARY KEY, free_seconds INTEGER NOT NULL CHECK(free_seconds >= 0),
|
||||||
|
saved_seconds INTEGER NOT NULL DEFAULT 0 CHECK(saved_seconds >= 0),
|
||||||
|
free_started_at INTEGER NOT NULL, active_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
kill_progress INTEGER NOT NULL DEFAULT 0, headshot_progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bonus_hour INTEGER NOT NULL DEFAULT 0, bonus_seconds INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS afk_operations (
|
||||||
|
server TEXT NOT NULL, request_id TEXT NOT NULL, instance_id TEXT NOT NULL,
|
||||||
|
guid TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL, PRIMARY KEY(server,request_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS afk_progress (
|
||||||
|
instance_id TEXT NOT NULL, match_key TEXT NOT NULL, guid TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL, active_seconds INTEGER NOT NULL, kills INTEGER NOT NULL,
|
||||||
|
headshots INTEGER NOT NULL, rounds INTEGER NOT NULL, revives INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(instance_id,match_key,guid)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS afk_bonus_rewards (
|
||||||
|
guid TEXT NOT NULL, active_second INTEGER NOT NULL, seconds INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS afk_bonus_rewards_guid ON afk_bonus_rewards(guid,active_second);
|
||||||
|
CREATE TABLE IF NOT EXISTS afk_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY, server TEXT NOT NULL, instance_id TEXT NOT NULL,
|
||||||
|
guid TEXT NOT NULL, match_key TEXT NOT NULL, match_id INTEGER,
|
||||||
|
state TEXT NOT NULL CHECK(state IN ('reserved','started','settled','unresolved','corrected')),
|
||||||
|
authorized_seconds INTEGER NOT NULL, free_seconds INTEGER NOT NULL,
|
||||||
|
saved_seconds INTEGER NOT NULL, free_started_at INTEGER NOT NULL,
|
||||||
|
consumed_seconds INTEGER, charged_seconds INTEGER, free_charge_at INTEGER, confirmed_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
end_reason TEXT, created_at TEXT NOT NULL, started_at TEXT, ended_at TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS afk_pending_guid ON afk_sessions(guid)
|
||||||
|
WHERE state IN ('reserved','started','unresolved');
|
||||||
|
CREATE TABLE IF NOT EXISTS afk_ledger (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL, server TEXT NOT NULL,
|
||||||
|
match_id INTEGER, operation_id TEXT NOT NULL, session_id TEXT,
|
||||||
|
kind TEXT NOT NULL, free_delta INTEGER NOT NULL, saved_delta INTEGER NOT NULL,
|
||||||
|
free_seconds INTEGER NOT NULL, saved_seconds INTEGER NOT NULL,
|
||||||
|
event_at TEXT NOT NULL, created_at TEXT NOT NULL, details TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS afk_ledger_guid ON afk_ledger(guid,id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS afk_sessions_guid ON afk_sessions(guid,created_at DESC);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -25,9 +25,19 @@ import { migrateGameFileSchema } from '../game-files/schema.js';
|
|||||||
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||||
import { migratePlayerScoreTotal } from './player-score-total.js';
|
import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||||
import { migrateBank } from './bank.js';
|
import { migrateBank } from './bank.js';
|
||||||
|
import { migrateAfk } from './afk.js';
|
||||||
import { migrateBankCredentials } from './bank-credentials.js';
|
import { migrateBankCredentials } from './bank-credentials.js';
|
||||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||||
import { migrateModeration } from '../moderation/schema.js';
|
import {
|
||||||
|
migrateModeration,
|
||||||
|
migrateModerationCases,
|
||||||
|
migrateModerationCaseActions,
|
||||||
|
migrateModerationCaseAssessments,
|
||||||
|
migrateModerationCaseRelationships,
|
||||||
|
migrateModerationCaseFollowups,
|
||||||
|
migrateModerationCaseReferenceIndexes,
|
||||||
|
migrateModerationCaseStates
|
||||||
|
} from '../moderation/schema.js';
|
||||||
import type { Migration } from './migration-runner.js';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
// Append only: never renumber entries or change an applied migration to add an
|
// Append only: never renumber entries or change an applied migration to add an
|
||||||
@@ -162,7 +172,45 @@ export const migrations: readonly Migration[] = [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ version: 28, name: 'known login devices', run: migrateLoginDevices }
|
{ version: 28, name: 'known login devices', run: migrateLoginDevices },
|
||||||
|
{
|
||||||
|
version: 29,
|
||||||
|
name: 'moderation case workflow and linked restrictions',
|
||||||
|
mode: 'foreign-keys-off',
|
||||||
|
run: migrateModerationCases
|
||||||
|
},
|
||||||
|
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk },
|
||||||
|
{
|
||||||
|
version: 31,
|
||||||
|
name: 'moderation case links to existing actions',
|
||||||
|
run: migrateModerationCaseActions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 32,
|
||||||
|
name: 'moderation case assessment revision history',
|
||||||
|
run: migrateModerationCaseAssessments
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 33,
|
||||||
|
name: 'moderation case relationships and duplicate review',
|
||||||
|
run: migrateModerationCaseRelationships
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 34,
|
||||||
|
name: 'moderation follow-up dates and personal updates',
|
||||||
|
run: migrateModerationCaseFollowups
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 35,
|
||||||
|
name: 'moderation case foreign key reference indexes',
|
||||||
|
run: migrateModerationCaseReferenceIndexes
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 36,
|
||||||
|
name: 'moderation case lifecycle states',
|
||||||
|
mode: 'foreign-keys-off',
|
||||||
|
run: migrateModerationCaseStates
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export const databaseVersion = migrations.length;
|
export const databaseVersion = migrations.length;
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { afkPolicy } from './afk.js';
|
||||||
|
import {
|
||||||
|
afkPageSize,
|
||||||
|
type AfkAccount,
|
||||||
|
type AfkLedgerEntry,
|
||||||
|
type AfkOverview,
|
||||||
|
type AfkPage,
|
||||||
|
type AfkQuery,
|
||||||
|
type AfkSession
|
||||||
|
} from '../../afk.js';
|
||||||
|
|
||||||
|
const day = 86_400_000;
|
||||||
|
// A string GUID owns one reserve, including players who appear in both games.
|
||||||
|
const identities = `WITH identities AS (
|
||||||
|
SELECT guid, name FROM (
|
||||||
|
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS rank
|
||||||
|
FROM leaderboard_players WHERE game IN ('t5', 't6')
|
||||||
|
) WHERE rank = 1
|
||||||
|
)`;
|
||||||
|
// Mirror freeAvailability in afk.ts: held time never expires, and confirmed free
|
||||||
|
// consumption becomes available 24 hours after its durable stop receipt.
|
||||||
|
const accounts = `${identities}, session_totals AS (
|
||||||
|
SELECT guid,
|
||||||
|
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN authorized_seconds ELSE 0 END) AS reserved,
|
||||||
|
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN saved_seconds ELSE 0 END) AS reserved_saved,
|
||||||
|
SUM(CASE WHEN state = 'unresolved' THEN 1 ELSE 0 END) AS unresolved,
|
||||||
|
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN free_seconds
|
||||||
|
WHEN free_charge_at > ? THEN MIN(free_seconds, charged_seconds) ELSE 0 END) AS free_used,
|
||||||
|
MIN(CASE WHEN free_charge_at > ? AND MIN(free_seconds, charged_seconds) > 0 THEN free_charge_at END) AS next_free_at,
|
||||||
|
MAX(MAX(created_at, COALESCE(started_at, created_at), COALESCE(ended_at, created_at))) AS last_activity
|
||||||
|
FROM afk_sessions GROUP BY guid
|
||||||
|
), ledger_totals AS (
|
||||||
|
SELECT guid, MAX(created_at) AS last_activity FROM afk_ledger GROUP BY guid
|
||||||
|
), account_guids AS (
|
||||||
|
SELECT guid FROM afk_accounts UNION SELECT guid FROM session_totals UNION SELECT guid FROM ledger_totals
|
||||||
|
), balances AS (
|
||||||
|
SELECT g.guid, p.name,
|
||||||
|
CASE WHEN a.guid IS NOT NULL THEN MAX(0, ? - COALESCE(s.free_used, 0)) ELSE 0 END AS freeSeconds,
|
||||||
|
COALESCE(a.saved_seconds, 0) AS savedSeconds,
|
||||||
|
COALESCE(s.reserved, 0) AS reservedSeconds,
|
||||||
|
COALESCE(s.reserved_saved, 0) AS reservedSavedSeconds,
|
||||||
|
COALESCE(s.unresolved, 0) AS unresolvedSessions,
|
||||||
|
NULLIF(MAX(COALESCE(s.last_activity, ''), COALESCE(l.last_activity, '')), '') AS lastActivity,
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', (s.next_free_at + ${day}) / 1000.0, 'unixepoch') AS freeRenewsAt
|
||||||
|
FROM account_guids g
|
||||||
|
LEFT JOIN afk_accounts a ON a.guid = g.guid
|
||||||
|
LEFT JOIN identities p ON p.guid = g.guid
|
||||||
|
LEFT JOIN session_totals s ON s.guid = g.guid
|
||||||
|
LEFT JOIN ledger_totals l ON l.guid = g.guid
|
||||||
|
), accounts AS (
|
||||||
|
SELECT *, freeSeconds + savedSeconds AS availableSeconds FROM balances
|
||||||
|
)`;
|
||||||
|
// Retained canonical server names identify the game after match/server removal.
|
||||||
|
const game = (alias: string) => `COALESCE(m.game, CASE
|
||||||
|
WHEN substr(${alias}.server, 1, 3) = 't5_' THEN 't5'
|
||||||
|
WHEN substr(${alias}.server, 1, 3) = 't6_' THEN 't6' END)`;
|
||||||
|
const sessions = `${identities}, sessions AS (
|
||||||
|
SELECT s.guid, p.name, s.session_id AS sessionId, s.server, ${game('s')} AS game,
|
||||||
|
s.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, s.state,
|
||||||
|
s.authorized_seconds AS authorizedSeconds, s.consumed_seconds AS consumedSeconds,
|
||||||
|
s.charged_seconds AS chargedSeconds, s.confirmed_seconds AS confirmedSeconds,
|
||||||
|
s.end_reason AS endReason, s.created_at AS createdAt, s.started_at AS startedAt, s.ended_at AS endedAt
|
||||||
|
FROM afk_sessions s LEFT JOIN identities p ON p.guid = s.guid
|
||||||
|
LEFT JOIN leaderboard_matches m ON m.id = s.match_id
|
||||||
|
)`;
|
||||||
|
const ledger = `${identities}, ledger AS (
|
||||||
|
SELECT l.guid, p.name, l.id, l.server, ${game('l')} AS game,
|
||||||
|
l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, l.kind,
|
||||||
|
l.free_delta AS freeDelta, l.saved_delta AS savedDelta,
|
||||||
|
l.free_seconds AS freeSeconds, l.saved_seconds AS savedSeconds,
|
||||||
|
l.operation_id AS operationId, l.session_id AS sessionId,
|
||||||
|
l.event_at AS eventAt, l.created_at AS createdAt
|
||||||
|
FROM afk_ledger l LEFT JOIN identities p ON p.guid = l.guid
|
||||||
|
LEFT JOIN leaderboard_matches m ON m.id = l.match_id
|
||||||
|
)`;
|
||||||
|
const emptyPage = <T>(): AfkPage<T> => ({ entries: [], total: 0, offset: 0 });
|
||||||
|
|
||||||
|
/** Read-only fleet report; reads never grant, settle, or update AFK balances. */
|
||||||
|
export function afkOverview(query: AfkQuery, now = new Date()): AfkOverview {
|
||||||
|
const handle = db();
|
||||||
|
return handle.transaction(() => {
|
||||||
|
const policy = afkPolicy();
|
||||||
|
const checkedAt = now.toISOString();
|
||||||
|
const accountValues = [now.getTime() - day, now.getTime() - day, policy.freeSeconds];
|
||||||
|
const summary = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM (SELECT guid FROM afk_accounts UNION SELECT guid FROM afk_sessions UNION SELECT guid FROM afk_ledger)) AS accounts,
|
||||||
|
(SELECT COALESCE(SUM(saved_seconds), 0) FROM afk_accounts) AS savedSeconds,
|
||||||
|
(SELECT COALESCE(SUM(authorized_seconds), 0) FROM afk_sessions WHERE state IN ('reserved','started','unresolved')) AS reservedSeconds,
|
||||||
|
(SELECT COUNT(*) FROM afk_sessions WHERE state = 'unresolved') AS unresolvedSessions`
|
||||||
|
)
|
||||||
|
.get() as AfkOverview['summary'];
|
||||||
|
const servers = (
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT server FROM afk_sessions UNION SELECT server FROM afk_ledger ORDER BY server'
|
||||||
|
)
|
||||||
|
.all() as { server: string }[]
|
||||||
|
).map((row) => row.server);
|
||||||
|
|
||||||
|
function page<T>(
|
||||||
|
cte: string,
|
||||||
|
table: string,
|
||||||
|
where: string[],
|
||||||
|
values: (string | number)[],
|
||||||
|
order: string
|
||||||
|
): AfkPage<T> {
|
||||||
|
const scope = `FROM ${table}${where.length ? ` WHERE ${where.join(' AND ')}` : ''}`;
|
||||||
|
const { total } = handle
|
||||||
|
.prepare(`${cte} SELECT COUNT(*) AS total ${scope}`)
|
||||||
|
.get(...values) as { total: number };
|
||||||
|
const offset = Math.min(
|
||||||
|
query.offset,
|
||||||
|
Math.max(0, Math.floor((total - 1) / afkPageSize) * afkPageSize)
|
||||||
|
);
|
||||||
|
const entries = handle
|
||||||
|
.prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`)
|
||||||
|
.all(...values, afkPageSize, offset) as T[];
|
||||||
|
return { entries, total, offset };
|
||||||
|
}
|
||||||
|
const where: string[] = [];
|
||||||
|
const values: (string | number)[] = [];
|
||||||
|
if (query.q) {
|
||||||
|
where.push(
|
||||||
|
"(instr(lower(COALESCE(name, '')), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0)"
|
||||||
|
);
|
||||||
|
values.push(query.q, query.q);
|
||||||
|
}
|
||||||
|
if (query.player) {
|
||||||
|
where.push('guid = ?');
|
||||||
|
values.push(query.player);
|
||||||
|
}
|
||||||
|
let accountPage = emptyPage<AfkAccount>();
|
||||||
|
let sessionPage = emptyPage<AfkSession>();
|
||||||
|
let ledgerPage = emptyPage<AfkLedgerEntry>();
|
||||||
|
if (query.view === 'accounts') {
|
||||||
|
if (query.balance === 'saved') where.push('savedSeconds > 0');
|
||||||
|
if (query.balance === 'reserved') where.push('reservedSeconds > 0');
|
||||||
|
if (query.balance === 'unresolved') where.push('unresolvedSessions > 0');
|
||||||
|
const order =
|
||||||
|
query.sort === 'recent'
|
||||||
|
? 'lastActivity DESC, guid'
|
||||||
|
: query.sort === 'available'
|
||||||
|
? 'availableSeconds DESC, guid'
|
||||||
|
: 'savedSeconds DESC, guid';
|
||||||
|
accountPage = page(accounts, 'accounts', where, [...accountValues, ...values], order);
|
||||||
|
} else {
|
||||||
|
if (query.game) {
|
||||||
|
where.push('game = ?');
|
||||||
|
values.push(query.game);
|
||||||
|
}
|
||||||
|
if (query.server) {
|
||||||
|
where.push('server = ?');
|
||||||
|
values.push(query.server);
|
||||||
|
}
|
||||||
|
if (query.from) {
|
||||||
|
where.push('createdAt >= ?');
|
||||||
|
values.push(`${query.from}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
if (query.to) {
|
||||||
|
where.push('createdAt <= ?');
|
||||||
|
values.push(`${query.to}T23:59:59.999Z`);
|
||||||
|
}
|
||||||
|
if (query.view === 'sessions') {
|
||||||
|
if (query.state) {
|
||||||
|
where.push('state = ?');
|
||||||
|
values.push(query.state);
|
||||||
|
}
|
||||||
|
sessionPage = page(
|
||||||
|
sessions,
|
||||||
|
'sessions',
|
||||||
|
where,
|
||||||
|
values,
|
||||||
|
'createdAt DESC, sessionId'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (query.kind) {
|
||||||
|
where.push('kind = ?');
|
||||||
|
values.push(query.kind);
|
||||||
|
}
|
||||||
|
ledgerPage = page(ledger, 'ledger', where, values, 'createdAt DESC, id DESC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
checkedAt,
|
||||||
|
capacitySeconds: policy.capacitySeconds,
|
||||||
|
summary,
|
||||||
|
servers,
|
||||||
|
accounts: accountPage,
|
||||||
|
sessions: sessionPage,
|
||||||
|
ledger: ledgerPage
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
export type AfkAction = 'balance' | 'event' | 'purchase' | 'reserve' | 'start' | 'settle';
|
||||||
|
const MAX = 2_147_483_647;
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const token = (v: unknown): v is string =>
|
||||||
|
typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v);
|
||||||
|
const integer = (v: unknown, min = 0, max = MAX): v is number =>
|
||||||
|
Number.isSafeInteger(v) && Number(v) >= min && Number(v) <= max;
|
||||||
|
|
||||||
|
export function afkPolicy() {
|
||||||
|
const setting = (name: string, fallback: number, min = 0, max = MAX) => {
|
||||||
|
const value = Number(process.env[`EC_AFK_${name}`] ?? fallback);
|
||||||
|
if (!integer(value, min, max)) throw new Error(`Invalid EC_AFK_${name}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
freeSeconds: setting('FREE_SECONDS', 1800, 0, 604800),
|
||||||
|
capacitySeconds: setting('CAPACITY_SECONDS', 21600, 1, 604800),
|
||||||
|
activeInterval: setting('ACTIVE_INTERVAL_SECONDS', 300, 1),
|
||||||
|
activeReward: setting('ACTIVE_REWARD_SECONDS', 60),
|
||||||
|
roundReward: setting('ROUND_REWARD_SECONDS', 15),
|
||||||
|
killInterval: setting('KILL_INTERVAL', 100, 1),
|
||||||
|
killReward: setting('KILL_REWARD_SECONDS', 30),
|
||||||
|
headshotInterval: setting('HEADSHOT_INTERVAL', 25, 1),
|
||||||
|
headshotReward: setting('HEADSHOT_REWARD_SECONDS', 15),
|
||||||
|
reviveReward: setting('REVIVE_REWARD_SECONDS', 30),
|
||||||
|
bonusCap: setting('BONUS_HOURLY_SECONDS', 480),
|
||||||
|
purchasePoints: setting('PURCHASE_POINTS', 10000, 1, 1000000),
|
||||||
|
purchaseSeconds: setting('PURCHASE_SECONDS', 600, 1, 604800)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
interface Account {
|
||||||
|
guid: string;
|
||||||
|
free_seconds: number;
|
||||||
|
saved_seconds: number;
|
||||||
|
free_started_at: number;
|
||||||
|
active_seconds: number;
|
||||||
|
kill_progress: number;
|
||||||
|
headshot_progress: number;
|
||||||
|
bonus_hour: number;
|
||||||
|
bonus_seconds: number;
|
||||||
|
}
|
||||||
|
interface Session {
|
||||||
|
session_id: string;
|
||||||
|
server: string;
|
||||||
|
instance_id: string;
|
||||||
|
guid: string;
|
||||||
|
match_key: string;
|
||||||
|
match_id: number | null;
|
||||||
|
state: 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected';
|
||||||
|
authorized_seconds: number;
|
||||||
|
free_seconds: number;
|
||||||
|
saved_seconds: number;
|
||||||
|
free_started_at: number;
|
||||||
|
consumed_seconds: number | null;
|
||||||
|
charged_seconds: number | null;
|
||||||
|
confirmed_seconds: number;
|
||||||
|
end_reason: string | null;
|
||||||
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
ended_at: string | null;
|
||||||
|
}
|
||||||
|
interface Progress {
|
||||||
|
seq: number;
|
||||||
|
active_seconds: number;
|
||||||
|
kills: number;
|
||||||
|
headshots: number;
|
||||||
|
rounds: number;
|
||||||
|
revives: number;
|
||||||
|
}
|
||||||
|
interface Input extends Progress {
|
||||||
|
server: string;
|
||||||
|
guid: string;
|
||||||
|
match: string;
|
||||||
|
request_id: string;
|
||||||
|
event_time?: string;
|
||||||
|
activity: string;
|
||||||
|
session_id: string;
|
||||||
|
seconds: number;
|
||||||
|
packs: number;
|
||||||
|
confirmed: boolean;
|
||||||
|
expected_purchase_points: number;
|
||||||
|
expected_purchase_seconds: number;
|
||||||
|
consumed_seconds: number | null;
|
||||||
|
confirmed_seconds?: number;
|
||||||
|
end_reason: string;
|
||||||
|
}
|
||||||
|
export interface AfkResult {
|
||||||
|
ok: boolean;
|
||||||
|
state: string;
|
||||||
|
code: string;
|
||||||
|
request_id: string;
|
||||||
|
free_seconds: number;
|
||||||
|
saved_seconds: number;
|
||||||
|
available_seconds: number;
|
||||||
|
reserved_seconds: number;
|
||||||
|
capacity_seconds: number;
|
||||||
|
authorized_seconds: number;
|
||||||
|
bank_balance: number;
|
||||||
|
purchase_points: number;
|
||||||
|
purchase_seconds: number;
|
||||||
|
max_purchase_packs: number;
|
||||||
|
session_id?: string;
|
||||||
|
consumed_seconds?: number | null;
|
||||||
|
awarded_seconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-owned cumulative counters, receipts and balances commit together. */
|
||||||
|
export function afkRequest(
|
||||||
|
action: AfkAction,
|
||||||
|
value: unknown,
|
||||||
|
authenticatedServer: string,
|
||||||
|
instanceId: string,
|
||||||
|
options: { receiptOnly?: boolean } = {}
|
||||||
|
): { status: number; body: AfkResult } {
|
||||||
|
const policy = afkPolicy();
|
||||||
|
const empty = (code: string, request_id = ''): AfkResult => ({
|
||||||
|
ok: false,
|
||||||
|
state: 'rejected',
|
||||||
|
code,
|
||||||
|
request_id,
|
||||||
|
free_seconds: 0,
|
||||||
|
saved_seconds: 0,
|
||||||
|
available_seconds: 0,
|
||||||
|
reserved_seconds: 0,
|
||||||
|
capacity_seconds: policy.capacitySeconds,
|
||||||
|
authorized_seconds: 0,
|
||||||
|
bank_balance: 0,
|
||||||
|
purchase_points: policy.purchasePoints,
|
||||||
|
purchase_seconds: policy.purchaseSeconds,
|
||||||
|
max_purchase_packs: 0
|
||||||
|
});
|
||||||
|
const fail = (status: number, code: string, id = '') => ({ status, body: empty(code, id) });
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
return fail(400, 'invalid_request');
|
||||||
|
const input = value as Input;
|
||||||
|
if (input.server !== authenticatedServer) return fail(403, 'wrong_server');
|
||||||
|
if (
|
||||||
|
!token(input.guid) ||
|
||||||
|
!token(input.match) ||
|
||||||
|
(action !== 'balance' && !token(input.request_id)) ||
|
||||||
|
(input.request_id !== undefined && !token(input.request_id))
|
||||||
|
)
|
||||||
|
return fail(400, 'invalid_request');
|
||||||
|
if (
|
||||||
|
input.event_time !== undefined &&
|
||||||
|
(typeof input.event_time !== 'string' ||
|
||||||
|
!Number.isFinite(Date.parse(input.event_time)) ||
|
||||||
|
Date.parse(input.event_time) > Date.now() + 300000)
|
||||||
|
)
|
||||||
|
return fail(400, 'invalid_event_time', input.request_id);
|
||||||
|
const counters = ['active_seconds', 'kills', 'headshots', 'rounds', 'revives'] as const;
|
||||||
|
if (
|
||||||
|
action === 'event' &&
|
||||||
|
(!integer(input.seq, 1) ||
|
||||||
|
counters.some((key) => !integer(input[key])) ||
|
||||||
|
input.headshots > input.kills ||
|
||||||
|
input.activity !== 'playing')
|
||||||
|
)
|
||||||
|
return fail(400, 'invalid_activity', input.request_id);
|
||||||
|
if (
|
||||||
|
action === 'purchase' &&
|
||||||
|
(!integer(input.packs, 1, 1000) ||
|
||||||
|
input.confirmed !== true ||
|
||||||
|
!integer(input.expected_purchase_points, 1, 1000000) ||
|
||||||
|
!integer(input.expected_purchase_seconds, 1, 604800))
|
||||||
|
)
|
||||||
|
return fail(400, 'invalid_purchase', input.request_id);
|
||||||
|
if (['reserve', 'start', 'settle'].includes(action) && !token(input.session_id))
|
||||||
|
return fail(400, 'invalid_session', input.request_id);
|
||||||
|
if (action === 'reserve' && !integer(input.seconds, 1, 604800))
|
||||||
|
return fail(400, 'invalid_seconds', input.request_id);
|
||||||
|
if (
|
||||||
|
action === 'settle' &&
|
||||||
|
(![
|
||||||
|
'manual',
|
||||||
|
'disconnect',
|
||||||
|
'death',
|
||||||
|
'match_end',
|
||||||
|
'expired',
|
||||||
|
'never_started',
|
||||||
|
'crash'
|
||||||
|
].includes(input.end_reason) ||
|
||||||
|
!(
|
||||||
|
integer(input.consumed_seconds, 0, 604800) ||
|
||||||
|
(input.consumed_seconds === null && input.end_reason === 'crash')
|
||||||
|
) ||
|
||||||
|
(input.confirmed_seconds !== undefined &&
|
||||||
|
!integer(input.confirmed_seconds, 0, 604800)) ||
|
||||||
|
(input.end_reason === 'never_started' && input.consumed_seconds !== 0))
|
||||||
|
)
|
||||||
|
return fail(400, 'invalid_settlement', input.request_id);
|
||||||
|
const handle = db();
|
||||||
|
// Canonical key order also binds extra caller fields, preventing ambiguous retries.
|
||||||
|
const fingerprint = JSON.stringify([
|
||||||
|
action,
|
||||||
|
Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)))
|
||||||
|
]);
|
||||||
|
return handle
|
||||||
|
.transaction(() => {
|
||||||
|
const { server, guid, match, request_id } = input;
|
||||||
|
const receipt =
|
||||||
|
action === 'balance'
|
||||||
|
? undefined
|
||||||
|
: (handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT instance_id,fingerprint,result FROM afk_operations WHERE server=? AND request_id=?'
|
||||||
|
)
|
||||||
|
.get(server, request_id) as
|
||||||
|
{ instance_id: string; fingerprint: string; result: string } | undefined);
|
||||||
|
if (receipt) {
|
||||||
|
if (receipt.instance_id !== instanceId || receipt.fingerprint !== fingerprint)
|
||||||
|
return fail(409, 'request_conflict', request_id);
|
||||||
|
return { status: 200, body: JSON.parse(receipt.result) as AfkResult };
|
||||||
|
}
|
||||||
|
if (options.receiptOnly) return fail(200, 'not_applied', request_id);
|
||||||
|
const session = token(input.session_id)
|
||||||
|
? (handle
|
||||||
|
.prepare('SELECT * FROM afk_sessions WHERE session_id=?')
|
||||||
|
.get(input.session_id) as Session | undefined)
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
session &&
|
||||||
|
(session.instance_id !== instanceId ||
|
||||||
|
session.server !== server ||
|
||||||
|
session.guid !== guid ||
|
||||||
|
session.match_key !== match)
|
||||||
|
)
|
||||||
|
return fail(409, 'session_conflict', request_id);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const nowMs = Date.parse(now);
|
||||||
|
const eventAt = input.event_time ?? now;
|
||||||
|
let matchId: number | null = session?.match_id ?? null;
|
||||||
|
let account = handle.prepare('SELECT * FROM afk_accounts WHERE guid=?').get(guid) as
|
||||||
|
Account | undefined;
|
||||||
|
const held = () =>
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"SELECT COALESCE(SUM(authorized_seconds),0) AS total, COALESCE(SUM(saved_seconds),0) AS saved FROM afk_sessions WHERE guid=? AND state IN ('reserved','started','unresolved')"
|
||||||
|
)
|
||||||
|
.get(guid) as { total: number; saved: number };
|
||||||
|
const reply = (
|
||||||
|
state: string,
|
||||||
|
code = 'ok',
|
||||||
|
extra: Partial<AfkResult> = {}
|
||||||
|
): AfkResult => {
|
||||||
|
const reserved = held();
|
||||||
|
const bank =
|
||||||
|
(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT balance FROM bank_accounts WHERE guid=?')
|
||||||
|
.get(guid) as { balance: number } | undefined
|
||||||
|
)?.balance ?? 0;
|
||||||
|
const pending = Boolean(
|
||||||
|
handle
|
||||||
|
.prepare("SELECT 1 FROM bank_requests WHERE guid=? AND state='prepared'")
|
||||||
|
.get(guid)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...empty(code, request_id ?? ''),
|
||||||
|
ok: state !== 'rejected',
|
||||||
|
state,
|
||||||
|
free_seconds: account?.free_seconds ?? 0,
|
||||||
|
saved_seconds: account?.saved_seconds ?? 0,
|
||||||
|
available_seconds: (account?.free_seconds ?? 0) + (account?.saved_seconds ?? 0),
|
||||||
|
reserved_seconds: reserved.total,
|
||||||
|
bank_balance: bank,
|
||||||
|
max_purchase_packs: pending
|
||||||
|
? 0
|
||||||
|
: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
Math.floor(bank / policy.purchasePoints),
|
||||||
|
Math.floor(
|
||||||
|
(policy.capacitySeconds -
|
||||||
|
(account?.saved_seconds ?? 0) -
|
||||||
|
reserved.saved) /
|
||||||
|
policy.purchaseSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
...extra
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const finish = (body: AfkResult) => {
|
||||||
|
if (action !== 'balance')
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO afk_operations(server,request_id,instance_id,guid,fingerprint,result,created_at) VALUES (?,?,?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
server,
|
||||||
|
request_id,
|
||||||
|
instanceId,
|
||||||
|
guid,
|
||||||
|
fingerprint,
|
||||||
|
JSON.stringify(body),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return { status: 200, body };
|
||||||
|
};
|
||||||
|
const reject = (code: string) => finish(reply('rejected', code));
|
||||||
|
let historicalEvent = false;
|
||||||
|
// Settlement belongs to the original immutable instance, independent of current membership.
|
||||||
|
if (
|
||||||
|
action !== 'settle' &&
|
||||||
|
!(
|
||||||
|
session &&
|
||||||
|
(action === 'start' || action === 'reserve') &&
|
||||||
|
['settled', 'corrected'].includes(session.state)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT 1 FROM native_instances WHERE id=? AND server=? AND retired_at IS NULL'
|
||||||
|
)
|
||||||
|
.get(instanceId, server)
|
||||||
|
)
|
||||||
|
return fail(403, 'retired_instance', request_id);
|
||||||
|
let member = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.id,m.started_at,COALESCE(m.ended_at,m.last_received_at) AS observed_at FROM leaderboard_matches m
|
||||||
|
JOIN servers s ON s.name=m.server AND s.destroyed_at IS NULL
|
||||||
|
JOIN leaderboard_match_players p ON p.match_id=m.id AND p.guid=? AND p.present=1
|
||||||
|
WHERE m.server=? AND m.reporter_match_key=? AND m.status='active'
|
||||||
|
AND m.id=(SELECT MAX(id) FROM leaderboard_matches WHERE server=?)`
|
||||||
|
)
|
||||||
|
.get(guid, server, match, server) as
|
||||||
|
{ id: number; started_at: string; observed_at: string } | undefined;
|
||||||
|
if (!member && action === 'event') {
|
||||||
|
member = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.id,m.started_at,COALESCE(m.ended_at,m.last_received_at) AS observed_at
|
||||||
|
FROM leaderboard_matches m JOIN leaderboard_match_players p ON p.match_id=m.id AND p.guid=?
|
||||||
|
JOIN native_instances n ON n.id=? AND n.server=m.server
|
||||||
|
WHERE m.server=? AND m.reporter_match_key=?
|
||||||
|
AND julianday(n.created_at)<=julianday(m.started_at)+60.0/86400
|
||||||
|
AND (n.retired_at IS NULL OR julianday(n.retired_at)>=julianday(m.started_at))
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM native_instances newer WHERE newer.server=m.server
|
||||||
|
AND newer.created_at>n.created_at AND newer.created_at<=m.started_at)
|
||||||
|
ORDER BY m.id DESC LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(guid, instanceId, server, match) as typeof member;
|
||||||
|
historicalEvent = Boolean(member);
|
||||||
|
if (!member) return reject('not_in_match');
|
||||||
|
}
|
||||||
|
if (!member) {
|
||||||
|
const known = handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT id,status FROM leaderboard_matches WHERE server=? AND reporter_match_key=? ORDER BY id DESC LIMIT 1'
|
||||||
|
)
|
||||||
|
.get(server, match) as { id: number; status: string } | undefined;
|
||||||
|
const latest = handle
|
||||||
|
.prepare('SELECT MAX(id) AS id FROM leaderboard_matches WHERE server=?')
|
||||||
|
.get(server) as { id: number | null };
|
||||||
|
const departed =
|
||||||
|
known &&
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT 1 FROM leaderboard_match_players WHERE match_id=? AND guid=? AND present=0'
|
||||||
|
)
|
||||||
|
.get(known.id, guid);
|
||||||
|
if (known && (known.status !== 'active' || known.id !== latest.id || departed))
|
||||||
|
return reject('not_in_match');
|
||||||
|
return fail(409, 'not_in_match', request_id);
|
||||||
|
}
|
||||||
|
matchId = member.id;
|
||||||
|
if (
|
||||||
|
action === 'event' &&
|
||||||
|
input.active_seconds >
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(
|
||||||
|
((historicalEvent
|
||||||
|
? Math.min(nowMs, Date.parse(member.observed_at))
|
||||||
|
: nowMs) -
|
||||||
|
Date.parse(member.started_at)) /
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
) +
|
||||||
|
60
|
||||||
|
)
|
||||||
|
return reject('invalid_active_time');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
action === 'settle' &&
|
||||||
|
!session &&
|
||||||
|
!(input.end_reason === 'never_started' && input.consumed_seconds === 0)
|
||||||
|
)
|
||||||
|
return reject('not_reserved');
|
||||||
|
if (
|
||||||
|
action === 'settle' &&
|
||||||
|
!session &&
|
||||||
|
!handle
|
||||||
|
.prepare('SELECT 1 FROM native_instances WHERE id=? AND server=?')
|
||||||
|
.get(instanceId, server)
|
||||||
|
)
|
||||||
|
return fail(403, 'unknown_instance', request_id);
|
||||||
|
const ledger = (kind: string, freeDelta = 0, savedDelta = 0, details: unknown = {}) => {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO afk_ledger(guid,server,match_id,operation_id,session_id,kind,free_delta,saved_delta,free_seconds,saved_seconds,event_at,created_at,details)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
guid,
|
||||||
|
server,
|
||||||
|
matchId,
|
||||||
|
request_id ?? `free:${nowMs}`,
|
||||||
|
input.session_id ?? null,
|
||||||
|
kind,
|
||||||
|
freeDelta,
|
||||||
|
savedDelta,
|
||||||
|
account?.free_seconds ?? 0,
|
||||||
|
account?.saved_seconds ?? 0,
|
||||||
|
eventAt,
|
||||||
|
now,
|
||||||
|
JSON.stringify(details)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const saveAccount = () => {
|
||||||
|
if (!account) throw new Error('Missing AFK account');
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`UPDATE afk_accounts SET free_seconds=?,saved_seconds=?,free_started_at=?,active_seconds=?,kill_progress=?,headshot_progress=?,bonus_hour=?,bonus_seconds=? WHERE guid=?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
account.free_seconds,
|
||||||
|
account.saved_seconds,
|
||||||
|
account.free_started_at,
|
||||||
|
account.active_seconds,
|
||||||
|
account.kill_progress,
|
||||||
|
account.headshot_progress,
|
||||||
|
account.bonus_hour,
|
||||||
|
account.bonus_seconds,
|
||||||
|
guid
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (!account) {
|
||||||
|
account = {
|
||||||
|
guid,
|
||||||
|
free_seconds: policy.freeSeconds,
|
||||||
|
saved_seconds: 0,
|
||||||
|
free_started_at: nowMs,
|
||||||
|
active_seconds: 0,
|
||||||
|
kill_progress: 0,
|
||||||
|
headshot_progress: 0,
|
||||||
|
bonus_hour: 0,
|
||||||
|
bonus_seconds: 0
|
||||||
|
};
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO afk_accounts(guid,free_seconds,free_started_at) VALUES (?,?,?)'
|
||||||
|
)
|
||||||
|
.run(guid, policy.freeSeconds, nowMs);
|
||||||
|
ledger('free_grant', policy.freeSeconds);
|
||||||
|
} else {
|
||||||
|
const free = freeAvailability(guid, policy.freeSeconds, nowMs);
|
||||||
|
const delta = free.seconds - account.free_seconds;
|
||||||
|
if (delta !== 0) {
|
||||||
|
account.free_seconds = free.seconds;
|
||||||
|
saveAccount();
|
||||||
|
ledger('free_grant', delta, 0, { rolling: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sessionReply = (row: Session) =>
|
||||||
|
reply(row.state, 'ok', {
|
||||||
|
session_id: row.session_id,
|
||||||
|
authorized_seconds: row.authorized_seconds,
|
||||||
|
consumed_seconds: row.consumed_seconds
|
||||||
|
});
|
||||||
|
if (action === 'balance') return finish(reply('balance'));
|
||||||
|
if (action === 'purchase') {
|
||||||
|
if (
|
||||||
|
input.expected_purchase_points !== policy.purchasePoints ||
|
||||||
|
input.expected_purchase_seconds !== policy.purchaseSeconds
|
||||||
|
)
|
||||||
|
return reject('price_changed');
|
||||||
|
const cost = input.packs * policy.purchasePoints;
|
||||||
|
const seconds = input.packs * policy.purchaseSeconds;
|
||||||
|
if (
|
||||||
|
handle
|
||||||
|
.prepare("SELECT 1 FROM bank_requests WHERE guid=? AND state='prepared'")
|
||||||
|
.get(guid)
|
||||||
|
)
|
||||||
|
return reject('bank_pending');
|
||||||
|
const bank =
|
||||||
|
(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT balance FROM bank_accounts WHERE guid=?')
|
||||||
|
.get(guid) as { balance: number } | undefined
|
||||||
|
)?.balance ?? 0;
|
||||||
|
if (cost > bank) return reject('insufficient_balance');
|
||||||
|
if (account.saved_seconds + held().saved + seconds > policy.capacitySeconds)
|
||||||
|
return reject('reserve_capacity');
|
||||||
|
const bankId = `afk:${createHash('sha256').update(`${instanceId}:${request_id}`).digest('hex').slice(0, 60)}`;
|
||||||
|
handle
|
||||||
|
.prepare('UPDATE bank_accounts SET balance=balance-? WHERE guid=?')
|
||||||
|
.run(cost, guid);
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(guid, server, bankId, matchId, -cost, bank - cost, now);
|
||||||
|
account.saved_seconds += seconds;
|
||||||
|
saveAccount();
|
||||||
|
ledger('purchase', 0, seconds, { points: cost, bank_request_id: bankId });
|
||||||
|
return finish(reply('purchased', 'ok', { awarded_seconds: seconds }));
|
||||||
|
}
|
||||||
|
if (action === 'event') {
|
||||||
|
if (
|
||||||
|
!historicalEvent &&
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"SELECT 1 FROM afk_sessions WHERE guid=? AND state IN ('reserved','started')"
|
||||||
|
)
|
||||||
|
.get(guid)
|
||||||
|
)
|
||||||
|
return reject('session_pending');
|
||||||
|
const previous = handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM afk_progress WHERE instance_id=? AND match_key=? AND guid=?'
|
||||||
|
)
|
||||||
|
.get(instanceId, match, guid) as Progress | undefined;
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
input.seq === previous.seq &&
|
||||||
|
counters.some((key) => input[key] !== previous[key])
|
||||||
|
)
|
||||||
|
return reject('progress_conflict');
|
||||||
|
if (previous && input.seq <= previous.seq) {
|
||||||
|
if (counters.some((key) => input[key] > previous[key]))
|
||||||
|
return reject('stale_progress');
|
||||||
|
return finish(reply('rewarded', 'stale', { awarded_seconds: 0 }));
|
||||||
|
}
|
||||||
|
if (previous && counters.some((key) => input[key] < previous[key]))
|
||||||
|
return reject('progress_regression');
|
||||||
|
const delta = Object.fromEntries(
|
||||||
|
counters.map((key) => [key, input[key] - (previous?.[key] ?? 0)])
|
||||||
|
) as Omit<Progress, 'seq'>;
|
||||||
|
const activeBefore = account.active_seconds;
|
||||||
|
account.active_seconds += delta.active_seconds;
|
||||||
|
const activeAward =
|
||||||
|
(Math.floor(account.active_seconds / policy.activeInterval) -
|
||||||
|
Math.floor(activeBefore / policy.activeInterval)) *
|
||||||
|
policy.activeReward;
|
||||||
|
const kills = account.kill_progress + delta.kills;
|
||||||
|
const headshots = account.headshot_progress + delta.headshots;
|
||||||
|
const performance =
|
||||||
|
delta.rounds * policy.roundReward +
|
||||||
|
Math.floor(kills / policy.killInterval) * policy.killReward +
|
||||||
|
Math.floor(headshots / policy.headshotInterval) * policy.headshotReward;
|
||||||
|
account.kill_progress = kills % policy.killInterval;
|
||||||
|
account.headshot_progress = headshots % policy.headshotInterval;
|
||||||
|
handle
|
||||||
|
.prepare('DELETE FROM afk_bonus_rewards WHERE guid=? AND active_second<=?')
|
||||||
|
.run(guid, account.active_seconds - 3600);
|
||||||
|
const spent = (
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT COALESCE(SUM(seconds),0) AS seconds FROM afk_bonus_rewards WHERE guid=?'
|
||||||
|
)
|
||||||
|
.get(guid) as { seconds: number }
|
||||||
|
).seconds;
|
||||||
|
const bonus = Math.min(performance, Math.max(0, policy.bonusCap - spent));
|
||||||
|
if (bonus > 0)
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO afk_bonus_rewards(guid,active_second,seconds) VALUES (?,?,?)'
|
||||||
|
)
|
||||||
|
.run(guid, account.active_seconds, bonus);
|
||||||
|
const reviveAward = delta.revives * policy.reviveReward;
|
||||||
|
const awarded = Math.min(
|
||||||
|
activeAward + bonus + reviveAward,
|
||||||
|
Math.max(0, policy.capacitySeconds - account.saved_seconds - held().saved)
|
||||||
|
);
|
||||||
|
account.saved_seconds += awarded;
|
||||||
|
saveAccount();
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO afk_progress(instance_id,match_key,guid,seq,active_seconds,kills,headshots,rounds,revives) VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(instance_id,match_key,guid) DO UPDATE SET seq=excluded.seq,active_seconds=excluded.active_seconds,kills=excluded.kills,headshots=excluded.headshots,rounds=excluded.rounds,revives=excluded.revives`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
instanceId,
|
||||||
|
match,
|
||||||
|
guid,
|
||||||
|
input.seq,
|
||||||
|
input.active_seconds,
|
||||||
|
input.kills,
|
||||||
|
input.headshots,
|
||||||
|
input.rounds,
|
||||||
|
input.revives
|
||||||
|
);
|
||||||
|
ledger('reward', 0, awarded, {
|
||||||
|
...delta,
|
||||||
|
active_reward: activeAward,
|
||||||
|
performance_reward: bonus,
|
||||||
|
revive_reward: reviveAward,
|
||||||
|
discarded_seconds: activeAward + bonus + reviveAward - awarded
|
||||||
|
});
|
||||||
|
return finish(reply('rewarded', 'ok', { awarded_seconds: awarded }));
|
||||||
|
}
|
||||||
|
if (action === 'reserve') {
|
||||||
|
if (session) return finish(sessionReply(session));
|
||||||
|
if (held().total > 0) return reject('session_pending');
|
||||||
|
const authorized = Math.min(
|
||||||
|
input.seconds,
|
||||||
|
account.free_seconds + account.saved_seconds
|
||||||
|
);
|
||||||
|
if (authorized === 0) return reject('empty');
|
||||||
|
const free = Math.min(account.free_seconds, authorized);
|
||||||
|
const saved = authorized - free;
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO afk_sessions(session_id,server,instance_id,guid,match_key,match_id,state,authorized_seconds,free_seconds,saved_seconds,free_started_at,created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,'reserved',?,?,?,?,?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
input.session_id,
|
||||||
|
server,
|
||||||
|
instanceId,
|
||||||
|
guid,
|
||||||
|
match,
|
||||||
|
matchId,
|
||||||
|
authorized,
|
||||||
|
free,
|
||||||
|
saved,
|
||||||
|
account.free_started_at,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
account.free_seconds -= free;
|
||||||
|
account.saved_seconds -= saved;
|
||||||
|
saveAccount();
|
||||||
|
ledger('reserve', -free, -saved, { authorized_seconds: authorized });
|
||||||
|
return finish(
|
||||||
|
reply('reserved', 'ok', {
|
||||||
|
session_id: input.session_id,
|
||||||
|
authorized_seconds: authorized,
|
||||||
|
consumed_seconds: null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (action === 'start') {
|
||||||
|
if (!session) return reject('not_reserved');
|
||||||
|
if (session.state !== 'reserved') return finish(sessionReply(session));
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE afk_sessions SET state='started',started_at=? WHERE session_id=?"
|
||||||
|
)
|
||||||
|
.run(eventAt, input.session_id);
|
||||||
|
ledger('start');
|
||||||
|
return finish(sessionReply({ ...session, state: 'started', started_at: eventAt }));
|
||||||
|
}
|
||||||
|
if (!session) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO afk_sessions(session_id,server,instance_id,guid,match_key,match_id,state,authorized_seconds,free_seconds,saved_seconds,free_started_at,consumed_seconds,end_reason,created_at,ended_at)
|
||||||
|
VALUES (?,?,?,?,?,?,'settled',0,0,0,?,0,'never_started',?,?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
input.session_id,
|
||||||
|
server,
|
||||||
|
instanceId,
|
||||||
|
guid,
|
||||||
|
match,
|
||||||
|
matchId,
|
||||||
|
account.free_started_at,
|
||||||
|
now,
|
||||||
|
eventAt
|
||||||
|
);
|
||||||
|
ledger('release', 0, 0, { never_started: true });
|
||||||
|
return finish(
|
||||||
|
reply('settled', 'ok', {
|
||||||
|
session_id: input.session_id,
|
||||||
|
authorized_seconds: 0,
|
||||||
|
consumed_seconds: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (['settled', 'corrected'].includes(session.state))
|
||||||
|
return finish(sessionReply(session));
|
||||||
|
const confirmed = input.confirmed_seconds ?? input.consumed_seconds ?? 0;
|
||||||
|
if (
|
||||||
|
confirmed > session.authorized_seconds ||
|
||||||
|
(input.consumed_seconds !== null &&
|
||||||
|
(input.consumed_seconds > session.authorized_seconds ||
|
||||||
|
input.consumed_seconds < session.confirmed_seconds ||
|
||||||
|
confirmed > input.consumed_seconds)) ||
|
||||||
|
(input.end_reason === 'expired' &&
|
||||||
|
input.consumed_seconds !== session.authorized_seconds)
|
||||||
|
)
|
||||||
|
return reject('invalid_consumption');
|
||||||
|
if (input.consumed_seconds === null) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE afk_sessions SET state='unresolved',confirmed_seconds=MAX(confirmed_seconds,?),end_reason='crash',ended_at=? WHERE session_id=?"
|
||||||
|
)
|
||||||
|
.run(confirmed, eventAt, input.session_id);
|
||||||
|
ledger('unresolved', 0, 0, {
|
||||||
|
confirmed_seconds: Math.max(confirmed, session.confirmed_seconds)
|
||||||
|
});
|
||||||
|
return finish(
|
||||||
|
sessionReply({ ...session, state: 'unresolved', confirmed_seconds: confirmed })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const consumed = input.consumed_seconds;
|
||||||
|
const freeConsumed = Math.min(session.free_seconds, consumed);
|
||||||
|
const savedConsumed = consumed - freeConsumed;
|
||||||
|
const priorFree = account.free_seconds;
|
||||||
|
const savedRelease = session.saved_seconds - savedConsumed;
|
||||||
|
account.saved_seconds += savedRelease;
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE afk_sessions SET state='settled',consumed_seconds=?,charged_seconds=?,free_charge_at=?,confirmed_seconds=?,end_reason=?,ended_at=? WHERE session_id=?"
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
consumed,
|
||||||
|
consumed,
|
||||||
|
nowMs,
|
||||||
|
consumed,
|
||||||
|
input.end_reason,
|
||||||
|
eventAt,
|
||||||
|
input.session_id
|
||||||
|
);
|
||||||
|
account.free_seconds = freeAvailability(guid, policy.freeSeconds, nowMs).seconds;
|
||||||
|
const freeRelease = account.free_seconds - priorFree;
|
||||||
|
saveAccount();
|
||||||
|
ledger('consume', 0, 0, {
|
||||||
|
consumed_seconds: consumed,
|
||||||
|
free_consumed: freeConsumed,
|
||||||
|
saved_consumed: savedConsumed,
|
||||||
|
end_reason: input.end_reason
|
||||||
|
});
|
||||||
|
ledger('release', freeRelease, savedRelease, {
|
||||||
|
expired_free_seconds: session.free_seconds - freeConsumed - freeRelease
|
||||||
|
});
|
||||||
|
return finish(
|
||||||
|
sessionReply({ ...session, state: 'settled', consumed_seconds: consumed })
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afkProfile(guid: string) {
|
||||||
|
const handle = db();
|
||||||
|
const account = handle.prepare('SELECT * FROM afk_accounts WHERE guid=?').get(guid) as
|
||||||
|
Account | undefined;
|
||||||
|
const summary = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT COALESCE(SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN authorized_seconds ELSE 0 END),0) AS reservedSeconds,
|
||||||
|
COALESCE(SUM(consumed_seconds),0) AS confirmedAfkSeconds,
|
||||||
|
SUM(CASE WHEN state='unresolved' THEN 1 ELSE 0 END) AS unresolvedSessions,
|
||||||
|
SUM(CASE WHEN end_reason='expired' AND state='settled' THEN 1 ELSE 0 END) AS expiredSessions,
|
||||||
|
SUM(CASE WHEN end_reason='manual' AND state='settled' THEN 1 ELSE 0 END) AS manualReturns
|
||||||
|
FROM afk_sessions WHERE guid=?`
|
||||||
|
)
|
||||||
|
.get(guid) as Record<string, number | null>;
|
||||||
|
const purchases = handle
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) AS purchaseCount,COALESCE(SUM(saved_delta),0) AS purchasedSeconds FROM afk_ledger WHERE guid=? AND kind='purchase'"
|
||||||
|
)
|
||||||
|
.get(guid) as { purchaseCount: number; purchasedSeconds: number };
|
||||||
|
const entries = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT l.id,l.server,l.kind,l.free_delta AS freeDelta,l.saved_delta AS savedDelta,
|
||||||
|
l.free_seconds AS freeSeconds,l.saved_seconds AS savedSeconds,l.session_id AS sessionId,l.operation_id AS operationId,
|
||||||
|
l.event_at AS eventAt,l.created_at AS createdAt,l.match_id AS matchId,CASE WHEN m.id IS NOT NULL THEN 1 ELSE 0 END AS matchAvailable
|
||||||
|
FROM afk_ledger l LEFT JOIN leaderboard_matches m ON m.id=l.match_id WHERE l.guid=? ORDER BY l.id DESC LIMIT 20`
|
||||||
|
)
|
||||||
|
.all(guid) as {
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
kind: string;
|
||||||
|
freeDelta: number;
|
||||||
|
savedDelta: number;
|
||||||
|
freeSeconds: number;
|
||||||
|
savedSeconds: number;
|
||||||
|
sessionId: string | null;
|
||||||
|
operationId: string;
|
||||||
|
eventAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
}[];
|
||||||
|
const sessions = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id AS sessionId,server,state,authorized_seconds AS authorizedSeconds,
|
||||||
|
consumed_seconds AS consumedSeconds,charged_seconds AS chargedSeconds,confirmed_seconds AS confirmedSeconds,end_reason AS endReason,started_at AS startedAt,ended_at AS endedAt
|
||||||
|
FROM afk_sessions WHERE guid=? ORDER BY created_at DESC LIMIT 20`
|
||||||
|
)
|
||||||
|
.all(guid) as {
|
||||||
|
sessionId: string;
|
||||||
|
server: string;
|
||||||
|
state: string;
|
||||||
|
authorizedSeconds: number;
|
||||||
|
consumedSeconds: number | null;
|
||||||
|
chargedSeconds: number | null;
|
||||||
|
confirmedSeconds: number;
|
||||||
|
endReason: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
endedAt: string | null;
|
||||||
|
}[];
|
||||||
|
const free = freeAvailability(guid, afkPolicy().freeSeconds);
|
||||||
|
return {
|
||||||
|
freeSeconds: account ? free.seconds : 0,
|
||||||
|
savedSeconds: account?.saved_seconds ?? 0,
|
||||||
|
availableSeconds: (account ? free.seconds : 0) + (account?.saved_seconds ?? 0),
|
||||||
|
reservedSeconds: summary.reservedSeconds ?? 0,
|
||||||
|
capacitySeconds: afkPolicy().capacitySeconds,
|
||||||
|
freeRenewsAt: free.nextAt ? new Date(free.nextAt).toISOString() : null,
|
||||||
|
activeSeconds: account?.active_seconds ?? 0,
|
||||||
|
confirmedAfkSeconds: summary.confirmedAfkSeconds ?? 0,
|
||||||
|
unresolvedSessions: summary.unresolvedSessions ?? 0,
|
||||||
|
expiredSessions: summary.expiredSessions ?? 0,
|
||||||
|
manualReturns: summary.manualReturns ?? 0,
|
||||||
|
...purchases,
|
||||||
|
entries,
|
||||||
|
sessions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Held authorization never ages out. Confirmed free use replenishes 24h after stop receipt. */
|
||||||
|
function freeAvailability(guid: string, allowance: number, now = Date.now()) {
|
||||||
|
const row = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN free_seconds
|
||||||
|
WHEN free_charge_at>? THEN MIN(free_seconds,charged_seconds) ELSE 0 END),0) AS used,
|
||||||
|
MIN(CASE WHEN free_charge_at>? AND MIN(free_seconds,charged_seconds)>0 THEN free_charge_at END) AS next_at
|
||||||
|
FROM afk_sessions WHERE guid=?`
|
||||||
|
)
|
||||||
|
.get(now - DAY, now - DAY, guid) as { used: number; next_at: number | null };
|
||||||
|
return {
|
||||||
|
seconds: Math.max(0, allowance - row.used),
|
||||||
|
nextAt: row.next_at === null ? null : row.next_at + DAY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An administrator records an explicit accounting correction, never a fabricated exact duration. */
|
||||||
|
export function afkCorrectSession(
|
||||||
|
value: unknown,
|
||||||
|
actor: string
|
||||||
|
): { status: number; body: AfkResult } {
|
||||||
|
const policy = afkPolicy();
|
||||||
|
const invalid = (code: string, status = 400) => ({
|
||||||
|
status,
|
||||||
|
body: {
|
||||||
|
ok: false,
|
||||||
|
state: 'rejected',
|
||||||
|
code,
|
||||||
|
request_id: '',
|
||||||
|
free_seconds: 0,
|
||||||
|
saved_seconds: 0,
|
||||||
|
available_seconds: 0,
|
||||||
|
reserved_seconds: 0,
|
||||||
|
capacity_seconds: policy.capacitySeconds,
|
||||||
|
authorized_seconds: 0,
|
||||||
|
bank_balance: 0,
|
||||||
|
purchase_points: policy.purchasePoints,
|
||||||
|
purchase_seconds: policy.purchaseSeconds,
|
||||||
|
max_purchase_packs: 0
|
||||||
|
} as AfkResult
|
||||||
|
});
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
return invalid('invalid_correction');
|
||||||
|
const input = value as {
|
||||||
|
session_id: string;
|
||||||
|
request_id: string;
|
||||||
|
consumed_seconds: number;
|
||||||
|
reason: string;
|
||||||
|
stopped: boolean;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!token(input.session_id) ||
|
||||||
|
!token(input.request_id) ||
|
||||||
|
!integer(input.consumed_seconds, 0, 604800) ||
|
||||||
|
input.stopped !== true ||
|
||||||
|
typeof input.reason !== 'string' ||
|
||||||
|
input.reason.trim().length < 5 ||
|
||||||
|
input.reason.length > 1000
|
||||||
|
)
|
||||||
|
return invalid('invalid_correction');
|
||||||
|
const handle = db();
|
||||||
|
return handle
|
||||||
|
.transaction(() => {
|
||||||
|
const session = handle
|
||||||
|
.prepare('SELECT * FROM afk_sessions WHERE session_id=?')
|
||||||
|
.get(input.session_id) as Session | undefined;
|
||||||
|
if (!session) return invalid('not_reserved', 404);
|
||||||
|
const request = {
|
||||||
|
server: session.server,
|
||||||
|
guid: session.guid,
|
||||||
|
match: session.match_key,
|
||||||
|
request_id: `correction:${createHash('sha256').update(input.request_id).digest('hex').slice(0, 53)}`,
|
||||||
|
session_id: input.session_id,
|
||||||
|
consumed_seconds: input.consumed_seconds,
|
||||||
|
end_reason: 'crash',
|
||||||
|
correction_reason: input.reason,
|
||||||
|
correction_actor: actor
|
||||||
|
};
|
||||||
|
const prior = afkRequest('settle', request, session.server, session.instance_id, {
|
||||||
|
receiptOnly: true
|
||||||
|
});
|
||||||
|
if (prior.body.code !== 'not_applied') return prior;
|
||||||
|
if (session.state !== 'unresolved') return invalid('not_unresolved', 409);
|
||||||
|
if (
|
||||||
|
input.consumed_seconds < session.confirmed_seconds ||
|
||||||
|
input.consumed_seconds > session.authorized_seconds
|
||||||
|
)
|
||||||
|
return invalid('invalid_consumption');
|
||||||
|
const result = afkRequest('settle', request, session.server, session.instance_id);
|
||||||
|
if (!result.body.ok) return result;
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE afk_sessions SET state='corrected',consumed_seconds=NULL,confirmed_seconds=? WHERE session_id=?"
|
||||||
|
)
|
||||||
|
.run(session.confirmed_seconds, session.session_id);
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE afk_ledger SET kind='correction',details=? WHERE server=? AND operation_id=? AND kind='consume'"
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
JSON.stringify({
|
||||||
|
actor,
|
||||||
|
reason: input.reason,
|
||||||
|
charged_seconds: input.consumed_seconds,
|
||||||
|
confirmed_seconds: session.confirmed_seconds
|
||||||
|
}),
|
||||||
|
session.server,
|
||||||
|
request.request_id
|
||||||
|
);
|
||||||
|
result.body.state = 'corrected';
|
||||||
|
result.body.consumed_seconds = null;
|
||||||
|
handle
|
||||||
|
.prepare('UPDATE afk_operations SET result=? WHERE server=? AND request_id=?')
|
||||||
|
.run(JSON.stringify(result.body), session.server, request.request_id);
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
@@ -123,9 +123,15 @@ export function serverChatTimeline(server: ServerRow, tail: TailResult): Context
|
|||||||
export function matchChatTimeline(matchId: number): ContextualChatEntry[] {
|
export function matchChatTimeline(matchId: number): ContextualChatEntry[] {
|
||||||
return (
|
return (
|
||||||
db()
|
db()
|
||||||
.prepare('SELECT entry FROM match_chat WHERE match_id = ? ORDER BY rowid')
|
.prepare(
|
||||||
.all(matchId) as { entry: string }[]
|
'SELECT message_key,occurrence,entry FROM match_chat WHERE match_id = ? ORDER BY rowid'
|
||||||
).map((row) => JSON.parse(row.entry));
|
)
|
||||||
|
.all(matchId) as { message_key: string; occurrence: number; entry: string }[]
|
||||||
|
).map((row) => ({
|
||||||
|
...JSON.parse(row.entry),
|
||||||
|
matchId,
|
||||||
|
messageId: `${matchId}:${row.message_key}:${row.occurrence}`
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save before filtering the live view so completed matches retain their messages. */
|
/** Save before filtering the live view so completed matches retain their messages. */
|
||||||
@@ -206,10 +212,14 @@ interface ChatReadState {
|
|||||||
entries: ContextualChatEntry[];
|
entries: ContextualChatEntry[];
|
||||||
contextVersion?: string;
|
contextVersion?: string;
|
||||||
wasOccupied: boolean;
|
wasOccupied: boolean;
|
||||||
|
needsDrain: boolean;
|
||||||
|
bytesRead: number;
|
||||||
}
|
}
|
||||||
const readers = new Map<string, ChatReadState>();
|
const readers = new Map<string, ChatReadState>();
|
||||||
const failures = new Map<string, number>();
|
const failures = new Map<string, number>();
|
||||||
const CHAT_READ_MIN_AGE_MS = 15_000;
|
const CHAT_READ_MIN_AGE_MS = 15_000;
|
||||||
|
const MAX_IDLE_STALLED_READS = 3;
|
||||||
|
const idleStalls = new Map<string, number>();
|
||||||
type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
|
type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
|
||||||
const lastReads = new Map<string, LastChatRead>();
|
const lastReads = new Map<string, LastChatRead>();
|
||||||
|
|
||||||
@@ -243,6 +253,7 @@ export function collectServerChat(
|
|||||||
const key = chatReadKey(server);
|
const key = chatReadKey(server);
|
||||||
const existing = pending.get(key);
|
const existing = pending.get(key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
let progressed = false;
|
||||||
const work = (async () => {
|
const work = (async () => {
|
||||||
const path = resolveLogPath(server.name, gameLogKey(server.name));
|
const path = resolveLogPath(server.name, gameLogKey(server.name));
|
||||||
if (!path) throw new Error('Invalid server log path');
|
if (!path) throw new Error('Invalid server log path');
|
||||||
@@ -257,7 +268,9 @@ export function collectServerChat(
|
|||||||
partial: Buffer.alloc(0),
|
partial: Buffer.alloc(0),
|
||||||
skipLine: false,
|
skipLine: false,
|
||||||
entries: [],
|
entries: [],
|
||||||
wasOccupied: false
|
wasOccupied: false,
|
||||||
|
needsDrain: false,
|
||||||
|
bytesRead: 0
|
||||||
};
|
};
|
||||||
const tail = await readLogChunk(path, state.cursor);
|
const tail = await readLogChunk(path, state.cursor);
|
||||||
const current = getServer(server.name);
|
const current = getServer(server.name);
|
||||||
@@ -284,7 +297,10 @@ export function collectServerChat(
|
|||||||
state.entries = [];
|
state.entries = [];
|
||||||
}
|
}
|
||||||
state.cursor = tail.cursor;
|
state.cursor = tail.cursor;
|
||||||
const bytes = Buffer.concat([state.partial, Buffer.from(tail.contentBase64, 'base64')]);
|
const content = Buffer.from(tail.contentBase64, 'base64');
|
||||||
|
progressed = content.length > 0;
|
||||||
|
state.bytesRead += content.length;
|
||||||
|
const bytes = Buffer.concat([state.partial, content]);
|
||||||
const end = bytes.lastIndexOf(10) + 1;
|
const end = bytes.lastIndexOf(10) + 1;
|
||||||
if (end) {
|
if (end) {
|
||||||
const lines = bytes
|
const lines = bytes
|
||||||
@@ -312,14 +328,17 @@ export function collectServerChat(
|
|||||||
state.skipLine = true;
|
state.skipLine = true;
|
||||||
}
|
}
|
||||||
// File mtime anchors the final line only after the backlog has been read.
|
// File mtime anchors the final line only after the backlog has been read.
|
||||||
if (tail.cursor.offset < tail.size || state.partial.length) {
|
state.needsDrain = tail.cursor.offset < tail.size || state.partial.length > 0;
|
||||||
|
if (state.needsDrain) {
|
||||||
state.signature = undefined;
|
state.signature = undefined;
|
||||||
readers.set(server.name, state);
|
readers.set(server.name, state);
|
||||||
return { entries: currentChatEntries(current, state.entries), missing: false };
|
return { entries: currentChatEntries(current, state.entries), missing: false };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Older agents remain readable during a rolling update.
|
// Older agents remain readable during a rolling update.
|
||||||
|
state.needsDrain = false;
|
||||||
changed = state.signature !== `${tail.size}:${tail.mtime}`;
|
changed = state.signature !== `${tail.size}:${tail.mtime}`;
|
||||||
|
progressed = changed;
|
||||||
if (changed) state.lines = tail.lines;
|
if (changed) state.lines = tail.lines;
|
||||||
}
|
}
|
||||||
const signature = `${tail.size}:${tail.mtime}`;
|
const signature = `${tail.size}:${tail.mtime}`;
|
||||||
@@ -348,6 +367,9 @@ export function collectServerChat(
|
|||||||
})()
|
})()
|
||||||
.then(
|
.then(
|
||||||
(result) => {
|
(result) => {
|
||||||
|
const state = readers.get(server.name);
|
||||||
|
if (progressed || result.missing || (state?.key === key && !state.needsDrain))
|
||||||
|
idleStalls.delete(key);
|
||||||
lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
|
lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -371,25 +393,53 @@ export async function collectFleetChat(): Promise<void> {
|
|||||||
for (const name of readers.keys()) if (!names.has(name)) readers.delete(name);
|
for (const name of readers.keys()) if (!names.has(name)) readers.delete(name);
|
||||||
for (const name of failures.keys()) if (!names.has(name)) failures.delete(name);
|
for (const name of failures.keys()) if (!names.has(name)) failures.delete(name);
|
||||||
for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key);
|
for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key);
|
||||||
|
for (const key of idleStalls.keys()) if (!keys.has(key)) idleStalls.delete(key);
|
||||||
const servers = live.filter((server) => {
|
const servers = live.filter((server) => {
|
||||||
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
|
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
|
||||||
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
|
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
|
||||||
return false;
|
return false;
|
||||||
const players = observe(server).last_players ?? server.last_players;
|
const players = observe(server).last_players ?? server.last_players;
|
||||||
// One final read after players leave retains messages written at match end.
|
const key = chatReadKey(server);
|
||||||
return players !== 0 || readers.get(server.name)?.wasOccupied;
|
if (players !== 0) {
|
||||||
|
idleStalls.delete(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const stalls = idleStalls.get(key) ?? 0;
|
||||||
|
if (stalls >= MAX_IDLE_STALLED_READS) return false;
|
||||||
|
const last = lastReads.get(key);
|
||||||
|
if (stalls > 0 && last && Date.now() - last.completedAt < CHAT_READ_MIN_AGE_MS)
|
||||||
|
return false;
|
||||||
|
const state = readers.get(server.name);
|
||||||
|
// Pick up surviving final logs after a collector restart, then finish any
|
||||||
|
// unread chunks even after players leave. Errors and incomplete final lines
|
||||||
|
// have bounded idle retries rather than polling an empty server forever.
|
||||||
|
return (
|
||||||
|
!last ||
|
||||||
|
'error' in last ||
|
||||||
|
(state?.key === key && (state.wasOccupied || state.needsDrain))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
// Bound agent traffic and isolate failures to their own server.
|
// Bound agent traffic and isolate failures to their own server.
|
||||||
async function collect() {
|
async function collect() {
|
||||||
for (let server = servers.shift(); server; server = servers.shift()) {
|
for (let server = servers.shift(); server; server = servers.shift()) {
|
||||||
|
const key = chatReadKey(server);
|
||||||
|
const before = readers.get(server.name);
|
||||||
try {
|
try {
|
||||||
await collectServerChat(server);
|
await collectServerChat(server);
|
||||||
const state = readers.get(server.name);
|
const state = readers.get(server.name);
|
||||||
if (state)
|
if (state?.key === key) {
|
||||||
state.wasOccupied =
|
state.wasOccupied =
|
||||||
(observe(server).last_players ?? server.last_players ?? 0) > 0;
|
(observe(server).last_players ?? server.last_players ?? 0) > 0;
|
||||||
|
if (!state.wasOccupied && state.needsDrain) {
|
||||||
|
const progressed =
|
||||||
|
before?.key !== key || state.bytesRead > before.bytesRead;
|
||||||
|
idleStalls.set(key, progressed ? 0 : (idleStalls.get(key) ?? 0) + 1);
|
||||||
|
} else idleStalls.delete(key);
|
||||||
|
}
|
||||||
failures.delete(server.name);
|
failures.delete(server.name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if ((observe(server).last_players ?? server.last_players) === 0)
|
||||||
|
idleStalls.set(key, (idleStalls.get(key) ?? 0) + 1);
|
||||||
if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) {
|
if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) {
|
||||||
console.error(`[chat] Could not collect chat for ${server.name}`, error);
|
console.error(`[chat] Could not collect chat for ${server.name}`, error);
|
||||||
failures.set(server.name, Date.now());
|
failures.set(server.name, Date.now());
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { AuthUser } from '../../auth.js';
|
||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import { moderationActor } from '../moderation/core.js';
|
||||||
|
|
||||||
|
export type MatchArchiveLocals = { user: AuthUser | null };
|
||||||
|
|
||||||
|
/** Recheck current account privileges before returning the expanded moderation archive. */
|
||||||
|
export function requireMatchArchiveAccess(user: Pick<AuthUser, 'id'> | null | undefined): void {
|
||||||
|
try {
|
||||||
|
moderationActor(user?.id ?? '');
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof AuthError) error(cause.status, cause.message);
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export function serverMatchHistory(
|
|||||||
now = Date.now()
|
now = Date.now()
|
||||||
): MatchHistoryResponse {
|
): MatchHistoryResponse {
|
||||||
const handle = db();
|
const handle = db();
|
||||||
const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`];
|
const clauses = [query.archive ? '1=1' : `m.highest_round >= ${MIN_RECORDED_ROUND}`];
|
||||||
const parameters: Array<string | number> = [];
|
const parameters: Array<string | number> = [];
|
||||||
if (server !== undefined) {
|
if (server !== undefined) {
|
||||||
clauses.push('m.server = ?');
|
clauses.push('m.server = ?');
|
||||||
@@ -93,14 +93,18 @@ export function serverMatchHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Read durable match records without depending on a current server or scoreboard. */
|
/** Read durable match records without depending on a current server or scoreboard. */
|
||||||
export function getMatchDetail(id: number): MatchDetail | null {
|
export function getMatchDetail(id: number, archive = false): MatchDetail | null {
|
||||||
if (!Number.isSafeInteger(id) || id < 1) return null;
|
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||||
const handle = db();
|
const handle = db();
|
||||||
const match = handle
|
const match = handle
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ${matchSummary}, m.start_observed AS startObserved
|
`SELECT ${matchSummary}, m.start_observed AS startObserved
|
||||||
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
|
FROM leaderboard_matches m WHERE m.id = ?${
|
||||||
|
archive
|
||||||
|
? ''
|
||||||
|
: ` AND m.status = 'completed'
|
||||||
AND m.highest_round >= ${MIN_RECORDED_ROUND}`
|
AND m.highest_round >= ${MIN_RECORDED_ROUND}`
|
||||||
|
}`
|
||||||
)
|
)
|
||||||
.get(id) as
|
.get(id) as
|
||||||
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number })
|
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number })
|
||||||
@@ -116,7 +120,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
|
|||||||
(SELECT identity.name FROM leaderboard_players identity
|
(SELECT identity.name FROM leaderboard_players identity
|
||||||
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
||||||
p.has_stats AS hasStats,
|
p.has_stats AS hasStats,
|
||||||
CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
|
CASE WHEN ? != 'completed' THEN 0 WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
|
||||||
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
|
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
|
||||||
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
|
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
|
||||||
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
|
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
|
||||||
@@ -131,7 +135,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
|
|||||||
FROM leaderboard_match_players p WHERE p.match_id = ?
|
FROM leaderboard_match_players p WHERE p.match_id = ?
|
||||||
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
|
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
|
||||||
)
|
)
|
||||||
.all(match.game, match.reporterMatchKey, id) as Array<
|
.all(match.game, match.status, match.reporterMatchKey, id) as Array<
|
||||||
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
||||||
hasStats: number;
|
hasStats: number;
|
||||||
presentAtEnd: number;
|
presentAtEnd: number;
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
|
import { archivedEvidenceSnapshots } from './evidence.js';
|
||||||
|
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
|
||||||
|
import { assessmentDraft } from './assessments.js';
|
||||||
|
import { linkCases, unlinkCases } from './relationships.js';
|
||||||
|
import { notifyCase, caseReviewSchedule, readCaseUpdates } from './followups.js';
|
||||||
import { AuthError } from '../auth/index.js';
|
import { AuthError } from '../auth/index.js';
|
||||||
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
||||||
import {
|
import {
|
||||||
canRevokeModerationRestriction,
|
canRevokeModerationRestriction,
|
||||||
moderationGuid,
|
isModerationCaseOpen,
|
||||||
|
moderationCaseOutcomes,
|
||||||
|
moderationCaseStatuses,
|
||||||
|
moderationCaseStatusLabels,
|
||||||
|
type ModerationCaseStatus,
|
||||||
|
type ModerationCasePriority,
|
||||||
type ModerationGame,
|
type ModerationGame,
|
||||||
type ModerationScope
|
type ModerationScope
|
||||||
} from '../../moderation.js';
|
} from '../../moderation.js';
|
||||||
@@ -34,8 +44,21 @@ type SanctionRow = {
|
|||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
revision: number;
|
revision: number;
|
||||||
revoked_at: string | null;
|
revoked_at: string | null;
|
||||||
|
case_id: string | null;
|
||||||
|
};
|
||||||
|
type CaseRow = {
|
||||||
|
id: string;
|
||||||
|
game: ModerationGame;
|
||||||
|
guid: string;
|
||||||
|
revision: number;
|
||||||
|
status: ModerationCaseStatus;
|
||||||
|
title: string;
|
||||||
|
priority: ModerationCasePriority;
|
||||||
|
assignee_id: string | null;
|
||||||
|
review_at: string | null;
|
||||||
|
review_reason: string;
|
||||||
|
review_sanction_id: string | null;
|
||||||
};
|
};
|
||||||
type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: string };
|
|
||||||
|
|
||||||
function serverTarget(name: unknown, game?: ModerationGame) {
|
function serverTarget(name: unknown, game?: ModerationGame) {
|
||||||
const server = textField(name, 'Server', 255);
|
const server = textField(name, 'Server', 255);
|
||||||
@@ -61,6 +84,15 @@ function serverTarget(name: unknown, game?: ModerationGame) {
|
|||||||
|
|
||||||
function createSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
function createSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||||
const target = subject(input);
|
const target = subject(input);
|
||||||
|
const linkedCase =
|
||||||
|
input.caseId === undefined || input.caseId === null ? null : caseRow(input.caseId);
|
||||||
|
if (linkedCase) {
|
||||||
|
checkCaseRevision(linkedCase, input.caseRevision);
|
||||||
|
if (!isModerationCaseOpen(linkedCase.status))
|
||||||
|
throw new AuthError('Reopen this case before applying a restriction.', 409);
|
||||||
|
if (linkedCase.game !== target.game || linkedCase.guid !== target.guid)
|
||||||
|
throw new AuthError('A case restriction must target the case’s game and player.');
|
||||||
|
}
|
||||||
if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.');
|
if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.');
|
||||||
if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope))
|
if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope))
|
||||||
throw new AuthError('Choose a restriction scope.');
|
throw new AuthError('Choose a restriction scope.');
|
||||||
@@ -117,8 +149,8 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
|||||||
ensureSubject(target.game, target.guid);
|
ensureSubject(target.game, target.guid);
|
||||||
db()
|
db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at)
|
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at,case_id)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
@@ -133,7 +165,8 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
|||||||
actor.id,
|
actor.id,
|
||||||
actor.username,
|
actor.username,
|
||||||
now,
|
now,
|
||||||
expires
|
expires,
|
||||||
|
linkedCase?.id ?? null
|
||||||
);
|
);
|
||||||
advancePolicy();
|
advancePolicy();
|
||||||
moderationEvent(
|
moderationEvent(
|
||||||
@@ -147,10 +180,22 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
|||||||
server: server?.name ?? null,
|
server: server?.name ?? null,
|
||||||
reason,
|
reason,
|
||||||
expiresAt: expires,
|
expiresAt: expires,
|
||||||
note
|
note,
|
||||||
|
caseId: linkedCase?.id ?? null
|
||||||
}),
|
}),
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
|
if (linkedCase) {
|
||||||
|
touchCase(linkedCase.id, now);
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'case.restriction',
|
||||||
|
linkedCase,
|
||||||
|
linkedCase.id,
|
||||||
|
JSON.stringify({ restrictionId: id, kind: input.kind, scope: input.scope, reason }),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||||
@@ -183,6 +228,13 @@ function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
|||||||
.run(now, id);
|
.run(now, id);
|
||||||
advancePolicy();
|
advancePolicy();
|
||||||
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
|
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE moderation_cases SET updated_at=?,revision=revision+1
|
||||||
|
WHERE id=? OR id IN (SELECT case_id FROM moderation_case_action_links
|
||||||
|
WHERE action_type='restriction' AND action_id=?)`
|
||||||
|
)
|
||||||
|
.run(now, row.case_id, id);
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||||
@@ -267,15 +319,43 @@ function caseRow(value: unknown): CaseRow {
|
|||||||
if (!row) throw new AuthError('Case not found.', 404);
|
if (!row) throw new AuthError('Case not found.', 404);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
function checkCaseRevision(row: CaseRow, revision: unknown) {
|
||||||
|
if (row.revision !== revision)
|
||||||
|
throw new AuthError('This case changed. Refresh before trying again.', 409);
|
||||||
|
}
|
||||||
|
function touchCase(id: string, now: string) {
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
|
||||||
|
.run(now, id);
|
||||||
|
}
|
||||||
|
function casePriority(value: unknown): ModerationCasePriority {
|
||||||
|
if (value !== 'low' && value !== 'normal' && value !== 'high' && value !== 'urgent')
|
||||||
|
throw new AuthError('Choose low, normal, high, or urgent priority.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function caseAssignee(value: unknown): { id: string | null; username: string | null } {
|
||||||
|
if (value === null) return { id: null, username: null };
|
||||||
|
const id = textField(value, 'Assignee', 128);
|
||||||
|
const user = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,username FROM auth_users
|
||||||
|
WHERE id=? AND role IN ('admin','moderator') AND disabled=0 AND must_change_password=0`
|
||||||
|
)
|
||||||
|
.get(id) as { id: string; username: string } | undefined;
|
||||||
|
if (!user) throw new AuthError('Choose an active administrator or moderator to own this case.');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
function evidence(
|
function evidence(
|
||||||
actor: AuthUser,
|
actor: AuthUser,
|
||||||
id: string,
|
id: string,
|
||||||
text: string,
|
text: string,
|
||||||
source: string,
|
source: string,
|
||||||
snapshot: string | null,
|
snapshot: string | null,
|
||||||
now: string
|
now: string,
|
||||||
|
decision = false
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
|
!decision &&
|
||||||
(
|
(
|
||||||
db()
|
db()
|
||||||
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
|
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
|
||||||
@@ -291,93 +371,301 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
|
|||||||
const target = subject(input);
|
const target = subject(input);
|
||||||
const title = textField(input.title, 'Case title', 160);
|
const title = textField(input.title, 'Case title', 160);
|
||||||
const note = textField(input.note, 'Case note', 4000, true);
|
const note = textField(input.note, 'Case note', 4000, true);
|
||||||
|
const priority = input.priority === undefined ? 'normal' : casePriority(input.priority);
|
||||||
|
const assignee =
|
||||||
|
input.assigneeId === undefined
|
||||||
|
? { id: null, username: null }
|
||||||
|
: caseAssignee(input.assigneeId);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
ensureSubject(target.game, target.guid);
|
ensureSubject(target.game, target.guid);
|
||||||
db()
|
db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,?,?,?,'open',?,?,?,?)`
|
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,priority,assignee_id) VALUES(?,?,?,?,'open',?,?,?,?,?,?)`
|
||||||
)
|
)
|
||||||
.run(id, target.game, target.guid, title, actor.id, actor.username, now, now);
|
.run(
|
||||||
|
id,
|
||||||
|
target.game,
|
||||||
|
target.guid,
|
||||||
|
title,
|
||||||
|
actor.id,
|
||||||
|
actor.username,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
priority,
|
||||||
|
assignee.id
|
||||||
|
);
|
||||||
if (note) evidence(actor, id, note, 'Staff note', null, now);
|
if (note) evidence(actor, id, note, 'Staff note', null, now);
|
||||||
if (input.evidence !== undefined) {
|
const attached =
|
||||||
const requested = input.evidence as { matchId: unknown; message: unknown };
|
input.evidence === undefined ? 0 : archivedEvidence(actor, id, target, input.evidence, now);
|
||||||
if (!requested || !Number.isSafeInteger(requested.matchId))
|
|
||||||
throw new AuthError('Choose a recorded chat message.');
|
|
||||||
const message = textField(requested.message, 'Chat message', 4096);
|
|
||||||
const match = db()
|
|
||||||
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
|
|
||||||
.get(requested.matchId) as
|
|
||||||
{ game: string; server: string; started_at: string } | undefined;
|
|
||||||
if (!match || match.game !== target.game)
|
|
||||||
throw new AuthError('The source match is unavailable.', 404);
|
|
||||||
const entries = db()
|
|
||||||
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
|
|
||||||
.all(requested.matchId) as { entry: string }[];
|
|
||||||
const found = entries
|
|
||||||
.filter((row) => {
|
|
||||||
const entry = JSON.parse(row.entry);
|
|
||||||
return (
|
|
||||||
entry.type === 'message' &&
|
|
||||||
moderationGuid(target.game, entry.message.guid) === target.guid &&
|
|
||||||
entry.message.message === message
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.slice(0, 10);
|
|
||||||
if (!found.length)
|
|
||||||
throw new AuthError(
|
|
||||||
'The original chat message could not be verified in the archive.',
|
|
||||||
409
|
|
||||||
);
|
|
||||||
for (const row of found)
|
|
||||||
evidence(
|
|
||||||
actor,
|
|
||||||
id,
|
|
||||||
message,
|
|
||||||
`Archived chat · ${match.server} · match ${requested.matchId}`,
|
|
||||||
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
|
|
||||||
now
|
|
||||||
);
|
|
||||||
}
|
|
||||||
moderationEvent(actor, 'case.opened', target, id, title, now);
|
moderationEvent(actor, 'case.opened', target, id, title, now);
|
||||||
|
if (priority !== 'normal' || assignee.id)
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'case.updated',
|
||||||
|
target,
|
||||||
|
id,
|
||||||
|
JSON.stringify({
|
||||||
|
priority: { from: 'normal', to: priority },
|
||||||
|
assignee: { from: null, to: assignee.username }
|
||||||
|
}),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
if (note || attached) notifyCase(actor.id, id, 'evidence', now);
|
||||||
|
if (assignee.id) notifyCase(actor.id, id, 'assignment', now);
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
|
function archivedEvidence(
|
||||||
|
actor: AuthUser,
|
||||||
|
id: string,
|
||||||
|
target: { game: ModerationGame; guid: string },
|
||||||
|
value: unknown,
|
||||||
|
now: string
|
||||||
|
): number {
|
||||||
|
const snapshots = archivedEvidenceSnapshots(id, target, value);
|
||||||
|
for (const snapshot of snapshots)
|
||||||
|
evidence(actor, id, snapshot.text, snapshot.source, snapshot.snapshot, now);
|
||||||
|
return snapshots.length;
|
||||||
|
}
|
||||||
|
function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
checkCaseRevision(row, input.revision);
|
||||||
|
const title = input.title === undefined ? row.title : textField(input.title, 'Case title', 160);
|
||||||
|
const priority = input.priority === undefined ? row.priority : casePriority(input.priority);
|
||||||
|
const assignee =
|
||||||
|
input.assigneeId === undefined || input.assigneeId === row.assignee_id
|
||||||
|
? null
|
||||||
|
: caseAssignee(input.assigneeId);
|
||||||
|
const assigneeId = assignee ? assignee.id : row.assignee_id;
|
||||||
|
const status = input.status === undefined ? row.status : input.status;
|
||||||
|
if (
|
||||||
|
input.status !== undefined &&
|
||||||
|
!moderationCaseStatuses.includes(status as ModerationCaseStatus)
|
||||||
|
)
|
||||||
|
throw new AuthError('Choose Open, In progress, or Closed.');
|
||||||
|
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
|
||||||
|
const changes: Record<string, unknown> = {};
|
||||||
|
if (title !== row.title) changes.title = { from: row.title, to: title };
|
||||||
|
if (priority !== row.priority) changes.priority = { from: row.priority, to: priority };
|
||||||
|
if (status !== row.status) changes.status = { from: row.status, to: status };
|
||||||
|
if (assigneeId !== row.assignee_id) {
|
||||||
|
const previous = row.assignee_id
|
||||||
|
? (db().prepare('SELECT username FROM auth_users WHERE id=?').get(row.assignee_id) as
|
||||||
|
{ username: string } | undefined)
|
||||||
|
: null;
|
||||||
|
changes.assignee = { from: previous?.username ?? null, to: assignee?.username ?? null };
|
||||||
|
changes.assigneeId = { from: row.assignee_id, to: assigneeId };
|
||||||
|
}
|
||||||
|
if (!Object.keys(changes).length) throw new AuthError('Change the case details before saving.');
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE moderation_cases SET title=?,priority=?,assignee_id=?,status=?,updated_at=?,revision=revision+1 WHERE id=?`
|
||||||
|
)
|
||||||
|
.run(title, priority, assigneeId, status, now, row.id);
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'case.updated',
|
||||||
|
row,
|
||||||
|
row.id,
|
||||||
|
JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
if (assigneeId !== row.assignee_id)
|
||||||
|
notifyCase(actor.id, row.id, 'assignment', now, row.assignee_id);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
|
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
|
||||||
return moderationMutation(actorId, input, (actor, now) => {
|
return moderationMutation(actorId, input, (actor, now) => {
|
||||||
if (input.op === 'create-sanction') return createSanction(actor, input, now);
|
if (input.op === 'create-sanction') return createSanction(actor, input, now);
|
||||||
if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now);
|
if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now);
|
||||||
if (input.op === 'action') return liveAction(actor, input, now);
|
if (input.op === 'action') return liveAction(actor, input, now);
|
||||||
if (input.op === 'create-case') return createCase(actor, input, now);
|
if (input.op === 'create-case') return createCase(actor, input, now);
|
||||||
if (input.op === 'case-note') {
|
if (input.op === 'update-case') return updateCase(actor, input, now);
|
||||||
|
if (input.op === 'schedule-case-review') {
|
||||||
const row = caseRow(input.id);
|
const row = caseRow(input.id);
|
||||||
const text = textField(input.text, 'Note', 4000);
|
checkCaseRevision(row, input.revision);
|
||||||
evidence(actor, row.id, text, 'Staff note', null, now);
|
const schedule = caseReviewSchedule(row, input, now);
|
||||||
|
if (
|
||||||
|
row.review_at === schedule.reviewAt &&
|
||||||
|
row.review_reason === schedule.reason &&
|
||||||
|
row.review_sanction_id === schedule.restrictionId
|
||||||
|
)
|
||||||
|
return { id: row.id };
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
|
.prepare(
|
||||||
.run(now, row.id);
|
`UPDATE moderation_cases SET review_at=?,review_reason=?,review_sanction_id=?,
|
||||||
moderationEvent(actor, 'case.note', row, row.id, text, now);
|
updated_at=?,revision=revision+1 WHERE id=?`
|
||||||
return { id: row.id };
|
)
|
||||||
}
|
.run(schedule.reviewAt, schedule.reason, schedule.restrictionId, now, row.id);
|
||||||
if (input.op === 'resolve-case') {
|
moderationEvent(
|
||||||
const row = caseRow(input.id);
|
|
||||||
if (row.revision !== input.revision || row.status !== 'open')
|
|
||||||
throw new AuthError('This case changed. Refresh before resolving it.', 409);
|
|
||||||
if (input.status !== 'resolved' && input.status !== 'dismissed')
|
|
||||||
throw new AuthError('Choose resolved or dismissed.');
|
|
||||||
const reason = textField(input.reason, 'Decision reason', 4000);
|
|
||||||
evidence(
|
|
||||||
actor,
|
actor,
|
||||||
|
'case.review_scheduled',
|
||||||
|
row,
|
||||||
row.id,
|
row.id,
|
||||||
reason,
|
JSON.stringify(schedule),
|
||||||
input.status === 'resolved' ? 'Resolution' : 'Dismissal',
|
|
||||||
null,
|
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
if (input.op === 'read-case-updates') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
readCaseUpdates(actor.id, row.id, input.throughId, now);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
if (input.op === 'link-case' || input.op === 'unlink-case') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
checkCaseRevision(row, input.revision);
|
||||||
|
if (input.op === 'link-case')
|
||||||
|
linkCases(
|
||||||
|
actor,
|
||||||
|
row,
|
||||||
|
caseRow(input.relatedCaseId),
|
||||||
|
input.relation,
|
||||||
|
input.reason,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
else unlinkCases(actor, row, input.relationshipId, input.reason, now);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
if (input.op === 'assess-case') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
checkCaseRevision(row, input.revision);
|
||||||
|
const draft = assessmentDraft(input);
|
||||||
|
const data = JSON.stringify(draft);
|
||||||
|
const prior = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT data FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1'
|
||||||
|
)
|
||||||
|
.get(row.id) as { data: string } | undefined;
|
||||||
|
if (prior?.data === data) throw new AuthError('Change the assessment before saving.');
|
||||||
|
db()
|
||||||
|
.prepare('INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)')
|
||||||
|
.run(row.id, row.revision + 1, data, actor.id, actor.username, now);
|
||||||
|
touchCase(row.id, now);
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'case.assessed',
|
||||||
|
row,
|
||||||
|
row.id,
|
||||||
|
JSON.stringify({ assessmentRevision: row.revision + 1, summary: draft.summary }),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
if (input.op === 'link-case-action') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
checkCaseRevision(row, input.revision);
|
||||||
|
const reference = caseActionReference({ type: input.actionType, id: input.actionId });
|
||||||
|
const reason = textField(input.reason, 'Link reason', 4000);
|
||||||
|
recordedCaseAction(row, reference, now);
|
||||||
|
if (isCaseActionLinked(row.id, reference)) return { id: row.id };
|
||||||
|
db()
|
||||||
|
.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)')
|
||||||
|
.run(row.id, reference.type, reference.id, actor.username, now);
|
||||||
|
touchCase(row.id, now);
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'case.action_linked',
|
||||||
|
row,
|
||||||
|
row.id,
|
||||||
|
JSON.stringify({ actionReference: reference, reason }),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
if (input.op === 'case-note') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
if (input.revision !== undefined) checkCaseRevision(row, input.revision);
|
||||||
|
const text = textField(input.text, 'Note', 4000);
|
||||||
|
evidence(actor, row.id, text, 'Staff note', null, now);
|
||||||
|
touchCase(row.id, now);
|
||||||
|
moderationEvent(actor, 'case.note', row, row.id, text, now);
|
||||||
|
notifyCase(actor.id, row.id, 'evidence', now);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
// Older clients may still record decisions, but every closure now stores only Closed.
|
||||||
|
if (input.op === 'resolve-case') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
|
||||||
|
throw new AuthError('This case changed. Refresh before resolving it.', 409);
|
||||||
|
const outcome = moderationCaseOutcomes.find((status) => status === input.status);
|
||||||
|
if (!outcome) throw new AuthError('Choose a case outcome.');
|
||||||
|
const reason = textField(input.reason, 'Decision reason', 4000);
|
||||||
|
const reference =
|
||||||
|
input.actionReference === undefined || input.actionReference === null
|
||||||
|
? null
|
||||||
|
: caseActionReference(input.actionReference);
|
||||||
|
if (reference) {
|
||||||
|
const action = recordedCaseAction(row, reference, now);
|
||||||
|
if (!isCaseActionLinked(row.id, reference))
|
||||||
|
throw new AuthError(
|
||||||
|
'Link the recorded action to this case before citing it.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
outcome === 'warning_issued' &&
|
||||||
|
(action.type !== 'action' ||
|
||||||
|
action.kind !== 'warn' ||
|
||||||
|
action.state !== 'confirmed')
|
||||||
|
)
|
||||||
|
throw new AuthError('Warning issued must reference a confirmed warning.');
|
||||||
|
if (outcome === 'restriction_applied' && action.type !== 'restriction')
|
||||||
|
throw new AuthError(
|
||||||
|
'Restriction applied must reference a recorded restriction.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
|
||||||
db()
|
db()
|
||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
||||||
)
|
)
|
||||||
.run(input.status, now, row.id);
|
.run('closed', now, row.id);
|
||||||
moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now);
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
`case.${outcome}`,
|
||||||
|
row,
|
||||||
|
row.id,
|
||||||
|
reference ? JSON.stringify({ reason, actionReference: reference }) : reason,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
if (input.op === 'reopen-case') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
checkCaseRevision(row, input.revision);
|
||||||
|
if (isModerationCaseOpen(row.status))
|
||||||
|
throw new AuthError('This case is already active.', 409);
|
||||||
|
const reason = textField(input.reason, 'Reopening reason', 4000);
|
||||||
|
evidence(actor, row.id, reason, 'Reopened', null, now, true);
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE moderation_cases SET status='open',updated_at=?,revision=revision+1 WHERE id=?"
|
||||||
|
)
|
||||||
|
.run(now, row.id);
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'case.reopened',
|
||||||
|
row,
|
||||||
|
row.id,
|
||||||
|
JSON.stringify({ from: row.status, reason }),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
if (input.op === 'attach-evidence') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
checkCaseRevision(row, input.revision);
|
||||||
|
const count = archivedEvidence(actor, row.id, row, input.evidence, now);
|
||||||
|
if (!count) return { id: row.id };
|
||||||
|
touchCase(row.id, now);
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'case.evidence_attached',
|
||||||
|
row,
|
||||||
|
row.id,
|
||||||
|
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
notifyCase(actor.id, row.id, 'evidence', now);
|
||||||
return { id: row.id };
|
return { id: row.id };
|
||||||
}
|
}
|
||||||
if (input.op === 'note') {
|
if (input.op === 'note') {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
moderationConcernCategories,
|
||||||
|
moderationFindingOutcomes,
|
||||||
|
type ModerationCaseAssessment
|
||||||
|
} from '../../moderation.js';
|
||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { textField } from './core.js';
|
||||||
|
|
||||||
|
type AssessmentDraft = Pick<ModerationCaseAssessment, 'summary' | 'rules' | 'findings'>;
|
||||||
|
|
||||||
|
export function assessmentDraft(input: Record<string, unknown>): AssessmentDraft {
|
||||||
|
const summary = textField(input.summary, 'Assessment summary', 2000);
|
||||||
|
if (!Array.isArray(input.rules) || input.rules.length > 20)
|
||||||
|
throw new AuthError('Choose up to 20 applicable rules.');
|
||||||
|
const rules = input.rules.map((rule) => textField(rule, 'Applicable rule', 160));
|
||||||
|
if (
|
||||||
|
!Array.isArray(input.findings) ||
|
||||||
|
!input.findings.length ||
|
||||||
|
input.findings.length > moderationConcernCategories.length
|
||||||
|
)
|
||||||
|
throw new AuthError('Record between 1 and 7 findings, one per concern category.');
|
||||||
|
const categories = new Set<string>();
|
||||||
|
const findings = input.findings.map((value): AssessmentDraft['findings'][number] => {
|
||||||
|
if (!value || typeof value !== 'object')
|
||||||
|
throw new AuthError('Choose a concern category and finding.');
|
||||||
|
const category = moderationConcernCategories.find(
|
||||||
|
(category) => category === value.category
|
||||||
|
);
|
||||||
|
const finding = moderationFindingOutcomes.find((finding) => finding === value.finding);
|
||||||
|
if (!category || !finding)
|
||||||
|
throw new AuthError('Choose a known concern category and finding.');
|
||||||
|
if (categories.has(category))
|
||||||
|
throw new AuthError('Record each concern category only once.');
|
||||||
|
categories.add(category);
|
||||||
|
return { category, finding, detail: textField(value.detail, 'Finding detail', 1000) };
|
||||||
|
});
|
||||||
|
return { summary, rules, findings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseAssessmentHistory(caseId: string): ModerationCaseAssessment[] {
|
||||||
|
return (
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT revision,data,actor,created_at AS createdAt
|
||||||
|
FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC`
|
||||||
|
)
|
||||||
|
.all(caseId) as { revision: number; data: string; actor: string; createdAt: string }[]
|
||||||
|
).map(({ data, ...revision }) => ({ ...JSON.parse(data), ...revision }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentCaseAssessment(caseId: string): ModerationCaseAssessment | null {
|
||||||
|
const row = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT revision,data,actor,created_at AS createdAt
|
||||||
|
FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(caseId) as
|
||||||
|
{ revision: number; data: string; actor: string; createdAt: string } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
const { data, ...revision } = row;
|
||||||
|
return { ...JSON.parse(data), ...revision };
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import { textField } from './core.js';
|
||||||
|
import type { ModerationCaseAction, ModerationGame } from '../../moderation.js';
|
||||||
|
|
||||||
|
type Subject = { game: ModerationGame; guid: string };
|
||||||
|
export type CaseActionReference = Pick<ModerationCaseAction, 'type' | 'id'>;
|
||||||
|
|
||||||
|
export function caseActionReference(value: unknown): CaseActionReference {
|
||||||
|
if (!value || typeof value !== 'object') throw new AuthError('Choose a recorded action.');
|
||||||
|
const reference = value as Record<string, unknown>;
|
||||||
|
if (reference.type !== 'action' && reference.type !== 'restriction')
|
||||||
|
throw new AuthError('Choose a recorded warning, action, or restriction.');
|
||||||
|
return { type: reference.type, id: textField(reference.id, 'Action ID', 128) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const restrictionColumns = `id,'restriction' AS type,kind,reason,actor,
|
||||||
|
created_at AS createdAt,expires_at AS expiresAt,
|
||||||
|
CASE WHEN revoked_at IS NOT NULL THEN 'revoked'
|
||||||
|
WHEN expires_at IS NOT NULL AND expires_at<=? THEN 'expired' ELSE 'active' END AS state`;
|
||||||
|
const actionColumns = `id,'action' AS type,kind,reason,actor,
|
||||||
|
created_at AS createdAt,expires_at AS expiresAt,state`;
|
||||||
|
|
||||||
|
/** Only actual records for this account can become case evidence. */
|
||||||
|
export function recordedCaseAction(
|
||||||
|
subject: Subject,
|
||||||
|
reference: CaseActionReference,
|
||||||
|
now: string
|
||||||
|
): ModerationCaseAction {
|
||||||
|
const handle = db();
|
||||||
|
const action =
|
||||||
|
reference.type === 'restriction'
|
||||||
|
? handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${restrictionColumns} FROM moderation_sanctions
|
||||||
|
WHERE id=? AND guid=? AND (game=? OR scope='fleet')`
|
||||||
|
)
|
||||||
|
.get(now, reference.id, subject.guid, subject.game)
|
||||||
|
: handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${actionColumns} FROM moderation_actions
|
||||||
|
WHERE id=? AND guid=? AND game=?`
|
||||||
|
)
|
||||||
|
.get(reference.id, subject.guid, subject.game);
|
||||||
|
if (!action)
|
||||||
|
throw new AuthError('The recorded action is unavailable for this case’s player.', 404);
|
||||||
|
return action as ModerationCaseAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCaseActionLinked(caseId: string, reference: CaseActionReference): boolean {
|
||||||
|
return Boolean(
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 FROM moderation_case_action_links
|
||||||
|
WHERE case_id=? AND action_type=? AND action_id=?
|
||||||
|
UNION ALL SELECT 1 FROM moderation_sanctions
|
||||||
|
WHERE ?='restriction' AND id=? AND case_id=? LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(caseId, reference.type, reference.id, reference.type, reference.id, caseId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseActionLists(caseId: string, subject: Subject, now: string) {
|
||||||
|
const handle = db();
|
||||||
|
const records = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${restrictionColumns},
|
||||||
|
(case_id=? OR EXISTS (SELECT 1 FROM moderation_case_action_links l
|
||||||
|
WHERE l.case_id=? AND l.action_type='restriction' AND l.action_id=moderation_sanctions.id)) AS linked
|
||||||
|
FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet')
|
||||||
|
UNION ALL SELECT ${actionColumns},
|
||||||
|
EXISTS (SELECT 1 FROM moderation_case_action_links l
|
||||||
|
WHERE l.case_id=? AND l.action_type='action' AND l.action_id=moderation_actions.id) AS linked
|
||||||
|
FROM moderation_actions WHERE guid=? AND game=?
|
||||||
|
ORDER BY createdAt DESC,id`
|
||||||
|
)
|
||||||
|
.all(
|
||||||
|
now,
|
||||||
|
caseId,
|
||||||
|
caseId,
|
||||||
|
subject.guid,
|
||||||
|
subject.game,
|
||||||
|
caseId,
|
||||||
|
subject.guid,
|
||||||
|
subject.game
|
||||||
|
) as Array<ModerationCaseAction & { linked: number | null }>;
|
||||||
|
const linkedActions: ModerationCaseAction[] = [];
|
||||||
|
const availableActions: ModerationCaseAction[] = [];
|
||||||
|
for (const { linked, ...record } of records)
|
||||||
|
(linked ? linkedActions : availableActions).push(record);
|
||||||
|
return { linkedActions, availableActions };
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import { moderationActor, textField } from './core.js';
|
||||||
|
import { moderationOverview } from './overview.js';
|
||||||
|
import { caseActionLists } from './case-actions.js';
|
||||||
|
import { caseAssessmentHistory } from './assessments.js';
|
||||||
|
import { relatedCases, candidateCases } from './relationships.js';
|
||||||
|
import { caseNotifications, unreadCaseUpdates } from './followups.js';
|
||||||
|
import type {
|
||||||
|
ModerationCase,
|
||||||
|
ModerationCaseDetail,
|
||||||
|
ModerationEvent,
|
||||||
|
ModerationSanction
|
||||||
|
} from '../../moderation.js';
|
||||||
|
|
||||||
|
/** A case owns its evidence and timeline independently of the paginated queues. */
|
||||||
|
export function moderationCaseDetail(actorId: string, id: string): ModerationCaseDetail {
|
||||||
|
moderationActor(actorId);
|
||||||
|
const caseId = textField(id, 'Case ID', 128);
|
||||||
|
return db().transaction(() => {
|
||||||
|
const handle = db();
|
||||||
|
const row = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.id,c.game,c.guid,c.title,c.status,c.priority,
|
||||||
|
c.assignee_id AS assigneeId,u.username AS assignee,c.actor,c.created_at AS createdAt,
|
||||||
|
c.updated_at AS updatedAt,c.revision,c.review_at AS reviewAt,
|
||||||
|
c.review_reason AS reviewReason,c.review_sanction_id AS reviewSanctionId FROM moderation_cases c
|
||||||
|
LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?`
|
||||||
|
)
|
||||||
|
.get(caseId) as Omit<ModerationCase, 'evidence'> | undefined;
|
||||||
|
if (!row) throw new AuthError('Case not found.', 404);
|
||||||
|
const overview = moderationOverview(actorId, {
|
||||||
|
view: 'players',
|
||||||
|
q: '',
|
||||||
|
game: row.game,
|
||||||
|
guid: row.guid,
|
||||||
|
offset: 0
|
||||||
|
});
|
||||||
|
const evidence = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,text,source,snapshot,actor,created_at AS createdAt
|
||||||
|
FROM moderation_evidence WHERE case_id=? ORDER BY created_at,rowid`
|
||||||
|
)
|
||||||
|
.all(caseId) as ModerationCase['evidence'];
|
||||||
|
const sanctions = (
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note,
|
||||||
|
created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision
|
||||||
|
FROM moderation_sanctions WHERE case_id=? OR id IN
|
||||||
|
(SELECT action_id FROM moderation_case_action_links WHERE case_id=? AND action_type='restriction')
|
||||||
|
ORDER BY created_at DESC,id`
|
||||||
|
)
|
||||||
|
.all(caseId, caseId) as Omit<ModerationSanction, 'active'>[]
|
||||||
|
).map((restriction) => ({
|
||||||
|
...restriction,
|
||||||
|
active:
|
||||||
|
!restriction.revokedAt &&
|
||||||
|
(!restriction.expiresAt || restriction.expiresAt > overview.checkedAt)
|
||||||
|
}));
|
||||||
|
const events = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt
|
||||||
|
FROM moderation_events WHERE subject_id=? OR subject_id IN
|
||||||
|
(SELECT id FROM moderation_sanctions WHERE case_id=?) OR subject_id IN
|
||||||
|
(SELECT action_id FROM moderation_case_action_links WHERE case_id=?) ORDER BY id DESC`
|
||||||
|
)
|
||||||
|
.all(caseId, caseId, caseId) as ModerationEvent[];
|
||||||
|
const assignees = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
|
||||||
|
AND disabled=0 AND must_change_password=0 ORDER BY username COLLATE NOCASE,id`
|
||||||
|
)
|
||||||
|
.all() as ModerationCaseDetail['assignees'];
|
||||||
|
const assessmentHistory = caseAssessmentHistory(caseId);
|
||||||
|
return {
|
||||||
|
case: { ...row, evidence, unreadUpdates: unreadCaseUpdates(actorId, caseId) },
|
||||||
|
player: overview.players[0] ?? {
|
||||||
|
game: row.game,
|
||||||
|
guid: row.guid,
|
||||||
|
name: row.guid,
|
||||||
|
aliases: [],
|
||||||
|
servers: [],
|
||||||
|
restrictions: 0,
|
||||||
|
firstSeen: null,
|
||||||
|
lastSeen: null
|
||||||
|
},
|
||||||
|
sanctions,
|
||||||
|
...caseActionLists(caseId, row, overview.checkedAt),
|
||||||
|
assessment: assessmentHistory[0] ?? null,
|
||||||
|
assessmentHistory,
|
||||||
|
relatedCases: relatedCases(caseId),
|
||||||
|
candidateCases: candidateCases(row),
|
||||||
|
notifications: caseNotifications(actorId, caseId),
|
||||||
|
events,
|
||||||
|
coverage: overview.coverage,
|
||||||
|
assignees,
|
||||||
|
checkedAt: overview.checkedAt
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import { matchChatTimeline } from '../fleet/chat.js';
|
||||||
|
import { moderationGuid, type ModerationGame } from '../../moderation.js';
|
||||||
|
import type { ContextualChatEntry } from '../../game-chat.js';
|
||||||
|
import { textField } from './core.js';
|
||||||
|
|
||||||
|
type Message = Extract<ContextualChatEntry, { type: 'message' }>;
|
||||||
|
|
||||||
|
/** Verify against the archive, then preserve independent excerpts across all participants. */
|
||||||
|
export function archivedEvidenceSnapshots(
|
||||||
|
caseId: string,
|
||||||
|
target: { game: ModerationGame; guid: string },
|
||||||
|
value: unknown
|
||||||
|
) {
|
||||||
|
const requested = value as Record<string, unknown> | null;
|
||||||
|
if (!requested || !Number.isSafeInteger(requested.matchId))
|
||||||
|
throw new AuthError('Choose a recorded chat message.');
|
||||||
|
const matchId = requested.matchId as number;
|
||||||
|
const match = db()
|
||||||
|
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
|
||||||
|
.get(matchId) as { game: string; server: string; started_at: string } | undefined;
|
||||||
|
if (!match || match.game !== target.game)
|
||||||
|
throw new AuthError('The source match is unavailable.', 404);
|
||||||
|
const entries = matchChatTimeline(matchId).filter(
|
||||||
|
(entry): entry is Message => entry.type === 'message'
|
||||||
|
);
|
||||||
|
const byId = new Map(entries.map((entry) => [entry.messageId!, entry]));
|
||||||
|
const isSubject = (entry: Message) =>
|
||||||
|
moderationGuid(target.game, entry.message.guid) === target.guid;
|
||||||
|
const legacy = requested.messageIds === undefined;
|
||||||
|
let selected: Message[];
|
||||||
|
if (legacy) {
|
||||||
|
textField(requested.message, 'Chat message', 4096);
|
||||||
|
selected = entries.filter(
|
||||||
|
(entry) => isSubject(entry) && entry.message.message === requested.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
!Array.isArray(requested.messageIds) ||
|
||||||
|
!requested.messageIds.length ||
|
||||||
|
requested.messageIds.length > 100 ||
|
||||||
|
requested.messageIds.some((id) => typeof id !== 'string' || !byId.has(id))
|
||||||
|
)
|
||||||
|
throw new AuthError('Select between 1 and 100 archived message identifiers.');
|
||||||
|
const ids = new Set(requested.messageIds);
|
||||||
|
selected = entries.filter((entry) => ids.has(entry.messageId));
|
||||||
|
}
|
||||||
|
if (!selected.length || !selected.some(isSubject))
|
||||||
|
throw new AuthError(
|
||||||
|
'The original chat message could not be verified in the archive. Select a message from the case subject.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
const radius = (key: string) => {
|
||||||
|
const count = requested[key] ?? 3;
|
||||||
|
if (!Number.isSafeInteger(count) || (count as number) < 0 || (count as number) > 20)
|
||||||
|
throw new AuthError('Choose between 0 and 20 surrounding messages.');
|
||||||
|
return count as number;
|
||||||
|
};
|
||||||
|
const before = radius('contextBefore'),
|
||||||
|
after = radius('contextAfter');
|
||||||
|
const attached = new Set<string>();
|
||||||
|
const prior = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT snapshot FROM moderation_evidence WHERE case_id=? AND snapshot IS NOT NULL'
|
||||||
|
)
|
||||||
|
.all(caseId) as { snapshot: string }[];
|
||||||
|
for (const row of prior) {
|
||||||
|
try {
|
||||||
|
const snapshot = JSON.parse(row.snapshot);
|
||||||
|
if (snapshot.matchId !== matchId) continue;
|
||||||
|
if (Array.isArray(snapshot.selectedMessageIds)) {
|
||||||
|
for (const id of snapshot.selectedMessageIds)
|
||||||
|
if (typeof id === 'string') attached.add(id);
|
||||||
|
} else if (snapshot.entry?.type === 'message') {
|
||||||
|
// Older evidence predates IDs. Match each occurrence once without changing history.
|
||||||
|
const old = snapshot.entry.message;
|
||||||
|
const original = entries.find(
|
||||||
|
(entry) =>
|
||||||
|
!attached.has(entry.messageId!) &&
|
||||||
|
entry.message.guid === old.guid &&
|
||||||
|
entry.message.time === old.time &&
|
||||||
|
entry.message.player === old.player &&
|
||||||
|
entry.message.channel === old.channel &&
|
||||||
|
entry.message.message === old.message
|
||||||
|
);
|
||||||
|
if (original) attached.add(original.messageId!);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Keep malformed legacy snapshots readable without trusting them as identities. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fresh = selected.filter((entry) => !attached.has(entry.messageId!));
|
||||||
|
if (!fresh.length) return [];
|
||||||
|
// Keep the original selected subject as context when only a newly selected reply is fresh.
|
||||||
|
const groups = legacy ? fresh.map((entry) => [entry]) : [selected];
|
||||||
|
return groups.map((group) => {
|
||||||
|
const indexes = new Set<number>();
|
||||||
|
for (const entry of group) {
|
||||||
|
const index = entries.indexOf(entry);
|
||||||
|
for (
|
||||||
|
let i = Math.max(0, index - before);
|
||||||
|
i <= Math.min(entries.length - 1, index + after);
|
||||||
|
i++
|
||||||
|
)
|
||||||
|
indexes.add(i);
|
||||||
|
}
|
||||||
|
const primary = group.find(isSubject)!;
|
||||||
|
return {
|
||||||
|
text: group
|
||||||
|
.filter(isSubject)
|
||||||
|
.map((entry) => entry.message.message)
|
||||||
|
.join('\n'),
|
||||||
|
source: `Archived chat · ${match.server} · match ${matchId}`,
|
||||||
|
snapshot: JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
matchId,
|
||||||
|
match,
|
||||||
|
entry: primary,
|
||||||
|
entries: [...indexes].sort((a, b) => a - b).map((index) => entries[index]),
|
||||||
|
selectedMessageIds: group
|
||||||
|
.filter((entry) => !attached.has(entry.messageId!))
|
||||||
|
.map((entry) => entry.messageId),
|
||||||
|
highlightedMessageIds: group.filter(isSubject).map((entry) => entry.messageId)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import type { ModerationCaseNotification, ModerationGame } from '../../moderation.js';
|
||||||
|
import { textField } from './core.js';
|
||||||
|
import { recordedCaseAction, isCaseActionLinked } from './case-actions.js';
|
||||||
|
|
||||||
|
/** Called once per accepted mutation, within its existing audit transaction. */
|
||||||
|
export function notifyCase(
|
||||||
|
actorId: string,
|
||||||
|
caseId: string,
|
||||||
|
kind: ModerationCaseNotification['kind'],
|
||||||
|
now: string,
|
||||||
|
previousAssignee: string | null = null
|
||||||
|
) {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at)
|
||||||
|
SELECT u.id,?,?,? FROM auth_users u JOIN moderation_cases c ON c.id=?
|
||||||
|
WHERE (u.id=c.actor_id OR u.id=c.assignee_id OR u.id=?) AND u.id<>?
|
||||||
|
AND u.role IN ('admin','moderator') AND u.disabled=0 AND u.must_change_password=0`
|
||||||
|
)
|
||||||
|
.run(caseId, kind, now, caseId, previousAssignee, actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseNotifications(actorId: string, caseId?: string): ModerationCaseNotification[] {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT n.id,n.case_id AS caseId,c.title AS caseTitle,n.kind,
|
||||||
|
n.created_at AS createdAt,n.read_at AS readAt FROM moderation_case_notifications n
|
||||||
|
JOIN moderation_cases c ON c.id=n.case_id WHERE n.user_id=?
|
||||||
|
${caseId === undefined ? 'AND n.read_at IS NULL' : 'AND n.case_id=?'} ORDER BY n.id DESC LIMIT 25`
|
||||||
|
)
|
||||||
|
.all(
|
||||||
|
...(caseId === undefined ? [actorId] : [actorId, caseId])
|
||||||
|
) as ModerationCaseNotification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unreadCaseUpdates(actorId: string, caseId?: string): number {
|
||||||
|
return (
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS count FROM moderation_case_notifications
|
||||||
|
WHERE user_id=? AND read_at IS NULL${caseId === undefined ? '' : ' AND case_id=?'}`
|
||||||
|
)
|
||||||
|
.get(...(caseId === undefined ? [actorId] : [actorId, caseId])) as { count: number }
|
||||||
|
).count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCaseUpdates(actorId: string, caseId: string, throughId: unknown, now: string) {
|
||||||
|
if (!Number.isSafeInteger(throughId) || (throughId as number) < 0)
|
||||||
|
throw new AuthError('Choose the last update you have reviewed.');
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE moderation_case_notifications SET read_at=?
|
||||||
|
WHERE user_id=? AND case_id=? AND id<=? AND read_at IS NULL`
|
||||||
|
)
|
||||||
|
.run(now, actorId, caseId, throughId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalDate(value: unknown): string {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
|
||||||
|
!Number.isFinite(Date.parse(value)) ||
|
||||||
|
new Date(value).toISOString() !== value
|
||||||
|
)
|
||||||
|
throw new AuthError('Choose a valid review date and time.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseReviewSchedule(
|
||||||
|
subject: { id: string; game: ModerationGame; guid: string },
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
now: string
|
||||||
|
) {
|
||||||
|
const reason = textField(input.reason, 'Review reason', 4000);
|
||||||
|
if ((input.reviewAt === undefined) === (input.restrictionId === undefined))
|
||||||
|
throw new AuthError('Choose either a review date or a linked mute.');
|
||||||
|
if (input.restrictionId !== undefined) {
|
||||||
|
const id = textField(input.restrictionId, 'Restriction ID', 128);
|
||||||
|
const reference = { type: 'restriction' as const, id };
|
||||||
|
const restriction = recordedCaseAction(subject, reference, now);
|
||||||
|
if (!isCaseActionLinked(subject.id, reference))
|
||||||
|
throw new AuthError('Link the mute to this case before scheduling its review.', 409);
|
||||||
|
if (restriction.kind !== 'mute' || !restriction.expiresAt)
|
||||||
|
throw new AuthError('Choose a linked mute with a recorded expiry time.');
|
||||||
|
return { reviewAt: canonicalDate(restriction.expiresAt), reason, restrictionId: id };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reviewAt: input.reviewAt === null ? null : canonicalDate(input.reviewAt),
|
||||||
|
reason,
|
||||||
|
restrictionId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
|
import { currentCaseAssessment } from './assessments.js';
|
||||||
|
import { caseNotifications, unreadCaseUpdates } from './followups.js';
|
||||||
import { moderationActor, moderationNow, policyRevision } from './core.js';
|
import { moderationActor, moderationNow, policyRevision } from './core.js';
|
||||||
import { runtimeFreshMs } from './actions.js';
|
import { runtimeFreshMs } from './actions.js';
|
||||||
import { refreshActionStates } from './runtime.js';
|
import { refreshActionStates } from './runtime.js';
|
||||||
import {
|
import {
|
||||||
|
moderationCaseStatuses,
|
||||||
moderationGuid,
|
moderationGuid,
|
||||||
type ModerationOverview,
|
type ModerationOverview,
|
||||||
type ModerationQuery,
|
type ModerationQuery,
|
||||||
@@ -19,12 +22,25 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
|
|||||||
const game = params.get('game');
|
const game = params.get('game');
|
||||||
const guid = (params.get('guid') ?? '').slice(0, 128);
|
const guid = (params.get('guid') ?? '').slice(0, 128);
|
||||||
const offset = Number(params.get('offset') ?? 0);
|
const offset = Number(params.get('offset') ?? 0);
|
||||||
|
const caseStatus = params.get('caseStatus') ?? '';
|
||||||
|
const priority = params.get('priority') ?? '';
|
||||||
|
const review = params.get('review') ?? '';
|
||||||
return {
|
return {
|
||||||
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
|
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
|
||||||
q: (params.get('q') ?? '').trim().slice(0, 200),
|
q: (params.get('q') ?? '').trim().slice(0, 200),
|
||||||
game: game === 't5' || game === 't6' ? game : '',
|
game: game === 't5' || game === 't6' ? game : '',
|
||||||
guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid,
|
guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid,
|
||||||
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0
|
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0,
|
||||||
|
caseStatus: ['active', 'closed', ...moderationCaseStatuses].includes(caseStatus)
|
||||||
|
? (caseStatus as ModerationQuery['caseStatus'])
|
||||||
|
: '',
|
||||||
|
priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
|
||||||
|
? (priority as ModerationQuery['priority'])
|
||||||
|
: '',
|
||||||
|
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128),
|
||||||
|
review: ['due', 'scheduled', 'updates'].includes(review)
|
||||||
|
? (review as ModerationQuery['review'])
|
||||||
|
: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
|
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
|
||||||
@@ -79,7 +95,9 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
coverage,
|
coverage,
|
||||||
total: 0,
|
total: 0,
|
||||||
limit,
|
limit,
|
||||||
checkedAt: now
|
checkedAt: now,
|
||||||
|
notifications: caseNotifications(actorId),
|
||||||
|
unreadCount: unreadCaseUpdates(actorId)
|
||||||
};
|
};
|
||||||
const where: string[] = [],
|
const where: string[] = [],
|
||||||
values: (string | number)[] = [];
|
values: (string | number)[] = [];
|
||||||
@@ -93,6 +111,35 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
where.push('p.guid=?');
|
where.push('p.guid=?');
|
||||||
values.push(query.guid);
|
values.push(query.guid);
|
||||||
}
|
}
|
||||||
|
if (query.view === 'cases') {
|
||||||
|
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
|
||||||
|
where.push(
|
||||||
|
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review')`
|
||||||
|
);
|
||||||
|
} else if (query.caseStatus) {
|
||||||
|
where.push('p.status=?');
|
||||||
|
values.push(query.caseStatus);
|
||||||
|
}
|
||||||
|
if (query.priority) {
|
||||||
|
where.push('p.priority=?');
|
||||||
|
values.push(query.priority);
|
||||||
|
}
|
||||||
|
if (query.assignedTo === 'unassigned') where.push('p.assignee_id IS NULL');
|
||||||
|
else if (query.assignedTo) {
|
||||||
|
where.push('p.assignee_id=?');
|
||||||
|
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
|
||||||
|
}
|
||||||
|
if (query.review === 'due') {
|
||||||
|
where.push('p.review_at IS NOT NULL AND p.review_at<=?');
|
||||||
|
values.push(now);
|
||||||
|
} else if (query.review === 'scheduled') where.push('p.review_at IS NOT NULL');
|
||||||
|
else if (query.review === 'updates') {
|
||||||
|
where.push(
|
||||||
|
'EXISTS (SELECT 1 FROM moderation_case_notifications n WHERE n.case_id=p.id AND n.user_id=? AND n.read_at IS NULL)'
|
||||||
|
);
|
||||||
|
values.push(actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (query.q) {
|
if (query.q) {
|
||||||
const extra =
|
const extra =
|
||||||
query.view === 'restrictions'
|
query.view === 'restrictions'
|
||||||
@@ -108,7 +155,12 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
values.push(query.q, query.q, query.q);
|
values.push(query.q, query.q, query.q);
|
||||||
}
|
}
|
||||||
const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
|
const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
|
||||||
function rows<T>(table: string, select: string, order: string): T[] {
|
function rows<T>(
|
||||||
|
table: string,
|
||||||
|
select: string,
|
||||||
|
order: string,
|
||||||
|
orderValues: string[] = []
|
||||||
|
): T[] {
|
||||||
result.total = (
|
result.total = (
|
||||||
handle
|
handle
|
||||||
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
|
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
|
||||||
@@ -122,7 +174,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
|
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
|
||||||
)
|
)
|
||||||
.all(...values, limit, result.query.offset) as T[];
|
.all(...values, ...orderValues, limit, result.query.offset) as T[];
|
||||||
}
|
}
|
||||||
if (query.view === 'players') {
|
if (query.view === 'players') {
|
||||||
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
|
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
|
||||||
@@ -161,16 +213,19 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
} else if (query.view === 'restrictions') {
|
} else if (query.view === 'restrictions') {
|
||||||
result.sanctions = rows<Omit<ModerationSanction, 'active'>>(
|
result.sanctions = rows<Omit<ModerationSanction, 'active'>>(
|
||||||
'moderation_sanctions',
|
'moderation_sanctions',
|
||||||
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision',
|
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision,p.case_id AS caseId',
|
||||||
'p.created_at DESC,p.id'
|
'p.created_at DESC,p.id'
|
||||||
).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) }));
|
).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) }));
|
||||||
} else if (query.view === 'cases') {
|
} else if (query.view === 'cases') {
|
||||||
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
|
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
|
||||||
'moderation_cases',
|
'moderation_cases',
|
||||||
'p.id,p.game,p.guid,p.title,p.status,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
|
'p.id,p.game,p.guid,p.title,p.status,p.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision,p.review_at AS reviewAt,p.review_reason AS reviewReason,p.review_sanction_id AS reviewSanctionId',
|
||||||
'p.updated_at DESC,p.id'
|
"CASE WHEN p.review_at<=? THEN 0 ELSE 1 END,CASE WHEN p.review_at<=? THEN p.review_at END,CASE WHEN p.status IN ('open','in_review') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id",
|
||||||
|
[now, now]
|
||||||
).map((c) => ({
|
).map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
|
assessment: currentCaseAssessment(c.id),
|
||||||
|
unreadUpdates: unreadCaseUpdates(actorId, c.id),
|
||||||
evidence: handle
|
evidence: handle
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
|
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AuthUser } from '../../auth.js';
|
||||||
|
import {
|
||||||
|
moderationCaseRelations,
|
||||||
|
moderationGuid,
|
||||||
|
type ModerationCaseCandidate,
|
||||||
|
type ModerationCaseReference,
|
||||||
|
type ModerationCaseRelation,
|
||||||
|
type ModerationCaseRelationship
|
||||||
|
} from '../../moderation.js';
|
||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { moderationEvent, textField } from './core.js';
|
||||||
|
|
||||||
|
type RelationshipRow = {
|
||||||
|
id: string;
|
||||||
|
case_id: string;
|
||||||
|
target_case_id: string;
|
||||||
|
relation: ModerationCaseRelation;
|
||||||
|
removed_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function relationAudit(
|
||||||
|
actor: AuthUser,
|
||||||
|
relationship: RelationshipRow,
|
||||||
|
cases: ModerationCaseReference[],
|
||||||
|
action: 'case.linked' | 'case.unlinked',
|
||||||
|
reason: string,
|
||||||
|
now: string
|
||||||
|
) {
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id IN (?,?)')
|
||||||
|
.run(now, relationship.case_id, relationship.target_case_id);
|
||||||
|
for (const item of cases) {
|
||||||
|
const outgoing = item.id === relationship.case_id;
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
action,
|
||||||
|
item,
|
||||||
|
item.id,
|
||||||
|
JSON.stringify({
|
||||||
|
relationshipId: relationship.id,
|
||||||
|
relation: relationship.relation,
|
||||||
|
direction: outgoing ? 'outgoing' : 'incoming',
|
||||||
|
relatedCaseId: outgoing ? relationship.target_case_id : relationship.case_id,
|
||||||
|
reason
|
||||||
|
}),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkCases(
|
||||||
|
actor: AuthUser,
|
||||||
|
source: ModerationCaseReference,
|
||||||
|
target: ModerationCaseReference,
|
||||||
|
value: unknown,
|
||||||
|
reasonValue: unknown,
|
||||||
|
now: string
|
||||||
|
) {
|
||||||
|
const relation = moderationCaseRelations.find((relation) => relation === value);
|
||||||
|
if (!relation) throw new AuthError('Choose same incident, appeal of, or duplicate of.');
|
||||||
|
const reason = textField(reasonValue, 'Relationship reason', 4000);
|
||||||
|
if (source.id === target.id) throw new AuthError('A case cannot be related to itself.');
|
||||||
|
if (
|
||||||
|
relation !== 'same_incident' &&
|
||||||
|
(source.game !== target.game || source.guid !== target.guid)
|
||||||
|
)
|
||||||
|
throw new AuthError('Appeal and duplicate relationships require the same game and player.');
|
||||||
|
const [from, to] =
|
||||||
|
relation === 'same_incident' && source.id > target.id ? [target, source] : [source, target];
|
||||||
|
const handle = db();
|
||||||
|
if (
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 FROM moderation_case_relationships
|
||||||
|
WHERE case_id=? AND target_case_id=? AND relation=? AND removed_at IS NULL`
|
||||||
|
)
|
||||||
|
.get(from.id, to.id, relation)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (
|
||||||
|
relation !== 'same_incident' &&
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`WITH RECURSIVE descendants(id) AS (
|
||||||
|
SELECT target_case_id FROM moderation_case_relationships
|
||||||
|
WHERE case_id=? AND relation=? AND removed_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT r.target_case_id FROM moderation_case_relationships r
|
||||||
|
JOIN descendants d ON r.case_id=d.id WHERE r.relation=? AND r.removed_at IS NULL
|
||||||
|
) SELECT 1 FROM descendants WHERE id=? LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(to.id, relation, relation, from.id)
|
||||||
|
)
|
||||||
|
throw new AuthError(
|
||||||
|
'This relationship would create a cycle. Review the linked cases.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
const relationship: RelationshipRow = {
|
||||||
|
id: randomUUID(),
|
||||||
|
case_id: from.id,
|
||||||
|
target_case_id: to.id,
|
||||||
|
relation,
|
||||||
|
removed_at: null
|
||||||
|
};
|
||||||
|
handle
|
||||||
|
.prepare('INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)')
|
||||||
|
.run(relationship.id, from.id, to.id, relation, actor.id, actor.username, now);
|
||||||
|
relationAudit(actor, relationship, [from, to], 'case.linked', reason, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlinkCases(
|
||||||
|
actor: AuthUser,
|
||||||
|
source: ModerationCaseReference,
|
||||||
|
value: unknown,
|
||||||
|
reasonValue: unknown,
|
||||||
|
now: string
|
||||||
|
) {
|
||||||
|
const id = textField(value, 'Relationship ID', 128);
|
||||||
|
const reason = textField(reasonValue, 'Removal reason', 4000);
|
||||||
|
const handle = db();
|
||||||
|
const relationship = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,case_id,target_case_id,relation,removed_at
|
||||||
|
FROM moderation_case_relationships WHERE id=? AND (case_id=? OR target_case_id=?)`
|
||||||
|
)
|
||||||
|
.get(id, source.id, source.id) as RelationshipRow | undefined;
|
||||||
|
if (!relationship) throw new AuthError('The case relationship is unavailable.', 404);
|
||||||
|
if (relationship.removed_at) return;
|
||||||
|
const otherId =
|
||||||
|
relationship.case_id === source.id ? relationship.target_case_id : relationship.case_id;
|
||||||
|
const other = handle
|
||||||
|
.prepare('SELECT id,title,status,game,guid FROM moderation_cases WHERE id=?')
|
||||||
|
.get(otherId) as ModerationCaseReference;
|
||||||
|
handle.prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?').run(now, id);
|
||||||
|
relationAudit(actor, relationship, [source, other], 'case.unlinked', reason, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relationships remain readable from both endpoints, with the original direction intact. */
|
||||||
|
export function relatedCases(caseId: string): ModerationCaseRelationship[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id,r.relation,r.actor,r.created_at AS createdAt,
|
||||||
|
CASE WHEN r.case_id=? THEN 'outgoing' ELSE 'incoming' END AS direction,
|
||||||
|
c.id AS otherId,c.title,c.status,c.game,c.guid
|
||||||
|
FROM moderation_case_relationships r JOIN moderation_cases c
|
||||||
|
ON c.id=CASE WHEN r.case_id=? THEN r.target_case_id ELSE r.case_id END
|
||||||
|
WHERE (r.case_id=? OR r.target_case_id=?) AND r.removed_at IS NULL
|
||||||
|
ORDER BY r.created_at DESC,r.id`
|
||||||
|
)
|
||||||
|
.all(caseId, caseId, caseId, caseId) as Array<
|
||||||
|
Omit<ModerationCaseRelationship, 'case'> &
|
||||||
|
Omit<ModerationCaseReference, 'id'> & { otherId: string }
|
||||||
|
>;
|
||||||
|
return rows.map(({ otherId, title, status, game, guid, ...relationship }) => ({
|
||||||
|
...relationship,
|
||||||
|
case: { id: otherId, title, status, game, guid }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function archivedMatch(value: string, subject: ModerationCaseReference): number | null {
|
||||||
|
try {
|
||||||
|
const snapshot = JSON.parse(value);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(snapshot?.matchId) ||
|
||||||
|
snapshot.matchId < 1 ||
|
||||||
|
snapshot.match?.game !== subject.game ||
|
||||||
|
typeof snapshot.match?.server !== 'string' ||
|
||||||
|
typeof snapshot.match?.started_at !== 'string' ||
|
||||||
|
snapshot.entry?.type !== 'message' ||
|
||||||
|
typeof snapshot.entry.message?.guid !== 'string' ||
|
||||||
|
typeof snapshot.entry.message?.message !== 'string' ||
|
||||||
|
moderationGuid(subject.game, snapshot.entry.message.guid) !== subject.guid
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return snapshot.matchId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sharing archived match context is a review hint, never a duplicate decision. */
|
||||||
|
export function candidateCases(subject: ModerationCaseReference): ModerationCaseCandidate[] {
|
||||||
|
const handle = db();
|
||||||
|
const candidates = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,title,status,game,guid FROM moderation_cases
|
||||||
|
WHERE game=? AND guid=? AND id<>? ORDER BY updated_at DESC,id LIMIT 25`
|
||||||
|
)
|
||||||
|
.all(subject.game, subject.guid, subject.id) as ModerationCaseReference[];
|
||||||
|
const ids = [subject.id, ...candidates.map((candidate) => candidate.id)];
|
||||||
|
const snapshots = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT case_id,snapshot FROM moderation_evidence
|
||||||
|
WHERE case_id IN (${ids.map(() => '?').join(',')}) AND snapshot IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(...ids) as { case_id: string; snapshot: string }[];
|
||||||
|
const matches = new Map<string, Set<number>>();
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
const match = archivedMatch(snapshot.snapshot, subject);
|
||||||
|
if (match === null) continue;
|
||||||
|
const set = matches.get(snapshot.case_id) ?? new Set<number>();
|
||||||
|
set.add(match);
|
||||||
|
matches.set(snapshot.case_id, set);
|
||||||
|
}
|
||||||
|
const current = matches.get(subject.id) ?? new Set<number>();
|
||||||
|
return candidates.map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
sharedMatch: [...(matches.get(candidate.id) ?? [])].some((match) => current.has(match))
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -109,3 +109,168 @@ export function migrateModeration(handle: Database.Database): void {
|
|||||||
cursor = [last.game, last.guid];
|
cursor = [last.game, last.guid];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The migration runner disables foreign keys before opening this transaction. */
|
||||||
|
export function migrateModerationCases(handle: Database.Database): void {
|
||||||
|
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
|
||||||
|
if (!columns.some((column) => column.name === 'priority')) {
|
||||||
|
if (handle.pragma('foreign_keys', { simple: true }))
|
||||||
|
throw new Error('The moderation case migration requires foreign keys to be disabled');
|
||||||
|
// Do not rename the existing parent: archived evidence must keep its
|
||||||
|
// original case reference through the rebuild.
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE moderation_cases_expanded (
|
||||||
|
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('open','in_review','escalated','resolved','dismissed',
|
||||||
|
'warning_issued','restriction_applied','no_action','duplicate','appeal_upheld','appeal_denied')),
|
||||||
|
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
|
||||||
|
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
INSERT INTO moderation_cases_expanded(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
|
||||||
|
SELECT id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision FROM moderation_cases;
|
||||||
|
DROP TABLE moderation_cases;
|
||||||
|
ALTER TABLE moderation_cases_expanded RENAME TO moderation_cases;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(handle.pragma('table_info(moderation_sanctions)') as { name: string }[]).some(
|
||||||
|
(column) => column.name === 'case_id'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
handle.exec(
|
||||||
|
'ALTER TABLE moderation_sanctions ADD COLUMN case_id TEXT REFERENCES moderation_cases(id) ON DELETE SET NULL'
|
||||||
|
);
|
||||||
|
handle.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_cases_queue ON moderation_cases(status,priority,updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_cases_assignee ON moderation_cases(assignee_id,updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_sanctions_case ON moderation_sanctions(case_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Links record prior enforcement without changing its original case or issuing it again. */
|
||||||
|
export function migrateModerationCaseActions(handle: Database.Database): void {
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_case_action_links (
|
||||||
|
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||||
|
action_type TEXT NOT NULL CHECK(action_type IN ('action','restriction')),
|
||||||
|
action_id TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(case_id,action_type,action_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_action_source
|
||||||
|
ON moderation_case_action_links(action_type,action_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateModerationCaseAssessments(handle: Database.Database): void {
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_case_assessments (
|
||||||
|
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||||
|
revision INTEGER NOT NULL CHECK(revision > 0),
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(case_id,revision)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateModerationCaseRelationships(handle: Database.Database): void {
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_case_relationships (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||||
|
target_case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||||
|
relation TEXT NOT NULL CHECK(relation IN ('same_incident','appeal_of','duplicate_of')),
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
removed_at TEXT,
|
||||||
|
CHECK(case_id != target_case_id),
|
||||||
|
CHECK(relation != 'same_incident' OR case_id < target_case_id)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS moderation_case_relationship_active
|
||||||
|
ON moderation_case_relationships(case_id,target_case_id,relation) WHERE removed_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_relationship_target
|
||||||
|
ON moderation_case_relationships(target_case_id,removed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_subject_recent
|
||||||
|
ON moderation_cases(game,guid,updated_at DESC,id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateModerationCaseFollowups(handle: Database.Database): void {
|
||||||
|
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
|
||||||
|
if (!columns.some((column) => column.name === 'review_at'))
|
||||||
|
handle.exec(`ALTER TABLE moderation_cases ADD COLUMN review_at TEXT;
|
||||||
|
ALTER TABLE moderation_cases ADD COLUMN review_reason TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE moderation_cases ADD COLUMN review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL;`);
|
||||||
|
handle.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_review_due ON moderation_cases(review_at) WHERE review_at IS NOT NULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_case_notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
||||||
|
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('evidence','assignment')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
read_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_notifications_unread ON moderation_case_notifications(user_id,read_at,id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_notifications_case ON moderation_case_notifications(user_id,case_id,read_at,id DESC);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Foreign-key lookups must cover removed relationships and every notification recipient. */
|
||||||
|
export function migrateModerationCaseReferenceIndexes(handle: Database.Database): void {
|
||||||
|
handle.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_review_sanction
|
||||||
|
ON moderation_cases(review_sanction_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_relationship_source
|
||||||
|
ON moderation_case_relationships(case_id,removed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_notifications_parent
|
||||||
|
ON moderation_case_notifications(case_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize lifecycle state while retaining every prior decision and child record. */
|
||||||
|
export function migrateModerationCaseStates(handle: Database.Database): void {
|
||||||
|
if (handle.pragma('foreign_keys', { simple: true }))
|
||||||
|
throw new Error('The case status migration requires foreign keys to be disabled');
|
||||||
|
const objects = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT sql FROM sqlite_master
|
||||||
|
WHERE tbl_name='moderation_cases' AND type IN ('index','trigger') AND sql IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all() as { sql: string }[];
|
||||||
|
// Capture old statuses even for imported cases without a corresponding decision event.
|
||||||
|
handle.exec(`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||||
|
SELECT game,guid,'Maxis','case.status_migrated',id,
|
||||||
|
json_object('from',status,'to',CASE WHEN status='escalated' THEN 'in_review' ELSE 'closed' END),
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ','now') FROM moderation_cases
|
||||||
|
WHERE status NOT IN ('open','in_review');
|
||||||
|
CREATE TABLE moderation_cases_state (
|
||||||
|
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('open','in_review','closed')),
|
||||||
|
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
|
||||||
|
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL,
|
||||||
|
review_at TEXT, review_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
INSERT INTO moderation_cases_state
|
||||||
|
SELECT id,game,guid,title,
|
||||||
|
CASE WHEN status IN ('open','in_review') THEN status
|
||||||
|
WHEN status='escalated' THEN 'in_review' ELSE 'closed' END,
|
||||||
|
actor_id,actor,created_at,updated_at,
|
||||||
|
revision+CASE WHEN status IN ('open','in_review') THEN 0 ELSE 1 END,
|
||||||
|
priority,assignee_id,review_at,review_reason,review_sanction_id FROM moderation_cases;
|
||||||
|
DROP TABLE moderation_cases;
|
||||||
|
ALTER TABLE moderation_cases_state RENAME TO moderation_cases;
|
||||||
|
`);
|
||||||
|
for (const object of objects) handle.exec(object.sql);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { afkProfile } from '$lib/server/fleet/afk.js';
|
||||||
|
import { formatDuration } from '$ui/duration.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
|
import Button from './primitives/Button.svelte';
|
||||||
|
|
||||||
|
let { account, guid }: { account: ReturnType<typeof afkProfile>; guid?: string } = $props();
|
||||||
|
const duration = (seconds: number) => formatDuration(seconds * 1000);
|
||||||
|
const change = (seconds: number) =>
|
||||||
|
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
|
||||||
|
const label = (value: string) => value.replaceAll('_', ' ');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section aria-labelledby="afk-title">
|
||||||
|
<div class="heading">
|
||||||
|
<h2 id="afk-title">AFK reserve</h2>
|
||||||
|
{#if guid}<Button size="sm" href={`/afk?view=ledger&player=${encodeURIComponent(guid)}`}
|
||||||
|
>View AFK history</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong>{duration(account.freeSeconds)} free</strong> and
|
||||||
|
<strong>{duration(account.savedSeconds)} saved</strong>, shared across Black Ops I and II.
|
||||||
|
Saved capacity: {duration(account.capacitySeconds)}.
|
||||||
|
</p>
|
||||||
|
{#if account.freeRenewsAt}
|
||||||
|
<p>
|
||||||
|
Free time next replenishes {$timeFormat.dateTime(account.freeRenewsAt)}. Unused free
|
||||||
|
time does not accumulate.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if account.reservedSeconds > 0}
|
||||||
|
<p>
|
||||||
|
{duration(account.reservedSeconds)} is reserved for a session and unavailable to other servers.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Recorded active play</dt>
|
||||||
|
<dd>{duration(account.activeSeconds)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Confirmed AFK time</dt>
|
||||||
|
<dd>{duration(account.confirmedAfkSeconds)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Manual returns</dt>
|
||||||
|
<dd>{account.manualReturns}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Expirations</dt>
|
||||||
|
<dd>{account.expiredSessions}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Purchases</dt>
|
||||||
|
<dd>{account.purchaseCount} ({duration(account.purchasedSeconds)})</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{#if account.unresolvedSessions > 0}
|
||||||
|
<p>
|
||||||
|
{account.unresolvedSessions} session(s) have an unconfirmed duration. Their time is excluded
|
||||||
|
from confirmed AFK usage.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if account.entries.length}
|
||||||
|
<details>
|
||||||
|
<summary>Recent time ledger</summary>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<caption>Recent AFK grants, reservations and consumption</caption>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Time / event</th><th scope="col">Free change</th><th
|
||||||
|
scope="col">Saved change</th
|
||||||
|
><th scope="col">Balances after</th><th scope="col">Server / match</th
|
||||||
|
></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{#each account.entries as entry (entry.id)}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
>{$timeFormat.dateTime(entry.eventAt)}<small
|
||||||
|
>{label(entry.kind)}</small
|
||||||
|
><small>{entry.operationId}</small></td
|
||||||
|
>
|
||||||
|
<td>{change(entry.freeDelta)}</td>
|
||||||
|
<td>{change(entry.savedDelta)}</td>
|
||||||
|
<td
|
||||||
|
>{duration(entry.freeSeconds)} free<small
|
||||||
|
>{duration(entry.savedSeconds)} saved</small
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{serverTitle(entry.server)}<small
|
||||||
|
>{#if entry.matchAvailable}<a
|
||||||
|
href={`/matches/${entry.matchId}`}
|
||||||
|
>Match #{entry.matchId}</a
|
||||||
|
>{:else if entry.matchId}Match #{entry.matchId} (history expired){:else}—{/if}</small
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<p>No AFK activity recorded yet.</p>
|
||||||
|
{/if}
|
||||||
|
{#if account.sessions.length}
|
||||||
|
<details>
|
||||||
|
<summary>Recent AFK sessions</summary>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<caption>Accepted AFK transitions</caption>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Started</th><th scope="col">Server</th><th scope="col"
|
||||||
|
>Duration</th
|
||||||
|
><th scope="col">Outcome</th></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{#each account.sessions as session (session.sessionId)}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
>{session.startedAt
|
||||||
|
? $timeFormat.dateTime(session.startedAt)
|
||||||
|
: 'Start unconfirmed'}</td
|
||||||
|
>
|
||||||
|
<td>{serverTitle(session.server)}</td>
|
||||||
|
<td
|
||||||
|
>{session.consumedSeconds === null
|
||||||
|
? 'Unconfirmed'
|
||||||
|
: duration(session.consumedSeconds)}
|
||||||
|
{#if session.state === 'corrected' && session.chargedSeconds !== null}
|
||||||
|
<small
|
||||||
|
>Corrected charge: {duration(
|
||||||
|
session.chargedSeconds
|
||||||
|
)}</small
|
||||||
|
>
|
||||||
|
{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{label(
|
||||||
|
session.endReason ?? session.state
|
||||||
|
)}{#if session.endedAt}<small
|
||||||
|
>{$timeFormat.dateTime(session.endedAt)}</small
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
dt,
|
||||||
|
small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--accent-primary-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { canReadModeration } from '$lib/auth.js';
|
import { canModerate, canReadModeration } from '$lib/auth.js';
|
||||||
import { moderationHref } from '$ui/moderation.js';
|
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
|
||||||
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
|
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
|
||||||
import { formatDuration } from '$ui/duration.js';
|
import { formatDuration } from '$ui/duration.js';
|
||||||
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
|
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
|
import Badge from './primitives/Badge.svelte';
|
||||||
import Freshness from './Freshness.svelte';
|
import Freshness from './Freshness.svelte';
|
||||||
|
import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
server,
|
server,
|
||||||
@@ -40,6 +42,41 @@
|
|||||||
let follow = $state(true);
|
let follow = $state(true);
|
||||||
let paused = $state(false);
|
let paused = $state(false);
|
||||||
let controller: AbortController | null = null;
|
let controller: AbortController | null = null;
|
||||||
|
const selectableMessages = $derived(
|
||||||
|
history !== undefined &&
|
||||||
|
canModerate($page.data?.user?.role) &&
|
||||||
|
(moderationGame === 't5' || moderationGame === 't6')
|
||||||
|
? entries.filter(
|
||||||
|
(entry) => entry.type === 'message' && entry.messageId && entry.matchId
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
let selectedMessageIds = $state<string[]>([]);
|
||||||
|
let evidenceOpen = $state(false);
|
||||||
|
let attachedCaseId = $state('');
|
||||||
|
const selectedMatchId = $derived(
|
||||||
|
selectableMessages.find(
|
||||||
|
(entry) => entry.messageId && selectedMessageIds.includes(entry.messageId)
|
||||||
|
)?.matchId
|
||||||
|
);
|
||||||
|
const selectionCandidates = $derived(
|
||||||
|
selectableMessages.filter(
|
||||||
|
(entry) => entry.matchId === (selectedMatchId ?? selectableMessages[0]?.matchId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void history;
|
||||||
|
selectedMessageIds = [];
|
||||||
|
evidenceOpen = false;
|
||||||
|
attachedCaseId = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleEvidence(messageId: string) {
|
||||||
|
selectedMessageIds = selectedMessageIds.includes(messageId)
|
||||||
|
? selectedMessageIds.filter((id) => id !== messageId)
|
||||||
|
: [...selectedMessageIds, messageId];
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh(manual = false) {
|
async function refresh(manual = false) {
|
||||||
if (!server || hostOffline) return;
|
if (!server || hostOffline) return;
|
||||||
@@ -175,6 +212,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if selectableMessages.length}
|
||||||
|
<div class="evidence-toolbar" aria-label="Chat evidence selection">
|
||||||
|
<span>{selectedMessageIds.length} selected</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() =>
|
||||||
|
(selectedMessageIds = selectionCandidates
|
||||||
|
.slice(0, 100)
|
||||||
|
.map((entry) => entry.messageId!))}
|
||||||
|
>Select {selectionCandidates.length > 100 ? 'first 100' : 'all'}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!selectedMessageIds.length}
|
||||||
|
onclick={() => (selectedMessageIds = [])}>Clear selection</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!selectedMessageIds.length}
|
||||||
|
onclick={() => (evidenceOpen = true)}>Attach to case</Button
|
||||||
|
>
|
||||||
|
{#if selectedMessageIds.length >= 100}<span
|
||||||
|
>Attach up to 100 selected messages at a time.</span
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if attachedCaseId}<p class="evidence-notice" role="status">
|
||||||
|
Selected evidence saved. <a href={moderationCaseHref(attachedCaseId)}>View case</a>
|
||||||
|
</p>{/if}
|
||||||
{#if hostOffline}<p class="outage">
|
{#if hostOffline}<p class="outage">
|
||||||
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
|
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
|
||||||
? ' Showing the last loaded messages.'
|
? ' Showing the last loaded messages.'
|
||||||
@@ -236,7 +303,11 @@
|
|||||||
{@render matchBoundary(item.entries[item.entries.length - 1])}
|
{@render matchBoundary(item.entries[item.entries.length - 1])}
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li class="message">
|
<li
|
||||||
|
class="message"
|
||||||
|
class:selected={!!item.entry.messageId &&
|
||||||
|
selectedMessageIds.includes(item.entry.messageId)}
|
||||||
|
>
|
||||||
<span class="message-clock">
|
<span class="message-clock">
|
||||||
{#if item.entry.context.realTime}{@render clockTime(
|
{#if item.entry.context.realTime}{@render clockTime(
|
||||||
item.entry.context.realTime
|
item.entry.context.realTime
|
||||||
@@ -246,7 +317,31 @@
|
|||||||
</span>
|
</span>
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<div class="message-head">
|
<div class="message-head">
|
||||||
<strong>{item.entry.message.player}</strong
|
{#if selectableMessages.length && item.entry.messageId && item.entry.matchId}
|
||||||
|
<label class="evidence-select">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedMessageIds.includes(
|
||||||
|
item.entry.messageId
|
||||||
|
)}
|
||||||
|
disabled={!selectedMessageIds.includes(
|
||||||
|
item.entry.messageId
|
||||||
|
) &&
|
||||||
|
(selectedMessageIds.length >= 100 ||
|
||||||
|
(!!selectedMatchId &&
|
||||||
|
selectedMatchId !==
|
||||||
|
item.entry.matchId))}
|
||||||
|
onchange={() =>
|
||||||
|
toggleEvidence(item.entry.messageId!)}
|
||||||
|
aria-label={`Select message by ${item.entry.message.player} at ${item.entry.message.time}: ${item.entry.message.message}`}
|
||||||
|
/>
|
||||||
|
<span class="sr-only">Select evidence</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<strong
|
||||||
|
>{item.entry.message
|
||||||
|
.player}{#if item.entry.message.channel === 'team'}
|
||||||
|
<Badge>Team chat</Badge>{/if}</strong
|
||||||
>{@render chatContext(item.entry.context)}
|
>{@render chatContext(item.entry.context)}
|
||||||
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
|
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
|
||||||
class="moderation-link"
|
class="moderation-link"
|
||||||
@@ -271,6 +366,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if evidenceOpen && selectedMatchId && (moderationGame === 't5' || moderationGame === 't6')}
|
||||||
|
<MatchEvidenceDialog
|
||||||
|
bind:open={evidenceOpen}
|
||||||
|
game={moderationGame}
|
||||||
|
matchId={selectedMatchId}
|
||||||
|
entries={entries.filter((entry) => entry.matchId === selectedMatchId)}
|
||||||
|
{selectedMessageIds}
|
||||||
|
onsubmitted={(id) => {
|
||||||
|
attachedCaseId = id;
|
||||||
|
selectedMessageIds = [];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
section {
|
section {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
@@ -283,6 +392,31 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.evidence-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.evidence-notice {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.evidence-select {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.evidence-select input {
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
.message.selected {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
.chat-head {
|
.chat-head {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
@@ -488,5 +622,9 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: 4;
|
grid-row: 4;
|
||||||
}
|
}
|
||||||
|
.evidence-select {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { canReadModeration } from '$lib/auth.js';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import { timestampISO } from '$lib/time-zone.js';
|
import { timestampISO } from '$lib/time-zone.js';
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
let navigationId = 0;
|
let navigationId = 0;
|
||||||
const now = $derived(browser ? $uiNow : Date.now());
|
const now = $derived(browser ? $uiNow : Date.now());
|
||||||
const filters = $derived(readMatchQuery(matchApiParams($page.url)));
|
const filters = $derived(readMatchQuery(matchApiParams($page.url)));
|
||||||
|
const canViewArchive = $derived(canReadModeration($page.data?.user?.role));
|
||||||
const apiUrl = $derived.by(() => {
|
const apiUrl = $derived.by(() => {
|
||||||
const params = matchApiParams($page.url);
|
const params = matchApiParams($page.url);
|
||||||
params.set('limit', String(matchPageSize));
|
params.set('limit', String(matchPageSize));
|
||||||
@@ -131,6 +133,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="history" aria-label="Match history">
|
<section class="history" aria-label="Match history">
|
||||||
|
{#if canViewArchive}<div class="archive-control">
|
||||||
|
<label
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.archive}
|
||||||
|
onchange={(event) =>
|
||||||
|
update({ archive: event.currentTarget.checked ? '1' : '' })}
|
||||||
|
/> Moderation archive</label
|
||||||
|
>
|
||||||
|
{#if filters.archive}<p>
|
||||||
|
Includes round-one, interrupted, and unfinished matches for evidence review.
|
||||||
|
</p>{/if}
|
||||||
|
</div>{/if}
|
||||||
{#snippet filterFields(suffix: string)}
|
{#snippet filterFields(suffix: string)}
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
{#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
|
{#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
|
||||||
@@ -357,9 +372,12 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><RelativeTime value={match.startedAt} /></td>
|
<td><RelativeTime value={match.startedAt} /></td>
|
||||||
<td class="match">
|
<td class="match">
|
||||||
{#if match.status === 'completed'}<a
|
{#if match.status === 'completed' || (canViewArchive && filters.archive)}<a
|
||||||
class="match-link"
|
class="match-link"
|
||||||
href={contextHref(`/matches/${match.id}`, $page.url)}
|
href={contextHref(
|
||||||
|
`/matches/${match.id}${canViewArchive && filters.archive ? '?matchArchive=1' : ''}`,
|
||||||
|
$page.url
|
||||||
|
)}
|
||||||
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
|
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
|
||||||
>{match.map} · Match #{match.id}</a
|
>{match.map} · Match #{match.id}</a
|
||||||
>
|
>
|
||||||
@@ -379,7 +397,8 @@
|
|||||||
? ` · ${duration}`
|
? ` · ${duration}`
|
||||||
: ''}</small
|
: ''}</small
|
||||||
>
|
>
|
||||||
{#if notReporting}<small class="mobile-match-context"
|
{#if notReporting || (filters.archive && match.status !== 'completed')}<small
|
||||||
|
class="mobile-match-context"
|
||||||
>Last report <RelativeTime
|
>Last report <RelativeTime
|
||||||
value={match.lastReportedAt}
|
value={match.lastReportedAt}
|
||||||
missing="Not recorded"
|
missing="Not recorded"
|
||||||
@@ -403,7 +422,7 @@
|
|||||||
class:interrupted={match.status === 'interrupted' ||
|
class:interrupted={match.status === 'interrupted' ||
|
||||||
match.status === 'end-report-missing' ||
|
match.status === 'end-report-missing' ||
|
||||||
notReporting}>{status}</span
|
notReporting}>{status}</span
|
||||||
>{#if notReporting}<small
|
>{#if notReporting || (filters.archive && match.status !== 'completed')}<small
|
||||||
>Last report <RelativeTime
|
>Last report <RelativeTime
|
||||||
value={match.lastReportedAt}
|
value={match.lastReportedAt}
|
||||||
missing="Not recorded"
|
missing="Not recorded"
|
||||||
@@ -469,14 +488,16 @@
|
|||||||
<summary>About match history</summary>
|
<summary>About match history</summary>
|
||||||
<p>
|
<p>
|
||||||
Players counts unique players across all reports. A dash means the value is unavailable,
|
Players counts unique players across all reports. A dash means the value is unavailable,
|
||||||
not zero. In-progress matches show elapsed time since their recorded start and link to
|
not zero. In-progress matches show elapsed time since their recorded start. {#if filters.archive}Archive
|
||||||
the live server while reports are current. Otherwise, they show Not reporting with the
|
links open retained match reports and chat.{:else}They link to the live server while
|
||||||
last report time. Completed durations require a recorded start and completed ending. End
|
reports are current.{/if} Otherwise, they show Not reporting with the last report time.
|
||||||
report missing means a newer game was reported before this game's end report arrived.
|
Completed durations require a recorded start and completed ending. End report missing means
|
||||||
Missing report counts show gaps in the reporter's sequence and can decrease as delayed
|
a newer game was reported before this game's end report arrived. Missing report counts show
|
||||||
reports arrive. Only completed games contribute to the leaderboard. Round shows the
|
gaps in the reporter's sequence and can decrease as delayed reports arrive. Only completed
|
||||||
final round for completed matches and the highest observed round for unfinished matches.
|
games contribute to the leaderboard. Round shows the final round for completed matches and
|
||||||
Open a completed match for exact timestamps and starting and ending player counts.
|
the highest observed round for unfinished matches. Open a {filters.archive
|
||||||
|
? 'recorded'
|
||||||
|
: 'completed'} match for timestamps and participant details.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
@@ -487,6 +508,26 @@
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.archive-control {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.archive-control label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.archive-control input {
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
.archive-control p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
.filters {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AfkAccount } from '$lib/afk.js';
|
||||||
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
|
import { formatDuration } from '$ui/duration.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import RelativeTime from '../RelativeTime.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
entries,
|
||||||
|
capacitySeconds,
|
||||||
|
source
|
||||||
|
}: { entries: AfkAccount[]; capacitySeconds: number; source: URL } = $props();
|
||||||
|
const duration = (seconds: number) => formatDuration(seconds * 1000);
|
||||||
|
const historyHref = (guid: string, view: string) =>
|
||||||
|
`/afk?${new URLSearchParams({ view, player: guid })}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||||
|
<div class="afk-table-scroll" role="region" aria-label="AFK accounts" tabindex="0">
|
||||||
|
<table class="afk-table">
|
||||||
|
<caption class="sr-only">Shared player AFK accounts</caption>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Player</th><th scope="col">Available time</th><th scope="col"
|
||||||
|
>Saved capacity</th
|
||||||
|
><th scope="col">Reserved time</th><th scope="col">Last activity</th><th scope="col"
|
||||||
|
>History</th
|
||||||
|
></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{#each entries as account (account.guid)}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
><a
|
||||||
|
class="afk-player"
|
||||||
|
href={contextHref(
|
||||||
|
`/leaderboard/players/${encodeURIComponent(account.guid)}`,
|
||||||
|
source
|
||||||
|
)}>{account.name ?? account.guid}</a
|
||||||
|
><small class="afk-id">{account.guid}</small></td
|
||||||
|
>
|
||||||
|
<td class="afk-number"
|
||||||
|
><strong>{duration(account.availableSeconds)}</strong><small
|
||||||
|
>{duration(account.freeSeconds)} free · {duration(account.savedSeconds)} saved</small
|
||||||
|
>{#if account.freeRenewsAt}<small
|
||||||
|
>Free time next replenishes {$timeFormat.dateTime(
|
||||||
|
account.freeRenewsAt
|
||||||
|
)}</small
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
><meter
|
||||||
|
min="0"
|
||||||
|
max={capacitySeconds}
|
||||||
|
value={Math.min(
|
||||||
|
account.savedSeconds + account.reservedSavedSeconds,
|
||||||
|
capacitySeconds
|
||||||
|
)}
|
||||||
|
aria-label={`${account.name ?? account.guid} saved time capacity`}
|
||||||
|
></meter><small
|
||||||
|
>{duration(account.savedSeconds + account.reservedSavedSeconds)} / {duration(
|
||||||
|
capacitySeconds
|
||||||
|
)}</small
|
||||||
|
>{#if account.reservedSavedSeconds > 0}<small
|
||||||
|
>Includes {duration(account.reservedSavedSeconds)} reserved</small
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
<td class="afk-number"
|
||||||
|
>{duration(account.reservedSeconds)}{#if account.unresolvedSessions}<small
|
||||||
|
class="afk-warning">Unresolved session</small
|
||||||
|
>{:else if account.reservedSeconds > 0}<small>Awaiting settlement</small
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{#if account.lastActivity}<RelativeTime
|
||||||
|
value={account.lastActivity}
|
||||||
|
/>{:else}No activity{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
><a href={historyHref(account.guid, 'sessions')}>Sessions</a><small
|
||||||
|
><a href={historyHref(account.guid, 'ledger')}>Time ledger</a></small
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
meter {
|
||||||
|
width: 110px;
|
||||||
|
height: 12px;
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AfkLedgerEntry } from '$lib/afk.js';
|
||||||
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
|
import { formatDuration } from '$ui/duration.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import ServerIdentity from '../ServerIdentity.svelte';
|
||||||
|
|
||||||
|
let { entries, source }: { entries: AfkLedgerEntry[]; source: URL } = $props();
|
||||||
|
const duration = (seconds: number) => formatDuration(seconds * 1000);
|
||||||
|
const change = (seconds: number) =>
|
||||||
|
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||||
|
<div class="afk-table-scroll" role="region" aria-label="AFK time ledger" tabindex="0">
|
||||||
|
<table class="afk-table">
|
||||||
|
<caption class="sr-only">AFK grants, purchases, reservations and consumption</caption>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Player</th><th scope="col">Received / event</th><th scope="col"
|
||||||
|
>Free change</th
|
||||||
|
><th scope="col">Saved change</th><th scope="col">Balances after</th><th scope="col"
|
||||||
|
>Server / match</th
|
||||||
|
></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{#each entries as entry (entry.id)}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
><a
|
||||||
|
class="afk-player"
|
||||||
|
href={contextHref(
|
||||||
|
`/leaderboard/players/${encodeURIComponent(entry.guid)}`,
|
||||||
|
source
|
||||||
|
)}>{entry.name ?? entry.guid}</a
|
||||||
|
><small class="afk-id">{entry.guid}</small></td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
><time datetime={entry.createdAt}
|
||||||
|
>{$timeFormat.dateTime(entry.createdAt)}</time
|
||||||
|
><small>{entry.kind.replaceAll('_', ' ')}</small
|
||||||
|
>{#if entry.eventAt !== entry.createdAt}<small
|
||||||
|
>Reported {$timeFormat.dateTime(entry.eventAt)}</small
|
||||||
|
>{/if}<small class="afk-id">{entry.operationId}</small></td
|
||||||
|
>
|
||||||
|
<td class="afk-number">{change(entry.freeDelta)}</td><td class="afk-number"
|
||||||
|
>{change(entry.savedDelta)}</td
|
||||||
|
>
|
||||||
|
<td class="afk-number"
|
||||||
|
>{duration(entry.freeSeconds)} free<small
|
||||||
|
>{duration(entry.savedSeconds)} saved</small
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
><ServerIdentity name={entry.server} /><small
|
||||||
|
>{#if entry.matchAvailable && entry.matchId}<a
|
||||||
|
href={contextHref(`/matches/${entry.matchId}`, source)}
|
||||||
|
>Match #{entry.matchId}</a
|
||||||
|
>{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}No
|
||||||
|
match history{/if}</small
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { afkPageSize } from '$lib/afk.js';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
let {
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
disabled = false,
|
||||||
|
onchange
|
||||||
|
}: {
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onchange: (offset: number) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="pagination" aria-label="AFK results pagination">
|
||||||
|
<span role="status"
|
||||||
|
>{total ? offset + 1 : 0}–{Math.min(offset + afkPageSize, total)} of {total.toLocaleString()}
|
||||||
|
results</span
|
||||||
|
>
|
||||||
|
{#if total > afkPageSize}<div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || offset === 0}
|
||||||
|
onclick={() => onchange(offset - afkPageSize)}>Previous</Button
|
||||||
|
><Button
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || offset + afkPageSize >= total}
|
||||||
|
onclick={() => onchange(offset + afkPageSize)}>Next</Button
|
||||||
|
>
|
||||||
|
</div>{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.pagination div {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AfkSession } from '$lib/afk.js';
|
||||||
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
|
import { formatDuration } from '$ui/duration.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import ServerIdentity from '../ServerIdentity.svelte';
|
||||||
|
|
||||||
|
let { entries, source }: { entries: AfkSession[]; source: URL } = $props();
|
||||||
|
const duration = (seconds: number) => formatDuration(seconds * 1000);
|
||||||
|
const label = (value: string) => value.replaceAll('_', ' ');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||||
|
<div class="afk-table-scroll" role="region" aria-label="AFK sessions" tabindex="0">
|
||||||
|
<table class="afk-table">
|
||||||
|
<caption class="sr-only">AFK session reservations and outcomes</caption>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Player</th><th scope="col">Session</th><th scope="col">Time</th><th
|
||||||
|
scope="col">Outcome</th
|
||||||
|
><th scope="col">Server / match</th></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{#each entries as session (session.sessionId)}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
><a
|
||||||
|
class="afk-player"
|
||||||
|
href={contextHref(
|
||||||
|
`/leaderboard/players/${encodeURIComponent(session.guid)}`,
|
||||||
|
source
|
||||||
|
)}>{session.name ?? session.guid}</a
|
||||||
|
><small class="afk-id">{session.guid}</small></td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
><time datetime={session.createdAt}
|
||||||
|
>{$timeFormat.dateTime(session.createdAt)}</time
|
||||||
|
><small
|
||||||
|
>{session.startedAt
|
||||||
|
? `Started ${$timeFormat.dateTime(session.startedAt)}`
|
||||||
|
: 'Start unconfirmed'}</small
|
||||||
|
><small class="afk-id">{session.sessionId}</small></td
|
||||||
|
>
|
||||||
|
<td class="afk-number"
|
||||||
|
><strong
|
||||||
|
>{session.consumedSeconds === null
|
||||||
|
? 'Unconfirmed duration'
|
||||||
|
: `${duration(session.consumedSeconds)} used`}</strong
|
||||||
|
><small>{duration(session.authorizedSeconds)} authorized</small
|
||||||
|
>{#if session.state === 'corrected' && session.chargedSeconds !== null}<small
|
||||||
|
>Corrected charge: {duration(session.chargedSeconds)}</small
|
||||||
|
>{/if}{#if session.consumedSeconds === null && session.confirmedSeconds > 0}<small
|
||||||
|
>Confirmed at least {duration(session.confirmedSeconds)}</small
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
><span class:afk-warning={session.state === 'unresolved'}
|
||||||
|
>{label(session.state)}</span
|
||||||
|
>{#if session.endReason}<small>{label(session.endReason)}</small
|
||||||
|
>{/if}{#if session.endedAt}<small
|
||||||
|
>{$timeFormat.dateTime(session.endedAt)}</small
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
><ServerIdentity name={session.server} /><small
|
||||||
|
>{#if session.matchAvailable && session.matchId}<a
|
||||||
|
href={contextHref(`/matches/${session.matchId}`, source)}
|
||||||
|
>Match #{session.matchId}</a
|
||||||
|
>{:else if session.matchId}Match #{session.matchId} · History expired{:else}No
|
||||||
|
match history{/if}</small
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
moderationConcernLabels,
|
||||||
|
moderationFindingLabels,
|
||||||
|
type ModerationCaseAssessment
|
||||||
|
} from '$lib/moderation.js';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import Badge from '../primitives/Badge.svelte';
|
||||||
|
import RelativeTime from '../RelativeTime.svelte';
|
||||||
|
import CaseAssessmentDialog from './CaseAssessmentDialog.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
caseId,
|
||||||
|
revision,
|
||||||
|
assessment = null,
|
||||||
|
history = [],
|
||||||
|
permitted,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
caseId: string;
|
||||||
|
revision: number;
|
||||||
|
assessment?: ModerationCaseAssessment | null;
|
||||||
|
history?: ModerationCaseAssessment[];
|
||||||
|
permitted: boolean;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
let open = $state(false);
|
||||||
|
const previous = $derived(history.filter((item) => item.revision !== assessment?.revision));
|
||||||
|
$effect(() => {
|
||||||
|
void caseId;
|
||||||
|
open = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet assessmentContent(value: ModerationCaseAssessment)}
|
||||||
|
<p class="assessment-summary">{value.summary}</p>
|
||||||
|
<div>
|
||||||
|
<h3>Applicable rules</h3>
|
||||||
|
{#if value.rules.length}<ul class="rules">
|
||||||
|
{#each value.rules as rule}<li>{rule}</li>{/each}
|
||||||
|
</ul>{:else}<p class="hint">No rule references recorded.</p>{/if}
|
||||||
|
</div>
|
||||||
|
<ol class="findings" aria-label="Structured findings">
|
||||||
|
{#each value.findings as finding}<li>
|
||||||
|
<div class="heading">
|
||||||
|
<strong>{moderationConcernLabels[finding.category]}</strong><Badge
|
||||||
|
variant={finding.finding === 'substantiated'
|
||||||
|
? 'danger'
|
||||||
|
: finding.finding === 'inconclusive'
|
||||||
|
? 'warning'
|
||||||
|
: 'neutral'}>{moderationFindingLabels[finding.finding]}</Badge
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p>{finding.detail}</p>
|
||||||
|
</li>{/each}
|
||||||
|
</ol>
|
||||||
|
<p class="hint">
|
||||||
|
Revision {value.revision} · {value.actor} · <RelativeTime value={value.createdAt} />
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<section class="assessment" aria-label="Case assessment">
|
||||||
|
<div class="heading">
|
||||||
|
<h2>Case assessment</h2>
|
||||||
|
{#if permitted}<Button size="sm" onclick={() => (open = true)}
|
||||||
|
>{assessment ? 'Edit assessment' : 'Assess case'}</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if assessment}{@render assessmentContent(assessment)}{:else}<p class="hint">
|
||||||
|
Not assessed. Record concerns, applicable rules, and what the evidence supports.
|
||||||
|
</p>{/if}
|
||||||
|
{#if previous.length}<details>
|
||||||
|
<summary>Previous assessments ({previous.length})</summary>
|
||||||
|
<ol class="history">
|
||||||
|
{#each previous as earlier (earlier.revision)}<li>
|
||||||
|
{@render assessmentContent(earlier)}
|
||||||
|
</li>{/each}
|
||||||
|
</ol>
|
||||||
|
</details>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if permitted && open}<CaseAssessmentDialog
|
||||||
|
bind:open
|
||||||
|
{caseId}
|
||||||
|
{revision}
|
||||||
|
{assessment}
|
||||||
|
{onsubmitted}
|
||||||
|
/>{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.assessment {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
.rules {
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.assessment-summary,
|
||||||
|
.findings p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.rules {
|
||||||
|
padding-left: var(--space-5);
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
}
|
||||||
|
.findings,
|
||||||
|
.history {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.findings > li {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.history {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
.history > li {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import {
|
||||||
|
moderationConcernCategories,
|
||||||
|
moderationConcernLabels,
|
||||||
|
moderationFindingOutcomes,
|
||||||
|
moderationFindingLabels,
|
||||||
|
type ModerationCaseAssessment,
|
||||||
|
type ModerationConcernCategory,
|
||||||
|
type ModerationFindingOutcome
|
||||||
|
} from '$lib/moderation.js';
|
||||||
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
|
import Field from '../primitives/Field.svelte';
|
||||||
|
import Select from '../primitives/Select.svelte';
|
||||||
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
|
||||||
|
interface DraftFinding {
|
||||||
|
category: ModerationConcernCategory | '';
|
||||||
|
finding: ModerationFindingOutcome | '';
|
||||||
|
detail: string;
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
caseId,
|
||||||
|
revision,
|
||||||
|
assessment,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
caseId: string;
|
||||||
|
revision: number;
|
||||||
|
assessment: ModerationCaseAssessment | null;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
const openedRevision = untrack(() => revision);
|
||||||
|
let summary = $state(untrack(() => assessment?.summary ?? ''));
|
||||||
|
let rulesText = $state(untrack(() => assessment?.rules.join('\n') ?? ''));
|
||||||
|
let nextKey = 0;
|
||||||
|
let findings = $state<DraftFinding[]>(
|
||||||
|
untrack(
|
||||||
|
() =>
|
||||||
|
assessment?.findings.map((finding) => ({ ...finding, key: nextKey++ })) ?? [
|
||||||
|
{ category: '', finding: '', detail: '', key: nextKey++ }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let busy = $state(false);
|
||||||
|
let uncertain = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let pending: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
if (!pending) {
|
||||||
|
const rules = rulesText
|
||||||
|
.split('\n')
|
||||||
|
.map((rule) => rule.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (
|
||||||
|
!summary.trim() ||
|
||||||
|
!findings.length ||
|
||||||
|
findings.some((item) => !item.category || !item.finding || !item.detail.trim())
|
||||||
|
) {
|
||||||
|
error = 'Add a summary and complete each concern, finding, and explanation.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rules.length > 20 || rules.some((rule) => rule.length > 160)) {
|
||||||
|
error = 'Use up to 20 applicable rules, with no more than 160 characters per line.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending = {
|
||||||
|
op: 'assess-case',
|
||||||
|
id: caseId,
|
||||||
|
revision: openedRevision,
|
||||||
|
summary: summary.trim(),
|
||||||
|
rules,
|
||||||
|
findings: findings.map(({ category, finding, detail }) => ({
|
||||||
|
category,
|
||||||
|
finding,
|
||||||
|
detail: detail.trim()
|
||||||
|
})),
|
||||||
|
idempotencyKey: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
uncertain = response.status >= 500 || response.ok;
|
||||||
|
if (!uncertain) pending = null;
|
||||||
|
throw new Error(result?.error || 'Could not save the assessment.');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
onsubmitted();
|
||||||
|
} catch (cause) {
|
||||||
|
if (pending) uncertain = true;
|
||||||
|
error = cause instanceof Error ? cause.message : 'Could not save the assessment.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
bind:open
|
||||||
|
title={assessment ? 'Edit case assessment' : 'Assess case'}
|
||||||
|
description="Record the current evidence assessment. Each saved revision is preserved in the case history."
|
||||||
|
{busy}
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<fieldset disabled={busy || uncertain}>
|
||||||
|
<Field label="Current summary" required
|
||||||
|
><Textarea bind:value={summary} required rows={3} maxlength={2000} /></Field
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label="Applicable rules"
|
||||||
|
hint="One rule or policy reference per line. Up to 20 lines, 160 characters each."
|
||||||
|
><Textarea bind:value={rulesText} rows={3} maxlength={3220} /></Field
|
||||||
|
>
|
||||||
|
<div class="findings">
|
||||||
|
{#each findings as item, index (item.key)}
|
||||||
|
<section class="finding" aria-label={`Finding ${index + 1}`}>
|
||||||
|
<div class="finding-heading">
|
||||||
|
<h3>Finding {index + 1}</h3>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={findings.length === 1}
|
||||||
|
ariaLabel={`Remove finding ${index + 1}`}
|
||||||
|
onclick={() =>
|
||||||
|
(findings = findings.filter(
|
||||||
|
(finding) => finding.key !== item.key
|
||||||
|
))}>Remove</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="paired">
|
||||||
|
<Field label={`Concern ${index + 1}`} required
|
||||||
|
><Select
|
||||||
|
bind:value={item.category}
|
||||||
|
required
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Select a concern' },
|
||||||
|
...moderationConcernCategories.map((category) => ({
|
||||||
|
value: category,
|
||||||
|
label: moderationConcernLabels[category],
|
||||||
|
disabled: findings.some(
|
||||||
|
(other) =>
|
||||||
|
other.key !== item.key &&
|
||||||
|
other.category === category
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label={`Finding ${index + 1}`} required
|
||||||
|
><Select
|
||||||
|
bind:value={item.finding}
|
||||||
|
required
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Select a finding' },
|
||||||
|
...moderationFindingOutcomes.map((finding) => ({
|
||||||
|
value: finding,
|
||||||
|
label: moderationFindingLabels[finding]
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
label={`Explanation ${index + 1}`}
|
||||||
|
required
|
||||||
|
hint="Explain how the evidence supports this finding, including uncertainty."
|
||||||
|
><Textarea
|
||||||
|
bind:value={item.detail}
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
maxlength={1000}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={findings.length >= moderationConcernCategories.length}
|
||||||
|
onclick={() =>
|
||||||
|
(findings = [
|
||||||
|
...findings,
|
||||||
|
{ category: '', finding: '', detail: '', key: nextKey++ }
|
||||||
|
])}>Add finding</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if uncertain}<p class="hint">
|
||||||
|
The result is uncertain. Retry sends the same assessment safely.
|
||||||
|
</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={busy}>{uncertain ? 'Retry same request' : 'Save assessment'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset,
|
||||||
|
.findings,
|
||||||
|
.finding {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.finding {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.finding-heading,
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
.paired {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import Badge from '../primitives/Badge.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import CaseReviewDialog from './CaseReviewDialog.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
actions = [],
|
||||||
|
checkedAt,
|
||||||
|
permitted,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
item: ModerationCase;
|
||||||
|
actions?: ModerationCaseAction[];
|
||||||
|
checkedAt: string;
|
||||||
|
permitted: boolean;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
let open = $state(false);
|
||||||
|
let clearing = $state(false);
|
||||||
|
const overdue = $derived(!!item.reviewAt && Date.parse(item.reviewAt) <= Date.parse(checkedAt));
|
||||||
|
$effect(() => {
|
||||||
|
void item.id;
|
||||||
|
open = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section aria-label="Follow-up review">
|
||||||
|
<div class="heading">
|
||||||
|
<h2>Follow-up review</h2>
|
||||||
|
{#if overdue}<Badge variant="warning">Overdue review</Badge>{/if}
|
||||||
|
</div>
|
||||||
|
{#if item.reviewAt}<p>
|
||||||
|
<time datetime={item.reviewAt}>{$timeFormat.dateTime(item.reviewAt)}</time>
|
||||||
|
</p>
|
||||||
|
{#if item.reviewSanctionId}<p class="hint">Review after linked mute expires.</p>{/if}
|
||||||
|
{:else}<p class="hint">No review scheduled.</p>{/if}
|
||||||
|
{#if item.reviewReason}<p class="reason">{item.reviewReason}</p>{/if}
|
||||||
|
{#if permitted}<div class="actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
clearing = false;
|
||||||
|
open = true;
|
||||||
|
}}>{item.reviewAt ? 'Reschedule review' : 'Schedule review'}</Button
|
||||||
|
>
|
||||||
|
{#if item.reviewAt}<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
clearing = true;
|
||||||
|
open = true;
|
||||||
|
}}>Complete or clear review</Button
|
||||||
|
>{/if}
|
||||||
|
</div>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if permitted && open}<CaseReviewDialog bind:open {item} {actions} {clearing} {onsubmitted} />{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heading,
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import {
|
||||||
|
moderationCaseRelations,
|
||||||
|
moderationCaseRelationLabels,
|
||||||
|
type ModerationCaseDetail,
|
||||||
|
type ModerationCaseRelationship,
|
||||||
|
type ModerationCaseRelation
|
||||||
|
} from '$lib/moderation.js';
|
||||||
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
|
import Field from '../primitives/Field.svelte';
|
||||||
|
import Input from '../primitives/Input.svelte';
|
||||||
|
import Select from '../primitives/Select.svelte';
|
||||||
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
caseId,
|
||||||
|
revision,
|
||||||
|
candidates = [],
|
||||||
|
relationship,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
caseId: string;
|
||||||
|
revision: number;
|
||||||
|
candidates?: NonNullable<ModerationCaseDetail['candidateCases']>;
|
||||||
|
relationship?: ModerationCaseRelationship;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
const openedRevision = untrack(() => revision);
|
||||||
|
let selected = $state('');
|
||||||
|
let caseReference = $state('');
|
||||||
|
let relation = $state<ModerationCaseRelation>('same_incident');
|
||||||
|
let reason = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let uncertain = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let pending: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
function relatedId(): string {
|
||||||
|
if (selected) return selected;
|
||||||
|
const value = caseReference.trim();
|
||||||
|
if (value.startsWith('/') || /^https?:\/\//i.test(value)) {
|
||||||
|
const path = new URL(value, 'http://localhost').pathname;
|
||||||
|
const match = /^\/moderation\/cases\/([^/]+)\/?$/.exec(path);
|
||||||
|
if (!match) throw new Error('Enter a case ID or a moderation case URL.');
|
||||||
|
return decodeURIComponent(match[1]);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
error = '';
|
||||||
|
if (!pending) {
|
||||||
|
try {
|
||||||
|
const target = relationship ? '' : relatedId();
|
||||||
|
if ((!relationship && !target) || !reason.trim())
|
||||||
|
throw new Error('Choose a related case and provide a reason for this change.');
|
||||||
|
pending = {
|
||||||
|
id: caseId,
|
||||||
|
revision: openedRevision,
|
||||||
|
reason: reason.trim(),
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
...(relationship
|
||||||
|
? { op: 'unlink-case', relationshipId: relationship.id }
|
||||||
|
: { op: 'link-case', relatedCaseId: target, relation })
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : 'Enter a valid case reference.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
uncertain = response.status >= 500 || response.ok;
|
||||||
|
if (!uncertain) pending = null;
|
||||||
|
throw new Error(result?.error || 'Could not save the case relationship.');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
onsubmitted();
|
||||||
|
} catch (cause) {
|
||||||
|
if (pending) uncertain = true;
|
||||||
|
error =
|
||||||
|
cause instanceof Error ? cause.message : 'Could not save the case relationship.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
bind:open
|
||||||
|
title={relationship ? 'Remove case relationship' : 'Link related case'}
|
||||||
|
{busy}
|
||||||
|
description="Relationships connect case records while preserving each case and its history."
|
||||||
|
>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<fieldset disabled={busy || uncertain}>
|
||||||
|
{#if relationship}<p>
|
||||||
|
Remove the relationship with <strong>{relationship.case.title}</strong>. The
|
||||||
|
relationship history remains recorded.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<Field label="Related case"
|
||||||
|
><Select
|
||||||
|
bind:value={selected}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Enter a case ID or URL' },
|
||||||
|
...candidates.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.title}${item.sharedMatch ? ' · Shared archived match' : ''}`
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{#if !selected}<Field label="Case ID or URL" required
|
||||||
|
><Input bind:value={caseReference} required maxlength={2048} /></Field
|
||||||
|
>{/if}
|
||||||
|
<Field label="Relationship"
|
||||||
|
><Select
|
||||||
|
bind:value={relation}
|
||||||
|
options={moderationCaseRelations.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: moderationCaseRelationLabels[value]
|
||||||
|
}))}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<p class="hint">
|
||||||
|
Appeal and duplicate relationships refer to the same player. “Same incident” can
|
||||||
|
connect cases involving different players.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<Field label="Relationship reason" required
|
||||||
|
><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if uncertain}<p class="hint">
|
||||||
|
The result is uncertain. Retry sends the same request safely.
|
||||||
|
</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={busy}
|
||||||
|
>{uncertain
|
||||||
|
? 'Retry same request'
|
||||||
|
: relationship
|
||||||
|
? 'Remove relationship'
|
||||||
|
: 'Link cases'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
moderationCaseRelationLabels,
|
||||||
|
moderationCaseStatusLabels,
|
||||||
|
type ModerationCaseDetail,
|
||||||
|
type ModerationCaseRelationship
|
||||||
|
} from '$lib/moderation.js';
|
||||||
|
import { moderationCaseHref } from '$ui/moderation.js';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import Badge from '../primitives/Badge.svelte';
|
||||||
|
import RelativeTime from '../RelativeTime.svelte';
|
||||||
|
import CaseRelationshipDialog from './CaseRelationshipDialog.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
caseId,
|
||||||
|
revision,
|
||||||
|
relationships = [],
|
||||||
|
candidates = [],
|
||||||
|
permitted,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
caseId: string;
|
||||||
|
revision: number;
|
||||||
|
relationships?: ModerationCaseRelationship[];
|
||||||
|
candidates?: NonNullable<ModerationCaseDetail['candidateCases']>;
|
||||||
|
permitted: boolean;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
let open = $state(false);
|
||||||
|
let removing = $state<ModerationCaseRelationship | undefined>();
|
||||||
|
$effect(() => {
|
||||||
|
void caseId;
|
||||||
|
open = false;
|
||||||
|
});
|
||||||
|
function label(relationship: ModerationCaseRelationship) {
|
||||||
|
if (relationship.direction === 'incoming') {
|
||||||
|
if (relationship.relation === 'appeal_of') return 'Appealed by';
|
||||||
|
if (relationship.relation === 'duplicate_of') return 'Has duplicate';
|
||||||
|
}
|
||||||
|
return moderationCaseRelationLabels[relationship.relation];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section aria-label="Related cases">
|
||||||
|
<div class="heading">
|
||||||
|
<h2>Related cases</h2>
|
||||||
|
{#if permitted}<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
removing = undefined;
|
||||||
|
open = true;
|
||||||
|
}}>Link case</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if relationships.length}<ul class="records">
|
||||||
|
{#each relationships as relationship (relationship.id)}<li>
|
||||||
|
<div class="heading">
|
||||||
|
<strong>{label(relationship)}</strong><Badge
|
||||||
|
>{moderationCaseStatusLabels[relationship.case.status]}</Badge
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<a href={moderationCaseHref(relationship.case.id)}>{relationship.case.title}</a>
|
||||||
|
<p class="hint">
|
||||||
|
{relationship.actor} · <RelativeTime value={relationship.createdAt} />
|
||||||
|
</p>
|
||||||
|
{#if permitted}<Button
|
||||||
|
size="sm"
|
||||||
|
ariaLabel={`Remove relationship with ${relationship.case.title}`}
|
||||||
|
onclick={() => {
|
||||||
|
removing = relationship;
|
||||||
|
open = true;
|
||||||
|
}}>Remove link</Button
|
||||||
|
>{/if}
|
||||||
|
</li>{/each}
|
||||||
|
</ul>{:else}<p class="hint">No cases linked yet.</p>{/if}
|
||||||
|
{#if candidates.length}<details>
|
||||||
|
<summary>Other cases for this player ({candidates.length})</summary>
|
||||||
|
<ul class="records">
|
||||||
|
{#each candidates as candidate}<li>
|
||||||
|
<a href={moderationCaseHref(candidate.id)}>{candidate.title}</a>
|
||||||
|
<span class="hint">{moderationCaseStatusLabels[candidate.status]}</span>
|
||||||
|
{#if candidate.sharedMatch}<Badge variant="warning"
|
||||||
|
>Shared archived match</Badge
|
||||||
|
>
|
||||||
|
<p class="hint">
|
||||||
|
Review for a possible duplicate or related incident.
|
||||||
|
</p>{/if}
|
||||||
|
</li>{/each}
|
||||||
|
</ul>
|
||||||
|
</details>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if permitted && open}<CaseRelationshipDialog
|
||||||
|
bind:open
|
||||||
|
{caseId}
|
||||||
|
{revision}
|
||||||
|
{candidates}
|
||||||
|
relationship={removing}
|
||||||
|
{onsubmitted}
|
||||||
|
/>{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.records {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.records > li {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
details .records {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
|
import Field from '../primitives/Field.svelte';
|
||||||
|
import Input from '../primitives/Input.svelte';
|
||||||
|
import Select from '../primitives/Select.svelte';
|
||||||
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
item,
|
||||||
|
actions,
|
||||||
|
clearing = false,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
item: ModerationCase;
|
||||||
|
actions: ModerationCaseAction[];
|
||||||
|
clearing?: boolean;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
const openedRevision = untrack(() => item.revision);
|
||||||
|
const caseId = untrack(() => item.id);
|
||||||
|
const mutes = $derived(
|
||||||
|
actions.filter(
|
||||||
|
(action) => action.type === 'restriction' && action.kind === 'mute' && action.expiresAt
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let source = $state(untrack(() => item.reviewSanctionId ?? 'custom'));
|
||||||
|
function browserDate(iso?: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (!Number.isFinite(date.getTime())) return '';
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
let date = $state(untrack(() => browserDate(item.reviewAt)));
|
||||||
|
let reason = $state(untrack(() => (clearing ? '' : (item.reviewReason ?? ''))));
|
||||||
|
let busy = $state(false);
|
||||||
|
let uncertain = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let pending: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
if (!pending) {
|
||||||
|
const chosen = new Date(date);
|
||||||
|
if (
|
||||||
|
!reason.trim() ||
|
||||||
|
(!clearing && source === 'custom' && (!date || !Number.isFinite(chosen.getTime())))
|
||||||
|
) {
|
||||||
|
error = 'Choose a review date and record a reason for this follow-up change.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!clearing && source !== 'custom' && !mutes.some((mute) => mute.id === source)) {
|
||||||
|
error = 'Choose an available linked mute or a custom review date.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending = {
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
id: caseId,
|
||||||
|
revision: openedRevision,
|
||||||
|
reason: reason.trim(),
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
...(clearing
|
||||||
|
? { reviewAt: null }
|
||||||
|
: source === 'custom'
|
||||||
|
? { reviewAt: chosen.toISOString() }
|
||||||
|
: { restrictionId: source })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
uncertain = response.status >= 500 || response.ok;
|
||||||
|
if (!uncertain) pending = null;
|
||||||
|
throw new Error(result?.error || 'Could not update the follow-up review.');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
onsubmitted();
|
||||||
|
} catch (cause) {
|
||||||
|
if (pending) uncertain = true;
|
||||||
|
error =
|
||||||
|
cause instanceof Error ? cause.message : 'Could not update the follow-up review.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
bind:open
|
||||||
|
title={clearing ? 'Complete or clear review' : 'Schedule follow-up review'}
|
||||||
|
{busy}
|
||||||
|
description={clearing
|
||||||
|
? 'Record the review result or why the follow-up is no longer needed. This clears its due date.'
|
||||||
|
: 'Follow-up reviews remain scheduled when a case is closed.'}
|
||||||
|
>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<fieldset disabled={busy || uncertain}>
|
||||||
|
{#if !clearing}
|
||||||
|
<Field label="Review timing"
|
||||||
|
><Select
|
||||||
|
bind:value={source}
|
||||||
|
options={[
|
||||||
|
{ value: 'custom', label: 'Choose a date and time' },
|
||||||
|
...mutes.map((mute) => ({
|
||||||
|
value: mute.id,
|
||||||
|
label: `After mute expires · ${$timeFormat.dateTime(mute.expiresAt!)} · ${mute.reason}`
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{#if source === 'custom'}<Field label="Review date (browser local time)" required
|
||||||
|
><Input type="datetime-local" bind:value={date} required /></Field
|
||||||
|
>
|
||||||
|
{:else}<p class="hint">
|
||||||
|
The review will be due at the linked mute's recorded expiration time.
|
||||||
|
</p>{/if}
|
||||||
|
{/if}
|
||||||
|
<Field
|
||||||
|
label={clearing ? 'Review result or clearing reason' : 'Follow-up reason'}
|
||||||
|
required><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if uncertain}<p class="hint">
|
||||||
|
The result is uncertain. Retry sends the same request safely.
|
||||||
|
</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={busy}
|
||||||
|
>{uncertain
|
||||||
|
? 'Retry same request'
|
||||||
|
: clearing
|
||||||
|
? 'Complete or clear review'
|
||||||
|
: 'Save follow-up'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ModerationCaseNotification } from '$lib/moderation.js';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import Badge from '../primitives/Badge.svelte';
|
||||||
|
import RelativeTime from '../RelativeTime.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
caseId,
|
||||||
|
notifications = [],
|
||||||
|
unread = 0,
|
||||||
|
permitted,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
caseId: string;
|
||||||
|
notifications?: ModerationCaseNotification[];
|
||||||
|
unread?: number;
|
||||||
|
permitted: boolean;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let pending: Record<string, unknown> | null = $state(null);
|
||||||
|
const throughId = $derived(Math.max(0, ...notifications.map((item) => item.id)));
|
||||||
|
$effect(() => {
|
||||||
|
void caseId;
|
||||||
|
pending = null;
|
||||||
|
error = '';
|
||||||
|
});
|
||||||
|
async function markSeen() {
|
||||||
|
if (busy || !permitted || (!pending && !throughId)) return;
|
||||||
|
pending ??= {
|
||||||
|
op: 'read-case-updates',
|
||||||
|
id: caseId,
|
||||||
|
throughId,
|
||||||
|
idempotencyKey: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
if (response.status < 500 && !response.ok) pending = null;
|
||||||
|
throw new Error(result?.error || 'Could not mark these updates as seen.');
|
||||||
|
}
|
||||||
|
pending = null;
|
||||||
|
onsubmitted();
|
||||||
|
} catch (cause) {
|
||||||
|
error =
|
||||||
|
cause instanceof Error ? cause.message : 'Could not mark these updates as seen.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section aria-label="Your case updates">
|
||||||
|
<div class="heading">
|
||||||
|
<h2>Your updates</h2>
|
||||||
|
{#if unread}<Badge variant="warning">{unread} unread</Badge>{/if}
|
||||||
|
</div>
|
||||||
|
{#if notifications.length}<ol>
|
||||||
|
{#each notifications as update (update.id)}<li>
|
||||||
|
<div class="heading">
|
||||||
|
<strong
|
||||||
|
>{update.kind === 'evidence'
|
||||||
|
? 'Evidence or note added'
|
||||||
|
: 'Assignment changed'}</strong
|
||||||
|
>{#if !update.readAt}<Badge>Unread</Badge>{/if}
|
||||||
|
</div>
|
||||||
|
<RelativeTime value={update.createdAt} />
|
||||||
|
</li>{/each}
|
||||||
|
</ol>{:else}<p class="hint">No personal case updates.</p>{/if}
|
||||||
|
{#if notifications.length >= 25}<p class="hint">
|
||||||
|
Showing your latest 25 updates for this case.
|
||||||
|
</p>{/if}
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if permitted && (unread || pending)}<p class="hint">
|
||||||
|
Marks your updates through the newest shown entry as read.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" disabled={!throughId} loading={busy} onclick={markSeen}
|
||||||
|
>{pending && error ? 'Retry same request' : 'Mark updates as seen'}</Button
|
||||||
|
>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ContextualChatEntry } from '$lib/game-chat.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import Badge from '../primitives/Badge.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
entries,
|
||||||
|
selectedMessageIds = [],
|
||||||
|
highlightedMessageIds = []
|
||||||
|
}: {
|
||||||
|
entries: ContextualChatEntry[];
|
||||||
|
selectedMessageIds?: string[];
|
||||||
|
highlightedMessageIds?: string[];
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class="excerpt-note">
|
||||||
|
Selected messages and surrounding context in archive order. Messages between separate selections
|
||||||
|
may be omitted.
|
||||||
|
</p>
|
||||||
|
<ol class="excerpt" aria-label="Preserved conversation excerpt">
|
||||||
|
{#each entries as entry}
|
||||||
|
{#if entry.type === 'message'}
|
||||||
|
{@const highlighted =
|
||||||
|
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
|
||||||
|
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
|
||||||
|
<li class:highlighted>
|
||||||
|
<div class="author">
|
||||||
|
<strong>{entry.message.player}</strong>
|
||||||
|
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if}
|
||||||
|
{#if highlighted}<Badge variant="warning">Subject message</Badge>
|
||||||
|
{:else if selected}<Badge>Selected context</Badge>
|
||||||
|
{:else}<span>Surrounding context</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="metadata">
|
||||||
|
<span>GUID {entry.message.guid}</span>
|
||||||
|
{#if entry.context.realTime}<span
|
||||||
|
>Estimated time <time datetime={entry.context.realTime}
|
||||||
|
>{$timeFormat.dateTime(entry.context.realTime)}</time
|
||||||
|
></span
|
||||||
|
>{:else}<span>Message time unknown</span>{/if}
|
||||||
|
<span>Log {entry.message.time}</span>
|
||||||
|
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<p>{entry.message.message}</p>
|
||||||
|
{#if entry.messageId}<details>
|
||||||
|
<summary>Message identifier</summary><code>{entry.messageId}</code>
|
||||||
|
</details>{/if}
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.excerpt {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--border-subtle);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
li.highlighted {
|
||||||
|
border-left-color: var(--state-warning);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
.author,
|
||||||
|
.metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.author > span,
|
||||||
|
.metadata {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.metadata {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.excerpt-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ModerationGame, ModerationOverview } from '$lib/moderation.js';
|
||||||
|
import { moderationCaseStatusLabels } from '$lib/moderation.js';
|
||||||
|
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
|
||||||
|
|
||||||
|
let { game, guid }: { game: ModerationGame; guid: string } = $props();
|
||||||
|
let report = $state<ModerationOverview | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let failed = $state(false);
|
||||||
|
let retry = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
const target = guid.trim();
|
||||||
|
const targetGame = game;
|
||||||
|
void retry;
|
||||||
|
const controller = new AbortController();
|
||||||
|
report = null;
|
||||||
|
failed = false;
|
||||||
|
loading = !!target;
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
view: 'cases',
|
||||||
|
game: targetGame,
|
||||||
|
guid: target
|
||||||
|
});
|
||||||
|
const response = await fetch(`/api/moderation?${params}`, {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Case lookup failed');
|
||||||
|
const result: ModerationOverview = await response.json();
|
||||||
|
if (!Array.isArray(result.cases) || !Number.isSafeInteger(result.total))
|
||||||
|
throw new Error('Invalid case lookup response');
|
||||||
|
if (!controller.signal.aborted) report = result;
|
||||||
|
} catch {
|
||||||
|
if (!controller.signal.aborted) failed = true;
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) loading = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside aria-label="Existing cases for this player">
|
||||||
|
{#if loading}<p role="status">Checking existing cases for this player…</p>
|
||||||
|
{:else if failed}<p role="status">
|
||||||
|
Existing cases could not be loaded. You can still open a case. <button
|
||||||
|
type="button"
|
||||||
|
onclick={() => retry++}>Retry lookup</button
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{:else if report?.cases.length}
|
||||||
|
<strong
|
||||||
|
>{report.total} existing {report.total === 1 ? 'case' : 'cases'} for this player</strong
|
||||||
|
>
|
||||||
|
<p>Review these before opening another case.</p>
|
||||||
|
<ul>
|
||||||
|
{#each report.cases as item}<li>
|
||||||
|
<a href={moderationCaseHref(item.id)} target="_blank" rel="noreferrer"
|
||||||
|
>{item.title}</a
|
||||||
|
>
|
||||||
|
· {moderationCaseStatusLabels[item.status]}
|
||||||
|
</li>{/each}
|
||||||
|
</ul>
|
||||||
|
<a href={`${moderationHref({ game, guid })}&view=cases`} target="_blank" rel="noreferrer"
|
||||||
|
>View all cases for this player</a
|
||||||
|
>
|
||||||
|
{:else if report}<p>No existing cases found for this player.</p>{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
aside {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
padding-left: var(--space-5);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ModerationCaseAction } from '$lib/moderation.js';
|
||||||
|
import { moderationActionName } from '$ui/moderation.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
|
import Field from '../primitives/Field.svelte';
|
||||||
|
import Select from '../primitives/Select.svelte';
|
||||||
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import StatusBadge from '../primitives/StatusBadge.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
caseId,
|
||||||
|
revision,
|
||||||
|
actions,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
caseId: string;
|
||||||
|
revision: number;
|
||||||
|
actions: ModerationCaseAction[];
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
let selected = $state('');
|
||||||
|
let reason = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let uncertain = $state(false);
|
||||||
|
let pending: Record<string, unknown> | null = null;
|
||||||
|
const action = $derived(actions.find((item) => `${item.type}:${item.id}` === selected));
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
if (!pending) {
|
||||||
|
if (!action || !reason.trim()) {
|
||||||
|
error = 'Select an existing action and explain its connection to this case.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending = {
|
||||||
|
op: 'link-case-action',
|
||||||
|
id: caseId,
|
||||||
|
revision,
|
||||||
|
actionType: action.type,
|
||||||
|
actionId: action.id,
|
||||||
|
reason: reason.trim(),
|
||||||
|
idempotencyKey: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
uncertain = response.status >= 500 || response.ok;
|
||||||
|
if (!uncertain) pending = null;
|
||||||
|
throw new Error(result?.error || 'Could not link the existing action.');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
onsubmitted();
|
||||||
|
} catch (cause) {
|
||||||
|
if (pending) uncertain = true;
|
||||||
|
error = cause instanceof Error ? cause.message : 'Could not link the existing action.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
bind:open
|
||||||
|
title="Link existing action"
|
||||||
|
description="Connect a previously recorded action to this case. Linking preserves its original date and outcome without issuing it again."
|
||||||
|
{busy}
|
||||||
|
>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<fieldset disabled={busy || uncertain}>
|
||||||
|
<Field label="Existing action" required>
|
||||||
|
<Select
|
||||||
|
bind:value={selected}
|
||||||
|
required
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Select an action' },
|
||||||
|
...actions.map((item) => ({
|
||||||
|
value: `${item.type}:${item.id}`,
|
||||||
|
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{#if action}<div class="action-detail">
|
||||||
|
<strong>{moderationActionName(action.kind)}</strong>
|
||||||
|
<StatusBadge state={`moderation-${action.state}`} />
|
||||||
|
<p>{action.reason}</p>
|
||||||
|
<p class="hint">
|
||||||
|
Recorded by {action.actor} · {$timeFormat.dateTime(action.createdAt)}
|
||||||
|
</p>
|
||||||
|
{#if action.type === 'restriction'}<p class="hint">
|
||||||
|
{action.expiresAt
|
||||||
|
? `Expires ${$timeFormat.dateTime(action.expiresAt)}`
|
||||||
|
: 'Permanent restriction'}
|
||||||
|
</p>{/if}
|
||||||
|
</div>{/if}
|
||||||
|
<Field label="Link reason" required hint="Explain how this action relates to the case."
|
||||||
|
><Textarea bind:value={reason} rows={3} maxlength={4000} required /></Field
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if uncertain}<p class="hint">
|
||||||
|
The result is uncertain. Retry sends the same request safely.
|
||||||
|
</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={busy}>{uncertain ? 'Retry same request' : 'Link action'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.action-detail {
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.action-detail p {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import type { ContextualChatEntry } from '$lib/game-chat.js';
|
||||||
|
import {
|
||||||
|
moderationGuid,
|
||||||
|
moderationCaseStatusLabels,
|
||||||
|
type ModerationCase,
|
||||||
|
type ModerationGame,
|
||||||
|
type ModerationOverview
|
||||||
|
} from '$lib/moderation.js';
|
||||||
|
import { conversationExcerpt } from '$ui/moderation-evidence.js';
|
||||||
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
|
import Field from '../primitives/Field.svelte';
|
||||||
|
import Input from '../primitives/Input.svelte';
|
||||||
|
import Select from '../primitives/Select.svelte';
|
||||||
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import EvidenceMessages from './EvidenceMessages.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
game,
|
||||||
|
matchId,
|
||||||
|
entries,
|
||||||
|
selectedMessageIds,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
game: ModerationGame;
|
||||||
|
matchId: number;
|
||||||
|
entries: ContextualChatEntry[];
|
||||||
|
selectedMessageIds: string[];
|
||||||
|
onsubmitted: (id: string) => void;
|
||||||
|
} = $props();
|
||||||
|
const selected = $derived(
|
||||||
|
entries.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.type === 'message' &&
|
||||||
|
!!entry.messageId &&
|
||||||
|
selectedMessageIds.includes(entry.messageId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const participants = $derived.by(() => {
|
||||||
|
const players = new Map<string, string>();
|
||||||
|
for (const entry of selected) {
|
||||||
|
if (entry.type === 'message')
|
||||||
|
players.set(moderationGuid(game, entry.message.guid), entry.message.player);
|
||||||
|
}
|
||||||
|
return [...players].map(([guid, name]) => ({
|
||||||
|
value: guid,
|
||||||
|
label: `${name} · GUID ${guid}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
let guid = $state(untrack(() => participants[0]?.value ?? ''));
|
||||||
|
let destination = $state('');
|
||||||
|
let title = $state('');
|
||||||
|
let note = $state('');
|
||||||
|
let contextBefore = $state<number | undefined>(3);
|
||||||
|
let contextAfter = $state<number | undefined>(3);
|
||||||
|
let cases = $state<ModerationCase[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let loadError = $state('');
|
||||||
|
let reload = $state(0);
|
||||||
|
let busy = $state(false);
|
||||||
|
let uncertain = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let pending: Record<string, unknown> | null = null;
|
||||||
|
const excerpt = $derived(
|
||||||
|
conversationExcerpt(entries, selectedMessageIds, contextBefore ?? 0, contextAfter ?? 0)
|
||||||
|
);
|
||||||
|
const highlightedMessageIds = $derived(
|
||||||
|
selected.flatMap((entry) =>
|
||||||
|
entry.type === 'message' &&
|
||||||
|
entry.messageId &&
|
||||||
|
moderationGuid(game, entry.message.guid) === guid
|
||||||
|
? [entry.messageId]
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const selectedGuid = guid;
|
||||||
|
const selectedGame = game;
|
||||||
|
void reload;
|
||||||
|
const controller = new AbortController();
|
||||||
|
cases = [];
|
||||||
|
destination = '';
|
||||||
|
loadError = '';
|
||||||
|
loading = true;
|
||||||
|
async function loadCases() {
|
||||||
|
try {
|
||||||
|
const loaded: ModerationCase[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
view: 'cases',
|
||||||
|
game: selectedGame,
|
||||||
|
guid: selectedGuid,
|
||||||
|
offset: String(offset)
|
||||||
|
});
|
||||||
|
const response = await fetch(`/api/moderation?${params}`, {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
'Could not load existing cases. Retry before attaching evidence.'
|
||||||
|
);
|
||||||
|
const report: ModerationOverview = await response.json();
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
loaded.push(...report.cases);
|
||||||
|
offset += report.cases.length;
|
||||||
|
if (!report.cases.length || offset >= report.total) break;
|
||||||
|
}
|
||||||
|
cases = loaded;
|
||||||
|
} catch (cause) {
|
||||||
|
if (!controller.signal.aborted)
|
||||||
|
loadError =
|
||||||
|
cause instanceof Error ? cause.message : 'Could not load existing cases.';
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadCases();
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || (!pending && (loading || loadError))) return;
|
||||||
|
if (!pending) {
|
||||||
|
const target = cases.find((item) => item.id === destination);
|
||||||
|
if (!guid || !highlightedMessageIds.length || (destination && !target)) {
|
||||||
|
error = 'Choose a case subject with at least one selected message.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
![contextBefore, contextAfter].every(
|
||||||
|
(value) => Number.isInteger(value) && value! >= 0 && value! <= 20
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
error = 'Choose between 0 and 20 surrounding messages on each side.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending = {
|
||||||
|
...(target
|
||||||
|
? { op: 'attach-evidence', id: target.id, revision: target.revision }
|
||||||
|
: { op: 'create-case', game, guid, title: title.trim(), note: note.trim() }),
|
||||||
|
evidence: { matchId, messageIds: selectedMessageIds, contextBefore, contextAfter },
|
||||||
|
idempotencyKey: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
uncertain = response.status >= 500 || response.ok;
|
||||||
|
if (!uncertain) pending = null;
|
||||||
|
if (response.status === 409) reload++;
|
||||||
|
throw new Error(result?.error || 'Could not attach the selected evidence.');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
onsubmitted(result.id);
|
||||||
|
} catch (cause) {
|
||||||
|
if (pending) uncertain = true;
|
||||||
|
error =
|
||||||
|
cause instanceof Error ? cause.message : 'Could not attach the selected evidence.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
bind:open
|
||||||
|
title="Attach to case"
|
||||||
|
{busy}
|
||||||
|
wide
|
||||||
|
description={`${selectedMessageIds.length} selected messages from match #${matchId}`}
|
||||||
|
>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<fieldset disabled={busy || uncertain}>
|
||||||
|
<Field
|
||||||
|
label="Case subject"
|
||||||
|
required
|
||||||
|
hint="Select the player being reviewed. Other participants remain in the preserved conversation."
|
||||||
|
>
|
||||||
|
<Select bind:value={guid} options={participants} required />
|
||||||
|
</Field>
|
||||||
|
{#if loading}<p role="status">Loading this player's cases…</p>
|
||||||
|
{:else if loadError}<p class="ui-error" role="alert">
|
||||||
|
{loadError}
|
||||||
|
<Button size="sm" onclick={() => reload++}>Retry</Button>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<Field
|
||||||
|
label="Destination"
|
||||||
|
hint={cases.length
|
||||||
|
? 'Review existing cases before opening another.'
|
||||||
|
: 'No existing cases for this player.'}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
bind:value={destination}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Open a new case' },
|
||||||
|
...cases.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.title} · ${moderationCaseStatusLabels[item.status]}`
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{#if !destination}
|
||||||
|
<Field label="Case title" required
|
||||||
|
><Input bind:value={title} maxlength={160} required /></Field
|
||||||
|
>
|
||||||
|
<Field label="Private staff note"
|
||||||
|
><Textarea bind:value={note} rows={3} maxlength={4000} /></Field
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<div class="paired">
|
||||||
|
<Field label="Messages before" hint="Per selected message"
|
||||||
|
><Input
|
||||||
|
type="number"
|
||||||
|
bind:value={contextBefore}
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
required
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Messages after" hint="Per selected message"
|
||||||
|
><Input
|
||||||
|
type="number"
|
||||||
|
bind:value={contextAfter}
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
required
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
|
||||||
|
Messages already preserved as evidence in this case are not attached again.
|
||||||
|
</p>
|
||||||
|
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
|
||||||
|
</fieldset>
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if uncertain}<p class="hint">
|
||||||
|
The result is uncertain. Retry the same request to avoid duplicate evidence.
|
||||||
|
</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={busy}
|
||||||
|
disabled={!uncertain && (loading || !!loadError)}
|
||||||
|
>{uncertain
|
||||||
|
? 'Retry same request'
|
||||||
|
: destination
|
||||||
|
? 'Attach evidence'
|
||||||
|
: 'Open case with evidence'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.paired {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import type { ModerationCoverage, ModerationGame, ModerationScope } from '$lib/moderation.js';
|
import {
|
||||||
|
moderationCaseStatuses,
|
||||||
|
moderationCaseStatusLabels,
|
||||||
|
type ModerationCoverage,
|
||||||
|
type ModerationGame,
|
||||||
|
type ModerationScope,
|
||||||
|
type ModerationCase
|
||||||
|
} from '$lib/moderation.js';
|
||||||
import { moderationGameName } from '$ui/moderation.js';
|
import { moderationGameName } from '$ui/moderation.js';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
@@ -10,6 +17,7 @@
|
|||||||
import Select from '../primitives/Select.svelte';
|
import Select from '../primitives/Select.svelte';
|
||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
import Button from '../primitives/Button.svelte';
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import ExistingCaseNotice from './ExistingCaseNotice.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
@@ -17,12 +25,23 @@
|
|||||||
coverage,
|
coverage,
|
||||||
admin,
|
admin,
|
||||||
seed = {},
|
seed = {},
|
||||||
|
assignees = [],
|
||||||
onsubmitted
|
onsubmitted
|
||||||
}: {
|
}: {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
mode: 'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve';
|
mode:
|
||||||
|
| 'restriction'
|
||||||
|
| 'action'
|
||||||
|
| 'case'
|
||||||
|
| 'note'
|
||||||
|
| 'revoke'
|
||||||
|
| 'case-note'
|
||||||
|
| 'case-status'
|
||||||
|
| 'edit-case'
|
||||||
|
| 'evidence';
|
||||||
coverage: ModerationCoverage[];
|
coverage: ModerationCoverage[];
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
|
assignees?: { id: string; username: string }[];
|
||||||
seed?: {
|
seed?: {
|
||||||
game?: ModerationGame;
|
game?: ModerationGame;
|
||||||
guid?: string;
|
guid?: string;
|
||||||
@@ -31,8 +50,15 @@
|
|||||||
revision?: number;
|
revision?: number;
|
||||||
matchId?: number;
|
matchId?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
caseId?: string;
|
||||||
|
caseRevision?: number;
|
||||||
|
title?: string;
|
||||||
|
priority?: ModerationCase['priority'];
|
||||||
|
assigneeId?: string | null;
|
||||||
|
assignee?: string | null;
|
||||||
|
status?: ModerationCase['status'];
|
||||||
};
|
};
|
||||||
onsubmitted: (target: { game?: ModerationGame; guid?: string | null }) => void;
|
onsubmitted: (target: { game?: ModerationGame; guid?: string | null; id?: string }) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
|
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
|
||||||
let guid = $state(untrack(() => seed.guid ?? ''));
|
let guid = $state(untrack(() => seed.guid ?? ''));
|
||||||
@@ -43,10 +69,16 @@
|
|||||||
let duration = $state('60');
|
let duration = $state('60');
|
||||||
let reason = $state('');
|
let reason = $state('');
|
||||||
let note = $state('');
|
let note = $state('');
|
||||||
let title = $state('');
|
let title = $state(untrack(() => seed.title ?? ''));
|
||||||
|
let priority = $state(untrack(() => seed.priority ?? 'normal'));
|
||||||
|
let assigneeId = $state(untrack(() => seed.assigneeId ?? ''));
|
||||||
|
let status = $state(untrack(() => seed.status ?? 'open'));
|
||||||
|
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
|
||||||
|
let message = $state(untrack(() => seed.message ?? ''));
|
||||||
|
const statusUnchanged = $derived(mode === 'case-status' && status === seed.status);
|
||||||
|
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
|
||||||
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
||||||
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
||||||
let resolution = $state('resolved');
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let uncertain = $state(false);
|
let uncertain = $state(false);
|
||||||
@@ -59,7 +91,9 @@
|
|||||||
note: 'Add private note',
|
note: 'Add private note',
|
||||||
revoke: 'Revoke restriction',
|
revoke: 'Revoke restriction',
|
||||||
'case-note': 'Add case note',
|
'case-note': 'Add case note',
|
||||||
resolve: 'Review case'
|
'case-status': 'Change status',
|
||||||
|
'edit-case': 'Edit case',
|
||||||
|
evidence: 'Attach archived evidence'
|
||||||
}[mode]
|
}[mode]
|
||||||
);
|
);
|
||||||
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
|
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
|
||||||
@@ -101,7 +135,8 @@
|
|||||||
server: scope === 'server' ? server : undefined,
|
server: scope === 'server' ? server : undefined,
|
||||||
reason: reason.trim(),
|
reason: reason.trim(),
|
||||||
note: note.trim(),
|
note: note.trim(),
|
||||||
durationMinutes: duration === 'permanent' ? null : Number(duration)
|
durationMinutes: duration === 'permanent' ? null : Number(duration),
|
||||||
|
...(seed.caseId ? { caseId: seed.caseId, caseRevision: seed.caseRevision } : {})
|
||||||
};
|
};
|
||||||
if (mode === 'action')
|
if (mode === 'action')
|
||||||
return {
|
return {
|
||||||
@@ -124,7 +159,27 @@
|
|||||||
: {})
|
: {})
|
||||||
};
|
};
|
||||||
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
||||||
if (mode === 'case-note') return { op: 'case-note', id: seed.id, text: note.trim() };
|
if (mode === 'case-note')
|
||||||
|
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
|
||||||
|
if (mode === 'edit-case')
|
||||||
|
return {
|
||||||
|
op: 'update-case',
|
||||||
|
id: seed.id,
|
||||||
|
revision: seed.revision,
|
||||||
|
title: title.trim(),
|
||||||
|
priority,
|
||||||
|
...(assigneeId !== (seed.assigneeId ?? '')
|
||||||
|
? { assigneeId: assigneeId || null }
|
||||||
|
: {}),
|
||||||
|
reason: reason.trim()
|
||||||
|
};
|
||||||
|
if (mode === 'evidence')
|
||||||
|
return {
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id: seed.id,
|
||||||
|
revision: seed.revision,
|
||||||
|
evidence: { matchId, message }
|
||||||
|
};
|
||||||
if (mode === 'revoke')
|
if (mode === 'revoke')
|
||||||
return {
|
return {
|
||||||
op: 'revoke-sanction',
|
op: 'revoke-sanction',
|
||||||
@@ -133,10 +188,10 @@
|
|||||||
reason: reason.trim()
|
reason: reason.trim()
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
op: 'resolve-case',
|
op: 'update-case',
|
||||||
id: seed.id,
|
id: seed.id,
|
||||||
revision: seed.revision,
|
revision: seed.revision,
|
||||||
status: resolution,
|
status,
|
||||||
reason: reason.trim()
|
reason: reason.trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -186,7 +241,8 @@
|
|||||||
open = false;
|
open = false;
|
||||||
onsubmitted({
|
onsubmitted({
|
||||||
game: pending.game as ModerationGame | undefined,
|
game: pending.game as ModerationGame | undefined,
|
||||||
guid: pending.guid as string | null | undefined
|
guid: pending.guid as string | null | undefined,
|
||||||
|
id: typeof result.id === 'string' ? result.id : undefined
|
||||||
});
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (pending) uncertain = true;
|
if (pending) uncertain = true;
|
||||||
@@ -211,6 +267,7 @@
|
|||||||
><Select
|
><Select
|
||||||
bind:value={game}
|
bind:value={game}
|
||||||
required
|
required
|
||||||
|
disabled={lockedTarget}
|
||||||
onchange={() => (server = '')}
|
onchange={() => (server = '')}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select a game' },
|
{ value: '', label: 'Select a game' },
|
||||||
@@ -229,7 +286,13 @@
|
|||||||
label="Player GUID"
|
label="Player GUID"
|
||||||
required
|
required
|
||||||
hint="Use the recorded player identity. Names can change."
|
hint="Use the recorded player identity. Names can change."
|
||||||
><Input name="target-guid" bind:value={guid} required mono /></Field
|
><Input
|
||||||
|
name="target-guid"
|
||||||
|
bind:value={guid}
|
||||||
|
required
|
||||||
|
mono
|
||||||
|
readonly={lockedTarget}
|
||||||
|
/></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -293,11 +356,20 @@
|
|||||||
required capability and current policy; review enforcement coverage after
|
required capability and current policy; review enforcement coverage after
|
||||||
saving.
|
saving.
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
{#if mode === 'case'}
|
{#if lockedTarget}<p class="hint">
|
||||||
|
This restriction will be linked to the case and its recorded player identity.
|
||||||
|
Applying it does not close the case; change its status when your work is
|
||||||
|
complete.
|
||||||
|
</p>{/if}
|
||||||
|
{#if mode === 'case' || mode === 'edit-case'}
|
||||||
|
{#if mode === 'case' && open && game && guid.trim()}<ExistingCaseNotice
|
||||||
|
{game}
|
||||||
|
{guid}
|
||||||
|
/>{/if}
|
||||||
<Field label="Case title" required
|
<Field label="Case title" required
|
||||||
><Input bind:value={title} required maxlength={160} /></Field
|
><Input bind:value={title} required maxlength={160} /></Field
|
||||||
>
|
>
|
||||||
{#if seed.matchId && seed.message}<div class="evidence">
|
{#if mode === 'case' && seed.matchId && seed.message}<div class="evidence">
|
||||||
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
|
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
|
||||||
<p>{seed.message}</p>
|
<p>{seed.message}</p>
|
||||||
<small
|
<small
|
||||||
@@ -306,22 +378,81 @@
|
|||||||
>
|
>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if mode === 'resolve'}<Field label="Decision"
|
{#if mode === 'edit-case'}
|
||||||
|
<div class="paired">
|
||||||
|
<Field label="Priority"
|
||||||
|
><Select
|
||||||
|
bind:value={priority}
|
||||||
|
options={[
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
{ value: 'urgent', label: 'Urgent' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Assigned to"
|
||||||
|
><Select
|
||||||
|
bind:value={assigneeId}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Unassigned' },
|
||||||
|
...assignees.map((person) => ({
|
||||||
|
value: person.id,
|
||||||
|
label: person.username
|
||||||
|
})),
|
||||||
|
...(seed.assigneeId &&
|
||||||
|
!assignees.some((person) => person.id === seed.assigneeId)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: seed.assigneeId,
|
||||||
|
label: `${seed.assignee ?? 'Previous assignee'} (unavailable)`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Field label="Change reason" hint="Optional note about these edits.">
|
||||||
|
<Textarea bind:value={reason} rows={3} />
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
{#if mode === 'evidence'}
|
||||||
|
<p class="hint">
|
||||||
|
Choose a match and copy the exact archived message from this player. The archive
|
||||||
|
must verify the case's game and player identity before evidence is attached.
|
||||||
|
</p>
|
||||||
|
<Field label="Match ID" required
|
||||||
|
><Input type="number" bind:value={matchId} min={1} step={1} required /></Field
|
||||||
|
>
|
||||||
|
<Field label="Exact archived message" required
|
||||||
|
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if mode === 'case-status'}
|
||||||
|
<Field label="Status"
|
||||||
><Select
|
><Select
|
||||||
bind:value={resolution}
|
bind:value={status}
|
||||||
options={[
|
options={moderationCaseStatuses.map((value) => ({
|
||||||
{ value: 'resolved', label: 'Resolved' },
|
value,
|
||||||
{ value: 'dismissed', label: 'Dismissed' }
|
label: moderationCaseStatusLabels[value]
|
||||||
]}
|
}))}
|
||||||
/></Field
|
/></Field
|
||||||
>{/if}
|
>
|
||||||
{#if ['restriction', 'action', 'revoke', 'resolve'].includes(mode)}
|
<p class="hint">
|
||||||
|
Track whether this case is open, in progress, or closed. Findings and recorded
|
||||||
|
actions stay in the case history.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if ['restriction', 'action', 'revoke', 'case-status'].includes(mode)}
|
||||||
<Field
|
<Field
|
||||||
label={mode === 'action' && ['message', 'announce'].includes(action)
|
label={mode === 'action' && ['message', 'announce'].includes(action)
|
||||||
? 'Message'
|
? 'Message'
|
||||||
: mode === 'restriction' || mode === 'action'
|
: mode === 'restriction' || mode === 'action'
|
||||||
? 'Player-visible reason'
|
? 'Player-visible reason'
|
||||||
: 'Review reason'}
|
: mode === 'case-status'
|
||||||
|
? 'Change reason'
|
||||||
|
: 'Review reason'}
|
||||||
hint={gameText
|
hint={gameText
|
||||||
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
||||||
: undefined}
|
: undefined}
|
||||||
@@ -352,7 +483,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!uncertain && actionUnavailable}
|
disabled={!uncertain && (actionUnavailable || statusUnchanged)}
|
||||||
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
|
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
|
||||||
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
|
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ContextualChatEntry } from '$lib/game-chat.js';
|
||||||
|
|
||||||
|
/** Select surrounding messages by archive order, preserving each message once. */
|
||||||
|
export function conversationExcerpt(
|
||||||
|
entries: readonly ContextualChatEntry[],
|
||||||
|
selectedMessageIds: readonly string[],
|
||||||
|
contextBefore = 3,
|
||||||
|
contextAfter = 3
|
||||||
|
): ContextualChatEntry[] {
|
||||||
|
const messages = entries.filter((entry) => entry.type === 'message');
|
||||||
|
const selected = new Set(selectedMessageIds);
|
||||||
|
const included = new Set<number>();
|
||||||
|
messages.forEach((entry, index) => {
|
||||||
|
if (!entry.messageId || !selected.has(entry.messageId)) return;
|
||||||
|
for (
|
||||||
|
let i = Math.max(0, index - contextBefore);
|
||||||
|
i <= Math.min(messages.length - 1, index + contextAfter);
|
||||||
|
i++
|
||||||
|
)
|
||||||
|
included.add(i);
|
||||||
|
});
|
||||||
|
return messages.filter((_, index) => included.has(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Old single-message snapshots remain readable alongside conversation excerpts. */
|
||||||
|
export function preservedConversation(value: unknown): {
|
||||||
|
entries: ContextualChatEntry[];
|
||||||
|
selectedMessageIds: string[];
|
||||||
|
highlightedMessageIds: string[];
|
||||||
|
} | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const snapshot = value as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(snapshot.entries)) return null;
|
||||||
|
const entries = snapshot.entries.filter(
|
||||||
|
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
|
||||||
|
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
|
||||||
|
return (
|
||||||
|
typeof entry.message?.player === 'string' &&
|
||||||
|
typeof entry.message?.guid === 'string' &&
|
||||||
|
typeof entry.message?.message === 'string' &&
|
||||||
|
typeof entry.message?.time === 'string' &&
|
||||||
|
entry.context &&
|
||||||
|
typeof entry.context === 'object'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!entries.length) return null;
|
||||||
|
const strings = (input: unknown): string[] =>
|
||||||
|
Array.isArray(input) ? input.filter((id): id is string => typeof id === 'string') : [];
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
selectedMessageIds: strings(snapshot.selectedMessageIds),
|
||||||
|
highlightedMessageIds: strings(snapshot.highlightedMessageIds)
|
||||||
|
};
|
||||||
|
}
|
||||||
+116
-1
@@ -1,6 +1,16 @@
|
|||||||
import type { ModerationGame } from '$lib/moderation.js';
|
import {
|
||||||
|
moderationCaseStatusLabels,
|
||||||
|
moderationCaseRelationLabels,
|
||||||
|
type ModerationCaseRelation,
|
||||||
|
type ModerationCaseStatus,
|
||||||
|
type ModerationGame
|
||||||
|
} from '$lib/moderation.js';
|
||||||
import { serverTitle } from './server-labels.js';
|
import { serverTitle } from './server-labels.js';
|
||||||
|
|
||||||
|
export function moderationCaseHref(id: string): string {
|
||||||
|
return `/moderation/cases/${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function moderationHref(context: {
|
export function moderationHref(context: {
|
||||||
game?: string;
|
game?: string;
|
||||||
guid?: string;
|
guid?: string;
|
||||||
@@ -35,6 +45,9 @@ export const moderationActionLabels: Record<string, string> = {
|
|||||||
'create-case': 'Case opened',
|
'create-case': 'Case opened',
|
||||||
'case-note': 'Case note added',
|
'case-note': 'Case note added',
|
||||||
'resolve-case': 'Case reviewed',
|
'resolve-case': 'Case reviewed',
|
||||||
|
'reopen-case': 'Case reopened',
|
||||||
|
'update-case': 'Case updated',
|
||||||
|
'attach-evidence': 'Case evidence attached',
|
||||||
note: 'Private note',
|
note: 'Private note',
|
||||||
'restriction.created': 'Restriction created',
|
'restriction.created': 'Restriction created',
|
||||||
'restriction.revoked': 'Restriction revoked',
|
'restriction.revoked': 'Restriction revoked',
|
||||||
@@ -47,11 +60,113 @@ export const moderationActionLabels: Record<string, string> = {
|
|||||||
'case.note': 'Case note added',
|
'case.note': 'Case note added',
|
||||||
'case.resolved': 'Case resolved',
|
'case.resolved': 'Case resolved',
|
||||||
'case.dismissed': 'Case dismissed',
|
'case.dismissed': 'Case dismissed',
|
||||||
|
'case.in_review': 'Case under review',
|
||||||
|
'case.escalated': 'Case escalated',
|
||||||
|
'case.warning_issued': 'Warning issued',
|
||||||
|
'case.restriction_applied': 'Restriction applied',
|
||||||
|
'case.no_action': 'Closed without action',
|
||||||
|
'case.duplicate': 'Closed as duplicate',
|
||||||
|
'case.appeal_upheld': 'Appeal upheld',
|
||||||
|
'case.appeal_denied': 'Appeal denied',
|
||||||
|
'case.reopened': 'Case reopened',
|
||||||
|
'case.updated': 'Case updated',
|
||||||
|
'case.status_migrated': 'Case status migrated',
|
||||||
|
'case.assessed': 'Assessment revised',
|
||||||
|
'case.review_scheduled': 'Follow-up updated',
|
||||||
|
'schedule-case-review': 'Follow-up updated',
|
||||||
|
'case.linked': 'Case relationship added',
|
||||||
|
'case.unlinked': 'Case relationship removed',
|
||||||
|
'link-case': 'Case relationship added',
|
||||||
|
'unlink-case': 'Case relationship removed',
|
||||||
|
'assess-case': 'Assessment revised',
|
||||||
|
'case.evidence_attached': 'Evidence attached',
|
||||||
|
'case.restriction': 'Restriction linked to case',
|
||||||
|
'case.action_linked': 'Existing action linked to case',
|
||||||
|
'link-case-action': 'Existing action linked to case',
|
||||||
'player.note': 'Private player note',
|
'player.note': 'Private player note',
|
||||||
'runtime.configure': 'Integration configuration requested'
|
'runtime.configure': 'Integration configuration requested'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function moderationActionName(kind: string): string {
|
||||||
|
return (
|
||||||
|
moderationActionLabels[kind] ?? (kind === 'ban' ? 'Ban' : kind === 'mute' ? 'Mute' : kind)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function moderationEventDetail(detail: string, action: string): string {
|
export function moderationEventDetail(detail: string, action: string): string {
|
||||||
|
if (action.startsWith('case.')) {
|
||||||
|
try {
|
||||||
|
const record: unknown = JSON.parse(detail);
|
||||||
|
if (!record || typeof record !== 'object') return detail;
|
||||||
|
const value = record as Record<string, unknown>;
|
||||||
|
const display = (item: unknown, status = false): string =>
|
||||||
|
item === null
|
||||||
|
? 'Unassigned'
|
||||||
|
: typeof item === 'string'
|
||||||
|
? status && Object.hasOwn(moderationCaseStatusLabels, item)
|
||||||
|
? moderationCaseStatusLabels[item as ModerationCaseStatus]
|
||||||
|
: item
|
||||||
|
: '';
|
||||||
|
if (
|
||||||
|
(action === 'case.linked' || action === 'case.unlinked') &&
|
||||||
|
typeof value.relation === 'string' &&
|
||||||
|
Object.hasOwn(moderationCaseRelationLabels, value.relation) &&
|
||||||
|
typeof value.relatedCaseId === 'string' &&
|
||||||
|
typeof value.reason === 'string'
|
||||||
|
) {
|
||||||
|
const label =
|
||||||
|
value.direction === 'incoming' && value.relation !== 'same_incident'
|
||||||
|
? value.relation === 'appeal_of'
|
||||||
|
? 'Appealed by'
|
||||||
|
: 'Has duplicate'
|
||||||
|
: moderationCaseRelationLabels[value.relation as ModerationCaseRelation];
|
||||||
|
return `${label}: ${value.relatedCaseId}\n${value.reason}`;
|
||||||
|
}
|
||||||
|
if (action === 'case.review_scheduled' && typeof value.reason === 'string')
|
||||||
|
return `${typeof value.reviewAt === 'string' ? `Review scheduled for ${value.reviewAt}` : 'Follow-up cleared'}\n${value.reason}`;
|
||||||
|
if (action === 'case.assessed' && typeof value.summary === 'string')
|
||||||
|
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
|
||||||
|
if (action === 'case.updated') {
|
||||||
|
const changes = ['title', 'priority', 'assignee', 'status'].flatMap((key) => {
|
||||||
|
const change = value[key];
|
||||||
|
if (!change || typeof change !== 'object') return [];
|
||||||
|
const { from, to } = change as { from?: unknown; to?: unknown };
|
||||||
|
return [
|
||||||
|
`${key === 'assignee' ? 'Assigned to' : key.charAt(0).toUpperCase() + key.slice(1)}: ${display(from, key === 'status')} → ${display(to, key === 'status')}`
|
||||||
|
];
|
||||||
|
});
|
||||||
|
if (typeof value.reason === 'string') changes.push(value.reason);
|
||||||
|
return changes.join('\n') || detail;
|
||||||
|
}
|
||||||
|
if (action === 'case.status_migrated')
|
||||||
|
return `Previous status: ${display(value.from, true)} → ${display(value.to, true)}. Earlier decisions and actions are preserved.`;
|
||||||
|
if (action === 'case.reopened' && typeof value.reason === 'string')
|
||||||
|
return `Previous status: ${display(value.from, true)}\n${value.reason}`;
|
||||||
|
if (
|
||||||
|
action === 'case.evidence_attached' &&
|
||||||
|
typeof value.count === 'number' &&
|
||||||
|
typeof value.matchId === 'number'
|
||||||
|
)
|
||||||
|
return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`;
|
||||||
|
if (action === 'case.restriction' && typeof value.reason === 'string')
|
||||||
|
return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`;
|
||||||
|
if (
|
||||||
|
typeof value.reason === 'string' &&
|
||||||
|
value.actionReference &&
|
||||||
|
typeof value.actionReference === 'object'
|
||||||
|
) {
|
||||||
|
const reference = value.actionReference as { type?: unknown; id?: unknown };
|
||||||
|
if (
|
||||||
|
(reference.type === 'restriction' || reference.type === 'action') &&
|
||||||
|
typeof reference.id === 'string'
|
||||||
|
)
|
||||||
|
return `${value.reason}\nReferenced ${reference.type === 'restriction' ? 'restriction' : 'action'}: ${reference.id}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Older case events and decision reasons are plain staff-authored text.
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
if (!['restriction.created', 'action.queued'].includes(action)) return detail;
|
if (!['restriction.created', 'action.queued'].includes(action)) return detail;
|
||||||
try {
|
try {
|
||||||
const value: unknown = JSON.parse(detail);
|
const value: unknown = JSON.parse(detail);
|
||||||
|
|||||||
@@ -23,8 +23,17 @@ const states: Record<string, [string, StatusTone]> = {
|
|||||||
'moderation-active': ['Active', 'warning'],
|
'moderation-active': ['Active', 'warning'],
|
||||||
'moderation-revoked': ['Revoked', 'neutral'],
|
'moderation-revoked': ['Revoked', 'neutral'],
|
||||||
'moderation-open': ['Open', 'info'],
|
'moderation-open': ['Open', 'info'],
|
||||||
|
'moderation-in_review': ['In progress', 'info'],
|
||||||
|
'moderation-closed': ['Closed', 'neutral'],
|
||||||
|
'moderation-escalated': ['Escalated', 'warning'],
|
||||||
'moderation-resolved': ['Resolved', 'online'],
|
'moderation-resolved': ['Resolved', 'online'],
|
||||||
'moderation-dismissed': ['Dismissed', 'neutral'],
|
'moderation-dismissed': ['Dismissed', 'neutral'],
|
||||||
|
'moderation-warning_issued': ['Warning issued', 'online'],
|
||||||
|
'moderation-restriction_applied': ['Restriction applied', 'online'],
|
||||||
|
'moderation-no_action': ['No action needed', 'neutral'],
|
||||||
|
'moderation-duplicate': ['Duplicate', 'neutral'],
|
||||||
|
'moderation-appeal_upheld': ['Appeal upheld', 'online'],
|
||||||
|
'moderation-appeal_denied': ['Appeal denied', 'neutral'],
|
||||||
'moderation-queued': ['Queued', 'neutral'],
|
'moderation-queued': ['Queued', 'neutral'],
|
||||||
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
|
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
|
||||||
'moderation-confirmed': ['Confirmed', 'online'],
|
'moderation-confirmed': ['Confirmed', 'online'],
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
.afk-table-scroll {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.afk-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.afk-table th {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.afk-table th,
|
||||||
|
.afk-table td {
|
||||||
|
padding: var(--space-4);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.afk-table tbody tr + tr {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.afk-table td {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
.afk-table small {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.afk-id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.afk-number {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.afk-table a {
|
||||||
|
color: var(--accent-primary-hover);
|
||||||
|
}
|
||||||
|
.afk-player {
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.afk-table .afk-warning {
|
||||||
|
color: var(--state-warning);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { readAfkQuery } from '$lib/afk.js';
|
||||||
|
import { afkOverview } from '$lib/server/fleet/afk-overview.js';
|
||||||
|
|
||||||
|
export function load({ url }: { url: URL }) {
|
||||||
|
let query;
|
||||||
|
try {
|
||||||
|
query = readAfkQuery(url.searchParams);
|
||||||
|
} catch (cause) {
|
||||||
|
error(400, cause instanceof Error ? cause.message : 'Invalid AFK filters.');
|
||||||
|
}
|
||||||
|
return { report: afkOverview(query) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||||
|
import { page, navigating } from '$app/stores';
|
||||||
|
import { afkLedgerKinds, type AfkOverview, type AfkQuery } from '$lib/afk.js';
|
||||||
|
import { pollVisible } from '$ui/poll.js';
|
||||||
|
import { formatDuration } from '$ui/duration.js';
|
||||||
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||||
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
|
import Freshness from '$ui/components/Freshness.svelte';
|
||||||
|
import AfkAccounts from '$ui/components/afk/AfkAccounts.svelte';
|
||||||
|
import AfkSessions from '$ui/components/afk/AfkSessions.svelte';
|
||||||
|
import AfkLedger from '$ui/components/afk/AfkLedger.svelte';
|
||||||
|
import AfkPagination from '$ui/components/afk/AfkPagination.svelte';
|
||||||
|
import '$ui/styles/afk.css';
|
||||||
|
|
||||||
|
let { data }: { data: import('./$types').PageData } = $props();
|
||||||
|
const number = new Intl.NumberFormat('en-US');
|
||||||
|
const duration = (seconds: number) => formatDuration(seconds * 1000);
|
||||||
|
const label = (value: string) => value.replaceAll('_', ' ');
|
||||||
|
let snapshot = $state<AfkOverview | null>(null);
|
||||||
|
const report = $derived(snapshot ?? data.report);
|
||||||
|
let filters = $state<AfkQuery>(untrack(() => ({ ...data.report.query })));
|
||||||
|
let refreshError = $state('');
|
||||||
|
let navigationError = $state('');
|
||||||
|
let updatedAt = $state<number | null>(null);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let controller: AbortController | undefined;
|
||||||
|
let generation = 0;
|
||||||
|
const busy = $derived(!!$navigating);
|
||||||
|
const results = $derived(report[report.query.view]);
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'accounts', label: 'Accounts' },
|
||||||
|
{ id: 'sessions', label: 'Sessions' },
|
||||||
|
{ id: 'ledger', label: 'Time ledger' }
|
||||||
|
];
|
||||||
|
const serverOptions = $derived([
|
||||||
|
{ value: '', label: 'All servers' },
|
||||||
|
...[...new Set([...report.servers, ...(filters.server ? [filters.server] : [])])].map(
|
||||||
|
(server) => ({ value: server, label: serverTitle(server) })
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
const clearedFilters = {
|
||||||
|
q: null,
|
||||||
|
balance: null,
|
||||||
|
sort: null,
|
||||||
|
game: null,
|
||||||
|
server: null,
|
||||||
|
state: null,
|
||||||
|
kind: null,
|
||||||
|
from: null,
|
||||||
|
to: null,
|
||||||
|
offset: null
|
||||||
|
};
|
||||||
|
$effect(() => {
|
||||||
|
const initial = data.report;
|
||||||
|
untrack(() => {
|
||||||
|
cancelRefresh();
|
||||||
|
snapshot = null;
|
||||||
|
filters = { ...initial.query };
|
||||||
|
refreshError = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function cancelRefresh() {
|
||||||
|
generation++;
|
||||||
|
controller?.abort();
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
beforeNavigate(cancelRefresh);
|
||||||
|
afterNavigate(() => {
|
||||||
|
updatedAt = Date.now();
|
||||||
|
navigationError = '';
|
||||||
|
});
|
||||||
|
async function refresh(signal?: AbortSignal) {
|
||||||
|
if (busy || refreshing) return;
|
||||||
|
const current = ++generation;
|
||||||
|
const request = new AbortController();
|
||||||
|
controller = request;
|
||||||
|
const abort = () => request.abort();
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
if (signal?.aborted) request.abort();
|
||||||
|
const timeout = setTimeout(abort, 30_000);
|
||||||
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/afk${$page.url.search}`, { signal: request.signal });
|
||||||
|
if (!response.ok) throw new Error('AFK refresh failed.');
|
||||||
|
const next: AfkOverview = await response.json();
|
||||||
|
if (current !== generation || request.signal.aborted) return;
|
||||||
|
snapshot = next;
|
||||||
|
updatedAt = Date.now();
|
||||||
|
refreshError = '';
|
||||||
|
} catch {
|
||||||
|
if (current === generation) refreshError = 'AFK updates paused.';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
if (current === generation) refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
updatedAt = Date.now();
|
||||||
|
const stop = pollVisible(refresh, 15_000, false);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
cancelRefresh();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
async function navigate(changes: Record<string, string | number | null>) {
|
||||||
|
const params = new URLSearchParams($page.url.searchParams);
|
||||||
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
|
if (value === null || value === '') params.delete(key);
|
||||||
|
else params.set(key, String(value));
|
||||||
|
}
|
||||||
|
navigationError = '';
|
||||||
|
try {
|
||||||
|
await goto(`/afk?${params}`, { keepFocus: true, noScroll: true });
|
||||||
|
} catch {
|
||||||
|
navigationError = 'Could not load this AFK view. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function changeView(view: string) {
|
||||||
|
void navigate({ ...clearedFilters, q: report.query.q, view });
|
||||||
|
}
|
||||||
|
function applyFilters(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
void navigate({
|
||||||
|
q: filters.q,
|
||||||
|
balance: filters.balance,
|
||||||
|
sort: filters.sort,
|
||||||
|
game: filters.game,
|
||||||
|
server: filters.server,
|
||||||
|
state: filters.state,
|
||||||
|
kind: filters.kind,
|
||||||
|
from: filters.from,
|
||||||
|
to: filters.to,
|
||||||
|
offset: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>AFK · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="afk-page">
|
||||||
|
<SectionHeader
|
||||||
|
title="AFK"
|
||||||
|
eyebrow="Players"
|
||||||
|
subtitle="Shared AFK time across Black Ops I & II."
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Freshness
|
||||||
|
{updatedAt}
|
||||||
|
error={refreshError}
|
||||||
|
intervalLabel="every 15 s"
|
||||||
|
retryDisabled={refreshing || busy}
|
||||||
|
onRetry={() => void refresh()}
|
||||||
|
/>
|
||||||
|
<Button size="sm" loading={refreshing} disabled={busy} onclick={() => void refresh()}
|
||||||
|
>Refresh</Button
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</SectionHeader>
|
||||||
|
|
||||||
|
<dl class="summary">
|
||||||
|
<div>
|
||||||
|
<dt>AFK accounts</dt>
|
||||||
|
<dd>{number.format(report.summary.accounts)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Available saved time</dt>
|
||||||
|
<dd>{duration(report.summary.savedSeconds)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Reserved time</dt>
|
||||||
|
<dd>{duration(report.summary.reservedSeconds)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Unresolved sessions</dt>
|
||||||
|
<dd>
|
||||||
|
<button
|
||||||
|
class="summary-link"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() =>
|
||||||
|
void navigate({
|
||||||
|
...clearedFilters,
|
||||||
|
player: null,
|
||||||
|
view: 'sessions',
|
||||||
|
state: 'unresolved'
|
||||||
|
})}>{number.format(report.summary.unresolvedSessions)}</button
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="hint">
|
||||||
|
Totals cover all accounts, regardless of filters. Reserved time is held until settlement and
|
||||||
|
is excluded from available balances.
|
||||||
|
</p>
|
||||||
|
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
{tabs}
|
||||||
|
value={report.query.view}
|
||||||
|
onchange={changeView}
|
||||||
|
ariaLabel="AFK views"
|
||||||
|
panelId="afk-results"
|
||||||
|
idPrefix="afk-tab"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="afk-results"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`afk-tab-${report.query.view}`}
|
||||||
|
aria-busy={busy}
|
||||||
|
class="results"
|
||||||
|
>
|
||||||
|
{#if report.query.player}
|
||||||
|
<div class="player-filter">
|
||||||
|
<span>Player GUID: <strong>{report.query.player}</strong></span><Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => void navigate({ player: null, offset: null })}
|
||||||
|
>All players</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<form class="filters" method="GET" action="/afk" onsubmit={applyFilters}>
|
||||||
|
<input type="hidden" name="view" value={report.query.view} />
|
||||||
|
{#if report.query.player}<input
|
||||||
|
type="hidden"
|
||||||
|
name="player"
|
||||||
|
value={report.query.player}
|
||||||
|
/>{/if}
|
||||||
|
<div class="search">
|
||||||
|
<Field label="Player name or GUID"
|
||||||
|
><Input
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
bind:value={filters.q}
|
||||||
|
maxlength={100}
|
||||||
|
placeholder="Search players…"
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if report.query.view === 'accounts'}
|
||||||
|
<Field label="Accounts"
|
||||||
|
><Select
|
||||||
|
name="balance"
|
||||||
|
bind:value={filters.balance}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All accounts' },
|
||||||
|
{ value: 'saved', label: 'Saved time available' },
|
||||||
|
{ value: 'reserved', label: 'Reserved time' },
|
||||||
|
{ value: 'unresolved', label: 'Unresolved sessions' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Sort by"
|
||||||
|
><Select
|
||||||
|
name="sort"
|
||||||
|
bind:value={filters.sort}
|
||||||
|
options={[
|
||||||
|
{ value: 'saved', label: 'Most saved time' },
|
||||||
|
{ value: 'available', label: 'Most available time' },
|
||||||
|
{ value: 'recent', label: 'Recent activity' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<Field label="Game"
|
||||||
|
><Select
|
||||||
|
name="game"
|
||||||
|
bind:value={filters.game}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All games' },
|
||||||
|
{ value: 't5', label: 'Black Ops I' },
|
||||||
|
{ value: 't6', label: 'Black Ops II' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Server"
|
||||||
|
><Select
|
||||||
|
name="server"
|
||||||
|
bind:value={filters.server}
|
||||||
|
options={serverOptions}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{#if report.query.view === 'sessions'}
|
||||||
|
<Field label="Session state"
|
||||||
|
><Select
|
||||||
|
name="state"
|
||||||
|
bind:value={filters.state}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All states' },
|
||||||
|
{ value: 'reserved', label: 'Reserved' },
|
||||||
|
{ value: 'started', label: 'Started' },
|
||||||
|
{ value: 'settled', label: 'Settled' },
|
||||||
|
{ value: 'unresolved', label: 'Unresolved' },
|
||||||
|
{ value: 'corrected', label: 'Corrected' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<Field label="Event"
|
||||||
|
><Select
|
||||||
|
name="kind"
|
||||||
|
bind:value={filters.kind}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All events' },
|
||||||
|
...afkLedgerKinds.map((kind) => ({
|
||||||
|
value: kind,
|
||||||
|
label: label(kind)
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<Field label="From (UTC)"
|
||||||
|
><Input
|
||||||
|
name="from"
|
||||||
|
type="date"
|
||||||
|
bind:value={filters.from}
|
||||||
|
max={filters.to || undefined}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Through (UTC)"
|
||||||
|
><Input
|
||||||
|
name="to"
|
||||||
|
type="date"
|
||||||
|
bind:value={filters.to}
|
||||||
|
min={filters.from || undefined}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<div class="filter-actions">
|
||||||
|
<Button type="submit" disabled={busy}>Apply</Button><Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => void navigate({ ...clearedFilters, player: null })}>Reset</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if report.query.view === 'accounts'}
|
||||||
|
<p class="hint">
|
||||||
|
Free time replenishes on a rolling 24-hour basis. Saved capacity is {duration(
|
||||||
|
report.capacitySeconds
|
||||||
|
)} per player, including saved time reserved for sessions.
|
||||||
|
</p>
|
||||||
|
{#if report.accounts.entries.length}<AfkAccounts
|
||||||
|
entries={report.accounts.entries}
|
||||||
|
capacitySeconds={report.capacitySeconds}
|
||||||
|
source={$page.url}
|
||||||
|
/>{:else}<EmptyState
|
||||||
|
title="No AFK accounts found"
|
||||||
|
description="Try another player name or GUID, or reset the filters. Accounts appear after their first AFK service request."
|
||||||
|
/>{/if}
|
||||||
|
{:else if report.query.view === 'sessions'}
|
||||||
|
<p class="hint">
|
||||||
|
Dates filter when reservations were created. Unresolved sessions keep their time
|
||||||
|
reserved until a confirmed stop or an administrator correction. Corrected charges
|
||||||
|
are separate from confirmed usage.
|
||||||
|
</p>
|
||||||
|
{#if report.sessions.entries.length}<AfkSessions
|
||||||
|
entries={report.sessions.entries}
|
||||||
|
source={$page.url}
|
||||||
|
/>{:else}<EmptyState
|
||||||
|
title="No AFK sessions found"
|
||||||
|
description="No sessions match these filters."
|
||||||
|
/>{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="hint">
|
||||||
|
Dates filter when events were received. Balances after each event are historical
|
||||||
|
snapshots; current balances appear in Accounts.
|
||||||
|
</p>
|
||||||
|
{#if report.ledger.entries.length}<AfkLedger
|
||||||
|
entries={report.ledger.entries}
|
||||||
|
source={$page.url}
|
||||||
|
/>{:else}<EmptyState
|
||||||
|
title="No time ledger entries found"
|
||||||
|
description="No AFK grants, purchases or session activity match these filters."
|
||||||
|
/>{/if}
|
||||||
|
{/if}
|
||||||
|
<AfkPagination
|
||||||
|
total={results.total}
|
||||||
|
offset={results.offset}
|
||||||
|
disabled={busy}
|
||||||
|
onchange={(offset) => void navigate({ offset })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.afk-page,
|
||||||
|
.results {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.summary > div {
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.summary-link {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent-primary-hover);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.player-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.player-filter strong {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.summary {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.summary > div {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { readAfkQuery } from '$lib/afk.js';
|
||||||
|
import { afkOverview } from '$lib/server/fleet/afk-overview.js';
|
||||||
|
|
||||||
|
export function GET({ url }: { url: URL }) {
|
||||||
|
let query;
|
||||||
|
try {
|
||||||
|
query = readAfkQuery(url.searchParams);
|
||||||
|
} catch (cause) {
|
||||||
|
error(400, cause instanceof Error ? cause.message : 'Invalid AFK filters.');
|
||||||
|
}
|
||||||
|
return json(afkOverview(query), { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { json, isHttpError } from '@sveltejs/kit';
|
||||||
|
import { authenticateBank } from '$lib/server/auth/bank.js';
|
||||||
|
import { afkRequest, type AfkAction } from '$lib/server/fleet/afk.js';
|
||||||
|
import { jsonBody } from '$lib/server/request-body.js';
|
||||||
|
|
||||||
|
export async function POST({ request, params }: { request: Request; params: { action: string } }) {
|
||||||
|
const headers = { 'Cache-Control': 'no-store' };
|
||||||
|
const authentication = authenticateBank(request);
|
||||||
|
if (!authentication.ok)
|
||||||
|
return json(
|
||||||
|
{ ok: false, code: authentication.code },
|
||||||
|
{ status: authentication.status, headers }
|
||||||
|
);
|
||||||
|
if (!['balance', 'event', 'purchase', 'reserve', 'start', 'settle'].includes(params.action))
|
||||||
|
return json({ ok: false, code: 'invalid_action' }, { status: 404, headers });
|
||||||
|
if (
|
||||||
|
request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !==
|
||||||
|
'application/json'
|
||||||
|
)
|
||||||
|
return json({ ok: false, code: 'invalid_content_type' }, { status: 415, headers });
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = await jsonBody(request, 4096);
|
||||||
|
} catch (cause) {
|
||||||
|
if (isHttpError(cause, 413)) throw cause;
|
||||||
|
return json({ ok: false, code: 'invalid_request' }, { status: 400, headers });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { body, status } = afkRequest(
|
||||||
|
params.action as AfkAction,
|
||||||
|
value,
|
||||||
|
authentication.server,
|
||||||
|
authentication.instanceId
|
||||||
|
);
|
||||||
|
return json(body, { status, headers });
|
||||||
|
} catch {
|
||||||
|
console.error('AFK transaction failed; request may be retried with the same identity.');
|
||||||
|
return json({ ok: false, code: 'unavailable' }, { status: 503, headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { jsonBody } from '$lib/server/request-body.js';
|
||||||
|
import { afkCorrectSession } from '$lib/server/fleet/afk.js';
|
||||||
|
|
||||||
|
/** Human reconciliation is restricted to sessions already confirmed stopped. */
|
||||||
|
export async function POST({ request, locals }: { request: Request; locals: App.Locals }) {
|
||||||
|
if (!locals.user || locals.user.role !== 'admin') error(403, 'Administrator access required.');
|
||||||
|
const result = afkCorrectSession(await jsonBody(request, 4096), locals.user.id);
|
||||||
|
return json(result.body, { status: result.status, headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { authenticateAgent, objectBody } from '$lib/server/hosts/http.js';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { afkRequest } from '$lib/server/fleet/afk.js';
|
||||||
|
|
||||||
|
/** Only the assigned teardown host may reconcile a stopped runtime. */
|
||||||
|
export async function POST({ request }: { request: Request }) {
|
||||||
|
const host = authenticateAgent(request);
|
||||||
|
const body = await objectBody(request, 24576);
|
||||||
|
if (
|
||||||
|
typeof body.operationId !== 'string' ||
|
||||||
|
!['settle', 'event', 'purchase'].includes(String(body.action))
|
||||||
|
)
|
||||||
|
error(400, 'Invalid AFK recovery.');
|
||||||
|
const scope = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT n.id, n.server FROM ec_host_operations o
|
||||||
|
JOIN native_instances n ON n.id=o.instance_id
|
||||||
|
WHERE o.id=? AND o.host_id=? AND o.kind IN ('instance.remove','instance.rebuild','instance.cancel-create')
|
||||||
|
AND o.state IN ('pending','accepted','running','unknown')`
|
||||||
|
)
|
||||||
|
.get(body.operationId, host.id) as { id: string; server: string } | undefined;
|
||||||
|
if (!scope) error(403, 'AFK recovery requires an assigned, unfinished teardown.');
|
||||||
|
const result = afkRequest(
|
||||||
|
body.action as 'settle' | 'event' | 'purchase',
|
||||||
|
body.transfer,
|
||||||
|
scope.server,
|
||||||
|
scope.id,
|
||||||
|
{ receiptOnly: body.action !== 'settle' }
|
||||||
|
);
|
||||||
|
return json(result.body, { status: result.status, headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { readMatchQuery } from '$lib/match-history-query.js';
|
import { readMatchQuery } from '$lib/match-history-query.js';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
||||||
|
import {
|
||||||
|
requireMatchArchiveAccess,
|
||||||
|
type MatchArchiveLocals
|
||||||
|
} from '$lib/server/fleet/match-history-access.js';
|
||||||
|
import { authError } from '$lib/server/auth/http.js';
|
||||||
|
|
||||||
export function GET({ url }: { url: URL }) {
|
export function GET({ url, locals }: { url: URL; locals?: MatchArchiveLocals }) {
|
||||||
const limit = Number(url.searchParams.get('limit') ?? 25);
|
const limit = Number(url.searchParams.get('limit') ?? 25);
|
||||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
||||||
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
||||||
@@ -13,5 +18,13 @@ export function GET({ url }: { url: URL }) {
|
|||||||
} catch {
|
} catch {
|
||||||
return json({ error: 'Invalid match history filters.' }, { status: 400 });
|
return json({ error: 'Invalid match history filters.' }, { status: 400 });
|
||||||
}
|
}
|
||||||
return json(serverMatchHistory(undefined, limit, query));
|
try {
|
||||||
|
if (query.archive) requireMatchArchiveAccess(locals?.user);
|
||||||
|
return json(
|
||||||
|
serverMatchHistory(undefined, limit, query),
|
||||||
|
query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
return authError(cause);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const GET: RequestHandler = ({ locals, url }) => {
|
|||||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||||
try {
|
try {
|
||||||
const actor = requireUser(locals.user);
|
const actor = requireUser(locals.user);
|
||||||
const body = await authBody(request, 16 * 1024);
|
const body = await authBody(request, 64 * 1024);
|
||||||
if (body.op === 'configure')
|
if (body.op === 'configure')
|
||||||
return json(
|
return json(
|
||||||
moderationMutation(actor.id, body, () =>
|
moderationMutation(actor.id, body, () =>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { authError, requireUser } from '$lib/server/auth/http.js';
|
||||||
|
import { moderationCaseDetail } from '$lib/server/moderation/cases.js';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ locals, params }) => {
|
||||||
|
try {
|
||||||
|
return json(moderationCaseDetail(requireUser(locals.user).id, params.id), {
|
||||||
|
headers: { 'Cache-Control': 'no-store' }
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
return authError(cause);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
import { readMatchQuery } from '$lib/match-history-query.js';
|
import { readMatchQuery } from '$lib/match-history-query.js';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
||||||
|
import {
|
||||||
|
requireMatchArchiveAccess,
|
||||||
|
type MatchArchiveLocals
|
||||||
|
} from '$lib/server/fleet/match-history-access.js';
|
||||||
|
import { authError } from '$lib/server/auth/http.js';
|
||||||
|
|
||||||
export function GET({ params, url }: { params: { name: string }; url: URL }) {
|
export function GET({
|
||||||
|
params,
|
||||||
|
url,
|
||||||
|
locals
|
||||||
|
}: {
|
||||||
|
params: { name: string };
|
||||||
|
url: URL;
|
||||||
|
locals?: MatchArchiveLocals;
|
||||||
|
}) {
|
||||||
const limit = Number(url.searchParams.get('limit') ?? 25);
|
const limit = Number(url.searchParams.get('limit') ?? 25);
|
||||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
||||||
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
||||||
@@ -13,5 +26,13 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) {
|
|||||||
} catch {
|
} catch {
|
||||||
return json({ error: 'Invalid match history filters.' }, { status: 400 });
|
return json({ error: 'Invalid match history filters.' }, { status: 400 });
|
||||||
}
|
}
|
||||||
return json(serverMatchHistory(params.name, limit, query));
|
try {
|
||||||
|
if (query.archive) requireMatchArchiveAccess(locals?.user);
|
||||||
|
return json(
|
||||||
|
serverMatchHistory(params.name, limit, query),
|
||||||
|
query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
return authError(cause);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { bankProfile } from '$lib/server/fleet/bank.js';
|
import { bankProfile } from '$lib/server/fleet/bank.js';
|
||||||
|
import { afkProfile } from '$lib/server/fleet/afk.js';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { playerReport } from '$lib/server/reports/index.js';
|
import { playerReport } from '$lib/server/reports/index.js';
|
||||||
import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
|
import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
|
||||||
@@ -40,6 +41,7 @@ export async function load({ params, url }: { params: { guid: string }; url: URL
|
|||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
bank: bankProfile(params.guid),
|
bank: bankProfile(params.guid),
|
||||||
|
afk: afkProfile(params.guid),
|
||||||
returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
|
returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
|
||||||
scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry }
|
scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { bankProfile } from '$lib/server/fleet/bank.js';
|
import type { bankProfile } from '$lib/server/fleet/bank.js';
|
||||||
|
import type { afkProfile } from '$lib/server/fleet/afk.js';
|
||||||
|
import AfkReserveCard from '$ui/components/AfkReserveCard.svelte';
|
||||||
import Select from '$ui/components/primitives/Select.svelte';
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
import Field from '$ui/components/primitives/Field.svelte';
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
data: {
|
data: {
|
||||||
profile: PlayerProfile;
|
profile: PlayerProfile;
|
||||||
bank: ReturnType<typeof bankProfile>;
|
bank: ReturnType<typeof bankProfile>;
|
||||||
|
afk: ReturnType<typeof afkProfile>;
|
||||||
returnTo: string;
|
returnTo: string;
|
||||||
scope: {
|
scope: {
|
||||||
game: string;
|
game: string;
|
||||||
@@ -220,6 +223,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}<p>No bank transfers yet.</p>{/if}
|
{:else}<p>No bank transfers yet.</p>{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<AfkReserveCard account={data.afk} guid={profile.guid} />
|
||||||
<nav class="jump-links" aria-label="Player sections">
|
<nav class="jump-links" aria-label="Player sections">
|
||||||
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
|
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
|
||||||
>Matches</a
|
>Matches</a
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
||||||
import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.js';
|
import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.js';
|
||||||
export function load({ url }: { url: URL }) {
|
import {
|
||||||
|
requireMatchArchiveAccess,
|
||||||
|
type MatchArchiveLocals
|
||||||
|
} from '$lib/server/fleet/match-history-access.js';
|
||||||
|
export function load({
|
||||||
|
url,
|
||||||
|
locals,
|
||||||
|
setHeaders
|
||||||
|
}: {
|
||||||
|
url: URL;
|
||||||
|
locals?: MatchArchiveLocals;
|
||||||
|
setHeaders?: (headers: Record<string, string>) => void;
|
||||||
|
}) {
|
||||||
let query;
|
let query;
|
||||||
try {
|
try {
|
||||||
query = readMatchQuery(matchApiParams(url));
|
query = readMatchQuery(matchApiParams(url));
|
||||||
} catch {
|
} catch {
|
||||||
error(400, 'Invalid match history filters.');
|
error(400, 'Invalid match history filters.');
|
||||||
}
|
}
|
||||||
|
if (query.archive) {
|
||||||
|
setHeaders?.({ 'Cache-Control': 'no-store' });
|
||||||
|
requireMatchArchiveAccess(locals?.user);
|
||||||
|
}
|
||||||
return { history: serverMatchHistory(undefined, matchPageSize, query) };
|
return { history: serverMatchHistory(undefined, matchPageSize, query) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,22 @@
|
|||||||
let { data }: { data: import('./$types').PageData } = $props();
|
let { data }: { data: import('./$types').PageData } = $props();
|
||||||
import MatchHistory from '$ui/components/MatchHistory.svelte';
|
import MatchHistory from '$ui/components/MatchHistory.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Matches · Electric Cherry</title></svelte:head>
|
<svelte:head
|
||||||
|
><title>{archive ? 'Moderation match archive' : 'Matches'} · Electric Cherry</title
|
||||||
|
></svelte:head
|
||||||
|
>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Matches" subtitle="Recorded matches across your servers." />
|
<SectionHeader
|
||||||
|
title={archive ? 'Moderation match archive' : 'Matches'}
|
||||||
|
subtitle={archive
|
||||||
|
? 'Retained match reports and chat for evidence review.'
|
||||||
|
: 'Recorded matches across your servers.'}
|
||||||
|
/>
|
||||||
<MatchHistory initial={data.history} />
|
<MatchHistory initial={data.history} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getMatchDetail } from '$lib/server/fleet/match-history.js';
|
import { getMatchDetail } from '$lib/server/fleet/match-history.js';
|
||||||
|
import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js';
|
||||||
|
import {
|
||||||
|
requireMatchArchiveAccess,
|
||||||
|
type MatchArchiveLocals
|
||||||
|
} from '$lib/server/fleet/match-history-access.js';
|
||||||
|
|
||||||
export function load({ params }: { params: { id: string } }) {
|
export function load({
|
||||||
|
params,
|
||||||
|
url,
|
||||||
|
locals,
|
||||||
|
setHeaders
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
url?: URL;
|
||||||
|
locals?: MatchArchiveLocals;
|
||||||
|
setHeaders?: (headers: Record<string, string>) => void;
|
||||||
|
}) {
|
||||||
|
let query;
|
||||||
|
try {
|
||||||
|
query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams());
|
||||||
|
} catch {
|
||||||
|
error(400, 'Invalid match history filters.');
|
||||||
|
}
|
||||||
|
if (query.archive) {
|
||||||
|
setHeaders?.({ 'Cache-Control': 'no-store' });
|
||||||
|
requireMatchArchiveAccess(locals?.user);
|
||||||
|
}
|
||||||
if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found');
|
if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found');
|
||||||
const match = getMatchDetail(Number(params.id));
|
const match = getMatchDetail(Number(params.id), query.archive);
|
||||||
if (!match) error(404, 'Match not found');
|
if (!match) error(404, 'Match not found');
|
||||||
return { match };
|
return { match };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { timestampISO } from '$lib/time-zone.js';
|
||||||
|
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||||
|
import { uiNow } from '$ui/stores/servers.js';
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||||
import type { MatchDetail } from '$lib/match-history.js';
|
import type { MatchDetail } from '$lib/match-history.js';
|
||||||
@@ -16,6 +20,27 @@
|
|||||||
|
|
||||||
let { data }: { data: { match: MatchDetail } } = $props();
|
let { data }: { data: { match: MatchDetail } } = $props();
|
||||||
const match = $derived(data.match);
|
const match = $derived(data.match);
|
||||||
|
const completed = $derived(match.status === 'completed');
|
||||||
|
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
|
||||||
|
const reporting = $derived(
|
||||||
|
match.status === 'active' &&
|
||||||
|
!match.server_deleted_at &&
|
||||||
|
freshStatusTimestamp(
|
||||||
|
timestampISO(match.lastReportedAt) ?? null,
|
||||||
|
browser ? $uiNow : Date.now()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const status = $derived(
|
||||||
|
completed
|
||||||
|
? 'Completed'
|
||||||
|
: match.status === 'active'
|
||||||
|
? reporting
|
||||||
|
? 'In progress'
|
||||||
|
: 'Not reporting'
|
||||||
|
: match.status === 'end-report-missing'
|
||||||
|
? 'End report missing'
|
||||||
|
: 'Interrupted'
|
||||||
|
);
|
||||||
const gameName = $derived(
|
const gameName = $derived(
|
||||||
match.game === 't5'
|
match.game === 't5'
|
||||||
? 'Black Ops I'
|
? 'Black Ops I'
|
||||||
@@ -45,22 +70,27 @@
|
|||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={match.map}
|
title={match.map}
|
||||||
eyebrow={`Match #${match.id}`}
|
eyebrow={`Match #${match.id}`}
|
||||||
subtitle={`${gameName} · Recorded match results`}
|
subtitle={`${gameName} · ${completed ? 'Recorded match results' : 'Recorded match archive'}`}
|
||||||
>
|
>
|
||||||
{#snippet crumbs()}<Breadcrumbs
|
{#snippet crumbs()}<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Match History', href: '/matches' },
|
{
|
||||||
|
label: archive ? 'Moderation archive' : 'Match History',
|
||||||
|
href: archive ? '/matches?matchArchive=1' : '/matches'
|
||||||
|
},
|
||||||
{ label: `Match #${match.id}` }
|
{ label: `Match #${match.id}` }
|
||||||
]}
|
]}
|
||||||
/>{/snippet}
|
/>{/snippet}
|
||||||
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
|
{#snippet actions()}<Badge variant={completed || reporting ? 'online' : 'warning'} size="md"
|
||||||
|
>{status}</Badge
|
||||||
|
>{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
|
||||||
<section class="summary" aria-label="Match summary">
|
<section class="summary" class:unfinished={!completed} aria-label="Match summary">
|
||||||
<Card
|
<Card
|
||||||
><Stat
|
><Stat
|
||||||
label="Final round"
|
label={completed ? 'Final round' : 'Highest observed round'}
|
||||||
value={match.finalRound}
|
value={completed ? match.finalRound : match.observedRound}
|
||||||
tone="accent"
|
tone="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
tween={false}
|
tween={false}
|
||||||
@@ -68,7 +98,7 @@
|
|||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
><Stat
|
><Stat
|
||||||
label="Game time"
|
label={completed ? 'Game time' : 'Recorded duration'}
|
||||||
value={match.durationMs === null ? null : formatDuration(match.durationMs)}
|
value={match.durationMs === null ? null : formatDuration(match.durationMs)}
|
||||||
size="lg"
|
size="lg"
|
||||||
tween={false}
|
tween={false}
|
||||||
@@ -82,8 +112,14 @@
|
|||||||
tween={false}
|
tween={false}
|
||||||
/></Card
|
/></Card
|
||||||
>
|
>
|
||||||
<Card><Stat label="Players at end" value={match.endPlayers} size="lg" tween={false} /></Card
|
{#if completed}<Card
|
||||||
>
|
><Stat
|
||||||
|
label="Players at end"
|
||||||
|
value={match.endPlayers}
|
||||||
|
size="lg"
|
||||||
|
tween={false}
|
||||||
|
/></Card
|
||||||
|
>{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
@@ -104,9 +140,12 @@
|
|||||||
<dd><RelativeTime value={match.startedAt} /></dd>
|
<dd><RelativeTime value={match.startedAt} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Ended</dt>
|
<dt>{completed ? 'Ended' : 'Last reported'}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{#if match.endedAt}<RelativeTime value={match.endedAt} />{:else}—{/if}
|
<RelativeTime
|
||||||
|
value={completed ? match.endedAt : match.lastReportedAt}
|
||||||
|
missing="Not recorded"
|
||||||
|
/>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -119,13 +158,19 @@
|
|||||||
game time is unknown.
|
game time is unknown.
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
{#if !completed}<p class="note">
|
||||||
|
This match has no completed results. The archive preserves observed participants,
|
||||||
|
reported stats, and retained chat for review.
|
||||||
|
</p>{/if}
|
||||||
|
|
||||||
<section class="results" aria-labelledby="players-title">
|
<section class="results" aria-labelledby="players-title">
|
||||||
<div class="results-heading">
|
<div class="results-heading">
|
||||||
<h2 id="players-title">Player results</h2>
|
<h2 id="players-title">{completed ? 'Player results' : 'Recorded participants'}</h2>
|
||||||
<p>
|
<p>
|
||||||
Every recorded player, including those absent from the final report. Stats are the
|
{completed
|
||||||
highest reported counters for this match.
|
? 'Every recorded player, including those absent from the final report.'
|
||||||
|
: 'Every participant recorded before reporting stopped or in the latest available reports.'}
|
||||||
|
Stats are the highest reported counters for this match.
|
||||||
{#if hasScoreTotals}
|
{#if hasScoreTotals}
|
||||||
Total score is the highest recorded cumulative points earned in this match.
|
Total score is the highest recorded cumulative points earned in this match.
|
||||||
{:else}
|
{:else}
|
||||||
@@ -163,10 +208,15 @@
|
|||||||
<small class="guid">{player.guid}</small>
|
<small class="guid">{player.guid}</small>
|
||||||
</th>
|
</th>
|
||||||
<td class="participation"
|
<td class="participation"
|
||||||
><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}
|
><Badge
|
||||||
>{player.presentAtEnd
|
variant={completed && player.presentAtEnd
|
||||||
? 'At match end'
|
? 'online'
|
||||||
: 'Not at match end'}</Badge
|
: 'neutral'}
|
||||||
|
>{!completed
|
||||||
|
? 'Recorded participant'
|
||||||
|
: player.presentAtEnd
|
||||||
|
? 'At match end'
|
||||||
|
: 'Not at match end'}</Badge
|
||||||
>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td
|
>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td
|
||||||
>
|
>
|
||||||
{#each stats as stat}<td class:round={stat.key === 'highestRound'}
|
{#each stats as stat}<td class:round={stat.key === 'highestRound'}
|
||||||
@@ -191,8 +241,8 @@
|
|||||||
{#if hasScoreTotals}
|
{#if hasScoreTotals}
|
||||||
<p>
|
<p>
|
||||||
Total score is the highest recorded cumulative points earned in this match. Players
|
Total score is the highest recorded cumulative points earned in this match. Players
|
||||||
absent from the final report retain their latest recorded total. A dash in Total
|
{completed ? 'absent from the final report' : 'absent from later reports'} retain their
|
||||||
score means no total was recorded for that player.
|
latest recorded total. A dash in Total score means no total was recorded for that player.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>
|
<p>
|
||||||
@@ -203,9 +253,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<p>
|
<p>
|
||||||
Highest round includes late joins. Rounds survived counts only observed round
|
Highest round includes late joins. Rounds survived counts only observed round
|
||||||
progression while the player was present. “At match end” means the player appeared in
|
progression while the player was present. {#if completed}“At match end” means the player
|
||||||
the final report; other players keep their earlier stats. A dash means no value was
|
appeared in the final report; other players keep their earlier stats.{:else}Participation
|
||||||
recorded.
|
confirms a recorded report and does not establish who was present at the end.{/if} A dash
|
||||||
|
means no value was recorded.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Player names use the latest recorded identity. Times reflect when reports were received
|
Player names use the latest recorded identity. Times reflect when reports were received
|
||||||
@@ -240,6 +291,9 @@
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.summary.unfinished {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.details {
|
.details {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -341,7 +395,8 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.summary {
|
.summary,
|
||||||
|
.summary.unfinished {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.details {
|
.details {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
import { canModerate } from '$lib/auth.js';
|
import { canModerate } from '$lib/auth.js';
|
||||||
import {
|
import {
|
||||||
canRevokeModerationRestriction,
|
canRevokeModerationRestriction,
|
||||||
|
moderationCaseStatuses,
|
||||||
|
moderationCaseStatusLabels,
|
||||||
|
moderationConcernLabels,
|
||||||
|
moderationFindingLabels,
|
||||||
type ModerationOverview,
|
type ModerationOverview,
|
||||||
type ModerationQuery,
|
type ModerationQuery,
|
||||||
type ModerationGame
|
type ModerationGame
|
||||||
@@ -14,7 +18,8 @@
|
|||||||
import {
|
import {
|
||||||
moderationActionLabels,
|
moderationActionLabels,
|
||||||
moderationGameName,
|
moderationGameName,
|
||||||
moderationEventDetail
|
moderationEventDetail,
|
||||||
|
moderationCaseHref
|
||||||
} from '$ui/moderation.js';
|
} from '$ui/moderation.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import Freshness from '$ui/components/Freshness.svelte';
|
import Freshness from '$ui/components/Freshness.svelte';
|
||||||
@@ -52,9 +58,7 @@
|
|||||||
let generation = 0;
|
let generation = 0;
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
let dialogKey = $state(0);
|
let dialogKey = $state(0);
|
||||||
let mode = $state<
|
let mode = $state<'restriction' | 'action' | 'case' | 'note' | 'revoke'>('restriction');
|
||||||
'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve'
|
|
||||||
>('restriction');
|
|
||||||
let seed = $state<{
|
let seed = $state<{
|
||||||
game?: ModerationGame;
|
game?: ModerationGame;
|
||||||
guid?: string;
|
guid?: string;
|
||||||
@@ -160,7 +164,15 @@
|
|||||||
dialogKey++;
|
dialogKey++;
|
||||||
dialogOpen = true;
|
dialogOpen = true;
|
||||||
}
|
}
|
||||||
function submitted(target: { game?: ModerationGame; guid?: string | null }) {
|
function submitted(target: { game?: ModerationGame; guid?: string | null; id?: string }) {
|
||||||
|
if (mode === 'case' && target.id) {
|
||||||
|
cancelRefresh();
|
||||||
|
void goto(moderationCaseHref(target.id), { invalidateAll: true }).catch(() => {
|
||||||
|
navigationError = 'Case saved. Open it from the case list or refresh to try again.';
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
notice = 'Saved. Enforcement and delivery status are shown below.';
|
notice = 'Saved. Enforcement and delivery status are shown below.';
|
||||||
cancelRefresh();
|
cancelRefresh();
|
||||||
const view =
|
const view =
|
||||||
@@ -237,6 +249,10 @@
|
|||||||
error={refreshError ? 'Updates paused' : ''}
|
error={refreshError ? 'Updates paused' : ''}
|
||||||
/>
|
/>
|
||||||
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
|
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
|
||||||
|
<Button href="/matches?matchArchive=1">Chat archive</Button>
|
||||||
|
{#if report.unreadCount}<Button href="/moderation?view=cases&review=updates"
|
||||||
|
>Your case updates ({report.unreadCount})</Button
|
||||||
|
>{/if}
|
||||||
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
|
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
|
||||||
>Restrict player</Button
|
>Restrict player</Button
|
||||||
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
|
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
|
||||||
@@ -320,7 +336,16 @@
|
|||||||
class="filters"
|
class="filters"
|
||||||
onsubmit={(event) => {
|
onsubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void navigate({ q: filters.q, game: filters.game, guid: filters.guid, offset: null });
|
void navigate({
|
||||||
|
q: filters.q,
|
||||||
|
game: filters.game,
|
||||||
|
guid: filters.guid,
|
||||||
|
caseStatus: filters.caseStatus ?? null,
|
||||||
|
priority: filters.priority ?? null,
|
||||||
|
assignedTo: filters.assignedTo ?? null,
|
||||||
|
review: filters.review ?? null,
|
||||||
|
offset: null
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label="Search moderation" class="search"
|
<Field label="Search moderation" class="search"
|
||||||
@@ -343,6 +368,58 @@
|
|||||||
/></Field
|
/></Field
|
||||||
>
|
>
|
||||||
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
|
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
|
||||||
|
{#if report.query.view === 'cases'}
|
||||||
|
<Field label="Review queue"
|
||||||
|
><Select
|
||||||
|
name="review"
|
||||||
|
bind:value={filters.review}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All cases' },
|
||||||
|
{ value: 'due', label: 'Overdue reviews' },
|
||||||
|
{ value: 'scheduled', label: 'Scheduled reviews' },
|
||||||
|
{ value: 'updates', label: 'Your unread updates' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Case status"
|
||||||
|
><Select
|
||||||
|
name="caseStatus"
|
||||||
|
bind:value={filters.caseStatus}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All statuses' },
|
||||||
|
{ value: 'active', label: 'Active cases' },
|
||||||
|
...moderationCaseStatuses.map((status) => ({
|
||||||
|
value: status,
|
||||||
|
label: moderationCaseStatusLabels[status]
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Priority"
|
||||||
|
><Select
|
||||||
|
name="priority"
|
||||||
|
bind:value={filters.priority}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All priorities' },
|
||||||
|
{ value: 'urgent', label: 'Urgent' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'low', label: 'Low' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
<Field label="Assignment"
|
||||||
|
><Select
|
||||||
|
name="assignedTo"
|
||||||
|
bind:value={filters.assignedTo}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Anyone' },
|
||||||
|
{ value: 'me', label: 'Assigned to me' },
|
||||||
|
{ value: 'unassigned', label: 'Unassigned' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
<Button type="submit" disabled={busy}>Apply</Button>
|
<Button type="submit" disabled={busy}>Apply</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -354,7 +431,11 @@
|
|||||||
offset: null,
|
offset: null,
|
||||||
server: null,
|
server: null,
|
||||||
message: null,
|
message: null,
|
||||||
matchId: null
|
matchId: null,
|
||||||
|
caseStatus: null,
|
||||||
|
priority: null,
|
||||||
|
assignedTo: null,
|
||||||
|
review: null
|
||||||
})}>Reset</Button
|
})}>Reset</Button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
@@ -507,6 +588,9 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{#if sanction.caseId}<a href={moderationCaseHref(sanction.caseId)}
|
||||||
|
>View linked case</a
|
||||||
|
>{/if}
|
||||||
{#if sanction.note}<p class="private-note">
|
{#if sanction.note}<p class="private-note">
|
||||||
<strong>Private note</strong>
|
<strong>Private note</strong>
|
||||||
{sanction.note}
|
{sanction.note}
|
||||||
@@ -530,53 +614,114 @@
|
|||||||
description="Restrictions matching these filters will appear here, including expiry and revocation history."
|
description="Restrictions matching these filters will appear here, including expiry and revocation history."
|
||||||
/>{/if}
|
/>{/if}
|
||||||
{:else if report.query.view === 'cases'}
|
{:else if report.query.view === 'cases'}
|
||||||
|
{#if report.notifications?.length}
|
||||||
|
<section class="record" aria-label="Your case notifications">
|
||||||
|
<h2>Your case updates ({report.unreadCount ?? report.notifications.length})</h2>
|
||||||
|
<ul class="notification-list">
|
||||||
|
{#each report.notifications as notification (notification.id)}
|
||||||
|
<li>
|
||||||
|
<a href={moderationCaseHref(notification.caseId)}
|
||||||
|
>{notification.caseTitle}</a
|
||||||
|
>
|
||||||
|
· {notification.kind === 'evidence'
|
||||||
|
? 'Evidence or note added'
|
||||||
|
: 'Assignment changed'} · <RelativeTime
|
||||||
|
value={notification.createdAt}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="hint">Open a case to review and mark its updates read.</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
<div class="view-actions">
|
||||||
|
<Button href="/moderation?view=cases&review=due">Overdue reviews</Button><Button
|
||||||
|
href="/moderation?view=cases&review=updates">Your unread updates</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
{#if permitted}<div class="view-actions">
|
{#if permitted}<div class="view-actions">
|
||||||
<Button onclick={() => openDialog('case')}>Open case</Button>
|
<Button onclick={() => openDialog('case')}>Open case</Button>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{#if report.cases.length}<div class="record-list">
|
{#if report.cases.length}<div class="record-list">
|
||||||
{#each report.cases as item (item.id)}<article class="record">
|
{#each report.cases as item (item.id)}<article class="record">
|
||||||
<div class="record-heading">
|
<div class="record-heading">
|
||||||
<h2>{item.title}</h2>
|
<h2><a href={moderationCaseHref(item.id)}>{item.title}</a></h2>
|
||||||
<StatusBadge state={`moderation-${item.status}`} />
|
<StatusBadge state={`moderation-${item.status}`} />
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
|
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
|
||||||
>{item.guid}</a
|
>{item.guid}</a
|
||||||
>
|
>
|
||||||
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime
|
· {moderationGameName(item.game)}
|
||||||
value={item.createdAt}
|
|
||||||
/>
|
|
||||||
</p>
|
</p>
|
||||||
<details>
|
{#if item.assessment}
|
||||||
<summary>Evidence and staff notes ({item.evidence.length})</summary>
|
<p class="assessment-summary">{item.assessment.summary}</p>
|
||||||
<ol class="evidence-list">
|
<div class="record-heading" aria-label="Assessment findings">
|
||||||
{#each item.evidence as evidence (evidence.id)}<li>
|
{#each item.assessment.findings as finding}
|
||||||
<p>{evidence.text}</p>
|
<Badge
|
||||||
<small
|
variant={finding.finding === 'substantiated'
|
||||||
>{evidence.source} · {evidence.actor} · <RelativeTime
|
? 'warning'
|
||||||
value={evidence.createdAt}
|
: 'neutral'}
|
||||||
/></small
|
>
|
||||||
>
|
{moderationConcernLabels[finding.category]} · {moderationFindingLabels[
|
||||||
</li>{:else}<li>No evidence or notes recorded.</li>{/each}
|
finding.finding
|
||||||
</ol>
|
]}
|
||||||
</details>
|
</Badge>
|
||||||
<div class="record-heading">
|
{/each}
|
||||||
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div
|
</div>
|
||||||
class="row-actions"
|
{:else}<p class="hint">Not assessed</p>{/if}
|
||||||
|
{#if item.reviewAt}<div class="record-heading">
|
||||||
|
<Badge
|
||||||
|
variant={Date.parse(item.reviewAt) <=
|
||||||
|
Date.parse(report.checkedAt)
|
||||||
|
? 'warning'
|
||||||
|
: 'info'}
|
||||||
|
>{Date.parse(item.reviewAt) <= Date.parse(report.checkedAt)
|
||||||
|
? 'Overdue review'
|
||||||
|
: 'Review scheduled'}</Badge
|
||||||
>
|
>
|
||||||
<Button
|
<time datetime={item.reviewAt}
|
||||||
size="sm"
|
>{$timeFormat.dateTime(item.reviewAt)}</time
|
||||||
onclick={() => openDialog('case-note', { id: item.id })}
|
>
|
||||||
>Add case note</Button
|
</div>{/if}
|
||||||
>{#if item.status === 'open'}<Button
|
{#if item.unreadUpdates}<p>
|
||||||
size="sm"
|
<Badge variant="info"
|
||||||
onclick={() =>
|
>{item.unreadUpdates} unread {item.unreadUpdates === 1
|
||||||
openDialog('resolve', {
|
? 'update'
|
||||||
id: item.id,
|
: 'updates'}</Badge
|
||||||
revision: item.revision
|
>
|
||||||
})}>Review case</Button
|
</p>{/if}
|
||||||
>{/if}
|
<dl>
|
||||||
</div>{/if}
|
<div>
|
||||||
|
<dt>Priority</dt>
|
||||||
|
<dd>
|
||||||
|
{item.priority.charAt(0).toUpperCase() +
|
||||||
|
item.priority.slice(1)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Assigned to</dt>
|
||||||
|
<dd>{item.assignee ?? 'Unassigned'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Updated</dt>
|
||||||
|
<dd><RelativeTime value={item.updatedAt} /></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Opened by</dt>
|
||||||
|
<dd>{item.actor} · <RelativeTime value={item.createdAt} /></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="record-heading">
|
||||||
|
<span class="hint"
|
||||||
|
>{item.evidence.length} evidence and staff note {item.evidence
|
||||||
|
.length === 1
|
||||||
|
? 'entry'
|
||||||
|
: 'entries'}</span
|
||||||
|
>
|
||||||
|
<Button href={moderationCaseHref(item.id)} size="sm"
|
||||||
|
>View case</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</article>{/each}
|
</article>{/each}
|
||||||
</div>{:else}<EmptyState
|
</div>{:else}<EmptyState
|
||||||
@@ -628,10 +773,12 @@
|
|||||||
: ''}{event.game
|
: ''}{event.game
|
||||||
? ` · ${moderationGameName(event.game)}`
|
? ` · ${moderationGameName(event.game)}`
|
||||||
: ''}</small
|
: ''}</small
|
||||||
>{#if event.subjectId}<CopyValue
|
>{#if event.subjectId}
|
||||||
value={event.subjectId}
|
{#if event.action.startsWith('case.')}<a
|
||||||
label="Record ID"
|
href={moderationCaseHref(event.subjectId)}>View case</a
|
||||||
/>{/if}
|
>{/if}
|
||||||
|
<CopyValue value={event.subjectId} label="Record ID" />
|
||||||
|
{/if}
|
||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ol>{:else}<EmptyState
|
</ol>{:else}<EmptyState
|
||||||
title="No history found"
|
title="No history found"
|
||||||
@@ -788,15 +935,6 @@
|
|||||||
.private-note {
|
.private-note {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
.evidence-list {
|
|
||||||
padding-left: var(--space-5);
|
|
||||||
}
|
|
||||||
.evidence-list li {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
.evidence-list small {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.audit-list {
|
.audit-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -818,4 +956,17 @@
|
|||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.assessment-summary {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.notification-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-5);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { AuthError } from '$lib/server/auth/index.js';
|
||||||
|
import { requireUser } from '$lib/server/auth/http.js';
|
||||||
|
import { moderationCaseDetail } from '$lib/server/moderation/cases.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ locals, params, setHeaders }) => {
|
||||||
|
setHeaders({ 'Cache-Control': 'no-store' });
|
||||||
|
try {
|
||||||
|
return { moderationCase: moderationCaseDetail(requireUser(locals.user).id, params.id) };
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof AuthError) error(cause.status, cause.message);
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack, type ComponentProps } from 'svelte';
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
import { navigating, page } from '$app/stores';
|
||||||
|
import { canModerate } from '$lib/auth.js';
|
||||||
|
import {
|
||||||
|
canRevokeModerationRestriction,
|
||||||
|
isModerationCaseOpen,
|
||||||
|
type ModerationCaseDetail
|
||||||
|
} from '$lib/moderation.js';
|
||||||
|
import { playerProfileHref } from '$lib/leaderboard.js';
|
||||||
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
|
import {
|
||||||
|
moderationActionLabels,
|
||||||
|
moderationActionName,
|
||||||
|
moderationEventDetail,
|
||||||
|
moderationGameName,
|
||||||
|
moderationHref
|
||||||
|
} from '$ui/moderation.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import { pollVisible } from '$ui/poll.js';
|
||||||
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
|
import Freshness from '$ui/components/Freshness.svelte';
|
||||||
|
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||||
|
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||||
|
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
|
||||||
|
import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte';
|
||||||
|
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
|
||||||
|
import CaseAssessment from '$ui/components/moderation/CaseAssessment.svelte';
|
||||||
|
import CaseRelationships from '$ui/components/moderation/CaseRelationships.svelte';
|
||||||
|
import CaseFollowUp from '$ui/components/moderation/CaseFollowUp.svelte';
|
||||||
|
import CaseUpdates from '$ui/components/moderation/CaseUpdates.svelte';
|
||||||
|
import { preservedConversation } from '$ui/moderation-evidence.js';
|
||||||
|
|
||||||
|
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
|
||||||
|
let snapshot = $state<ModerationCaseDetail | null>(null);
|
||||||
|
const report = $derived(snapshot ?? data.moderationCase);
|
||||||
|
const item = $derived(report.case);
|
||||||
|
const active = $derived(isModerationCaseOpen(item.status));
|
||||||
|
const permitted = $derived(canModerate($page.data?.user?.role));
|
||||||
|
const admin = $derived($page.data?.user?.role === 'admin');
|
||||||
|
const busy = $derived(!!$navigating);
|
||||||
|
const archivedEvidence = $derived(item.evidence.filter((entry) => !!entry.snapshot));
|
||||||
|
const notes = $derived(item.evidence.filter((entry) => !entry.snapshot));
|
||||||
|
const coverage = $derived(
|
||||||
|
report.coverage.filter(
|
||||||
|
(server) =>
|
||||||
|
server.game === item.game ||
|
||||||
|
report.sanctions.some((restriction) => restriction.scope === 'fleet')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let refreshError = $state('');
|
||||||
|
let notice = $state('');
|
||||||
|
let updatedAt = $state<number | null>(null);
|
||||||
|
let generation = 0;
|
||||||
|
let controller: AbortController | undefined;
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let linkActionOpen = $state(false);
|
||||||
|
let dialogKey = $state(0);
|
||||||
|
let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note');
|
||||||
|
let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const initial = data.moderationCase;
|
||||||
|
untrack(() => {
|
||||||
|
cancelRefresh();
|
||||||
|
snapshot = null;
|
||||||
|
refreshError = '';
|
||||||
|
notice = '';
|
||||||
|
dialogOpen = false;
|
||||||
|
linkActionOpen = false;
|
||||||
|
updatedAt = Date.parse(initial.checkedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancelRefresh() {
|
||||||
|
generation++;
|
||||||
|
controller?.abort();
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
beforeNavigate(cancelRefresh);
|
||||||
|
async function refresh(signal?: AbortSignal) {
|
||||||
|
if (busy || refreshing) return;
|
||||||
|
const current = ++generation;
|
||||||
|
const request = new AbortController();
|
||||||
|
controller = request;
|
||||||
|
const abort = () => request.abort();
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
if (signal?.aborted) request.abort();
|
||||||
|
const timeout = setTimeout(abort, 30_000);
|
||||||
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/moderation/cases/${encodeURIComponent(item.id)}`, {
|
||||||
|
signal: request.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Refresh failed');
|
||||||
|
const next: ModerationCaseDetail = await response.json();
|
||||||
|
if (current !== generation || request.signal.aborted) return;
|
||||||
|
snapshot = next;
|
||||||
|
updatedAt = Date.now();
|
||||||
|
refreshError = '';
|
||||||
|
} catch {
|
||||||
|
if (current === generation)
|
||||||
|
refreshError =
|
||||||
|
'Case updates paused. Showing the last loaded records. Refresh before making another change.';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
if (current === generation) refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
updatedAt = Date.now();
|
||||||
|
const stop = pollVisible(refresh, 15_000, false);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
cancelRefresh();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
function openDialog(next: typeof mode, target: typeof seed = {}) {
|
||||||
|
mode = next;
|
||||||
|
const matchId = Number($page.url.searchParams.get('matchId'));
|
||||||
|
const message = $page.url.searchParams.get('message');
|
||||||
|
seed = {
|
||||||
|
game: item.game,
|
||||||
|
guid: item.guid,
|
||||||
|
id: item.id,
|
||||||
|
revision: item.revision,
|
||||||
|
title: item.title,
|
||||||
|
priority: item.priority,
|
||||||
|
assigneeId: item.assigneeId,
|
||||||
|
assignee: item.assignee,
|
||||||
|
status: item.status,
|
||||||
|
...(next === 'restriction' ? { caseId: item.id, caseRevision: item.revision } : {}),
|
||||||
|
...(next === 'evidence' && Number.isSafeInteger(matchId) && matchId > 0 && message
|
||||||
|
? { matchId, message }
|
||||||
|
: {}),
|
||||||
|
...target
|
||||||
|
};
|
||||||
|
dialogKey++;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
function submitted() {
|
||||||
|
notice =
|
||||||
|
mode === 'restriction'
|
||||||
|
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you change its status.'
|
||||||
|
: mode === 'revoke'
|
||||||
|
? 'Revocation saved. Servers apply it when they receive the latest policy.'
|
||||||
|
: 'Case updated. The change is recorded in the timeline.';
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
function actionLinked() {
|
||||||
|
notice =
|
||||||
|
'Existing action linked to this case. Its original date and outcome are preserved.';
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
function assessmentSaved() {
|
||||||
|
notice = 'Case assessment saved. The earlier assessment remains in revision history.';
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveContext(raw: string | null | undefined) {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
return {
|
||||||
|
matchId:
|
||||||
|
Number.isSafeInteger(value.matchId) && value.matchId > 0
|
||||||
|
? (value.matchId as number)
|
||||||
|
: null,
|
||||||
|
server:
|
||||||
|
typeof value.match?.server === 'string' ? (value.match.server as string) : null,
|
||||||
|
startedAt:
|
||||||
|
typeof value.match?.started_at === 'string'
|
||||||
|
? (value.match.started_at as string)
|
||||||
|
: null,
|
||||||
|
player:
|
||||||
|
typeof value.entry?.message?.player === 'string'
|
||||||
|
? (value.entry.message.player as string)
|
||||||
|
: null,
|
||||||
|
time:
|
||||||
|
typeof value.entry?.message?.time === 'string'
|
||||||
|
? (value.entry.message.time as string)
|
||||||
|
: null,
|
||||||
|
realTime:
|
||||||
|
typeof value.entry?.context?.realTime === 'string'
|
||||||
|
? (value.entry.context.realTime as string)
|
||||||
|
: null,
|
||||||
|
round: Number.isInteger(value.entry?.context?.round)
|
||||||
|
? (value.entry.context.round as number)
|
||||||
|
: null,
|
||||||
|
original: JSON.stringify(value, null, 2),
|
||||||
|
excerpt: preservedConversation(value)
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{item.title} · Moderation · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="case-page">
|
||||||
|
<SectionHeader
|
||||||
|
title={item.title}
|
||||||
|
eyebrow="Moderation case"
|
||||||
|
subtitle={`${moderationGameName(item.game)} · ${report.player.name}`}
|
||||||
|
>
|
||||||
|
{#snippet crumbs()}<a href="/moderation?view=cases">Moderation cases</a>{/snippet}
|
||||||
|
{#snippet actions()}
|
||||||
|
<Freshness
|
||||||
|
{updatedAt}
|
||||||
|
intervalLabel="every 15 s"
|
||||||
|
error={refreshError ? 'Updates paused' : ''}
|
||||||
|
/>
|
||||||
|
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
|
||||||
|
{#if permitted}
|
||||||
|
<Button variant="primary" onclick={() => openDialog('case-status')}
|
||||||
|
>Change status</Button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</SectionHeader>
|
||||||
|
|
||||||
|
{#if !permitted}<p class="notice">
|
||||||
|
Read-only moderation access. You can inspect this case, its evidence, and linked actions
|
||||||
|
and restrictions.
|
||||||
|
</p>{/if}
|
||||||
|
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||||
|
{#if refreshError}<div class="ui-error" role="alert">
|
||||||
|
{refreshError}<Button size="sm" onclick={() => refresh()} loading={refreshing}
|
||||||
|
>Retry</Button
|
||||||
|
>
|
||||||
|
</div>{/if}
|
||||||
|
|
||||||
|
<section class="case-summary panel" aria-label="Case overview">
|
||||||
|
<div class="row">
|
||||||
|
<StatusBadge state={`moderation-${item.status}`} />
|
||||||
|
<Badge
|
||||||
|
variant={item.priority === 'urgent'
|
||||||
|
? 'danger'
|
||||||
|
: item.priority === 'high'
|
||||||
|
? 'warning'
|
||||||
|
: 'neutral'}
|
||||||
|
>{item.priority[0].toUpperCase() + item.priority.slice(1)} priority</Badge
|
||||||
|
>
|
||||||
|
<span>{active ? 'Active case' : 'Closed case'}</span>
|
||||||
|
{#if permitted}<Button size="sm" onclick={() => openDialog('edit-case')}
|
||||||
|
>Edit case</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
<dl class="case-metadata">
|
||||||
|
<div>
|
||||||
|
<dt>Assigned to</dt>
|
||||||
|
<dd>{item.assignee ?? 'Unassigned'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Opened by</dt>
|
||||||
|
<dd>{item.actor} · <RelativeTime value={item.createdAt} /></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Last updated</dt>
|
||||||
|
<dd><RelativeTime value={item.updatedAt} /></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<CopyValue value={item.id} label="Case ID" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<div class="case-main">
|
||||||
|
<CaseAssessment
|
||||||
|
caseId={item.id}
|
||||||
|
revision={item.revision}
|
||||||
|
assessment={report.assessment}
|
||||||
|
history={report.assessmentHistory}
|
||||||
|
{permitted}
|
||||||
|
onsubmitted={assessmentSaved}
|
||||||
|
/>
|
||||||
|
<section class="panel" aria-labelledby="evidence-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="evidence-title">
|
||||||
|
Archived evidence <span>({archivedEvidence.length})</span>
|
||||||
|
</h2>
|
||||||
|
{#if permitted}<Button size="sm" onclick={() => openDialog('evidence')}
|
||||||
|
>Attach evidence</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if archivedEvidence.length}
|
||||||
|
<ol class="records">
|
||||||
|
{#each archivedEvidence as evidence (evidence.id)}
|
||||||
|
{@const archive = archiveContext(evidence.snapshot)}
|
||||||
|
<li class="record">
|
||||||
|
<div class="row">
|
||||||
|
<strong>{archive?.player ?? 'Archived message'}</strong
|
||||||
|
>{#if archive?.round !== null && archive?.round !== undefined}<Badge
|
||||||
|
>Round {archive.round}</Badge
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if archive?.excerpt}
|
||||||
|
<EvidenceMessages {...archive.excerpt} />
|
||||||
|
{:else}<blockquote>{evidence.text}</blockquote>{/if}
|
||||||
|
<p class="hint">{evidence.source}</p>
|
||||||
|
{#if archive}<div class="source-context">
|
||||||
|
{#if archive.matchId}<a
|
||||||
|
href={contextHref(
|
||||||
|
`/matches/${archive.matchId}?matchArchive=1`,
|
||||||
|
$page.url
|
||||||
|
)}>View match #{archive.matchId}</a
|
||||||
|
>{/if}
|
||||||
|
{#if archive.realTime}<span
|
||||||
|
>Estimated message time · {$timeFormat.dateTime(
|
||||||
|
archive.realTime
|
||||||
|
)}</span
|
||||||
|
>{/if}
|
||||||
|
{#if archive.time}<span
|
||||||
|
>Original log time · {archive.time}</span
|
||||||
|
>{/if}
|
||||||
|
{#if archive.startedAt}<span
|
||||||
|
>Match started · {$timeFormat.dateTime(
|
||||||
|
archive.startedAt
|
||||||
|
)}</span
|
||||||
|
>{/if}
|
||||||
|
</div>{/if}
|
||||||
|
<p class="hint">
|
||||||
|
Preserved by {evidence.actor} · <RelativeTime
|
||||||
|
value={evidence.createdAt}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
{#if archive}<details>
|
||||||
|
<summary>Preserved archive snapshot</summary>
|
||||||
|
<pre>{archive.original}</pre>
|
||||||
|
</details>{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{:else}<EmptyState
|
||||||
|
title="No archived evidence yet"
|
||||||
|
description="Attach a verified chat message to preserve its original player, match, and timing context."
|
||||||
|
/>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="notes-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="notes-title">
|
||||||
|
Staff notes and decisions <span>({notes.length})</span>
|
||||||
|
</h2>
|
||||||
|
{#if permitted}<Button size="sm" onclick={() => openDialog('case-note')}
|
||||||
|
>Add case note</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if notes.length}<ol class="records">
|
||||||
|
{#each notes as evidence (evidence.id)}<li class="record">
|
||||||
|
<div class="row">
|
||||||
|
<strong>{evidence.source}</strong><RelativeTime
|
||||||
|
value={evidence.createdAt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="record-text">{evidence.text}</p>
|
||||||
|
<p class="hint">{evidence.actor}</p>
|
||||||
|
</li>{/each}
|
||||||
|
</ol>{:else}<p class="hint">No staff notes or decisions recorded.</p>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="timeline-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="timeline-title">Case timeline</h2>
|
||||||
|
<span class="hint">Newest first</span>
|
||||||
|
</div>
|
||||||
|
{#if report.events.length}<ol class="records timeline">
|
||||||
|
{#each report.events as event (event.id)}<li class="record">
|
||||||
|
<div class="section-heading">
|
||||||
|
<strong
|
||||||
|
>{moderationActionLabels[event.action] ??
|
||||||
|
event.action}</strong
|
||||||
|
><RelativeTime value={event.createdAt} />
|
||||||
|
</div>
|
||||||
|
<p class="record-text">
|
||||||
|
{moderationEventDetail(event.detail, event.action)}
|
||||||
|
</p>
|
||||||
|
<p class="hint">{event.actor}</p>
|
||||||
|
</li>{/each}
|
||||||
|
</ol>{:else}<p class="hint">No case activity recorded yet.</p>{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="case-sidebar">
|
||||||
|
<CaseFollowUp
|
||||||
|
{item}
|
||||||
|
actions={report.linkedActions}
|
||||||
|
checkedAt={report.checkedAt}
|
||||||
|
{permitted}
|
||||||
|
onsubmitted={() => {
|
||||||
|
notice = 'Follow-up review updated.';
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CaseUpdates
|
||||||
|
caseId={item.id}
|
||||||
|
notifications={report.notifications}
|
||||||
|
unread={item.unreadUpdates}
|
||||||
|
{permitted}
|
||||||
|
onsubmitted={() => {
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CaseRelationships
|
||||||
|
caseId={item.id}
|
||||||
|
revision={item.revision}
|
||||||
|
relationships={report.relatedCases}
|
||||||
|
candidates={report.candidateCases}
|
||||||
|
{permitted}
|
||||||
|
onsubmitted={() => {
|
||||||
|
notice =
|
||||||
|
'Case relationship updated. The change is recorded in both case histories.';
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<section class="panel" aria-labelledby="player-title">
|
||||||
|
<h2 id="player-title">Player identity</h2>
|
||||||
|
<a class="player-name" href={contextHref(playerProfileHref(item.guid), $page.url)}
|
||||||
|
>{report.player.name}</a
|
||||||
|
>
|
||||||
|
<p class="hint">{moderationGameName(item.game)}</p>
|
||||||
|
<CopyValue value={item.guid} label="Player GUID" />
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Known aliases</dt>
|
||||||
|
<dd>{report.player.aliases.join(', ') || 'None recorded'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Last seen</dt>
|
||||||
|
<dd>
|
||||||
|
<RelativeTime value={report.player.lastSeen} missing="Not recorded" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Active restrictions</dt>
|
||||||
|
<dd>{report.player.restrictions}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<a
|
||||||
|
href={`${moderationHref({ game: item.game, guid: item.guid })}&view=restrictions`}
|
||||||
|
>View all player restrictions</a
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="actions-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="actions-title">Linked actions</h2>
|
||||||
|
{#if permitted}<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!report.availableActions?.length}
|
||||||
|
onclick={() => (linkActionOpen = true)}>Link existing action</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if report.linkedActions?.length}<ol class="records">
|
||||||
|
{#each report.linkedActions as action (`${action.type}:${action.id}`)}<li
|
||||||
|
class="record"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<strong>{moderationActionName(action.kind)}</strong><StatusBadge
|
||||||
|
state={`moderation-${action.state}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="record-text">{action.reason}</p>
|
||||||
|
<p class="hint">
|
||||||
|
{action.actor} · <RelativeTime value={action.createdAt} />
|
||||||
|
</p>
|
||||||
|
<CopyValue
|
||||||
|
value={action.id}
|
||||||
|
label={action.type === 'restriction'
|
||||||
|
? 'Restriction ID'
|
||||||
|
: 'Action ID'}
|
||||||
|
/>
|
||||||
|
</li>{/each}
|
||||||
|
</ol>{:else}<p class="hint">
|
||||||
|
No existing actions are linked to this case.
|
||||||
|
</p>{/if}
|
||||||
|
{#if permitted}<p class="hint">
|
||||||
|
{report.availableActions?.length
|
||||||
|
? `${report.availableActions.length} previous actions available for this player.`
|
||||||
|
: 'No additional recorded actions are available for this player.'}
|
||||||
|
</p>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="restrictions-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="restrictions-title">Linked restrictions</h2>
|
||||||
|
{#if permitted && active}<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onclick={() => openDialog('restriction')}>Restrict player</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if !active && permitted}<p class="hint">
|
||||||
|
Reopen this case before applying another restriction.
|
||||||
|
</p>{/if}
|
||||||
|
{#if report.sanctions.length}<ol class="records">
|
||||||
|
{#each report.sanctions as restriction (restriction.id)}<li class="record">
|
||||||
|
<div class="row">
|
||||||
|
<strong>{restriction.kind === 'ban' ? 'Ban' : 'Mute'}</strong
|
||||||
|
><StatusBadge
|
||||||
|
state={`moderation-${restriction.active ? 'active' : restriction.revokedAt ? 'revoked' : 'expired'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="record-text">{restriction.reason}</p>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Scope</dt>
|
||||||
|
<dd>
|
||||||
|
{#if restriction.server}<ServerIdentity
|
||||||
|
name={restriction.server}
|
||||||
|
/>{:else}{restriction.scope === 'fleet'
|
||||||
|
? 'Entire fleet'
|
||||||
|
: moderationGameName(restriction.game)}{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Expires</dt>
|
||||||
|
<dd>
|
||||||
|
{restriction.expiresAt
|
||||||
|
? $timeFormat.dateTime(restriction.expiresAt)
|
||||||
|
: 'Permanent'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Applied by</dt>
|
||||||
|
<dd>
|
||||||
|
{restriction.actor} · <RelativeTime
|
||||||
|
value={restriction.createdAt}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{#if restriction.revokedAt}<div>
|
||||||
|
<dt>Revoked</dt>
|
||||||
|
<dd>{$timeFormat.dateTime(restriction.revokedAt)}</dd>
|
||||||
|
</div>{/if}
|
||||||
|
</dl>
|
||||||
|
{#if restriction.note}<p class="record-text">
|
||||||
|
<strong>Private note</strong> · {restriction.note}
|
||||||
|
</p>{/if}
|
||||||
|
<CopyValue value={restriction.id} label="Restriction ID" />
|
||||||
|
{#if restriction.active && canRevokeModerationRestriction($page.data?.user, restriction)}<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() =>
|
||||||
|
openDialog('revoke', {
|
||||||
|
id: restriction.id,
|
||||||
|
revision: restriction.revision
|
||||||
|
})}>Revoke restriction</Button
|
||||||
|
>{/if}
|
||||||
|
</li>{/each}
|
||||||
|
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
|
||||||
|
<p class="hint">
|
||||||
|
Applying or revoking a restriction does not change the case status.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="coverage-title">
|
||||||
|
<h2 id="coverage-title">Enforcement coverage</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Current means a server acknowledged the latest policy. Pending or unavailable
|
||||||
|
servers may not apply a new restriction or revocation yet.
|
||||||
|
</p>
|
||||||
|
{#if coverage.length}<ul class="records">
|
||||||
|
{#each coverage as server (server.server)}<li class="record">
|
||||||
|
<a href={`/servers/${encodeURIComponent(server.server)}`}
|
||||||
|
><ServerIdentity name={server.server} /></a
|
||||||
|
><StatusBadge state={`moderation-${server.state}`} />
|
||||||
|
<p class="hint">
|
||||||
|
Policy {server.revision ?? '—'} / {server.desiredRevision} · {server.capabilities.join(
|
||||||
|
', '
|
||||||
|
) || 'No capabilities reported'}
|
||||||
|
</p>
|
||||||
|
</li>{/each}
|
||||||
|
</ul>{:else}<p class="hint">
|
||||||
|
No server enforcement reports are available yet.
|
||||||
|
</p>{/if}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if permitted}{#key dialogKey}<ModerationDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{mode}
|
||||||
|
{seed}
|
||||||
|
{admin}
|
||||||
|
coverage={report.coverage}
|
||||||
|
assignees={report.assignees}
|
||||||
|
onsubmitted={submitted}
|
||||||
|
/>{/key}{/if}
|
||||||
|
|
||||||
|
{#if permitted && linkActionOpen}<LinkCaseActionDialog
|
||||||
|
bind:open={linkActionOpen}
|
||||||
|
caseId={item.id}
|
||||||
|
revision={item.revision}
|
||||||
|
actions={report.availableActions ?? []}
|
||||||
|
onsubmitted={actionLinked}
|
||||||
|
/>{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.case-page,
|
||||||
|
.case-main,
|
||||||
|
.case-sidebar,
|
||||||
|
.panel,
|
||||||
|
.records,
|
||||||
|
.record {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.case-page,
|
||||||
|
.case-main,
|
||||||
|
.case-sidebar {
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr);
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.case-main,
|
||||||
|
.case-sidebar {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.row,
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.section-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.row > span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
h2 span {
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.records {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.record {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.record-text,
|
||||||
|
blockquote,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.record-text,
|
||||||
|
blockquote {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--accent-primary);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.hint,
|
||||||
|
dt,
|
||||||
|
.source-context,
|
||||||
|
summary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.source-context {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.case-metadata {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
dl > div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.player-name {
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.case-metadata {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
import { serverSnapshot } from '$lib/server/fleet/snapshot.js';
|
import { serverSnapshot } from '$lib/server/fleet/snapshot.js';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js';
|
||||||
|
import {
|
||||||
|
requireMatchArchiveAccess,
|
||||||
|
type MatchArchiveLocals
|
||||||
|
} from '$lib/server/fleet/match-history-access.js';
|
||||||
|
|
||||||
export function load({ params }: { params: { name: string } }) {
|
export function load({
|
||||||
|
params,
|
||||||
|
url,
|
||||||
|
locals,
|
||||||
|
setHeaders
|
||||||
|
}: {
|
||||||
|
params: { name: string };
|
||||||
|
url?: URL;
|
||||||
|
locals?: MatchArchiveLocals;
|
||||||
|
setHeaders?: (headers: Record<string, string>) => void;
|
||||||
|
}) {
|
||||||
|
let query;
|
||||||
|
try {
|
||||||
|
query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams());
|
||||||
|
} catch {
|
||||||
|
error(400, 'Invalid match history filters.');
|
||||||
|
}
|
||||||
|
if (query.archive) {
|
||||||
|
setHeaders?.({ 'Cache-Control': 'no-store' });
|
||||||
|
requireMatchArchiveAccess(locals?.user);
|
||||||
|
}
|
||||||
return { serverSnapshot: serverSnapshot(params.name) };
|
return { serverSnapshot: serverSnapshot(params.name) };
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-2
@@ -35,7 +35,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.11.0"
|
VERSION = "1.12.0"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -49,6 +49,7 @@ PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
|
|||||||
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
|
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
|
||||||
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
|
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
|
||||||
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
|
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
|
||||||
|
AFK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/afk"
|
||||||
MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg"
|
MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg"
|
||||||
MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n"
|
MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n"
|
||||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||||||
@@ -1053,6 +1054,86 @@ class LibraryTransfer:
|
|||||||
self.persist(force=True)
|
self.persist(force=True)
|
||||||
|
|
||||||
|
|
||||||
|
def afk_recovery_requests(journal):
|
||||||
|
"""Mirror the native journal's deterministic recovery after a proven stop.
|
||||||
|
|
||||||
|
Active crashes never invent an elapsed duration. Their checkpoint is only
|
||||||
|
a lower bound; EC retains the hold for an audited correction. Requests that
|
||||||
|
may already have reached EC preserve their original identities and bodies.
|
||||||
|
"""
|
||||||
|
def identifier(value):
|
||||||
|
return isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", value)
|
||||||
|
|
||||||
|
def seconds(value):
|
||||||
|
return type(value) is int and 0 <= value <= 604800
|
||||||
|
|
||||||
|
def decode(raw):
|
||||||
|
if not isinstance(raw, str) or not 0 < len(raw.encode()) < 0x5000:
|
||||||
|
raise ValueError()
|
||||||
|
value = json.loads(raw)
|
||||||
|
if (not isinstance(value, dict) or value.get("server") != journal["server"]
|
||||||
|
or not all(identifier(value.get(key)) for key in ("guid", "match", "request_id"))
|
||||||
|
or value.get("action") not in ("event", "purchase", "reserve", "start", "settle")):
|
||||||
|
raise ValueError()
|
||||||
|
if value["action"] in ("reserve", "start", "settle") and not identifier(value.get("session_id")):
|
||||||
|
raise ValueError()
|
||||||
|
if value["action"] == "reserve" and (not seconds(value.get("seconds")) or value["seconds"] == 0):
|
||||||
|
raise ValueError()
|
||||||
|
return value
|
||||||
|
|
||||||
|
try:
|
||||||
|
if (not isinstance(journal, dict) or type(journal.get("version")) is not int
|
||||||
|
or journal["version"] != 1 or not isinstance(journal.get("server"), str)
|
||||||
|
or not 0 < len(journal["server"]) <= 128
|
||||||
|
or journal.get("endpoint") != AFK_GAME_ENDPOINT
|
||||||
|
or not isinstance(journal.get("requests"), list)
|
||||||
|
or not isinstance(journal.get("sessions"), list)
|
||||||
|
or len(journal["requests"]) + len(journal["sessions"]) > 4096):
|
||||||
|
raise ValueError()
|
||||||
|
requests = {}
|
||||||
|
for raw in journal["requests"]:
|
||||||
|
value = decode(raw)
|
||||||
|
if value["request_id"] in requests:
|
||||||
|
raise ValueError()
|
||||||
|
requests[value["request_id"]] = value
|
||||||
|
sessions = set()
|
||||||
|
for session in journal["sessions"]:
|
||||||
|
data = decode(session["body"])
|
||||||
|
session_id = data.get("session_id")
|
||||||
|
phase = session["phase"]
|
||||||
|
authorized, consumed = session["authorized_seconds"], session["consumed_seconds"]
|
||||||
|
if (data["action"] != "reserve" or session_id in sessions
|
||||||
|
or phase not in ("prepared", "authorized", "active")
|
||||||
|
or not seconds(authorized) or not seconds(consumed)
|
||||||
|
or consumed > authorized or authorized > data["seconds"]
|
||||||
|
or (phase == "prepared" and authorized != 0)
|
||||||
|
or (phase != "prepared" and authorized == 0)
|
||||||
|
or (phase != "active" and consumed != 0)):
|
||||||
|
raise ValueError()
|
||||||
|
sessions.add(session_id)
|
||||||
|
requests = {key: value for key, value in requests.items()
|
||||||
|
if not (value["action"] in ("reserve", "start") and value["session_id"] == session_id)}
|
||||||
|
hashed = 14695981039346656037
|
||||||
|
scope = journal["server"] + "\0" + journal["endpoint"] + "\0" + session_id
|
||||||
|
for byte in scope.encode():
|
||||||
|
hashed = ((hashed ^ byte) * 1099511628211) & 0xffffffffffffffff
|
||||||
|
transfer = {"server": journal["server"], "guid": data["guid"], "match": data["match"],
|
||||||
|
"session_id": session_id, "request_id": f"afk-stop-{hashed:016x}",
|
||||||
|
"action": "settle", "end_reason": "crash" if phase == "active" else "never_started",
|
||||||
|
"consumed_seconds": None if phase == "active" else 0}
|
||||||
|
if phase == "active":
|
||||||
|
transfer["confirmed_seconds"] = consumed
|
||||||
|
previous = requests.get(transfer["request_id"])
|
||||||
|
if previous is not None and previous != transfer:
|
||||||
|
raise ValueError()
|
||||||
|
requests[transfer["request_id"]] = transfer
|
||||||
|
if any(value["action"] in ("reserve", "start") for value in requests.values()):
|
||||||
|
raise ValueError()
|
||||||
|
return list(requests.values())
|
||||||
|
except (ValueError, TypeError, KeyError, AttributeError):
|
||||||
|
raise AgentError("AFK journal is invalid; runtime storage retained") from None
|
||||||
|
|
||||||
|
|
||||||
class Agent:
|
class Agent:
|
||||||
def __init__(self, config, docker=None):
|
def __init__(self, config, docker=None):
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -1574,7 +1655,7 @@ class Agent:
|
|||||||
if line.startswith("PRETTY_NAME="):
|
if line.startswith("PRETTY_NAME="):
|
||||||
os_name = line.split("=", 1)[1].strip('"')
|
os_name = line.split("=", 1)[1].strip('"')
|
||||||
update = self.update_inventory()
|
update = self.update_inventory()
|
||||||
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement", "afk.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||||
plutonium_cache = self.plutonium_cache_inventory()
|
plutonium_cache = self.plutonium_cache_inventory()
|
||||||
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
||||||
|
|
||||||
@@ -2237,6 +2318,52 @@ class Agent:
|
|||||||
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
|
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
|
||||||
except (ValueError, TypeError, KeyError, AttributeError):
|
except (ValueError, TypeError, KeyError, AttributeError):
|
||||||
raise AgentError("Bank journal is invalid; runtime storage retained") from None
|
raise AgentError("Bank journal is invalid; runtime storage retained") from None
|
||||||
|
self.settle_afk(instance_id, generation, operation_id)
|
||||||
|
|
||||||
|
def settle_afk(self, instance_id, generation, operation_id=None):
|
||||||
|
"""Retain AFK evidence until stopped-process reconciliation is acknowledged."""
|
||||||
|
app = scoped(self.instance_dir(instance_id, generation), "app")
|
||||||
|
if not app.exists():
|
||||||
|
return
|
||||||
|
if self.inspect(instance_id, generation)["running"]:
|
||||||
|
raise AgentError("AFK settlement requires a stopped instance")
|
||||||
|
operation_id = operation_id or self.executing_id
|
||||||
|
visited, journals = 0, []
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
|
||||||
|
def unreadable(_error):
|
||||||
|
raise AgentError("AFK journal discovery failed; runtime storage retained")
|
||||||
|
|
||||||
|
for directory, directories, files in os.walk(app, followlinks=False, onerror=unreadable):
|
||||||
|
relative = Path(directory).relative_to(app)
|
||||||
|
inside = "tws-afk" in relative.parts
|
||||||
|
visited += 1
|
||||||
|
if visited > 100000 or time.monotonic() > deadline:
|
||||||
|
raise AgentError("AFK journal discovery limit exceeded; runtime storage retained")
|
||||||
|
for name in directories + files:
|
||||||
|
if (inside or name == "tws-afk") and (Path(directory) / name).is_symlink():
|
||||||
|
raise AgentError("AFK journal paths must not be symlinks")
|
||||||
|
if inside:
|
||||||
|
journals.extend((relative / name).as_posix() for name in files if name.endswith(".json"))
|
||||||
|
if len(journals) > 4096:
|
||||||
|
raise AgentError("Too many AFK journals; runtime storage retained")
|
||||||
|
for path in sorted(journals):
|
||||||
|
try:
|
||||||
|
raw = managed_read(app, path)
|
||||||
|
if raw is None or len(raw) > 16 * 1024 * 1024:
|
||||||
|
raise ValueError()
|
||||||
|
transfers = afk_recovery_requests(json.loads(raw))
|
||||||
|
except (Rejected, ValueError, UnicodeError):
|
||||||
|
raise AgentError("AFK journal cannot be read; runtime storage retained") from None
|
||||||
|
if transfers and not operation_id:
|
||||||
|
raise AgentError("AFK settlement requires a teardown operation")
|
||||||
|
for transfer in transfers:
|
||||||
|
response = self.request("POST", "/api/agent/afk/settle", {
|
||||||
|
"operationId": operation_id, "action": transfer["action"], "transfer": transfer})
|
||||||
|
expected = ("settled", "unresolved", "corrected") if transfer["action"] == "settle" else ("rewarded", "purchased", "rejected")
|
||||||
|
if (not isinstance(response, dict) or response.get("request_id") != transfer["request_id"]
|
||||||
|
or response.get("state") not in expected):
|
||||||
|
raise AgentError("AFK recovery was not acknowledged; runtime storage retained")
|
||||||
|
|
||||||
def lifecycle(self, kind, instance_id, generation, payload):
|
def lifecycle(self, kind, instance_id, generation, payload):
|
||||||
unhealthy_recovery = payload.get("unhealthyRecovery") is True
|
unhealthy_recovery = payload.get("unhealthyRecovery") is True
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "1.11.0",
|
"version": "1.12.0",
|
||||||
"protocolVersion": 1,
|
"protocolVersion": 1,
|
||||||
"platform": "debian-amd64",
|
"platform": "debian-amd64",
|
||||||
"sha256": "3cb08d5199a6a3bbdc5641e17a7f752b65c79935813f299ea245ffe449a4e861",
|
"sha256": "7b8212ea4a57ef7a37ee3916a0ec0e19965928e373304a64645f3998f87d51d7",
|
||||||
"updaterProtocolVersion": 2,
|
"updaterProtocolVersion": 2,
|
||||||
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
|
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
|
||||||
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
|
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
-----BEGIN PGP SIGNATURE-----
|
||||||
|
|
||||||
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCatJ0WhsUgAAAAAAEAA5t
|
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCatQBURsUgAAAAAAEAA5t
|
||||||
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfqjWQD8CaV5ZH/kD1AhJ15B9czG
|
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfry+gD/diPi+1DJCtK5TH7ViW/6
|
||||||
x69WnENeMXHMjHt0vnIlSuQBAKE+r6scq6M5Y2rbUDbgDFDjir5lR36qR8DGBi6A
|
BsLEaKNo8KrpZn/u9GfcTyMA/2mwyjwWI6EyOqnRJ2VG3DygHSNV0ivK1HLV/3RB
|
||||||
VMQH
|
x68H
|
||||||
=V8dS
|
=nDDI
|
||||||
-----END PGP SIGNATURE-----
|
-----END PGP SIGNATURE-----
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||||
|
import { fromStore, writable } from 'svelte/store';
|
||||||
|
import AfkPage from '../../src/routes/afk/+page.svelte';
|
||||||
|
import { readAfkQuery, type AfkOverview } from '../../src/lib/afk.js';
|
||||||
|
|
||||||
|
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
|
||||||
|
const currentPage = writable({ url: new URL('http://localhost/afk'), data: { user: null } });
|
||||||
|
vi.mock('$app/stores', () => ({
|
||||||
|
page: {
|
||||||
|
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||||
|
},
|
||||||
|
navigating: writable(null)
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
goto: navigation.goto,
|
||||||
|
beforeNavigate: (fn: () => void) => navigation.before.push(fn),
|
||||||
|
afterNavigate: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
beforeEach(() => {
|
||||||
|
navigation.goto.mockReset().mockResolvedValue(undefined);
|
||||||
|
navigation.before.length = 0;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const guid = '18446744073709551615';
|
||||||
|
const server = 't5_zombie_theater_1';
|
||||||
|
const checkedAt = '2026-10-17T12:00:00.000Z';
|
||||||
|
|
||||||
|
function report(search = ''): AfkOverview {
|
||||||
|
const query = readAfkQuery(new URLSearchParams(search));
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
checkedAt,
|
||||||
|
capacitySeconds: 21600,
|
||||||
|
summary: { accounts: 1, savedSeconds: 7200, reservedSeconds: 600, unresolvedSessions: 1 },
|
||||||
|
servers: [server],
|
||||||
|
accounts: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
guid,
|
||||||
|
name: 'Dempsey',
|
||||||
|
freeSeconds: 1200,
|
||||||
|
savedSeconds: 7200,
|
||||||
|
availableSeconds: 8400,
|
||||||
|
reservedSeconds: 600,
|
||||||
|
reservedSavedSeconds: 300,
|
||||||
|
unresolvedSessions: 1,
|
||||||
|
lastActivity: checkedAt,
|
||||||
|
freeRenewsAt: null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
offset: query.offset
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
guid,
|
||||||
|
name: 'Dempsey',
|
||||||
|
sessionId: 'crash-session',
|
||||||
|
server,
|
||||||
|
game: 't5',
|
||||||
|
matchId: 17,
|
||||||
|
matchAvailable: 0,
|
||||||
|
state: 'corrected',
|
||||||
|
authorizedSeconds: 600,
|
||||||
|
consumedSeconds: null,
|
||||||
|
chargedSeconds: 45,
|
||||||
|
confirmedSeconds: 40,
|
||||||
|
endReason: 'crash',
|
||||||
|
createdAt: '2026-10-17T11:00:00.000Z',
|
||||||
|
startedAt: '2026-10-17T11:00:00.000Z',
|
||||||
|
endedAt: '2026-10-17T11:00:45.000Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
offset: query.offset
|
||||||
|
},
|
||||||
|
ledger: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
guid,
|
||||||
|
name: 'Dempsey',
|
||||||
|
id: 1,
|
||||||
|
server,
|
||||||
|
game: 't5',
|
||||||
|
matchId: 17,
|
||||||
|
matchAvailable: 0,
|
||||||
|
kind: 'purchase',
|
||||||
|
freeDelta: 0,
|
||||||
|
savedDelta: 600,
|
||||||
|
freeSeconds: 1200,
|
||||||
|
savedSeconds: 7200,
|
||||||
|
operationId: 'purchase-1',
|
||||||
|
sessionId: null,
|
||||||
|
eventAt: checkedAt,
|
||||||
|
createdAt: checkedAt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
offset: query.offset
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(initial = report()) {
|
||||||
|
const data = writable({ report: initial, user: null, setupRequired: false });
|
||||||
|
const state = fromStore(data);
|
||||||
|
const updatePage = (next: AfkOverview) =>
|
||||||
|
currentPage.set({
|
||||||
|
url: new URL(
|
||||||
|
`http://localhost/afk?${new URLSearchParams(Object.entries(next.query).map(([key, value]) => [key, String(value)]))}`
|
||||||
|
),
|
||||||
|
data: { user: null }
|
||||||
|
});
|
||||||
|
updatePage(initial);
|
||||||
|
component = mount(AfkPage, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
get data() {
|
||||||
|
return state.current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
return (next: AfkOverview) => {
|
||||||
|
updatePage(next);
|
||||||
|
data.set({ report: next, user: null, setupRequired: false });
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = (label: string) =>
|
||||||
|
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
|
||||||
|
const lastNavigation = () => new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||||
|
const enter = (name: string, value: string) => {
|
||||||
|
const input = document.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||||
|
input.value = value;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
const select = (name: string, value: string) => {
|
||||||
|
const node = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`)!;
|
||||||
|
// happy-dom's :checked selector only matches inputs, not selected options.
|
||||||
|
const query = node.querySelector.bind(node);
|
||||||
|
node.querySelector = ((selector: string) =>
|
||||||
|
selector === ':checked'
|
||||||
|
? (node.selectedOptions[0] ?? null)
|
||||||
|
: query(selector)) as typeof node.querySelector;
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
const submit = () =>
|
||||||
|
document
|
||||||
|
.querySelector('form')!
|
||||||
|
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
it('links the exact account identity to its profile, sessions and ledger', () => {
|
||||||
|
show(report('q=Dempsey&sort=recent&offset=25'));
|
||||||
|
const panel = document.querySelector('#afk-results')!;
|
||||||
|
const links = [...panel.querySelectorAll('a')];
|
||||||
|
const profile = links.find((link) => link.textContent === 'Dempsey')!;
|
||||||
|
const profileUrl = new URL(profile.getAttribute('href')!, 'http://localhost');
|
||||||
|
expect(profileUrl.pathname).toBe(`/leaderboard/players/${guid}`);
|
||||||
|
expect(profileUrl.searchParams.get('returnTo')).toContain('q=Dempsey');
|
||||||
|
expect(profileUrl.searchParams.get('returnTo')).toContain('offset=25');
|
||||||
|
for (const [label, view] of [
|
||||||
|
['Sessions', 'sessions'],
|
||||||
|
['Time ledger', 'ledger']
|
||||||
|
]) {
|
||||||
|
const history = links.find((link) => link.textContent === label)!;
|
||||||
|
const historyUrl = new URL(history.getAttribute('href')!, 'http://localhost');
|
||||||
|
expect(historyUrl.pathname).toBe('/afk');
|
||||||
|
expect(historyUrl.searchParams.get('view')).toBe(view);
|
||||||
|
expect(historyUrl.searchParams.get('player')).toBe(guid);
|
||||||
|
expect(historyUrl.searchParams.has('q')).toBe(false);
|
||||||
|
expect(historyUrl.searchParams.has('offset')).toBe(false);
|
||||||
|
}
|
||||||
|
// Held saved time still occupies account capacity.
|
||||||
|
expect(panel.querySelector('meter')!.getAttribute('value')).toBe('7500');
|
||||||
|
expect(panel.textContent).toContain('Unresolved session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies session filters without losing an exact player and resets pagination', () => {
|
||||||
|
show(report(`view=sessions&offset=25&player=${guid}`));
|
||||||
|
enter('q', 'Nikolai');
|
||||||
|
select('game', 't6');
|
||||||
|
select('state', 'unresolved');
|
||||||
|
enter('from', '2026-10-01');
|
||||||
|
enter('to', '2026-10-17');
|
||||||
|
submit();
|
||||||
|
expect(lastNavigation().searchParams.get('q')).toBe('Nikolai');
|
||||||
|
expect(lastNavigation().searchParams.get('game')).toBe('t6');
|
||||||
|
expect(lastNavigation().searchParams.get('state')).toBe('unresolved');
|
||||||
|
expect(lastNavigation().searchParams.get('from')).toBe('2026-10-01');
|
||||||
|
expect(lastNavigation().searchParams.get('to')).toBe('2026-10-17');
|
||||||
|
expect(lastNavigation().searchParams.get('player')).toBe(guid);
|
||||||
|
expect(lastNavigation().searchParams.has('offset')).toBe(false);
|
||||||
|
button('Reset').click();
|
||||||
|
expect(lastNavigation().searchParams.get('view')).toBe('sessions');
|
||||||
|
for (const key of ['q', 'game', 'state', 'from', 'to', 'player', 'offset'])
|
||||||
|
expect(lastNavigation().searchParams.has(key)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the exact player across tabs and pagination', () => {
|
||||||
|
const update = show(report(`q=Dempsey&player=${guid}&offset=25`));
|
||||||
|
document.querySelector<HTMLButtonElement>('#afk-tab-sessions')!.click();
|
||||||
|
expect(lastNavigation().searchParams.get('view')).toBe('sessions');
|
||||||
|
expect(lastNavigation().searchParams.get('q')).toBe('Dempsey');
|
||||||
|
expect(lastNavigation().searchParams.get('player')).toBe(guid);
|
||||||
|
expect(lastNavigation().searchParams.has('offset')).toBe(false);
|
||||||
|
const next = report(`view=sessions&player=${guid}&state=unresolved&game=t5`);
|
||||||
|
next.sessions.total = 30;
|
||||||
|
update(next);
|
||||||
|
button('Next').click();
|
||||||
|
expect(lastNavigation().searchParams.get('offset')).toBe('25');
|
||||||
|
expect(lastNavigation().searchParams.get('player')).toBe(guid);
|
||||||
|
expect(lastNavigation().searchParams.get('state')).toBe('unresolved');
|
||||||
|
expect(lastNavigation().searchParams.get('game')).toBe('t5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens unresolved sessions across the fleet instead of retaining list filters', () => {
|
||||||
|
show(report(`view=ledger&player=${guid}&q=Dempsey&game=t5&kind=purchase&offset=25`));
|
||||||
|
document.querySelector<HTMLButtonElement>('.summary button')!.click();
|
||||||
|
const destination = lastNavigation();
|
||||||
|
expect(destination.searchParams.get('view')).toBe('sessions');
|
||||||
|
expect(destination.searchParams.get('state')).toBe('unresolved');
|
||||||
|
for (const key of ['player', 'q', 'game', 'kind', 'offset'])
|
||||||
|
expect(destination.searchParams.has(key)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps corrected charges distinct from confirmed time and avoids expired match links', () => {
|
||||||
|
const update = show(report('view=sessions'));
|
||||||
|
const panel = document.querySelector('#afk-results')!;
|
||||||
|
expect(panel.textContent).toContain('Unconfirmed duration');
|
||||||
|
expect(panel.textContent).toContain('Corrected charge: 45s');
|
||||||
|
expect(panel.textContent).toContain('Confirmed at least 40s');
|
||||||
|
expect(panel.textContent).not.toContain('45s used');
|
||||||
|
expect(panel.textContent).toContain('Match #17 · History expired');
|
||||||
|
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
|
||||||
|
update(report('view=ledger'));
|
||||||
|
expect(panel.textContent).toContain('purchase-1');
|
||||||
|
expect(panel.textContent).toContain('+10m 00s');
|
||||||
|
expect(panel.textContent).toContain('Match #17 · History expired');
|
||||||
|
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the last snapshot and draft filters after a refresh failure and retries', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify(report())));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
enter('q', 'draft');
|
||||||
|
const before = document.querySelector('.summary')!.textContent;
|
||||||
|
button('Refresh').click();
|
||||||
|
await vi.waitFor(() => expect(document.body.textContent).toContain('AFK updates paused.'));
|
||||||
|
expect(document.querySelector('.summary')!.textContent).toBe(before);
|
||||||
|
button('Retry').click();
|
||||||
|
await vi.waitFor(() => expect(document.body.textContent).not.toContain('AFK updates paused.'));
|
||||||
|
expect(document.querySelector<HTMLInputElement>('input[name="q"]')!.value).toBe('draft');
|
||||||
|
expect(fetch.mock.calls[0][0]).toContain('/api/afk?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards an old refresh after navigation changes the AFK view', async () => {
|
||||||
|
let resolve!: (response: Response) => void;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const update = show();
|
||||||
|
button('Refresh').click();
|
||||||
|
navigation.before.forEach((fn) => fn());
|
||||||
|
const next = report('view=ledger');
|
||||||
|
next.summary.accounts = 42;
|
||||||
|
update(next);
|
||||||
|
resolve(new Response(JSON.stringify(report())));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelector('.summary')?.textContent).toContain('42');
|
||||||
|
expect(document.querySelector('#afk-tab-ledger')?.getAttribute('aria-selected')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls after 15 seconds, pauses while hidden, and refreshes when visible again', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let visibility: DocumentVisibilityState = 'visible';
|
||||||
|
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility);
|
||||||
|
const fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify(report()))));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
await vi.advanceTimersByTimeAsync(14_999);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
visibility = 'hidden';
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
visibility = 'visible';
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
await tick();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, unmount } from 'svelte';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import MatchHistory from '../../src/lib/ui/components/MatchHistory.svelte';
|
||||||
|
import type { MatchHistoryResponse, ServerMatch } from '../../src/lib/match-history.js';
|
||||||
|
import type { UserRole } from '../../src/lib/auth.js';
|
||||||
|
|
||||||
|
const currentPage = writable({
|
||||||
|
url: new URL('http://localhost/matches'),
|
||||||
|
data: { user: { role: 'moderator' as UserRole } }
|
||||||
|
});
|
||||||
|
const navigation = vi.hoisted(() => ({ goto: vi.fn().mockResolvedValue(undefined) }));
|
||||||
|
vi.mock('$app/stores', () => ({
|
||||||
|
page: {
|
||||||
|
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: navigation.goto }));
|
||||||
|
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
navigation.goto.mockClear();
|
||||||
|
});
|
||||||
|
const statuses: ServerMatch['status'][] = [
|
||||||
|
'completed',
|
||||||
|
'interrupted',
|
||||||
|
'end-report-missing',
|
||||||
|
'active'
|
||||||
|
];
|
||||||
|
const report: MatchHistoryResponse = {
|
||||||
|
total: 4,
|
||||||
|
page: 1,
|
||||||
|
limit: 25,
|
||||||
|
maps: [],
|
||||||
|
matches: statuses.map((status, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
game: 't5',
|
||||||
|
server: 't5_zombie_theater_1',
|
||||||
|
map: 'Kino Der Toten',
|
||||||
|
observedRound: 1,
|
||||||
|
finalRound: status === 'completed' ? 1 : null,
|
||||||
|
status,
|
||||||
|
reporterMatchKey: null,
|
||||||
|
missingReports: null,
|
||||||
|
startedAt: '2026-10-17T10:00:00Z',
|
||||||
|
lastReportedAt: '2026-10-17T10:01:00Z',
|
||||||
|
endedAt: status === 'completed' ? '2026-10-17T10:01:00Z' : null,
|
||||||
|
durationMs: null,
|
||||||
|
startPlayers: 1,
|
||||||
|
uniquePlayers: 1,
|
||||||
|
endPlayers: status === 'completed' ? 1 : null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
function show(search = '', role: UserRole = 'moderator') {
|
||||||
|
currentPage.set({
|
||||||
|
url: new URL(`http://localhost/matches${search}`),
|
||||||
|
data: { user: { role } }
|
||||||
|
});
|
||||||
|
component = mount(MatchHistory, { target: document.body, props: { initial: report } });
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('enables the moderation archive through a namespaced URL while preserving filters', async () => {
|
||||||
|
show('?matchGame=t5&matchPage=2');
|
||||||
|
document.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
|
||||||
|
await vi.waitFor(() => expect(navigation.goto).toHaveBeenCalledOnce());
|
||||||
|
expect(navigation.goto.mock.calls[0][0]).toBe('/matches?matchGame=t5&matchArchive=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links round-one and unfinished archive rows to retained match detail with return context', () => {
|
||||||
|
show('?matchArchive=1&matchGame=t5');
|
||||||
|
expect(document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(true);
|
||||||
|
const links = [...document.querySelectorAll<HTMLAnchorElement>('tbody a.match-link')];
|
||||||
|
expect(links).toHaveLength(4);
|
||||||
|
for (const [index, link] of links.entries()) {
|
||||||
|
const url = new URL(link.href);
|
||||||
|
expect(url.pathname).toBe(`/matches/${index + 1}`);
|
||||||
|
expect(url.searchParams.get('matchArchive')).toBe('1');
|
||||||
|
expect(url.searchParams.get('returnTo')).toBe('/matches?matchArchive=1&matchGame=t5');
|
||||||
|
}
|
||||||
|
expect(document.body.textContent).toContain('Highest observed');
|
||||||
|
expect(document.body.textContent).toContain('Last report');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ordinary history navigation and hides archive controls from viewers', () => {
|
||||||
|
show('', 'viewer');
|
||||||
|
expect(document.querySelector('input[type="checkbox"]')).toBeNull();
|
||||||
|
const links = [...document.querySelectorAll<HTMLAnchorElement>('tbody a.match-link')];
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
expect(new URL(links[0].href).searchParams.has('matchArchive')).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
|
||||||
|
import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
|
||||||
|
import type { UserRole } from '../../src/lib/auth.js';
|
||||||
|
|
||||||
|
const currentPage = writable({ data: { user: { role: 'moderator' as UserRole } } });
|
||||||
|
vi.mock('$app/stores', () => ({
|
||||||
|
page: {
|
||||||
|
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const history: ContextualChatEntry[] = [
|
||||||
|
['11', 'Subject', 'A concerning reply'],
|
||||||
|
['22', 'Other player', 'The question that explains it'],
|
||||||
|
['11', 'Subject', 'A later reply']
|
||||||
|
].map(([guid, player, message], index) => ({
|
||||||
|
type: 'message',
|
||||||
|
matchId: 17,
|
||||||
|
messageId: `17:message-${index}:1`,
|
||||||
|
message: { guid, player, message, time: `1:0${index}`, slot: index },
|
||||||
|
context: { realTime: null, matchElapsedMs: index * 1000, round: 2 }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const button = (label: string) =>
|
||||||
|
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
|
||||||
|
const response = (value: unknown) => new Response(JSON.stringify(value));
|
||||||
|
function show(role: UserRole = 'moderator') {
|
||||||
|
currentPage.set({ data: { user: { role } } });
|
||||||
|
component = mount(ChatPanel, { target: document.body, props: { game: 't5', history } });
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
function select(label: string, value: string) {
|
||||||
|
const labelNode = [...document.querySelectorAll('dialog label')].find(
|
||||||
|
(node) => node.textContent?.replace('*', '').trim() === label
|
||||||
|
)!;
|
||||||
|
const node = document.querySelector<HTMLSelectElement>(`[aria-labelledby="${labelNode.id}"]`)!;
|
||||||
|
const originalQuery = node.querySelector.bind(node);
|
||||||
|
node.querySelector = ((selector: string) =>
|
||||||
|
selector === ':checked'
|
||||||
|
? (node.selectedOptions[0] ?? null)
|
||||||
|
: originalQuery(selector)) as typeof node.querySelector;
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
const submit = () =>
|
||||||
|
document
|
||||||
|
.querySelector('dialog form')!
|
||||||
|
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
it('selects messages across participants, previews their context, and attaches to an existing case', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response({
|
||||||
|
cases: [{ id: 'case-1', title: 'Existing review', status: 'open', revision: 4 }],
|
||||||
|
total: 1
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
expect(button('Attach to case').disabled).toBe(true);
|
||||||
|
const checkboxes = document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
|
||||||
|
checkboxes[0].click();
|
||||||
|
checkboxes[1].click();
|
||||||
|
flushSync();
|
||||||
|
expect(document.body.textContent).toContain('2 selected');
|
||||||
|
button('Attach to case').click();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('dialog')?.textContent).toContain('Existing review')
|
||||||
|
);
|
||||||
|
expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11');
|
||||||
|
expect(document.querySelector('dialog')?.textContent).toContain('GUID 22');
|
||||||
|
expect(document.querySelector('dialog')?.textContent).toContain('Message time unknown');
|
||||||
|
const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!;
|
||||||
|
expect(excerpt.querySelectorAll('li')).toHaveLength(3);
|
||||||
|
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
|
||||||
|
expect(excerpt.textContent).toContain('Selected context');
|
||||||
|
select('Destination', 'case-1');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(document.body.textContent).toContain('Selected evidence saved.'));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
evidence: {
|
||||||
|
matchId: 17,
|
||||||
|
messageIds: ['17:message-0:1', '17:message-1:1'],
|
||||||
|
contextBefore: 3,
|
||||||
|
contextAfter: 3
|
||||||
|
},
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
expect(document.querySelector('a[href="/moderation/cases/case-1"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
[...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].every(
|
||||||
|
(node) => !node.checked
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a new case with all selected messages and retries an uncertain request unchanged', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ cases: [], total: 0 }))
|
||||||
|
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'new-case' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
button('Select all').click();
|
||||||
|
flushSync();
|
||||||
|
button('Attach to case').click();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('dialog')?.textContent).toContain('No existing cases')
|
||||||
|
);
|
||||||
|
const title = document.querySelector<HTMLInputElement>('dialog input:not([type="number"])')!;
|
||||||
|
title.value = 'Conversation review';
|
||||||
|
title.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
|
||||||
|
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
|
||||||
|
button('Retry same request').click();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
|
||||||
|
expect(fetch.mock.calls[2][1].body).toBe(fetch.mock.calls[1][1].body);
|
||||||
|
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '11',
|
||||||
|
title: 'Conversation review',
|
||||||
|
evidence: { messageIds: history.map((entry) => entry.messageId) }
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps evidence selection unavailable to read-only viewers', () => {
|
||||||
|
show('super_viewer');
|
||||||
|
expect(document.querySelector('input[type="checkbox"]')).toBeNull();
|
||||||
|
expect(button('Attach to case')).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||||
|
import { fromStore, writable } from 'svelte/store';
|
||||||
|
import CaseAssessment from '../../src/lib/ui/components/moderation/CaseAssessment.svelte';
|
||||||
|
import type { ModerationCaseAssessment } from '../../src/lib/moderation.js';
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
const current: ModerationCaseAssessment = {
|
||||||
|
revision: 4,
|
||||||
|
summary: 'Slur documented; threatening intent unclear.',
|
||||||
|
rules: ['Rule 2: Respect other players'],
|
||||||
|
findings: [
|
||||||
|
{
|
||||||
|
category: 'hate_speech',
|
||||||
|
finding: 'substantiated',
|
||||||
|
detail: 'The preserved message contains a slur.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'threats',
|
||||||
|
finding: 'inconclusive',
|
||||||
|
detail: 'The reply could refer to gameplay.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-17T10:00:00Z'
|
||||||
|
};
|
||||||
|
function show(
|
||||||
|
assessment: ModerationCaseAssessment | null = null,
|
||||||
|
permitted = true,
|
||||||
|
history: ModerationCaseAssessment[] = []
|
||||||
|
) {
|
||||||
|
const props = writable({ revision: 4, assessment });
|
||||||
|
const state = fromStore(props);
|
||||||
|
const saved = vi.fn();
|
||||||
|
component = mount(CaseAssessment, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
caseId: 'case-1',
|
||||||
|
get revision() {
|
||||||
|
return state.current.revision;
|
||||||
|
},
|
||||||
|
get assessment() {
|
||||||
|
return state.current.assessment;
|
||||||
|
},
|
||||||
|
history,
|
||||||
|
permitted,
|
||||||
|
onsubmitted: saved
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
return {
|
||||||
|
saved,
|
||||||
|
update(revision: number, next = assessment) {
|
||||||
|
props.set({ revision, assessment: next });
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const button = (label: string) =>
|
||||||
|
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
|
||||||
|
function field<T extends HTMLTextAreaElement | HTMLSelectElement>(label: string): T {
|
||||||
|
const node = [...document.querySelectorAll('dialog label')].find(
|
||||||
|
(node) => node.textContent?.replace('*', '').trim() === label
|
||||||
|
)!;
|
||||||
|
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
|
||||||
|
}
|
||||||
|
function enter(label: string, value: string) {
|
||||||
|
const node = field<HTMLTextAreaElement>(label);
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
function select(label: string, value: string) {
|
||||||
|
const node = field<HTMLSelectElement>(label);
|
||||||
|
const query = node.querySelector.bind(node);
|
||||||
|
node.querySelector = ((selector: string) =>
|
||||||
|
selector === ':checked'
|
||||||
|
? (node.selectedOptions[0] ?? null)
|
||||||
|
: query(selector)) as typeof node.querySelector;
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
|
||||||
|
function complete() {
|
||||||
|
enter('Current summary', 'Preserved evidence supports a safeguarding review.');
|
||||||
|
enter('Applicable rules', ' Rule 4: Safeguarding\n\nRule 2: Respect ');
|
||||||
|
select('Concern 1', 'safeguarding');
|
||||||
|
select('Finding 1', 'inconclusive');
|
||||||
|
enter('Explanation 1', 'The surrounding messages establish age but intent remains unclear.');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates structured findings and separate rule references with one concern per category', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const { saved } = show();
|
||||||
|
expect(document.body.textContent).toContain('Not assessed');
|
||||||
|
button('Assess case').click();
|
||||||
|
await tick();
|
||||||
|
complete();
|
||||||
|
button('Add finding').click();
|
||||||
|
flushSync();
|
||||||
|
expect(
|
||||||
|
[...field<HTMLSelectElement>('Concern 2').options].find(
|
||||||
|
(option) => option.value === 'safeguarding'
|
||||||
|
)?.disabled
|
||||||
|
).toBe(true);
|
||||||
|
select('Concern 2', 'threats');
|
||||||
|
select('Finding 2', 'unsubstantiated');
|
||||||
|
enter('Explanation 2', 'No threat is present in the preserved exchange.');
|
||||||
|
button('Save assessment').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
op: 'assess-case',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
summary: 'Preserved evidence supports a safeguarding review.',
|
||||||
|
rules: ['Rule 4: Safeguarding', 'Rule 2: Respect'],
|
||||||
|
findings: [
|
||||||
|
{
|
||||||
|
category: 'safeguarding',
|
||||||
|
finding: 'inconclusive',
|
||||||
|
detail: 'The surrounding messages establish age but intent remains unclear.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'threats',
|
||||||
|
finding: 'unsubstantiated',
|
||||||
|
detail: 'No threat is present in the preserved exchange.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows current findings and preserves prior assessments for read-only viewers', () => {
|
||||||
|
const previous = {
|
||||||
|
...current,
|
||||||
|
revision: 2,
|
||||||
|
summary: 'Initial concern awaiting full context.',
|
||||||
|
actor: 'Moderator Two'
|
||||||
|
};
|
||||||
|
show(current, false, [current, previous]);
|
||||||
|
expect(document.body.textContent).toContain(current.summary);
|
||||||
|
expect(document.body.textContent).toContain('Substantiated');
|
||||||
|
expect(document.body.textContent).toContain('Inconclusive');
|
||||||
|
expect(document.querySelector('details summary')?.textContent).toBe('Previous assessments (1)');
|
||||||
|
expect(document.querySelector('details')?.textContent).toContain(previous.summary);
|
||||||
|
expect(document.querySelector('details')?.textContent).toContain('Moderator Two');
|
||||||
|
expect(button('Edit assessment')).toBeUndefined();
|
||||||
|
expect(document.querySelector('dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains a conflicted assessment and uses the refreshed revision after reopening', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response({ error: 'This case changed. Reload it before making another change.' }, 409)
|
||||||
|
)
|
||||||
|
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const { update, saved } = show(current);
|
||||||
|
button('Edit assessment').click();
|
||||||
|
await tick();
|
||||||
|
expect(field<HTMLTextAreaElement>('Current summary').value).toBe(current.summary);
|
||||||
|
enter('Current summary', 'Revised conclusion after review.');
|
||||||
|
button('Save assessment').click();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
|
||||||
|
'This case changed.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(field<HTMLTextAreaElement>('Current summary').value).toBe(
|
||||||
|
'Revised conclusion after review.'
|
||||||
|
);
|
||||||
|
expect(saved).not.toHaveBeenCalled();
|
||||||
|
button('Cancel').click();
|
||||||
|
await tick();
|
||||||
|
update(9);
|
||||||
|
button('Edit assessment').click();
|
||||||
|
await tick();
|
||||||
|
enter('Current summary', 'Reviewed against the refreshed case.');
|
||||||
|
document.querySelector<HTMLButtonElement>('[aria-label="Remove finding 2"]')!.click();
|
||||||
|
flushSync();
|
||||||
|
button('Save assessment').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
|
||||||
|
revision: 9,
|
||||||
|
findings: [current.findings[0]]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries an uncertain assessment as the exact same request', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||||
|
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const { saved } = show();
|
||||||
|
button('Assess case').click();
|
||||||
|
await tick();
|
||||||
|
complete();
|
||||||
|
button('Save assessment').click();
|
||||||
|
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
|
||||||
|
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
|
||||||
|
button('Retry same request').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
|
||||||
|
});
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||||
|
import { fromStore, writable } from 'svelte/store';
|
||||||
|
import CasePage from '../../src/routes/moderation/cases/[id]/+page.svelte';
|
||||||
|
import type { ModerationCaseDetail } from '../../src/lib/moderation.js';
|
||||||
|
import type { UserRole } from '../../src/lib/auth.js';
|
||||||
|
|
||||||
|
const navigation = vi.hoisted(() => ({ before: [] as (() => void)[] }));
|
||||||
|
const currentPage = writable({
|
||||||
|
url: new URL('http://localhost/moderation/cases/case-1'),
|
||||||
|
data: { user: { id: 'staff-1', role: 'moderator' as UserRole } }
|
||||||
|
});
|
||||||
|
vi.mock('$app/stores', () => ({
|
||||||
|
page: {
|
||||||
|
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||||
|
},
|
||||||
|
navigating: writable(null)
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: (fn: () => void) => navigation.before.push(fn)
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
beforeEach(() => {
|
||||||
|
navigation.before.length = 0;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const guid = '18446744073709551615';
|
||||||
|
function detail(): ModerationCaseDetail {
|
||||||
|
return {
|
||||||
|
case: {
|
||||||
|
id: 'case-1',
|
||||||
|
game: 't5',
|
||||||
|
guid,
|
||||||
|
title: 'Repeated chat abuse',
|
||||||
|
status: 'in_review',
|
||||||
|
priority: 'high',
|
||||||
|
assigneeId: 'staff-1',
|
||||||
|
assignee: 'Moderator One',
|
||||||
|
actor: 'Reporter',
|
||||||
|
createdAt: '2026-10-15T12:00:00Z',
|
||||||
|
updatedAt: '2026-10-16T12:00:00Z',
|
||||||
|
revision: 4,
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'evidence-1',
|
||||||
|
text: 'abusive message',
|
||||||
|
source: 'Archived chat · theater · match 17',
|
||||||
|
actor: 'Reporter',
|
||||||
|
createdAt: '2026-10-16T12:00:00Z',
|
||||||
|
snapshot: JSON.stringify({
|
||||||
|
matchId: 17,
|
||||||
|
match: {
|
||||||
|
game: 't5',
|
||||||
|
server: 't5_zombie_theater_1',
|
||||||
|
started_at: '2026-10-15T12:00:00Z'
|
||||||
|
},
|
||||||
|
entry: {
|
||||||
|
type: 'message',
|
||||||
|
message: {
|
||||||
|
guid,
|
||||||
|
player: 'Tank',
|
||||||
|
time: '12:34',
|
||||||
|
message: 'abusive message'
|
||||||
|
},
|
||||||
|
context: { realTime: '2026-10-15T12:12:34Z', round: 9 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'note-1',
|
||||||
|
text: 'Check the earlier warning.',
|
||||||
|
source: 'Staff note',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-16T12:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
game: 't5',
|
||||||
|
guid,
|
||||||
|
name: 'Dempsey',
|
||||||
|
aliases: ['Tank'],
|
||||||
|
firstSeen: null,
|
||||||
|
lastSeen: null,
|
||||||
|
servers: [],
|
||||||
|
restrictions: 1
|
||||||
|
},
|
||||||
|
sanctions: [
|
||||||
|
{
|
||||||
|
id: 'restriction-1',
|
||||||
|
game: 't5',
|
||||||
|
guid,
|
||||||
|
kind: 'mute',
|
||||||
|
scope: 'game',
|
||||||
|
server: null,
|
||||||
|
reason: 'Prior warning ignored',
|
||||||
|
note: '',
|
||||||
|
createdAt: '2026-10-15T12:00:00Z',
|
||||||
|
expiresAt: '2026-10-16T12:00:00Z',
|
||||||
|
revokedAt: null,
|
||||||
|
actorId: 'staff-1',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
revision: 2,
|
||||||
|
active: true,
|
||||||
|
caseId: 'case-1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
game: 't5',
|
||||||
|
guid,
|
||||||
|
action: 'case.opened',
|
||||||
|
subjectId: 'case-1',
|
||||||
|
actor: 'Reporter',
|
||||||
|
detail: 'Repeated chat abuse',
|
||||||
|
createdAt: '2026-10-15T12:00:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
coverage: [
|
||||||
|
{
|
||||||
|
server: 't5_zombie_theater_1',
|
||||||
|
game: 't5',
|
||||||
|
connectedAt: null,
|
||||||
|
revision: 1,
|
||||||
|
desiredRevision: 2,
|
||||||
|
state: 'pending',
|
||||||
|
capabilities: ['ban', 'mute']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
assignees: [
|
||||||
|
{ id: 'staff-1', username: 'Moderator One' },
|
||||||
|
{ id: 'staff-2', username: 'Moderator Two' }
|
||||||
|
],
|
||||||
|
checkedAt: '2026-10-16T12:00:00Z'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function show(initial = detail(), role: UserRole = 'moderator') {
|
||||||
|
const data = writable({ moderationCase: initial });
|
||||||
|
const state = fromStore(data);
|
||||||
|
currentPage.set({
|
||||||
|
url: new URL(`http://localhost/moderation/cases/${initial.case.id}`),
|
||||||
|
data: { user: { id: 'staff-1', role } }
|
||||||
|
});
|
||||||
|
component = mount(CasePage, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
get data() {
|
||||||
|
return state.current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
return (next: ModerationCaseDetail) => {
|
||||||
|
data.set({ moderationCase: next });
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const button = (label: string) =>
|
||||||
|
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
|
||||||
|
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||||
|
label: string
|
||||||
|
): T {
|
||||||
|
const labelNode = [...document.querySelectorAll('dialog label')].find(
|
||||||
|
(node) => node.textContent?.replace('*', '').trim() === label
|
||||||
|
)!;
|
||||||
|
return document.querySelector(`[aria-labelledby="${labelNode.id}"]`)! as T;
|
||||||
|
}
|
||||||
|
function enter(node: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
function select(label: string, value: string) {
|
||||||
|
const node = field<HTMLSelectElement>(label);
|
||||||
|
// happy-dom's :checked selector matches inputs but not selected options.
|
||||||
|
const originalQuery = node.querySelector.bind(node);
|
||||||
|
node.querySelector = ((selector: string) =>
|
||||||
|
selector === ':checked'
|
||||||
|
? (node.selectedOptions[0] ?? null)
|
||||||
|
: originalQuery(selector)) as typeof node.querySelector;
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
function submit() {
|
||||||
|
document
|
||||||
|
.querySelector('dialog form')!
|
||||||
|
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
function response(value: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(value), { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves original evidence context, linked restrictions, and staff history for read-only viewers', () => {
|
||||||
|
show(detail(), 'super_viewer');
|
||||||
|
expect(document.body.textContent).toContain('Read-only moderation access');
|
||||||
|
expect(document.body.textContent).toContain('Round 9');
|
||||||
|
expect(document.body.textContent).toContain('Original log time · 12:34');
|
||||||
|
expect(document.body.textContent).toContain('Check the earlier warning.');
|
||||||
|
expect(document.body.textContent).toContain('Prior warning ignored');
|
||||||
|
expect(document.body.textContent).toContain('Policy pending');
|
||||||
|
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
|
||||||
|
for (const label of [
|
||||||
|
'Edit case',
|
||||||
|
'Change status',
|
||||||
|
'Reopen case',
|
||||||
|
'Restrict player',
|
||||||
|
'Revoke restriction',
|
||||||
|
'Add case note',
|
||||||
|
'Attach evidence',
|
||||||
|
'Link existing action'
|
||||||
|
])
|
||||||
|
expect(button(label)).toBeUndefined();
|
||||||
|
expect(document.querySelector('dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks case restrictions to the recorded identity and revision without closing the case', async () => {
|
||||||
|
const updated = detail();
|
||||||
|
updated.case.revision = 5;
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'new-restriction' }))
|
||||||
|
.mockResolvedValue(response(updated));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
button('Restrict player').click();
|
||||||
|
await tick();
|
||||||
|
expect(field<HTMLSelectElement>('Game').disabled).toBe(true);
|
||||||
|
expect(field<HTMLInputElement>('Player GUID').readOnly).toBe(true);
|
||||||
|
expect(field<HTMLInputElement>('Player GUID').value).toBe(guid);
|
||||||
|
expect(document.querySelector('dialog')!.textContent).toContain('does not close the case');
|
||||||
|
enter(field('Player-visible reason'), 'Repeated abuse');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'create-sanction',
|
||||||
|
game: 't5',
|
||||||
|
guid,
|
||||||
|
caseId: 'case-1',
|
||||||
|
caseRevision: 4,
|
||||||
|
reason: 'Repeated abuse',
|
||||||
|
durationMinutes: 60
|
||||||
|
});
|
||||||
|
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
|
||||||
|
expect(button('Change status')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes and reopens through lifecycle status with a reason and the latest revision', async () => {
|
||||||
|
const closed = detail();
|
||||||
|
closed.case.status = 'closed';
|
||||||
|
closed.case.revision = 5;
|
||||||
|
const reopened = detail();
|
||||||
|
reopened.case.status = 'open';
|
||||||
|
reopened.case.revision = 6;
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValueOnce(response(closed))
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValueOnce(response(reopened));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
button('Change status').click();
|
||||||
|
await tick();
|
||||||
|
expect([...field<HTMLSelectElement>('Status').options].map((option) => option.value)).toEqual([
|
||||||
|
'open',
|
||||||
|
'in_review',
|
||||||
|
'closed'
|
||||||
|
]);
|
||||||
|
select('Status', 'closed');
|
||||||
|
enter(field('Change reason'), 'Warning was sufficient');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('dialog')?.open).not.toBe(true));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'update-case',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
status: 'closed',
|
||||||
|
reason: 'Warning was sufficient'
|
||||||
|
});
|
||||||
|
expect(button('Restrict player')).toBeUndefined();
|
||||||
|
button('Change status').click();
|
||||||
|
await tick();
|
||||||
|
select('Status', 'open');
|
||||||
|
enter(field('Change reason'), 'New evidence received');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
|
||||||
|
op: 'update-case',
|
||||||
|
status: 'open',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 5,
|
||||||
|
reason: 'New evidence received'
|
||||||
|
});
|
||||||
|
expect(document.body.textContent).toContain('Check the earlier warning.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits priority and assignment separately from lifecycle status', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValue(response(detail()));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
button('Edit case').click();
|
||||||
|
await tick();
|
||||||
|
enter(field('Case title'), 'Urgent report');
|
||||||
|
select('Priority', 'urgent');
|
||||||
|
select('Assigned to', 'staff-2');
|
||||||
|
expect(document.querySelector('dialog')?.textContent).not.toContain('Workflow status');
|
||||||
|
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(false);
|
||||||
|
enter(field('Change reason'), 'Needs administrator review');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'update-case',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
title: 'Urgent report',
|
||||||
|
priority: 'urgent',
|
||||||
|
assigneeId: 'staff-2',
|
||||||
|
reason: 'Needs administrator review'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
|
||||||
|
const initial = detail();
|
||||||
|
initial.case.status = 'closed';
|
||||||
|
initial.case.assigneeId = 'disabled-staff';
|
||||||
|
initial.case.assignee = 'Former staff';
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValue(response(initial));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show(initial);
|
||||||
|
button('Edit case').click();
|
||||||
|
await tick();
|
||||||
|
expect(field<HTMLSelectElement>('Assigned to').value).toBe('disabled-staff');
|
||||||
|
expect(document.querySelector('dialog')!.textContent).toContain('Former staff (unavailable)');
|
||||||
|
enter(field('Case title'), 'Clarified title');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
const payload = JSON.parse(fetch.mock.calls[0][1].body);
|
||||||
|
expect(payload.title).toBe('Clarified title');
|
||||||
|
expect(payload).not.toHaveProperty('assigneeId');
|
||||||
|
expect(payload).not.toHaveProperty('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches archived evidence by match and exact message with revision checks', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValue(response(detail()));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
button('Attach evidence').click();
|
||||||
|
await tick();
|
||||||
|
enter(field('Match ID'), '17');
|
||||||
|
enter(field('Exact archived message'), ' abusive message ');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
evidence: { matchId: 17, message: ' abusive message ' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links a prior mute with its recorded outcome without issuing another restriction', async () => {
|
||||||
|
const initial = detail();
|
||||||
|
const prior = {
|
||||||
|
id: 'earlier-mute',
|
||||||
|
type: 'restriction' as const,
|
||||||
|
kind: 'mute',
|
||||||
|
reason: 'Already applied before the report',
|
||||||
|
actor: 'Moderator Two',
|
||||||
|
createdAt: '2026-10-14T10:00:00Z',
|
||||||
|
state: 'expired',
|
||||||
|
expiresAt: '2026-10-14T11:00:00Z'
|
||||||
|
};
|
||||||
|
initial.availableActions = [prior];
|
||||||
|
initial.linkedActions = [];
|
||||||
|
const updated = detail();
|
||||||
|
updated.linkedActions = [prior];
|
||||||
|
updated.availableActions = [];
|
||||||
|
updated.case.revision = 5;
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValue(response(updated));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show(initial);
|
||||||
|
button('Link existing action').click();
|
||||||
|
await tick();
|
||||||
|
select('Existing action', 'restriction:earlier-mute');
|
||||||
|
expect(document.querySelector('dialog[open]')?.textContent).toContain('Expired');
|
||||||
|
expect(document.querySelector('dialog[open]')?.textContent).toContain('Moderator Two');
|
||||||
|
enter(field('Link reason'), 'This mute addressed the reported chat');
|
||||||
|
button('Link action').click();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
op: 'link-case-action',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
actionType: 'restriction',
|
||||||
|
actionId: 'earlier-mute',
|
||||||
|
reason: 'This mute addressed the reported chat',
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
|
||||||
|
'Already applied before the report'
|
||||||
|
);
|
||||||
|
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
|
||||||
|
'Expired'
|
||||||
|
);
|
||||||
|
expect(button('Link existing action').disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['in_review', 'closed'] as const)(
|
||||||
|
'changes status to %s without replacing findings or issuing an action',
|
||||||
|
async (status) => {
|
||||||
|
const initial = detail();
|
||||||
|
initial.case.status = 'open';
|
||||||
|
initial.linkedActions = [
|
||||||
|
{
|
||||||
|
id: 'warning-1',
|
||||||
|
type: 'action',
|
||||||
|
kind: 'warn',
|
||||||
|
reason: 'Confirmed warning',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-15T10:00:00Z',
|
||||||
|
state: 'confirmed',
|
||||||
|
expiresAt: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed-warning',
|
||||||
|
type: 'action',
|
||||||
|
kind: 'warn',
|
||||||
|
reason: 'Undelivered warning',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-15T09:00:00Z',
|
||||||
|
state: 'failed',
|
||||||
|
expiresAt: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'restriction-1',
|
||||||
|
type: 'restriction',
|
||||||
|
kind: 'mute',
|
||||||
|
reason: 'Prior mute',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-15T11:00:00Z',
|
||||||
|
state: 'expired',
|
||||||
|
expiresAt: '2026-10-15T12:00:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValue(response(initial));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show(initial);
|
||||||
|
button('Change status').click();
|
||||||
|
await tick();
|
||||||
|
expect(button('Save').disabled).toBe(true);
|
||||||
|
select('Status', status);
|
||||||
|
await tick();
|
||||||
|
expect(button('Save').disabled).toBe(false);
|
||||||
|
expect(document.querySelector('dialog')?.textContent).not.toContain('Supporting action');
|
||||||
|
enter(field('Change reason'), 'Reviewed the recorded action');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'update-case',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('actionReference');
|
||||||
|
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
|
||||||
|
'Confirmed warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
|
||||||
|
const updated = detail();
|
||||||
|
updated.case.revision = 8;
|
||||||
|
updated.case.title = 'Updated case after concurrent edit';
|
||||||
|
updated.case.status = 'in_review';
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response({ error: 'This case changed. Reload it before making another change.' }, 409)
|
||||||
|
)
|
||||||
|
.mockResolvedValue(response(updated));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
button('Change status').click();
|
||||||
|
await tick();
|
||||||
|
select('Status', 'closed');
|
||||||
|
enter(field('Change reason'), 'Evidence supports the restriction');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
|
||||||
|
'This case changed.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(field<HTMLSelectElement>('Status').value).toBe('closed');
|
||||||
|
expect(document.querySelector('dialog')!.open).toBe(true);
|
||||||
|
expect(button('Reopen case')).toBeUndefined();
|
||||||
|
button('Cancel').click();
|
||||||
|
await tick();
|
||||||
|
button('Refresh').click();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('h1')?.textContent).toBe('Updated case after concurrent edit')
|
||||||
|
);
|
||||||
|
button('Change status').click();
|
||||||
|
await tick();
|
||||||
|
select('Status', 'closed');
|
||||||
|
enter(field('Change reason'), 'Reviewed new context');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries uncertain case notes with the exact same idempotent request', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValue(response(detail()));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
button('Add case note').click();
|
||||||
|
await tick();
|
||||||
|
enter(field('Private staff note'), 'Follow up on appeal');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true)
|
||||||
|
);
|
||||||
|
button('Retry same request').click();
|
||||||
|
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||||
|
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'case-note',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
text: 'Follow up on appeal',
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps last loaded records when refresh fails and rejects an old response after changing cases', async () => {
|
||||||
|
let resolve!: (response: Response) => void;
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Offline'))
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const update = show();
|
||||||
|
button('Refresh').click();
|
||||||
|
await vi.waitFor(() => expect(document.body.textContent).toContain('Case updates paused.'));
|
||||||
|
expect(document.querySelector('h1')!.textContent).toBe('Repeated chat abuse');
|
||||||
|
button('Retry').click();
|
||||||
|
navigation.before.forEach((fn) => fn());
|
||||||
|
const next = detail();
|
||||||
|
next.case.id = 'case-2';
|
||||||
|
next.case.title = 'Another case';
|
||||||
|
update(next);
|
||||||
|
resolve(response(detail()));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelector('h1')!.textContent).toBe('Another case');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces role and ownership limits on linked restriction revocation', async () => {
|
||||||
|
const initial = detail();
|
||||||
|
initial.sanctions[0].actorId = 'staff-2';
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'restriction-1' }))
|
||||||
|
.mockResolvedValue(response(initial));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show(initial);
|
||||||
|
expect(button('Revoke restriction')).toBeUndefined();
|
||||||
|
currentPage.update((value) => ({ ...value, data: { user: { id: 'staff-1', role: 'admin' } } }));
|
||||||
|
flushSync();
|
||||||
|
button('Revoke restriction').click();
|
||||||
|
await tick();
|
||||||
|
enter(field('Review reason'), 'Appeal upheld');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'revoke-sanction',
|
||||||
|
id: 'restriction-1',
|
||||||
|
revision: 2,
|
||||||
|
reason: 'Appeal upheld'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||||
|
import { fromStore, writable } from 'svelte/store';
|
||||||
|
import CaseFollowUp from '../../src/lib/ui/components/moderation/CaseFollowUp.svelte';
|
||||||
|
import CaseUpdates from '../../src/lib/ui/components/moderation/CaseUpdates.svelte';
|
||||||
|
import type {
|
||||||
|
ModerationCase,
|
||||||
|
ModerationCaseAction,
|
||||||
|
ModerationCaseNotification
|
||||||
|
} from '../../src/lib/moderation.js';
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
const item: ModerationCase = {
|
||||||
|
id: 'case-1',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Chat review',
|
||||||
|
status: 'closed',
|
||||||
|
priority: 'normal',
|
||||||
|
assignee: null,
|
||||||
|
assigneeId: null,
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-16T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-16T10:00:00Z',
|
||||||
|
revision: 6,
|
||||||
|
evidence: []
|
||||||
|
};
|
||||||
|
const mute: ModerationCaseAction = {
|
||||||
|
id: 'mute-1',
|
||||||
|
type: 'restriction',
|
||||||
|
kind: 'mute',
|
||||||
|
reason: 'Earlier chat abuse',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-16T10:00:00Z',
|
||||||
|
state: 'active',
|
||||||
|
expiresAt: '2026-10-17T10:00:00Z'
|
||||||
|
};
|
||||||
|
function show(initial = item, permitted = true) {
|
||||||
|
const saved = vi.fn();
|
||||||
|
component = mount(CaseFollowUp, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
item: initial,
|
||||||
|
actions: [mute, { ...mute, id: 'permanent', expiresAt: null }],
|
||||||
|
checkedAt: '2026-10-17T12:00:00Z',
|
||||||
|
permitted,
|
||||||
|
onsubmitted: saved
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
const button = (label: string, dialog = false) =>
|
||||||
|
[...document.querySelectorAll(dialog ? 'dialog button' : 'button')].find(
|
||||||
|
(node) => node.textContent?.trim() === label
|
||||||
|
)! as HTMLButtonElement;
|
||||||
|
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||||
|
label: string
|
||||||
|
): T {
|
||||||
|
const node = [...document.querySelectorAll('dialog label')].find(
|
||||||
|
(node) => node.textContent?.replace('*', '').trim() === label
|
||||||
|
)!;
|
||||||
|
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
|
||||||
|
}
|
||||||
|
function enter(label: string, value: string) {
|
||||||
|
const node = field<HTMLInputElement | HTMLTextAreaElement>(label);
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
function select(label: string, value: string) {
|
||||||
|
const node = field<HTMLSelectElement>(label);
|
||||||
|
const query = node.querySelector.bind(node);
|
||||||
|
node.querySelector = ((selector: string) =>
|
||||||
|
selector === ':checked'
|
||||||
|
? (node.selectedOptions[0] ?? null)
|
||||||
|
: query(selector)) as typeof node.querySelector;
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
|
||||||
|
|
||||||
|
it('schedules a closed case using a browser-local date converted to canonical ISO', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = show();
|
||||||
|
button('Schedule review').click();
|
||||||
|
await tick();
|
||||||
|
enter('Review date (browser local time)', '2026-10-19T14:30');
|
||||||
|
enter('Follow-up reason', 'Check for additional reports');
|
||||||
|
button('Save follow-up').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 6,
|
||||||
|
reviewAt: new Date('2026-10-19T14:30').toISOString(),
|
||||||
|
reason: 'Check for additional reports',
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the linked finite mute ID to schedule an exact expiration review', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = show();
|
||||||
|
button('Schedule review').click();
|
||||||
|
await tick();
|
||||||
|
expect(
|
||||||
|
[...field<HTMLSelectElement>('Review timing').options].map((option) => option.value)
|
||||||
|
).toEqual(['custom', 'mute-1']);
|
||||||
|
select('Review timing', 'mute-1');
|
||||||
|
enter('Follow-up reason', 'Review after mute expires');
|
||||||
|
button('Save follow-up').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
const payload = JSON.parse(fetch.mock.calls[0][1].body);
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
restrictionId: 'mute-1',
|
||||||
|
revision: 6
|
||||||
|
});
|
||||||
|
expect(payload).not.toHaveProperty('reviewAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows overdue reviews and clears the date with a recorded result', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = show({
|
||||||
|
...item,
|
||||||
|
reviewAt: '2026-10-17T10:00:00Z',
|
||||||
|
reviewReason: 'Check once the mute ends',
|
||||||
|
reviewSanctionId: 'mute-1'
|
||||||
|
});
|
||||||
|
expect(document.body.textContent).toContain('Overdue review');
|
||||||
|
expect(document.body.textContent).toContain('Review after linked mute expires.');
|
||||||
|
button('Complete or clear review').click();
|
||||||
|
await tick();
|
||||||
|
enter('Review result or clearing reason', 'Review complete; no further reports');
|
||||||
|
button('Complete or clear review', true).click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 6,
|
||||||
|
reviewAt: null,
|
||||||
|
reason: 'Review complete; no further reports'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows overdue follow-up details without edit controls for read-only viewers', () => {
|
||||||
|
show({ ...item, reviewAt: '2026-10-17T10:00:00Z', reviewReason: 'Review reports' }, false);
|
||||||
|
expect(document.body.textContent).toContain('Overdue review');
|
||||||
|
expect(document.body.textContent).toContain('Review reports');
|
||||||
|
expect(button('Reschedule review')).toBeUndefined();
|
||||||
|
expect(button('Complete or clear review')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const notification = (id: number): ModerationCaseNotification => ({
|
||||||
|
id,
|
||||||
|
caseId: 'case-1',
|
||||||
|
caseTitle: item.title,
|
||||||
|
kind: id === 3 ? 'assignment' : 'evidence',
|
||||||
|
createdAt: '2026-10-17T10:00:00Z',
|
||||||
|
readAt: null
|
||||||
|
});
|
||||||
|
it('acknowledges only loaded updates and preserves the read-through ID on an uncertain retry', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||||
|
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const values = writable([notification(3), notification(9)]);
|
||||||
|
const state = fromStore(values);
|
||||||
|
const saved = vi.fn();
|
||||||
|
component = mount(CaseUpdates, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
caseId: 'case-1',
|
||||||
|
get notifications() {
|
||||||
|
return state.current;
|
||||||
|
},
|
||||||
|
unread: 2,
|
||||||
|
permitted: true,
|
||||||
|
onsubmitted: saved
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
expect(document.body.textContent).toContain('Assignment changed');
|
||||||
|
expect(document.body.textContent).toContain('Evidence or note added');
|
||||||
|
button('Mark updates as seen').click();
|
||||||
|
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
|
||||||
|
values.set([notification(12), notification(9), notification(3)]);
|
||||||
|
flushSync();
|
||||||
|
button('Retry same request').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
op: 'read-case-updates',
|
||||||
|
id: 'case-1',
|
||||||
|
throughId: 9,
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer notification acknowledgement to read-only viewers', () => {
|
||||||
|
component = mount(CaseUpdates, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
caseId: 'case-1',
|
||||||
|
notifications: [notification(3)],
|
||||||
|
unread: 1,
|
||||||
|
permitted: false,
|
||||||
|
onsubmitted: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
expect(document.body.textContent).toContain('1 unread');
|
||||||
|
expect(button('Mark updates as seen')).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -303,3 +303,125 @@ it('copies selected archived chat evidence separately from staff notes', async (
|
|||||||
evidence: { matchId: 17, message: 'abusive message' }
|
evidence: { matchId: 17, message: 'abusive message' }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens a newly created case page after preserving selected evidence', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(new Response(JSON.stringify({ ok: true, id: 'new-case' })));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show();
|
||||||
|
button('Open case').click();
|
||||||
|
await tick();
|
||||||
|
const inputs = document.querySelectorAll<HTMLInputElement>('dialog input');
|
||||||
|
enter(inputs[inputs.length - 1], 'Appeal review');
|
||||||
|
submitDialog();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(navigation.goto).toHaveBeenCalledWith('/moderation/cases/new-case', {
|
||||||
|
invalidateAll: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links case records to workspaces and preserves case filters while paging', () => {
|
||||||
|
const data = overview('cases');
|
||||||
|
data.query = {
|
||||||
|
...data.query,
|
||||||
|
caseStatus: 'active',
|
||||||
|
priority: 'high',
|
||||||
|
assignedTo: 'me',
|
||||||
|
review: 'due'
|
||||||
|
};
|
||||||
|
data.cases = [
|
||||||
|
{
|
||||||
|
id: 'case-1',
|
||||||
|
game: 't5',
|
||||||
|
guid,
|
||||||
|
title: 'Chat investigation',
|
||||||
|
reviewAt: '2026-10-14T12:00:00Z',
|
||||||
|
unreadUpdates: 2,
|
||||||
|
assessment: {
|
||||||
|
revision: 3,
|
||||||
|
summary: 'The threat remains ambiguous.',
|
||||||
|
rules: [],
|
||||||
|
findings: [
|
||||||
|
{
|
||||||
|
category: 'threats',
|
||||||
|
finding: 'inconclusive',
|
||||||
|
detail: 'Context is insufficient.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
actor: 'Moderator',
|
||||||
|
createdAt: '2026-10-16T12:00:00Z'
|
||||||
|
},
|
||||||
|
status: 'in_review',
|
||||||
|
priority: 'high',
|
||||||
|
assigneeId: 'staff-1',
|
||||||
|
assignee: 'Moderator',
|
||||||
|
actor: 'Reporter',
|
||||||
|
createdAt: '2026-10-15T12:00:00Z',
|
||||||
|
updatedAt: '2026-10-16T12:00:00Z',
|
||||||
|
revision: 3,
|
||||||
|
evidence: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
data.total = 40;
|
||||||
|
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me&review=due');
|
||||||
|
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
|
||||||
|
'Chat investigation'
|
||||||
|
);
|
||||||
|
expect(document.querySelector('#moderation-results')?.textContent).toContain('In progress');
|
||||||
|
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
|
||||||
|
expect(document.querySelector('#moderation-results')?.textContent).toContain(
|
||||||
|
'The threat remains ambiguous.'
|
||||||
|
);
|
||||||
|
expect(document.querySelector('[aria-label="Assessment findings"]')?.textContent).toMatch(
|
||||||
|
/Threats\s*·\s*Inconclusive/
|
||||||
|
);
|
||||||
|
button('Next').click();
|
||||||
|
const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||||
|
expect(destination.searchParams.get('caseStatus')).toBe('active');
|
||||||
|
expect(destination.searchParams.get('priority')).toBe('high');
|
||||||
|
expect(destination.searchParams.get('assignedTo')).toBe('me');
|
||||||
|
expect(destination.searchParams.get('review')).toBe('due');
|
||||||
|
expect(document.querySelector('#moderation-results')?.textContent).toContain('Overdue review');
|
||||||
|
expect(document.querySelector('#moderation-results')?.textContent).toContain(
|
||||||
|
'2 unread updates'
|
||||||
|
);
|
||||||
|
expect(destination.searchParams.get('offset')).toBe('25');
|
||||||
|
button('Reset').click();
|
||||||
|
const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||||
|
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'review', 'offset'])
|
||||||
|
expect(reset.searchParams.has(filter)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows personal evidence and assignment notifications with links into the actionable queue', () => {
|
||||||
|
const data = overview('cases');
|
||||||
|
data.unreadCount = 2;
|
||||||
|
data.notifications = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
caseId: 'follow-up',
|
||||||
|
caseTitle: 'Review context',
|
||||||
|
kind: 'evidence',
|
||||||
|
createdAt: data.checkedAt,
|
||||||
|
readAt: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
caseId: 'appeal',
|
||||||
|
caseTitle: 'Review appeal',
|
||||||
|
kind: 'assignment',
|
||||||
|
createdAt: data.checkedAt,
|
||||||
|
readAt: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
show(data);
|
||||||
|
const notifications = document.querySelector('[aria-label="Your case notifications"]')!;
|
||||||
|
expect(notifications.textContent).toContain('Evidence or note added');
|
||||||
|
expect(notifications.textContent).toContain('Assignment changed');
|
||||||
|
expect(notifications.querySelector('a[href="/moderation/cases/follow-up"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('a[href="/moderation?view=cases&review=updates"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(document.querySelector('a[href="/moderation?view=cases&review=due"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||||
|
import CaseRelationships from '../../src/lib/ui/components/moderation/CaseRelationships.svelte';
|
||||||
|
import ModerationDialog from '../../src/lib/ui/components/moderation/ModerationDialog.svelte';
|
||||||
|
import type { ModerationCaseRelationship } from '../../src/lib/moderation.js';
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
const candidate = {
|
||||||
|
id: 'case-2',
|
||||||
|
title: 'Earlier report',
|
||||||
|
status: 'open' as const,
|
||||||
|
game: 't5' as const,
|
||||||
|
guid: '123',
|
||||||
|
sharedMatch: true
|
||||||
|
};
|
||||||
|
const relationship: ModerationCaseRelationship = {
|
||||||
|
id: 'relationship-1',
|
||||||
|
relation: 'appeal_of',
|
||||||
|
direction: 'incoming',
|
||||||
|
case: candidate,
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-17T10:00:00Z'
|
||||||
|
};
|
||||||
|
function show(permitted = true, relationships: ModerationCaseRelationship[] = []) {
|
||||||
|
const saved = vi.fn();
|
||||||
|
component = mount(CaseRelationships, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
caseId: 'case-1',
|
||||||
|
revision: 6,
|
||||||
|
relationships,
|
||||||
|
candidates: [candidate],
|
||||||
|
permitted,
|
||||||
|
onsubmitted: saved
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
const button = (label: string) =>
|
||||||
|
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
|
||||||
|
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||||
|
label: string
|
||||||
|
): T {
|
||||||
|
const node = [...document.querySelectorAll('dialog label')].find(
|
||||||
|
(node) => node.textContent?.replace('*', '').trim() === label
|
||||||
|
)!;
|
||||||
|
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
|
||||||
|
}
|
||||||
|
function enter(label: string, value: string) {
|
||||||
|
const node = field<HTMLInputElement | HTMLTextAreaElement>(label);
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
function select(label: string, value: string) {
|
||||||
|
const node = field<HTMLSelectElement>(label);
|
||||||
|
const query = node.querySelector.bind(node);
|
||||||
|
node.querySelector = ((selector: string) =>
|
||||||
|
selector === ':checked'
|
||||||
|
? (node.selectedOptions[0] ?? null)
|
||||||
|
: query(selector)) as typeof node.querySelector;
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
|
||||||
|
|
||||||
|
it.each(['candidate', 'url'])(
|
||||||
|
'links a %s with an explicit relationship and reason',
|
||||||
|
async (source) => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = show();
|
||||||
|
expect(document.body.textContent).toContain('Shared archived match');
|
||||||
|
button('Link case').click();
|
||||||
|
await tick();
|
||||||
|
if (source === 'candidate') {
|
||||||
|
select('Related case', 'case-2');
|
||||||
|
select('Relationship', 'duplicate_of');
|
||||||
|
} else enter('Case ID or URL', 'https://example.com/moderation/cases/case-2?view=history');
|
||||||
|
enter('Relationship reason', 'Both reports refer to the same exchange');
|
||||||
|
button('Link cases').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
op: 'link-case',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 6,
|
||||||
|
relatedCaseId: 'case-2',
|
||||||
|
relation: source === 'candidate' ? 'duplicate_of' : 'same_incident',
|
||||||
|
reason: 'Both reports refer to the same exchange',
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('displays inverse relationships and removes links only with a recorded reason', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = show(true, [relationship]);
|
||||||
|
expect(document.body.textContent).toContain('Appealed by');
|
||||||
|
button('Remove link').click();
|
||||||
|
await tick();
|
||||||
|
expect(field<HTMLTextAreaElement>('Relationship reason').required).toBe(true);
|
||||||
|
enter('Relationship reason', 'Linked to the wrong incident');
|
||||||
|
button('Remove relationship').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'unlink-case',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 6,
|
||||||
|
relationshipId: 'relationship-1',
|
||||||
|
reason: 'Linked to the wrong incident'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets read-only viewers inspect candidate hints and inverse relationships', () => {
|
||||||
|
show(false, [
|
||||||
|
relationship,
|
||||||
|
{ ...relationship, id: 'relationship-2', relation: 'duplicate_of' }
|
||||||
|
]);
|
||||||
|
expect(document.body.textContent).toContain('Appealed by');
|
||||||
|
expect(document.body.textContent).toContain('Has duplicate');
|
||||||
|
expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull();
|
||||||
|
expect(button('Link case')).toBeUndefined();
|
||||||
|
expect(button('Remove link')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows existing cases in the generic case creation dialog', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ cases: [candidate], total: 31 }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
component = mount(ModerationDialog, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
open: true,
|
||||||
|
mode: 'case',
|
||||||
|
coverage: [],
|
||||||
|
admin: false,
|
||||||
|
seed: { game: 't5', guid: '123' },
|
||||||
|
onsubmitted: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
await vi.waitFor(() => expect(document.body.textContent).toContain('31 existing cases'));
|
||||||
|
expect(fetch.mock.calls[0][0]).toBe('/api/moderation?view=cases&game=t5&guid=123');
|
||||||
|
expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull();
|
||||||
|
expect(document.body.textContent).toContain('View all cases for this player');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows case creation when duplicate lookup fails', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Lookup unavailable'))
|
||||||
|
.mockResolvedValue(response({ ok: true, id: 'new-case' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = vi.fn();
|
||||||
|
component = mount(ModerationDialog, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
open: true,
|
||||||
|
mode: 'case',
|
||||||
|
coverage: [],
|
||||||
|
admin: false,
|
||||||
|
seed: { game: 't5', guid: '123' },
|
||||||
|
onsubmitted: saved
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.body.textContent).toContain('Existing cases could not be loaded')
|
||||||
|
);
|
||||||
|
enter('Case title', 'Distinct incident');
|
||||||
|
button('Save').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Distinct incident'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -123,6 +123,32 @@ function expectExplanation(totals: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('match result score presentation', () => {
|
describe('match result score presentation', () => {
|
||||||
|
it.each(['interrupted', 'end-report-missing', 'active'] as const)(
|
||||||
|
'describes %s archives as observed reports without final result claims',
|
||||||
|
(status) => {
|
||||||
|
const archived = match([player({ highestRound: 1, presentAtEnd: false })]);
|
||||||
|
archived.status = status;
|
||||||
|
archived.observedRound = 1;
|
||||||
|
archived.finalRound = null;
|
||||||
|
archived.endedAt = null;
|
||||||
|
archived.endPlayers = null;
|
||||||
|
archived.durationMs = null;
|
||||||
|
show(archived);
|
||||||
|
expect(document.body.textContent).toContain(
|
||||||
|
status === 'interrupted'
|
||||||
|
? 'Interrupted'
|
||||||
|
: status === 'end-report-missing'
|
||||||
|
? 'End report missing'
|
||||||
|
: 'Not reporting'
|
||||||
|
);
|
||||||
|
expect(document.body.textContent).toContain('Highest observed round');
|
||||||
|
expect(document.body.textContent).toContain('Last reported');
|
||||||
|
expect(document.querySelector('tbody')?.textContent).toContain('Recorded participant');
|
||||||
|
expect(document.body.textContent).not.toContain('At match end');
|
||||||
|
expect(document.body.textContent).not.toContain('Players at end');
|
||||||
|
expect(document.body.textContent).not.toContain('Final round');
|
||||||
|
}
|
||||||
|
);
|
||||||
it('shows recorded totals instead of balances, including totals for departed players', () => {
|
it('shows recorded totals instead of balances, including totals for departed players', () => {
|
||||||
show(match([player(), player({ guid: '202', presentAtEnd: false, scoreTotal: 6500 })]));
|
show(match([player(), player({ guid: '202', presentAtEnd: false, scoreTotal: 6500 })]));
|
||||||
expectScores('Total score', ['8,470', '6,500']);
|
expectScores('Total score', ['8,470', '6,500']);
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { readAfkQuery, type AfkQuery, type AfkSessionState } from '../../src/lib/afk.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
let overview: typeof import('../../src/lib/server/fleet/afk-overview.js').afkOverview;
|
||||||
|
const now = new Date('2026-10-17T12:00:00.000Z');
|
||||||
|
const day = 86_400_000;
|
||||||
|
const server = 't5_zombie_theater_1';
|
||||||
|
const other = 't6_zm_transit_1';
|
||||||
|
const guid = '18446744073709551615';
|
||||||
|
const query = (changes: Partial<AfkQuery> = {}) => ({
|
||||||
|
...readAfkQuery(new URLSearchParams()),
|
||||||
|
...changes
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
vi.stubEnv('EC_AFK_FREE_SECONDS', '1800');
|
||||||
|
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '21600');
|
||||||
|
handle = (await import('../../src/lib/server/db/index.js')).db();
|
||||||
|
overview = (await import('../../src/lib/server/fleet/afk-overview.js')).afkOverview;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await fixture.cleanup();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function identity(id = guid, name = 'Dempsey', game = 't5', seen = '2026-10-17T10:00:00.000Z') {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES (?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(game, id, name, seen, seen);
|
||||||
|
}
|
||||||
|
function account(id = guid, saved = 400, cachedFree = 0) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO afk_accounts(guid,free_seconds,saved_seconds,free_started_at) VALUES (?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(id, cachedFree, saved, now.getTime() - day);
|
||||||
|
}
|
||||||
|
let serial = 0;
|
||||||
|
function session(
|
||||||
|
options: {
|
||||||
|
guid?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
server?: string;
|
||||||
|
state?: AfkSessionState;
|
||||||
|
free?: number;
|
||||||
|
saved?: number;
|
||||||
|
consumed?: number | null;
|
||||||
|
charged?: number | null;
|
||||||
|
confirmed?: number;
|
||||||
|
chargeAt?: number | null;
|
||||||
|
createdAt?: string;
|
||||||
|
matchId?: number | null;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const id = options.sessionId ?? `session-${serial++}`;
|
||||||
|
const state = options.state ?? 'settled';
|
||||||
|
const held = ['reserved', 'started', 'unresolved'].includes(state);
|
||||||
|
const free = options.free ?? 100;
|
||||||
|
const saved = options.saved ?? 0;
|
||||||
|
const createdAt = options.createdAt ?? now.toISOString();
|
||||||
|
const consumed =
|
||||||
|
options.consumed === undefined ? (held ? null : free + saved) : options.consumed;
|
||||||
|
const charged = options.charged === undefined ? consumed : options.charged;
|
||||||
|
const chargeAt =
|
||||||
|
options.chargeAt === undefined ? (held ? null : now.getTime()) : options.chargeAt;
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO afk_sessions(session_id,server,instance_id,guid,match_key,match_id,state,
|
||||||
|
authorized_seconds,free_seconds,saved_seconds,free_started_at,consumed_seconds,charged_seconds,
|
||||||
|
free_charge_at,confirmed_seconds,end_reason,created_at,started_at,ended_at)
|
||||||
|
VALUES (?,?,'private-instance',?,'private-match-key',?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
options.server ?? server,
|
||||||
|
options.guid ?? guid,
|
||||||
|
options.matchId ?? null,
|
||||||
|
state,
|
||||||
|
free + saved,
|
||||||
|
free,
|
||||||
|
saved,
|
||||||
|
now.getTime() - day,
|
||||||
|
consumed,
|
||||||
|
charged,
|
||||||
|
chargeAt,
|
||||||
|
options.confirmed ?? 0,
|
||||||
|
state === 'unresolved' || state === 'corrected' ? 'crash' : held ? null : 'manual',
|
||||||
|
createdAt,
|
||||||
|
state === 'reserved' ? null : createdAt,
|
||||||
|
state === 'reserved' || state === 'started' ? null : createdAt
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function ledger(
|
||||||
|
options: {
|
||||||
|
guid?: string;
|
||||||
|
server?: string;
|
||||||
|
kind?: string;
|
||||||
|
savedDelta?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
eventAt?: string;
|
||||||
|
matchId?: number | null;
|
||||||
|
sessionId?: string | null;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
return handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO afk_ledger(guid,server,match_id,operation_id,session_id,kind,
|
||||||
|
free_delta,saved_delta,free_seconds,saved_seconds,event_at,created_at,details)
|
||||||
|
VALUES (?,?,?,?,?,?,0,?,1800,400,?,?,'{"secret":"private-details"}')`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
options.guid ?? guid,
|
||||||
|
options.server ?? server,
|
||||||
|
options.matchId ?? null,
|
||||||
|
`operation-${serial++}`,
|
||||||
|
options.sessionId ?? null,
|
||||||
|
options.kind ?? 'reward',
|
||||||
|
options.savedDelta ?? 60,
|
||||||
|
options.eventAt ?? now.toISOString(),
|
||||||
|
options.createdAt ?? now.toISOString()
|
||||||
|
).lastInsertRowid;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shares exact string GUID accounts across games and selects the latest player name', () => {
|
||||||
|
identity();
|
||||||
|
identity(guid, 'Latest name', 't6', '2026-10-17T11:00:00.000Z');
|
||||||
|
identity('00042', 'Leading zero');
|
||||||
|
identity('42', 'Separate account');
|
||||||
|
account();
|
||||||
|
account('00042', 100);
|
||||||
|
account('42', 0);
|
||||||
|
const report = overview(query(), now);
|
||||||
|
expect(report.accounts.total).toBe(3);
|
||||||
|
expect(report.accounts.entries[0]).toMatchObject({
|
||||||
|
guid,
|
||||||
|
name: 'Latest name',
|
||||||
|
freeSeconds: 1800,
|
||||||
|
savedSeconds: 400,
|
||||||
|
availableSeconds: 2200,
|
||||||
|
reservedSeconds: 0,
|
||||||
|
reservedSavedSeconds: 0,
|
||||||
|
unresolvedSessions: 0,
|
||||||
|
freeRenewsAt: null
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
overview(query({ player: '42' }), now).accounts.entries.map((row) => row.savedSeconds)
|
||||||
|
).toEqual([0]);
|
||||||
|
expect(
|
||||||
|
overview(query({ player: '00042' }), now).accounts.entries.map((row) => row.savedSeconds)
|
||||||
|
).toEqual([100]);
|
||||||
|
expect(overview(query({ player: 'unknown' }), now).accounts.total).toBe(0);
|
||||||
|
expect(report.summary).toEqual({
|
||||||
|
accounts: 3,
|
||||||
|
savedSeconds: 500,
|
||||||
|
reservedSeconds: 0,
|
||||||
|
unresolvedSessions: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes rolling free availability from durable sessions without refreshing balances or granting time', () => {
|
||||||
|
account();
|
||||||
|
session({ state: 'started', free: 300, saved: 200, createdAt: '2026-10-14T12:00:00.000Z' });
|
||||||
|
session({ free: 600, consumed: 400, chargeAt: now.getTime() - 4 * 3_600_000 });
|
||||||
|
session({ free: 900, consumed: 900, chargeAt: now.getTime() - day });
|
||||||
|
session({ free: 300, consumed: 300, chargeAt: now.getTime() - day - 1 });
|
||||||
|
const before = handle.prepare('SELECT * FROM afk_accounts').all();
|
||||||
|
const report = overview(query(), now);
|
||||||
|
expect(report.accounts.entries[0]).toMatchObject({
|
||||||
|
freeSeconds: 1100,
|
||||||
|
savedSeconds: 400,
|
||||||
|
availableSeconds: 1500,
|
||||||
|
reservedSeconds: 500,
|
||||||
|
reservedSavedSeconds: 200,
|
||||||
|
freeRenewsAt: '2026-10-18T08:00:00.000Z'
|
||||||
|
});
|
||||||
|
expect(overview(query(), new Date(now.getTime() + day)).accounts.entries[0]).toMatchObject({
|
||||||
|
freeSeconds: 1500,
|
||||||
|
reservedSeconds: 500,
|
||||||
|
freeRenewsAt: null
|
||||||
|
});
|
||||||
|
expect(handle.prepare('SELECT * FROM afk_accounts').all()).toEqual(before);
|
||||||
|
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_ledger').get()).toEqual({ total: 0 });
|
||||||
|
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_operations').get()).toEqual({
|
||||||
|
total: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves unresolved holds and distinguishes accounting corrections from confirmed consumption', () => {
|
||||||
|
account();
|
||||||
|
account('corrected', 21000);
|
||||||
|
session({
|
||||||
|
state: 'unresolved',
|
||||||
|
free: 1000,
|
||||||
|
saved: 300,
|
||||||
|
confirmed: 250,
|
||||||
|
createdAt: '2026-10-01T00:00:00.000Z'
|
||||||
|
});
|
||||||
|
session({
|
||||||
|
guid: 'corrected',
|
||||||
|
state: 'corrected',
|
||||||
|
free: 800,
|
||||||
|
consumed: null,
|
||||||
|
charged: 500,
|
||||||
|
confirmed: 200
|
||||||
|
});
|
||||||
|
expect(overview(query({ balance: 'unresolved' }), now).accounts.entries[0]).toMatchObject({
|
||||||
|
guid,
|
||||||
|
freeSeconds: 800,
|
||||||
|
savedSeconds: 400,
|
||||||
|
reservedSeconds: 1300,
|
||||||
|
reservedSavedSeconds: 300,
|
||||||
|
unresolvedSessions: 1,
|
||||||
|
freeRenewsAt: null
|
||||||
|
});
|
||||||
|
expect(overview(query({ player: 'corrected' }), now).accounts.entries[0]).toMatchObject({
|
||||||
|
freeSeconds: 1300,
|
||||||
|
savedSeconds: 21000,
|
||||||
|
reservedSeconds: 0
|
||||||
|
});
|
||||||
|
const report = overview(query({ view: 'sessions', state: 'corrected' }), now);
|
||||||
|
expect(report.sessions.entries[0]).toMatchObject({
|
||||||
|
state: 'corrected',
|
||||||
|
consumedSeconds: null,
|
||||||
|
chargedSeconds: 500,
|
||||||
|
confirmedSeconds: 200
|
||||||
|
});
|
||||||
|
expect(report.summary).toEqual({
|
||||||
|
accounts: 2,
|
||||||
|
savedSeconds: 21400,
|
||||||
|
reservedSeconds: 1300,
|
||||||
|
unresolvedSessions: 1
|
||||||
|
});
|
||||||
|
expect(report.capacitySeconds).toBe(21600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches literally, filters and sorts accounts while retaining fleet-wide summary totals', () => {
|
||||||
|
identity();
|
||||||
|
identity('other', '100%_Player');
|
||||||
|
account();
|
||||||
|
account('other', 800);
|
||||||
|
account('empty', 0);
|
||||||
|
session({ guid: 'other', state: 'reserved', free: 1800, saved: 200 });
|
||||||
|
ledger({ createdAt: '2026-10-17T11:00:00.000Z' });
|
||||||
|
ledger({ guid: 'empty', createdAt: '2026-10-17T12:01:00.000Z' });
|
||||||
|
expect(overview(query({ q: '%_' }), now).accounts.entries.map((row) => row.guid)).toEqual([
|
||||||
|
'other'
|
||||||
|
]);
|
||||||
|
expect(overview(query({ q: "' OR 1=1 --" }), now).accounts.total).toBe(0);
|
||||||
|
expect(overview(query({ q: 'dEmP' }), now).accounts.entries[0].guid).toBe(guid);
|
||||||
|
expect(
|
||||||
|
overview(query({ balance: 'saved' }), now).accounts.entries.map((row) => row.guid)
|
||||||
|
).toEqual(['other', guid]);
|
||||||
|
expect(
|
||||||
|
overview(query({ balance: 'reserved' }), now).accounts.entries.map((row) => row.guid)
|
||||||
|
).toEqual(['other']);
|
||||||
|
expect(
|
||||||
|
overview(query({ sort: 'available' }), now).accounts.entries.map((row) => row.guid)
|
||||||
|
).toEqual([guid, 'empty', 'other']);
|
||||||
|
expect(
|
||||||
|
overview(query({ sort: 'recent' }), now).accounts.entries.map((row) => row.guid)
|
||||||
|
).toEqual(['empty', 'other', guid]);
|
||||||
|
expect(overview(query({ q: 'not-found' }), now).summary).toEqual({
|
||||||
|
accounts: 3,
|
||||||
|
savedSeconds: 1200,
|
||||||
|
reservedSeconds: 2000,
|
||||||
|
unresolvedSessions: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains historical sessions and ledger entries after telemetry matches and identities are removed', () => {
|
||||||
|
identity();
|
||||||
|
account();
|
||||||
|
const matchId = Number(
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
|
||||||
|
VALUES (?,'t5','theater','theater','Kino',?,?,'game_start','private-fingerprint')`
|
||||||
|
)
|
||||||
|
.run(server, now.toISOString(), now.toISOString()).lastInsertRowid
|
||||||
|
);
|
||||||
|
const sessionId = session({ matchId });
|
||||||
|
ledger({ matchId, sessionId });
|
||||||
|
expect(overview(query({ view: 'sessions' }), now).sessions.entries[0].matchAvailable).toBe(1);
|
||||||
|
expect(overview(query({ view: 'ledger' }), now).ledger.entries[0].matchAvailable).toBe(1);
|
||||||
|
handle.prepare('DELETE FROM leaderboard_matches WHERE id = ?').run(matchId);
|
||||||
|
handle.prepare('DELETE FROM leaderboard_players WHERE guid = ?').run(guid);
|
||||||
|
for (const view of ['sessions', 'ledger'] as const) {
|
||||||
|
const report = overview(query({ view, game: 't5', q: guid, player: guid }), now);
|
||||||
|
expect(report[view].entries[0]).toMatchObject({
|
||||||
|
guid,
|
||||||
|
name: null,
|
||||||
|
matchId,
|
||||||
|
matchAvailable: 0,
|
||||||
|
game: 't5'
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(report)).not.toMatch(
|
||||||
|
/private-instance|private-match-key|private-fingerprint|private-details|instance_id|details/
|
||||||
|
);
|
||||||
|
expect(report.servers).toEqual([server]);
|
||||||
|
}
|
||||||
|
expect(overview(query(), now).accounts.entries[0].savedSeconds).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters session and ledger history by game, server, state or kind, exact GUID and inclusive UTC receipt dates', () => {
|
||||||
|
account();
|
||||||
|
const dates = [
|
||||||
|
'2026-10-16T23:59:59.999Z',
|
||||||
|
'2026-10-17T00:00:00.000Z',
|
||||||
|
'2026-10-17T23:59:59.999Z',
|
||||||
|
'2026-10-18T00:00:00.000Z'
|
||||||
|
];
|
||||||
|
for (const createdAt of dates) {
|
||||||
|
session({ server: other, createdAt });
|
||||||
|
ledger({ server: other, createdAt, eventAt: '2026-10-16T12:00:00.000Z', kind: 'purchase' });
|
||||||
|
}
|
||||||
|
session({ guid: 'someone-else', server: other });
|
||||||
|
ledger({ guid: 'someone-else', server: other });
|
||||||
|
session({ state: 'reserved', server });
|
||||||
|
ledger({ server, kind: 'reserve' });
|
||||||
|
for (const view of ['sessions', 'ledger'] as const) {
|
||||||
|
const report = overview(
|
||||||
|
query({
|
||||||
|
view,
|
||||||
|
game: 't6',
|
||||||
|
server: other,
|
||||||
|
player: guid,
|
||||||
|
from: '2026-10-17',
|
||||||
|
to: '2026-10-17'
|
||||||
|
}),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
expect(report[view].entries.map((row) => row.createdAt)).toEqual([dates[2], dates[1]]);
|
||||||
|
expect(overview(query({ view, server: 'missing' }), now)[view].total).toBe(0);
|
||||||
|
expect(overview(query({ view, player: guid }), now)[view].total).toBe(5);
|
||||||
|
expect(overview(query({ view, player: 'unknown' }), now)[view].total).toBe(0);
|
||||||
|
expect(overview(query({ view, game: 't5' }), now)[view].total).toBe(1);
|
||||||
|
}
|
||||||
|
expect(overview(query({ view: 'sessions', state: 'reserved' }), now).sessions.total).toBe(1);
|
||||||
|
expect(overview(query({ view: 'ledger', kind: 'purchase' }), now).ledger.total).toBe(4);
|
||||||
|
expect(
|
||||||
|
overview(query({ view: 'ledger' }), now).ledger.entries.every(
|
||||||
|
(row) => typeof row.operationId === 'string'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates every view deterministically and clamps stale offsets', () => {
|
||||||
|
for (let i = 0; i < 31; i++) {
|
||||||
|
const id = String(i).padStart(2, '0');
|
||||||
|
account(id, 100);
|
||||||
|
session({ guid: id });
|
||||||
|
ledger({ guid: id });
|
||||||
|
}
|
||||||
|
for (const view of ['accounts', 'sessions', 'ledger'] as const) {
|
||||||
|
const first = overview(query({ view }), now)[view];
|
||||||
|
const second = overview(query({ view, offset: 999999 }), now)[view];
|
||||||
|
expect(first.entries).toHaveLength(25);
|
||||||
|
expect(second).toMatchObject({ total: 31, offset: 25 });
|
||||||
|
expect(second.entries).toHaveLength(6);
|
||||||
|
expect(new Set([...first.entries, ...second.entries].map((row) => row.guid)).size).toBe(31);
|
||||||
|
expect(overview(query({ view, q: 'absent', offset: 999999 }), now)[view]).toEqual({
|
||||||
|
entries: [],
|
||||||
|
total: 0,
|
||||||
|
offset: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates free allowance calculations without changing retained saved balances or reservations', () => {
|
||||||
|
account();
|
||||||
|
session({ state: 'reserved', free: 1000, saved: 200 });
|
||||||
|
vi.stubEnv('EC_AFK_FREE_SECONDS', '300');
|
||||||
|
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '100');
|
||||||
|
expect(overview(query(), now)).toMatchObject({
|
||||||
|
capacitySeconds: 100,
|
||||||
|
accounts: {
|
||||||
|
entries: [
|
||||||
|
expect.objectContaining({
|
||||||
|
freeSeconds: 0,
|
||||||
|
savedSeconds: 400,
|
||||||
|
reservedSeconds: 1200,
|
||||||
|
reservedSavedSeconds: 200
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves an empty read-only report and uncached API, rejects invalid filters in both routes', async () => {
|
||||||
|
identity();
|
||||||
|
const { GET } = await import('../../src/routes/api/afk/+server.js');
|
||||||
|
const { load } = await import('../../src/routes/afk/+page.server.js');
|
||||||
|
const url = new URL('https://test/afk');
|
||||||
|
const response = GET({ url });
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
expect((await response.json()).summary).toEqual({
|
||||||
|
accounts: 0,
|
||||||
|
savedSeconds: 0,
|
||||||
|
reservedSeconds: 0,
|
||||||
|
unresolvedSessions: 0
|
||||||
|
});
|
||||||
|
expect(load({ url }).report.accounts.total).toBe(0);
|
||||||
|
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_accounts').get()).toEqual({
|
||||||
|
total: 0
|
||||||
|
});
|
||||||
|
for (const invalid of ['view=unknown', 'state=unknown', 'offset=-1', 'from=2026-02-30']) {
|
||||||
|
expect(() => GET({ url: new URL(`https://test/api/afk?${invalid}`) })).toThrow(
|
||||||
|
expect.objectContaining({ status: 400 })
|
||||||
|
);
|
||||||
|
expect(() => load({ url: new URL(`https://test/afk?${invalid}`) })).toThrow(
|
||||||
|
expect.objectContaining({ status: 400 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AFK query validation', () => {
|
||||||
|
it.each([
|
||||||
|
'view=nope',
|
||||||
|
'balance=nope',
|
||||||
|
'game=t7',
|
||||||
|
'sort=nope',
|
||||||
|
'state=crash',
|
||||||
|
'kind=secret',
|
||||||
|
'offset=NaN',
|
||||||
|
'offset=1.5',
|
||||||
|
'offset=Infinity',
|
||||||
|
'from=2026-02-30',
|
||||||
|
'to=2026-13-01',
|
||||||
|
'from=2026-10-18&to=2026-10-17',
|
||||||
|
`q=${'a'.repeat(101)}`,
|
||||||
|
`server=${'a'.repeat(257)}`,
|
||||||
|
`player=${'a'.repeat(129)}`
|
||||||
|
])('rejects %s', (value) => {
|
||||||
|
expect(() => readAfkQuery(new URLSearchParams(value))).toThrow();
|
||||||
|
});
|
||||||
|
it('retains string GUIDs and normalizes page boundaries', () => {
|
||||||
|
expect(readAfkQuery(new URLSearchParams(`player=${guid}&offset=27`))).toMatchObject({
|
||||||
|
player: guid,
|
||||||
|
offset: 25
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { routePermission } from '../../src/lib/server/auth/access.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
const server = 't5_zombie_theater_1';
|
||||||
|
const other = 't6_zm_transit_1';
|
||||||
|
let secret: string;
|
||||||
|
let otherSecret: string;
|
||||||
|
let instanceId: string;
|
||||||
|
let hostId: string;
|
||||||
|
let hostToken: string;
|
||||||
|
let serial = 0;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([server, other]);
|
||||||
|
vi.stubEnv('EC_TELEMETRY_TOKEN', 'telemetry_' + 't'.repeat(40));
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { bankCredential } = await import('../../src/lib/server/auth/bank.js');
|
||||||
|
const { createHost, createEnrollment, enrollHost } =
|
||||||
|
await import('../../src/lib/server/hosts/index.js');
|
||||||
|
hostId = createHost({ name: 'AFK host' }).id;
|
||||||
|
hostToken = enrollHost(createEnrollment(hostId).token, {}).credential;
|
||||||
|
for (const source of [server, other]) {
|
||||||
|
const id = randomUUID();
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)`
|
||||||
|
)
|
||||||
|
.run(id, source, hostId, new Date().toISOString());
|
||||||
|
if (source === server) {
|
||||||
|
instanceId = id;
|
||||||
|
secret = bankCredential(id);
|
||||||
|
} else otherSecret = bankCredential(id);
|
||||||
|
saveTelemetry({
|
||||||
|
server: source,
|
||||||
|
map: source === server ? 'zombie_theater' : 'zm_transit',
|
||||||
|
match: 'match-one',
|
||||||
|
seq: 1,
|
||||||
|
event: 'game_start',
|
||||||
|
players: [{ guid: 'ace', name: 'Ace' }],
|
||||||
|
receivedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE native_instances SET created_at=?')
|
||||||
|
.run(new Date(Date.now() - 86460000).toISOString());
|
||||||
|
// Gameplay snapshots may report up to a day of actual active time in these fixtures.
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE leaderboard_matches SET started_at=?')
|
||||||
|
.run(new Date(Date.now() - 86400000).toISOString());
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await fixture.cleanup();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
const base = (source = server) => ({
|
||||||
|
server: source,
|
||||||
|
guid: 'ace',
|
||||||
|
match: 'match-one',
|
||||||
|
request_id: `afk-${++serial}`
|
||||||
|
});
|
||||||
|
const event = (extra = {}) => ({
|
||||||
|
...base(),
|
||||||
|
seq: 1,
|
||||||
|
active_seconds: 0,
|
||||||
|
kills: 0,
|
||||||
|
headshots: 0,
|
||||||
|
rounds: 0,
|
||||||
|
revives: 0,
|
||||||
|
activity: 'playing',
|
||||||
|
...extra
|
||||||
|
});
|
||||||
|
const reserve = (extra = {}) => ({
|
||||||
|
...base(),
|
||||||
|
session_id: `session-${++serial}`,
|
||||||
|
seconds: 604800,
|
||||||
|
...extra
|
||||||
|
});
|
||||||
|
const start = (session_id: string) => ({ ...base(), session_id });
|
||||||
|
const settle = (
|
||||||
|
session_id: string,
|
||||||
|
consumed_seconds: number | null = 30,
|
||||||
|
end_reason = 'manual',
|
||||||
|
extra = {}
|
||||||
|
) => ({ ...base(), session_id, consumed_seconds, end_reason, ...extra });
|
||||||
|
async function post(action: string, body: unknown, credential = secret) {
|
||||||
|
const { POST } = await import('../../src/routes/api/afk/[action]/+server.js');
|
||||||
|
const response = await POST({
|
||||||
|
params: { action },
|
||||||
|
request: new Request(`https://test/api/afk/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${credential}` },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
return { status: response.status, ...(await response.json()) };
|
||||||
|
}
|
||||||
|
async function fund(points = 100000) {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('INSERT INTO bank_accounts(guid,balance) VALUES (?,?)').run('ace', points);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('authenticates before reading bodies and binds new operations to current membership', async () => {
|
||||||
|
expect(routePermission('/api/afk/[action]', 'POST')).toBe('machine');
|
||||||
|
expect(routePermission('/api/afk/[action]', 'GET')).toBe('viewer');
|
||||||
|
expect(routePermission('/api/afk/corrections', 'POST')).toBe('admin');
|
||||||
|
const { POST } = await import('../../src/routes/api/afk/[action]/+server.js');
|
||||||
|
for (const credential of ['', process.env.EC_TELEMETRY_TOKEN!]) {
|
||||||
|
const request = new Request('https://test/api/afk/event', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${credential}` },
|
||||||
|
body: 'invalid JSON'
|
||||||
|
});
|
||||||
|
expect((await POST({ params: { action: 'event' }, request })).status).toBe(401);
|
||||||
|
expect(request.bodyUsed).toBe(false);
|
||||||
|
}
|
||||||
|
expect(await post('balance', base(), otherSecret)).toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
code: 'wrong_server'
|
||||||
|
});
|
||||||
|
expect(await post('balance', { ...base(), guid: 'outsider' })).toMatchObject({
|
||||||
|
code: 'not_in_match'
|
||||||
|
});
|
||||||
|
expect(await post('reserve', { ...reserve(), match: 'old' })).toMatchObject({
|
||||||
|
code: 'not_in_match'
|
||||||
|
});
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('UPDATE leaderboard_match_players SET present=0').run();
|
||||||
|
expect(
|
||||||
|
await post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
code: 'not_in_match'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares GUID balances across games and rolls free allowance for actual spending without accumulation', async () => {
|
||||||
|
expect(await post('balance', base())).toMatchObject({ free_seconds: 1800, saved_seconds: 0 });
|
||||||
|
const session = reserve();
|
||||||
|
expect(await post('reserve', session)).toMatchObject({
|
||||||
|
authorized_seconds: 1800,
|
||||||
|
free_seconds: 0
|
||||||
|
});
|
||||||
|
await post('start', start(session.session_id));
|
||||||
|
expect(await post('settle', settle(session.session_id, 600))).toMatchObject({
|
||||||
|
free_seconds: 1200,
|
||||||
|
reserved_seconds: 0
|
||||||
|
});
|
||||||
|
expect(await post('balance', base(other), otherSecret)).toMatchObject({ free_seconds: 1200 });
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
closeDb();
|
||||||
|
expect(await post('balance', base())).toMatchObject({ free_seconds: 1200 });
|
||||||
|
db().prepare('UPDATE afk_sessions SET free_charge_at=free_charge_at-?').run(86400001);
|
||||||
|
expect(await post('balance', base())).toMatchObject({ free_seconds: 1800 });
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE afk_sessions SET free_charge_at=free_charge_at-?')
|
||||||
|
.run(10 * 86400000);
|
||||||
|
expect(await post('balance', base())).toMatchObject({ free_seconds: 1800 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never refunds a held free authorization because a day, heartbeat, or process has passed', async () => {
|
||||||
|
const session = reserve();
|
||||||
|
await post('reserve', session);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE afk_accounts SET free_started_at=free_started_at-?')
|
||||||
|
.run(2 * 86400000);
|
||||||
|
expect(await post('balance', base(other), otherSecret)).toMatchObject({
|
||||||
|
free_seconds: 0,
|
||||||
|
reserved_seconds: 1800
|
||||||
|
});
|
||||||
|
expect(await post('reserve', reserve({ server: other }), otherSecret)).toMatchObject({
|
||||||
|
code: 'session_pending'
|
||||||
|
});
|
||||||
|
expect(await post('settle', settle(session.session_id, 100))).toMatchObject({
|
||||||
|
free_seconds: 1700
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains partial progress across reconnects and games, caps performance, and never caps repeated revives', async () => {
|
||||||
|
expect(
|
||||||
|
await post('event', event({ active_seconds: 299, kills: 99, headshots: 24 }))
|
||||||
|
).toMatchObject({ awarded_seconds: 0 });
|
||||||
|
expect(
|
||||||
|
await post(
|
||||||
|
'event',
|
||||||
|
event({ server: other, active_seconds: 1, kills: 1, headshots: 1 }),
|
||||||
|
otherSecret
|
||||||
|
)
|
||||||
|
).toMatchObject({ awarded_seconds: 105 });
|
||||||
|
const big = event({
|
||||||
|
seq: 2,
|
||||||
|
active_seconds: 300,
|
||||||
|
kills: 2000,
|
||||||
|
headshots: 24,
|
||||||
|
rounds: 50,
|
||||||
|
revives: 20
|
||||||
|
});
|
||||||
|
expect(await post('event', big)).toMatchObject({ awarded_seconds: 1035 }); // remaining 435 cap + 600 revive
|
||||||
|
const more = event({
|
||||||
|
seq: 3,
|
||||||
|
active_seconds: 300,
|
||||||
|
kills: 2100,
|
||||||
|
headshots: 25,
|
||||||
|
rounds: 51,
|
||||||
|
revives: 21
|
||||||
|
});
|
||||||
|
const first = await post('event', more);
|
||||||
|
expect(first).toMatchObject({ awarded_seconds: 30 });
|
||||||
|
expect(await post('event', more)).toEqual(first);
|
||||||
|
expect(await post('event', { ...more, request_id: `new-${++serial}` })).toMatchObject({
|
||||||
|
awarded_seconds: 0
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await post(
|
||||||
|
'event',
|
||||||
|
event({
|
||||||
|
seq: 4,
|
||||||
|
active_seconds: 300,
|
||||||
|
kills: 2100,
|
||||||
|
headshots: 25,
|
||||||
|
rounds: 51,
|
||||||
|
revives: 22
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toMatchObject({ awarded_seconds: 30 });
|
||||||
|
expect(
|
||||||
|
await post(
|
||||||
|
'event',
|
||||||
|
event({
|
||||||
|
seq: 5,
|
||||||
|
active_seconds: 3900,
|
||||||
|
kills: 2200,
|
||||||
|
headshots: 25,
|
||||||
|
rounds: 52,
|
||||||
|
revives: 22
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toMatchObject({ awarded_seconds: 765 }); // 720 active +45 performance
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects spectator, AFK, impossible active time and counter regression; stale snapshots never replay progress', async () => {
|
||||||
|
for (const activity of ['afk', 'spectator', 'connected'])
|
||||||
|
expect(await post('event', event({ activity }))).toMatchObject({ status: 400 });
|
||||||
|
const snapshot = event({ seq: 3, active_seconds: 300, kills: 100, revives: 1 });
|
||||||
|
expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 120 });
|
||||||
|
expect(await post('event', event({ seq: 2, active_seconds: 200, kills: 50 }))).toMatchObject({
|
||||||
|
code: 'stale',
|
||||||
|
awarded_seconds: 0
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await post('event', event({ seq: 3, active_seconds: 300, kills: 100, revives: 2 }))
|
||||||
|
).toMatchObject({ code: 'progress_conflict' });
|
||||||
|
expect(await post('event', event({ seq: 4, active_seconds: 0, kills: 100 }))).toMatchObject({
|
||||||
|
code: 'progress_regression'
|
||||||
|
});
|
||||||
|
expect(await post('event', event({ seq: 5, active_seconds: 999999 }))).toMatchObject({
|
||||||
|
code: 'invalid_active_time'
|
||||||
|
});
|
||||||
|
const session = reserve();
|
||||||
|
await post('reserve', session);
|
||||||
|
expect(
|
||||||
|
await post('event', event({ seq: 6, active_seconds: 300, kills: 200, revives: 2 }))
|
||||||
|
).toMatchObject({ code: 'session_pending' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('atomically purchases from the existing bank, preserves receipts, and protects pending bank transfers', async () => {
|
||||||
|
await fund();
|
||||||
|
const purchase = {
|
||||||
|
...base(),
|
||||||
|
packs: 2,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
};
|
||||||
|
const first = await post('purchase', purchase);
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
state: 'purchased',
|
||||||
|
saved_seconds: 1200,
|
||||||
|
bank_balance: 80000,
|
||||||
|
max_purchase_packs: 8
|
||||||
|
});
|
||||||
|
expect(await post('purchase', purchase)).toEqual(first);
|
||||||
|
expect(await post('purchase', { ...purchase, packs: 1 })).toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: 'request_conflict'
|
||||||
|
});
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
expect(db().prepare('SELECT amount FROM bank_ledger').all()).toEqual([{ amount: -20000 }]);
|
||||||
|
const { bankRequest } = await import('../../src/lib/server/fleet/bank.js');
|
||||||
|
bankRequest(
|
||||||
|
'deposit',
|
||||||
|
{ ...base(), operation: 'deposit', amount: 1000, capacity: 1000000 },
|
||||||
|
server,
|
||||||
|
instanceId
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
code: 'bank_pending'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid, unaffordable and overcapacity purchases and rolls both ledgers back together', async () => {
|
||||||
|
await fund(10000);
|
||||||
|
for (const packs of [0, -1, 1.5, '1', 1001])
|
||||||
|
expect(
|
||||||
|
await post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: false,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 2,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
code: 'insufficient_balance'
|
||||||
|
});
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().exec(
|
||||||
|
"CREATE TRIGGER reject_afk BEFORE INSERT ON afk_ledger WHEN NEW.kind='purchase' BEGIN SELECT RAISE(ABORT,'test'); END"
|
||||||
|
);
|
||||||
|
const purchase = {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
};
|
||||||
|
expect(await post('purchase', purchase)).toMatchObject({ status: 503 });
|
||||||
|
expect(db().prepare('SELECT balance FROM bank_accounts').get()).toEqual({ balance: 10000 });
|
||||||
|
expect(db().prepare('SELECT * FROM bank_ledger').all()).toHaveLength(0);
|
||||||
|
db().exec('DROP TRIGGER reject_afk');
|
||||||
|
expect(await post('purchase', purchase)).toMatchObject({ saved_seconds: 600, bank_balance: 0 });
|
||||||
|
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '600');
|
||||||
|
expect(await post('event', event({ revives: 100 }))).toMatchObject({
|
||||||
|
saved_seconds: 600,
|
||||||
|
awarded_seconds: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes purchases and reservations across servers, counting held saved reserve toward capacity', async () => {
|
||||||
|
await fund(10000);
|
||||||
|
const purchases = await Promise.all([
|
||||||
|
post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
}),
|
||||||
|
post(
|
||||||
|
'purchase',
|
||||||
|
{
|
||||||
|
...base(other),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
},
|
||||||
|
otherSecret
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
expect(purchases.filter((x) => x.state === 'purchased')).toHaveLength(1);
|
||||||
|
const a = reserve(),
|
||||||
|
b = reserve({ server: other });
|
||||||
|
const reservations = await Promise.all([post('reserve', a), post('reserve', b, otherSecret)]);
|
||||||
|
expect(reservations.filter((x) => x.state === 'reserved')).toHaveLength(1);
|
||||||
|
expect(reservations.filter((x) => x.code === 'session_pending')).toHaveLength(1);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('UPDATE bank_accounts SET balance=10000').run();
|
||||||
|
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '600');
|
||||||
|
expect(
|
||||||
|
await post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
code: 'reserve_capacity'
|
||||||
|
});
|
||||||
|
const selected = reservations[0].state === 'reserved' ? a : b;
|
||||||
|
expect(
|
||||||
|
await post(
|
||||||
|
'settle',
|
||||||
|
{ ...settle(selected.session_id, 1800), server: selected.server },
|
||||||
|
selected.server === server ? secret : otherSecret
|
||||||
|
)
|
||||||
|
).toMatchObject({ saved_seconds: 600, free_seconds: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles actual seconds after disconnect/deletion and never reopens completed or pre-cancelled sessions', async () => {
|
||||||
|
const session = reserve();
|
||||||
|
await post('reserve', session);
|
||||||
|
await post('start', start(session.session_id));
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('DELETE FROM leaderboard_matches').run();
|
||||||
|
db().prepare('DELETE FROM servers').run();
|
||||||
|
closeDb();
|
||||||
|
const stop = settle(session.session_id, 45, 'disconnect');
|
||||||
|
const first = await post('settle', stop);
|
||||||
|
expect(first).toMatchObject({ state: 'settled', free_seconds: 1755, consumed_seconds: 45 });
|
||||||
|
expect(await post('settle', stop)).toEqual(first);
|
||||||
|
expect(await post('start', start(session.session_id))).toMatchObject({ state: 'settled' });
|
||||||
|
const early = reserve();
|
||||||
|
expect(await post('settle', settle(early.session_id, 0, 'never_started'))).toMatchObject({
|
||||||
|
state: 'settled'
|
||||||
|
});
|
||||||
|
expect(await post('reserve', early)).toMatchObject({ state: 'settled', authorized_seconds: 0 });
|
||||||
|
const { afkProfile } = await import('../../src/lib/server/fleet/afk.js');
|
||||||
|
expect(afkProfile('ace')).toMatchObject({ confirmedAfkSeconds: 45, reservedSeconds: 0 });
|
||||||
|
expect(afkProfile('ace').entries[0]).toMatchObject({ matchAvailable: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records unresolved crashes without invented durations and allows only explicit stopped-session corrections', async () => {
|
||||||
|
const session = reserve();
|
||||||
|
await post('reserve', session);
|
||||||
|
await post('start', start(session.session_id));
|
||||||
|
const crash = settle(session.session_id, null, 'crash', { confirmed_seconds: 10 });
|
||||||
|
expect(await post('settle', crash)).toMatchObject({
|
||||||
|
state: 'unresolved',
|
||||||
|
free_seconds: 0,
|
||||||
|
reserved_seconds: 1800,
|
||||||
|
consumed_seconds: null
|
||||||
|
});
|
||||||
|
expect(await post('event', event({ revives: 1 }))).toMatchObject({ awarded_seconds: 30 });
|
||||||
|
const { afkCorrectSession, afkProfile } = await import('../../src/lib/server/fleet/afk.js');
|
||||||
|
expect(afkProfile('ace')).toMatchObject({ confirmedAfkSeconds: 0, unresolvedSessions: 1 });
|
||||||
|
const correction = {
|
||||||
|
session_id: session.session_id,
|
||||||
|
request_id: 'correction-one',
|
||||||
|
consumed_seconds: 30,
|
||||||
|
reason: 'Verified stopped process; approved accounting adjustment.',
|
||||||
|
stopped: true
|
||||||
|
};
|
||||||
|
expect(afkCorrectSession({ ...correction, consumed_seconds: 9 }, 'admin').body.code).toBe(
|
||||||
|
'invalid_consumption'
|
||||||
|
);
|
||||||
|
expect(afkCorrectSession({ ...correction, stopped: false }, 'admin').status).toBe(400);
|
||||||
|
const first = afkCorrectSession(correction, 'admin');
|
||||||
|
expect(first.body).toMatchObject({
|
||||||
|
state: 'corrected',
|
||||||
|
free_seconds: 1770,
|
||||||
|
reserved_seconds: 0,
|
||||||
|
consumed_seconds: null
|
||||||
|
});
|
||||||
|
expect(afkCorrectSession(correction, 'admin')).toEqual(first);
|
||||||
|
expect(afkCorrectSession({ ...correction, consumed_seconds: 31 }, 'admin').status).toBe(409);
|
||||||
|
expect(afkProfile('ace').sessions[0]).toMatchObject({
|
||||||
|
state: 'corrected',
|
||||||
|
consumedSeconds: null,
|
||||||
|
chargedSeconds: 30,
|
||||||
|
confirmedSeconds: 10
|
||||||
|
});
|
||||||
|
expect(afkProfile('ace').entries.some((x) => x.kind === 'correction')).toBe(true);
|
||||||
|
expect(await post('start', start(session.session_id))).toMatchObject({ state: 'corrected' });
|
||||||
|
const next = reserve();
|
||||||
|
await post('reserve', next);
|
||||||
|
expect(
|
||||||
|
afkCorrectSession(
|
||||||
|
{ ...correction, request_id: 'other', session_id: next.session_id },
|
||||||
|
'admin'
|
||||||
|
).body.code
|
||||||
|
).toBe('not_unresolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires full authorized consumption for expiration and forbids rewinding confirmed checkpoints', async () => {
|
||||||
|
const session = reserve();
|
||||||
|
await post('reserve', session);
|
||||||
|
expect(await post('settle', settle(session.session_id, 1799, 'expired'))).toMatchObject({
|
||||||
|
code: 'invalid_consumption'
|
||||||
|
});
|
||||||
|
await post('settle', settle(session.session_id, null, 'crash', { confirmed_seconds: 20 }));
|
||||||
|
expect(await post('settle', settle(session.session_id, 19))).toMatchObject({
|
||||||
|
code: 'invalid_consumption'
|
||||||
|
});
|
||||||
|
expect(await post('settle', settle(session.session_id, 1800, 'expired'))).toMatchObject({
|
||||||
|
state: 'settled',
|
||||||
|
free_seconds: 0,
|
||||||
|
reserved_seconds: 0
|
||||||
|
});
|
||||||
|
const { afkProfile } = await import('../../src/lib/server/fleet/afk.js');
|
||||||
|
expect(afkProfile('ace').expiredSessions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorizes assigned unfinished teardown recovery and only reads preexisting purchase/event receipts', async () => {
|
||||||
|
await fund();
|
||||||
|
const purchase = {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
};
|
||||||
|
const bought = await post('purchase', purchase);
|
||||||
|
const session = reserve();
|
||||||
|
await post('reserve', session);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { queueHostOperation, createHost, createEnrollment, enrollHost } =
|
||||||
|
await import('../../src/lib/server/hosts/index.js');
|
||||||
|
const removal = queueHostOperation(
|
||||||
|
hostId,
|
||||||
|
'instance.remove',
|
||||||
|
{},
|
||||||
|
{ instanceId, generation: 1 }
|
||||||
|
);
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
|
||||||
|
.run(new Date().toISOString(), instanceId);
|
||||||
|
const { POST } = await import('../../src/routes/api/agent/afk/settle/+server.js');
|
||||||
|
const recover = (
|
||||||
|
action = 'settle',
|
||||||
|
transfer: unknown = settle(session.session_id, 0, 'never_started'),
|
||||||
|
credential = hostToken,
|
||||||
|
operationId = removal.id
|
||||||
|
) =>
|
||||||
|
POST({
|
||||||
|
request: new Request('https://test/api/agent/afk/settle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${credential}` },
|
||||||
|
body: JSON.stringify({ operationId, action, transfer })
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await expect(recover('settle', undefined, secret)).rejects.toMatchObject({ status: 401 });
|
||||||
|
const stranger = createHost({ name: 'Other host' });
|
||||||
|
const strangerToken = enrollHost(createEnrollment(stranger.id).token, {}).credential;
|
||||||
|
await expect(recover('settle', undefined, strangerToken)).rejects.toMatchObject({
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
await expect(recover('start')).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(await (await recover('purchase', purchase)).json()).toMatchObject({
|
||||||
|
state: 'purchased',
|
||||||
|
bank_balance: bought.bank_balance
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await (
|
||||||
|
await recover('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
).toMatchObject({ code: 'not_applied' });
|
||||||
|
expect(await (await recover()).json()).toMatchObject({ state: 'settled' });
|
||||||
|
db().prepare("UPDATE ec_host_operations SET state='completed' WHERE id=?").run(removal.id);
|
||||||
|
await expect(recover()).rejects.toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires an administrator for accounting corrections', async () => {
|
||||||
|
const { POST } = await import('../../src/routes/api/afk/corrections/+server.js');
|
||||||
|
for (const role of ['viewer', 'operator', 'moderator', 'super_viewer']) {
|
||||||
|
await expect(
|
||||||
|
POST({
|
||||||
|
request: new Request('https://test/api/afk/corrections', {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}'
|
||||||
|
}),
|
||||||
|
locals: { user: { id: 'someone', role } } as App.Locals
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ status: 403 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers verified gameplay after match completion while terminally rejecting late purchases and missing history', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const snapshot = event({ active_seconds: 300, revives: 2 });
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE leaderboard_matches SET status='completed',ended_at=last_received_at")
|
||||||
|
.run();
|
||||||
|
db().prepare('UPDATE leaderboard_match_players SET present=0').run();
|
||||||
|
expect(await post('event', snapshot)).toMatchObject({
|
||||||
|
status: 200,
|
||||||
|
state: 'rewarded',
|
||||||
|
awarded_seconds: 120
|
||||||
|
});
|
||||||
|
expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 120 });
|
||||||
|
expect(
|
||||||
|
await post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).toMatchObject({ status: 200, code: 'not_in_match' });
|
||||||
|
db().prepare('DELETE FROM leaderboard_matches').run();
|
||||||
|
expect(await post('event', event({ seq: 2, active_seconds: 600, revives: 3 }))).toMatchObject({
|
||||||
|
status: 200,
|
||||||
|
code: 'not_in_match'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stale purchase quotes without spending and retries initial membership publication lag', async () => {
|
||||||
|
await fund();
|
||||||
|
expect(
|
||||||
|
await post('purchase', {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 1,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
})
|
||||||
|
).toMatchObject({ code: 'price_changed', bank_balance: 100000 });
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const pending = reserve();
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE leaderboard_match_players SET guid='not-yet-published' WHERE guid='ace'")
|
||||||
|
.run();
|
||||||
|
expect(await post('reserve', pending)).toMatchObject({ status: 409, code: 'not_in_match' });
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE leaderboard_match_players SET guid='ace' WHERE guid='not-yet-published'")
|
||||||
|
.run();
|
||||||
|
expect(await post('reserve', pending)).toMatchObject({ state: 'reserved' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds reservation receipts to immutable instance/session owners and ignores raw score changes', async () => {
|
||||||
|
const snapshot = event({ score: 999999, score_total: 999999 });
|
||||||
|
expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 0 });
|
||||||
|
const session = reserve();
|
||||||
|
const first = await post('reserve', session);
|
||||||
|
expect(await post('reserve', session)).toEqual(first);
|
||||||
|
expect(await post('reserve', { ...session, seconds: 1 })).toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: 'request_conflict'
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await post('settle', { ...settle(session.session_id), server: other }, otherSecret)
|
||||||
|
).toMatchObject({ status: 409, code: 'session_conflict' });
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { bankCredential } = await import('../../src/lib/server/auth/bank.js');
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
|
||||||
|
.run(new Date().toISOString(), instanceId);
|
||||||
|
expect(await post('balance', base())).toMatchObject({ status: 401 });
|
||||||
|
const replacement = randomUUID();
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)`
|
||||||
|
)
|
||||||
|
.run(replacement, server, hostId, new Date().toISOString());
|
||||||
|
const replacementSecret = bankCredential(replacement);
|
||||||
|
expect(await post('reserve', session, replacementSecret)).toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: 'request_conflict'
|
||||||
|
});
|
||||||
|
expect(await post('settle', settle(session.session_id), replacementSecret)).toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: 'session_conflict'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('terminally rejects departed-player new work without blocking historical contribution recovery', async () => {
|
||||||
|
await fund();
|
||||||
|
const session = reserve();
|
||||||
|
await post('reserve', session);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('UPDATE leaderboard_match_players SET present=0').run();
|
||||||
|
const purchase = {
|
||||||
|
...base(),
|
||||||
|
packs: 1,
|
||||||
|
confirmed: true,
|
||||||
|
expected_purchase_points: 10000,
|
||||||
|
expected_purchase_seconds: 600
|
||||||
|
};
|
||||||
|
for (const [action, body] of [
|
||||||
|
['balance', base()],
|
||||||
|
['reserve', reserve()],
|
||||||
|
['start', start(session.session_id)],
|
||||||
|
['purchase', purchase]
|
||||||
|
] as const) {
|
||||||
|
expect(await post(action, body)).toMatchObject({
|
||||||
|
status: 200,
|
||||||
|
state: 'rejected',
|
||||||
|
code: 'not_in_match'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(await post('event', event({ active_seconds: 300, revives: 1 }))).toMatchObject({
|
||||||
|
status: 200,
|
||||||
|
state: 'rewarded',
|
||||||
|
awarded_seconds: 90
|
||||||
|
});
|
||||||
|
db().prepare('UPDATE leaderboard_match_players SET present=1').run();
|
||||||
|
expect(await post('purchase', purchase)).toMatchObject({
|
||||||
|
status: 200,
|
||||||
|
state: 'rejected',
|
||||||
|
code: 'not_in_match'
|
||||||
|
});
|
||||||
|
expect(db().prepare('SELECT balance FROM bank_accounts WHERE guid=?').get('ace')).toEqual({
|
||||||
|
balance: 100000
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,9 @@ describe('management route permissions', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
'/moderation',
|
'/moderation',
|
||||||
'/moderation/players/[guid]',
|
'/moderation/players/[guid]',
|
||||||
|
'/moderation/cases/[id]',
|
||||||
'/api/moderation',
|
'/api/moderation',
|
||||||
|
'/api/moderation/cases/[id]',
|
||||||
'/api/moderation/players/[guid]',
|
'/api/moderation/players/[guid]',
|
||||||
'/api/moderation/sanctions/[id]/revoke'
|
'/api/moderation/sanctions/[id]/revoke'
|
||||||
])('limits private moderation access at %s without changing fleet privileges', (route) => {
|
])('limits private moderation access at %s without changing fleet privileges', (route) => {
|
||||||
@@ -100,6 +102,8 @@ describe('management route permissions', () => {
|
|||||||
'/metrics',
|
'/metrics',
|
||||||
'/bank',
|
'/bank',
|
||||||
'/api/bank',
|
'/api/bank',
|
||||||
|
'/afk',
|
||||||
|
'/api/afk',
|
||||||
'/api/matches',
|
'/api/matches',
|
||||||
'/matches/[id]',
|
'/matches/[id]',
|
||||||
'/leaderboard/players/[game]/[guid]',
|
'/leaderboard/players/[game]/[guid]',
|
||||||
@@ -126,6 +130,21 @@ describe('management route permissions', () => {
|
|||||||
expect(allows('viewer', route, 'HEAD')).toBe(true);
|
expect(allows('viewer', route, 'HEAD')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps AFK reporting separate from game credentials and administrator corrections', () => {
|
||||||
|
for (const route of ['/afk', '/api/afk']) {
|
||||||
|
for (const method of ['GET', 'HEAD']) {
|
||||||
|
expect(routePermission(route, method)).toBe('viewer');
|
||||||
|
for (const role of roles) expect(allows(role, route, method)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(routePermission('/api/afk/[action]', 'POST')).toBe('machine');
|
||||||
|
for (const role of roles) {
|
||||||
|
expect(allows(role, '/api/afk/[action]', 'POST')).toBe(false);
|
||||||
|
expect(allows(role, '/api/afk/corrections', 'POST')).toBe(role === 'admin');
|
||||||
|
}
|
||||||
|
expect(routePermission('/api/afk/corrections', 'POST')).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/api/servers', 'POST'],
|
['/api/servers', 'POST'],
|
||||||
['/api/servers/[name]', 'DELETE'],
|
['/api/servers/[name]', 'DELETE'],
|
||||||
|
|||||||
@@ -290,21 +290,38 @@ describe('browser authentication guard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
it('protects moderation pages and Svelte page-data requests with the same role checks', async () => {
|
it.each([
|
||||||
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) {
|
['/moderation', '/moderation'],
|
||||||
signIn(role);
|
['/moderation/cases/[id]', '/moderation/cases/case-123']
|
||||||
for (const path of ['/moderation', '/moderation/__data.json']) {
|
])(
|
||||||
const call = requestFor('/moderation', { path });
|
'protects %s pages and Svelte page-data requests with the same role checks',
|
||||||
if (role === 'viewer' || role === 'operator') {
|
async (route, path) => {
|
||||||
await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
|
for (const role of [
|
||||||
expect(call.resolve).not.toHaveBeenCalled();
|
'viewer',
|
||||||
} else {
|
'super_viewer',
|
||||||
expect((await call.invoke()).status).toBe(200);
|
'operator',
|
||||||
expect(call.resolve).toHaveBeenCalledOnce();
|
'moderator',
|
||||||
|
'admin'
|
||||||
|
] as const) {
|
||||||
|
const session = signIn(role);
|
||||||
|
for (const requestPath of [path, `${path}/__data.json`]) {
|
||||||
|
for (const method of ['GET', 'HEAD']) {
|
||||||
|
const call = requestFor(route, { path: requestPath, method });
|
||||||
|
if (role === 'viewer' || role === 'operator') {
|
||||||
|
await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
|
||||||
|
expect(call.resolve).not.toHaveBeenCalled();
|
||||||
|
} else {
|
||||||
|
const response = await call.invoke();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('Cache-Control')).toContain('no-store');
|
||||||
|
expect(call.resolve).toHaveBeenCalledOnce();
|
||||||
|
expect(call.event.locals.user).toEqual(session.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
|
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
|
||||||
'permits only administrators to write settings and Super Viewers to read them',
|
'permits only administrators to write settings and Super Viewers to read them',
|
||||||
async (method) => {
|
async (method) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
let fixture: Fixture;
|
let fixture: Fixture;
|
||||||
@@ -10,7 +11,30 @@ afterEach(async () => {
|
|||||||
await fixture.cleanup();
|
await fixture.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('indexes campaign cascades and beta evidence on fresh and reopened databases', async () => {
|
function expectModerationReferenceIndexes(handle: Database.Database) {
|
||||||
|
for (const [query, index] of [
|
||||||
|
[
|
||||||
|
"SELECT id FROM moderation_cases WHERE review_sanction_id='restriction'",
|
||||||
|
'moderation_case_review_sanction'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"SELECT id FROM moderation_case_relationships WHERE case_id='source'",
|
||||||
|
'moderation_case_relationship_source'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"SELECT id FROM moderation_case_notifications WHERE case_id='source'",
|
||||||
|
'moderation_case_notifications_parent'
|
||||||
|
]
|
||||||
|
]) {
|
||||||
|
const plan = handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all() as { detail: string }[];
|
||||||
|
expect(
|
||||||
|
plan.some((row) => row.detail.includes(index)),
|
||||||
|
query
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('indexes campaign, beta and moderation references on fresh and reopened databases', async () => {
|
||||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
for (let pass = 0; pass < 2; pass++) {
|
for (let pass = 0; pass < 2; pass++) {
|
||||||
const handle = db();
|
const handle = db();
|
||||||
@@ -29,11 +53,48 @@ it('indexes campaign cascades and beta evidence on fresh and reopened databases'
|
|||||||
}[];
|
}[];
|
||||||
expect(plan.some((row) => row.detail.includes(index))).toBe(true);
|
expect(plan.some((row) => row.detail.includes(index))).toBe(true);
|
||||||
}
|
}
|
||||||
|
expectModerationReferenceIndexes(handle);
|
||||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||||
closeDb();
|
closeDb();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds complete moderation reference indexes when upgrading version 34 without changing records', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const legacy = new Database(':memory:');
|
||||||
|
legacy.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
runMigrations(legacy, migrations.slice(0, 34));
|
||||||
|
legacy.exec(`
|
||||||
|
INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES('moderator','moderator','Moderator','unused','moderator',0,0);
|
||||||
|
INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
|
||||||
|
VALUES('source','t5','123','Source case','open','moderator','Moderator','2026-10-17','2026-10-17'),
|
||||||
|
('target','t5','123','Target case','open','moderator','Moderator','2026-10-17','2026-10-17');
|
||||||
|
INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at)
|
||||||
|
VALUES('restriction','t5','123','mute','game','Recorded restriction','','moderator','Moderator','2026-10-17');
|
||||||
|
UPDATE moderation_cases SET review_sanction_id='restriction' WHERE id='source';
|
||||||
|
INSERT INTO moderation_case_relationships VALUES('relationship','source','target','same_incident','moderator','Moderator','2026-10-17','2026-10-18');
|
||||||
|
INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at)
|
||||||
|
VALUES('moderator','source','evidence','2026-10-17');
|
||||||
|
`);
|
||||||
|
const records = () => ({
|
||||||
|
cases: legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all(),
|
||||||
|
relationships: legacy.prepare('SELECT * FROM moderation_case_relationships').all(),
|
||||||
|
notifications: legacy.prepare('SELECT * FROM moderation_case_notifications').all()
|
||||||
|
});
|
||||||
|
const before = records();
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
expectModerationReferenceIndexes(legacy);
|
||||||
|
expect(records()).toEqual(before);
|
||||||
|
expect(legacy.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('covers every foreign key child prefix, including host and library schemas', async () => {
|
it('covers every foreign key child prefix, including host and library schemas', async () => {
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const handle = db();
|
const handle = db();
|
||||||
|
|||||||
@@ -51,11 +51,42 @@ describe('game chat', () => {
|
|||||||
'1:00 say;123;bad;Player;hello',
|
'1:00 say;123;bad;Player;hello',
|
||||||
'1:00 say;123;0;Player;\x15^7',
|
'1:00 say;123;0;Player;\x15^7',
|
||||||
'1:00 InitGame: say;123;0;Player;hello',
|
'1:00 InitGame: say;123;0;Player;hello',
|
||||||
'1:00 sayteam;123;0;Player;hello'
|
'1:00 sayteam;123;bad;Player;hello',
|
||||||
|
'1:00 sayteam;123;0;Player;\x15^7'
|
||||||
])
|
])
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps team and public chat in order with distinct channels and stable public identities', () => {
|
||||||
|
const messages = parseGameChat([
|
||||||
|
'1:00 say;123;0;Player;hello; café',
|
||||||
|
'1:00 sayteam;123;0;^2Player;\x15hello; café',
|
||||||
|
'1:01 sayteam;456;1;Teammate;reply'
|
||||||
|
]);
|
||||||
|
expect(messages).toEqual([
|
||||||
|
{ time: '1:00', guid: '123', slot: 0, player: 'Player', message: 'hello; café' },
|
||||||
|
{
|
||||||
|
time: '1:00',
|
||||||
|
guid: '123',
|
||||||
|
slot: 0,
|
||||||
|
player: 'Player',
|
||||||
|
message: 'hello; café',
|
||||||
|
channel: 'team'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: '1:01',
|
||||||
|
guid: '456',
|
||||||
|
slot: 1,
|
||||||
|
player: 'Teammate',
|
||||||
|
message: 'reply',
|
||||||
|
channel: 'team'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(messages[0])).toBe(
|
||||||
|
'{"time":"1:00","guid":"123","slot":0,"player":"Player","message":"hello; café"}'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('separates matches even when elapsed time keeps increasing', () => {
|
it('separates matches even when elapsed time keeps increasing', () => {
|
||||||
const entries = parseGameChatTimeline([
|
const entries = parseGameChatTimeline([
|
||||||
'1200:00 InitGame: \\mapname\\zombie_theater',
|
'1200:00 InitGame: \\mapname\\zombie_theater',
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { moderationConcernCategories } from '../../src/lib/moderation.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
const clock = '2026-10-17T12:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date(clock));
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
handle = db();
|
||||||
|
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(id: string, actor = 'moderator') {
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
return moderationCaseDetail(actor, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = () =>
|
||||||
|
mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct' });
|
||||||
|
const draft = {
|
||||||
|
summary: 'The documented slur is substantiated; the threatening intent remains unclear.',
|
||||||
|
rules: ['Rule 1: No discriminatory abuse', 'Rule 2: No threats'],
|
||||||
|
findings: [
|
||||||
|
{
|
||||||
|
category: 'hate_speech',
|
||||||
|
finding: 'substantiated',
|
||||||
|
detail: 'The preserved message contains a slur.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'threats',
|
||||||
|
finding: 'inconclusive',
|
||||||
|
detail: 'The context does not establish a threat.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const assess = (id: string, revision: number, overrides: Record<string, unknown> = {}) => ({
|
||||||
|
op: 'assess-case',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
...draft,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves current and historical assessments with case revisions, including closed cases', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
expect(await detail(id)).toMatchObject({ assessment: null, assessmentHistory: [] });
|
||||||
|
const request = { ...assess(id, 1), idempotencyKey: randomUUID() };
|
||||||
|
await mutate(request);
|
||||||
|
await mutate(request);
|
||||||
|
const first = (await detail(id)).assessment!;
|
||||||
|
expect(first).toEqual({ ...draft, revision: 2, actor: 'moderator', createdAt: clock });
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision: 2,
|
||||||
|
status: 'no_action',
|
||||||
|
reason: 'Initial review complete'
|
||||||
|
});
|
||||||
|
vi.setSystemTime(new Date(Date.parse(clock) + 60_000));
|
||||||
|
const updated = {
|
||||||
|
summary: 'Further context does not support a threat.',
|
||||||
|
rules: [],
|
||||||
|
findings: [
|
||||||
|
{
|
||||||
|
category: 'threats',
|
||||||
|
finding: 'unsubstantiated',
|
||||||
|
detail: 'The reply was about the game.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
await mutate(assess(id, 3, updated), 'admin');
|
||||||
|
const data = await detail(id, 'super_viewer');
|
||||||
|
expect(data.case).toMatchObject({ revision: 4, status: 'closed' });
|
||||||
|
expect(data.assessment).toEqual({
|
||||||
|
...updated,
|
||||||
|
revision: 4,
|
||||||
|
actor: 'admin',
|
||||||
|
createdAt: '2026-10-17T12:01:00.000Z'
|
||||||
|
});
|
||||||
|
expect(data.assessmentHistory).toEqual([data.assessment, first]);
|
||||||
|
expect(JSON.parse(data.events[0].detail)).toEqual({
|
||||||
|
assessmentRevision: 4,
|
||||||
|
summary: updated.summary
|
||||||
|
});
|
||||||
|
const { currentCaseAssessment } =
|
||||||
|
await import('../../src/lib/server/moderation/assessments.js');
|
||||||
|
expect(currentCaseAssessment(id)).toEqual(data.assessment);
|
||||||
|
expect(currentCaseAssessment('missing')).toBeNull();
|
||||||
|
expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual({ revision: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks staff access, case revision and normalized no-change submissions before writing history', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
for (const actor of ['viewer', 'super_viewer'])
|
||||||
|
await expect(mutate(assess(id, 1), actor)).rejects.toMatchObject({ status: 403 });
|
||||||
|
await mutate(assess(id, 1));
|
||||||
|
const before = await detail(id);
|
||||||
|
await expect(mutate(assess(id, 1, { summary: 'Stale edit' }))).rejects.toMatchObject({
|
||||||
|
status: 409
|
||||||
|
});
|
||||||
|
await expect(mutate(assess(id, 2, { summary: ` ${draft.summary} ` }))).rejects.toThrow(
|
||||||
|
'Change the assessment'
|
||||||
|
);
|
||||||
|
expect(await detail(id)).toEqual(before);
|
||||||
|
await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
|
||||||
|
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run();
|
||||||
|
await expect(mutate(assess(id, 2, { summary: 'Disabled account edit' }))).rejects.toMatchObject(
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid summaries, rules and findings without adding assessment or audit rows', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const invalid = [
|
||||||
|
{ summary: '' },
|
||||||
|
{ summary: 'a'.repeat(2001) },
|
||||||
|
{ summary: 'Invalid\u0000summary' },
|
||||||
|
{ rules: null },
|
||||||
|
{ rules: [''] },
|
||||||
|
{ rules: ['a'.repeat(161)] },
|
||||||
|
{ rules: Array(21).fill('Rule') },
|
||||||
|
{ findings: [] },
|
||||||
|
{ findings: null },
|
||||||
|
{ findings: [null] },
|
||||||
|
{ findings: [{ category: 'unknown', finding: 'substantiated', detail: 'Evidence' }] },
|
||||||
|
{ findings: [{ category: 'spam', finding: 'unknown', detail: 'Evidence' }] },
|
||||||
|
{ findings: [{ category: 'spam', finding: 'substantiated', detail: '' }] },
|
||||||
|
{ findings: [{ category: 'spam', finding: 'substantiated', detail: 'a'.repeat(1001) }] },
|
||||||
|
{ findings: [draft.findings[0], draft.findings[0]] },
|
||||||
|
{ findings: Array(8).fill(draft.findings[0]) }
|
||||||
|
];
|
||||||
|
const before = await detail(id);
|
||||||
|
for (const input of invalid)
|
||||||
|
await expect(mutate(assess(id, 1, input))).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(await detail(id)).toEqual(before);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back history, revisions and request receipts when assessment auditing fails', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const before = await detail(id);
|
||||||
|
handle.exec(`CREATE TRIGGER fail_assessment_audit BEFORE INSERT ON moderation_events
|
||||||
|
WHEN NEW.action='case.assessed' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
|
||||||
|
const request = { ...assess(id, 1), idempotencyKey: randomUUID() };
|
||||||
|
await expect(mutate(request)).rejects.toThrow('audit unavailable');
|
||||||
|
expect(await detail(id)).toEqual(before);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]);
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
|
||||||
|
.all(request.idempotencyKey)
|
||||||
|
).toEqual([]);
|
||||||
|
handle.exec('DROP TRIGGER fail_assessment_audit');
|
||||||
|
await mutate(request);
|
||||||
|
expect((await detail(id)).assessmentHistory).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps assessment revisions after source match, server and author cleanup', async () => {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO servers(name,game,map) VALUES('t5_zombie_theater_1','t5','zombie_theater')"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
saveTelemetry({
|
||||||
|
server: 't5_zombie_theater_1',
|
||||||
|
map: 'zombie_theater',
|
||||||
|
event: 'game_start',
|
||||||
|
players: [{ guid: '123', name: 'Player' }],
|
||||||
|
receivedAt: clock
|
||||||
|
});
|
||||||
|
const { id } = await create();
|
||||||
|
await mutate(assess(id, 1));
|
||||||
|
const before = (await detail(id)).assessmentHistory;
|
||||||
|
handle.prepare('DELETE FROM leaderboard_matches').run();
|
||||||
|
handle.prepare('DELETE FROM servers').run();
|
||||||
|
handle.prepare("DELETE FROM auth_users WHERE id='moderator'").run();
|
||||||
|
expect((await detail(id, 'admin')).assessmentHistory).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid detailed multibyte assessment over 16 KiB while retaining the 64 KiB request boundary', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const { POST } = await import('../../src/routes/api/moderation/+server.js');
|
||||||
|
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||||
|
const input = {
|
||||||
|
...assess(id, 1, {
|
||||||
|
summary: '界'.repeat(2000),
|
||||||
|
rules: Array.from({ length: 20 }, (_, index) => `${index} ${'界'.repeat(155)}`),
|
||||||
|
findings: moderationConcernCategories.map((category) => ({
|
||||||
|
category,
|
||||||
|
finding: 'inconclusive',
|
||||||
|
detail: '界'.repeat(1000)
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
};
|
||||||
|
const body = JSON.stringify(input);
|
||||||
|
expect(Buffer.byteLength(body)).toBeGreaterThan(16 * 1024);
|
||||||
|
const post = (body: string) =>
|
||||||
|
POST({
|
||||||
|
locals: { user: getUser('moderator') },
|
||||||
|
request: new Request('http://localhost/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
} as Parameters<typeof POST>[0]);
|
||||||
|
const response = await post(body);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((await detail(id)).assessment?.findings).toHaveLength(7);
|
||||||
|
const oversized = await post(
|
||||||
|
JSON.stringify({ ...input, revision: 2, padding: 'a'.repeat(64 * 1024) })
|
||||||
|
);
|
||||||
|
expect(oversized.status).toBe(413);
|
||||||
|
expect((await detail(id)).assessmentHistory).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds assessment storage to an existing database without altering case records', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const legacy = new Database(':memory:');
|
||||||
|
legacy.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
runMigrations(legacy, migrations.slice(0, 31));
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
|
||||||
|
VALUES('case','t5','123','Existing case','no_action','former','Former staff',?,?,41)`
|
||||||
|
)
|
||||||
|
.run(clock, clock);
|
||||||
|
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
|
||||||
|
runMigrations(legacy, migrations.slice(0, 32));
|
||||||
|
runMigrations(legacy, migrations.slice(0, 32));
|
||||||
|
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
|
||||||
|
const insert = legacy.prepare(
|
||||||
|
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
|
||||||
|
);
|
||||||
|
insert.run('case', 42, JSON.stringify(draft), 'former', 'Former staff', clock);
|
||||||
|
expect(() => insert.run('case', 42, '{}', 'former', 'Former staff', clock)).toThrow(
|
||||||
|
'UNIQUE'
|
||||||
|
);
|
||||||
|
expect(() => insert.run('missing', 2, '{}', 'former', 'Former staff', clock)).toThrow(
|
||||||
|
'FOREIGN KEY'
|
||||||
|
);
|
||||||
|
expect(legacy.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
const clock = '2026-10-17T12:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date(clock));
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
handle = db();
|
||||||
|
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(id: string, actor = 'moderator') {
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
return moderationCaseDetail(actor, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct', ...overrides });
|
||||||
|
|
||||||
|
const restriction = (overrides: Record<string, unknown> = {}, actor = 'moderator') =>
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
op: 'create-sanction',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
kind: 'mute',
|
||||||
|
scope: 'game',
|
||||||
|
durationMinutes: 60,
|
||||||
|
reason: 'Earlier moderation action',
|
||||||
|
...overrides
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
);
|
||||||
|
|
||||||
|
function action(overrides: Record<string, string | null> = {}) {
|
||||||
|
const record = {
|
||||||
|
id: randomUUID(),
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
kind: 'warn',
|
||||||
|
state: 'confirmed',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,
|
||||||
|
kind,reason,actor_id,actor,created_at,expires_at,state)
|
||||||
|
VALUES(?,?,?,'retired-server','retired-instance',1,'old-session',?,
|
||||||
|
'Earlier warning','moderator','Former moderator',?,?,?)`
|
||||||
|
)
|
||||||
|
.run(record.id, record.game, record.guid, record.kind, clock, clock, record.state);
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||||
|
VALUES(?,?,'Former moderator',?,?,'Recorded delivery result',?)`
|
||||||
|
)
|
||||||
|
.run(record.game, record.guid, `action.${record.state}`, record.id, clock);
|
||||||
|
return record.id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforcement() {
|
||||||
|
return {
|
||||||
|
sanctions: handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all(),
|
||||||
|
actions: handle.prepare('SELECT * FROM moderation_actions ORDER BY id').all(),
|
||||||
|
policy: handle.prepare('SELECT * FROM moderation_policy').all()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = (id: string, revision: number, actionType: string, actionId: string) => ({
|
||||||
|
op: 'link-case-action',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
actionType,
|
||||||
|
actionId,
|
||||||
|
reason: 'This earlier action addresses the incident under review.'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links a prior restriction to multiple cases without changing enforcement or its original case', async () => {
|
||||||
|
const original = await create();
|
||||||
|
const sanction = await restriction({ caseId: original.id, caseRevision: 1 });
|
||||||
|
const current = await create();
|
||||||
|
const before = enforcement();
|
||||||
|
const request = {
|
||||||
|
...link(current.id, 1, 'restriction', sanction.id),
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
};
|
||||||
|
await mutate(request);
|
||||||
|
await mutate(request);
|
||||||
|
let data = await detail(current.id);
|
||||||
|
expect(data.case.revision).toBe(2);
|
||||||
|
expect(data.sanctions).toMatchObject([{ id: sanction.id, caseId: original.id }]);
|
||||||
|
expect(data.linkedActions).toMatchObject([
|
||||||
|
{ id: sanction.id, type: 'restriction', state: 'active' }
|
||||||
|
]);
|
||||||
|
expect(data.availableActions).toEqual([]);
|
||||||
|
expect(data.events.map((event) => event.action)).toContain('restriction.created');
|
||||||
|
expect((await detail(original.id)).linkedActions).toMatchObject([{ id: sanction.id }]);
|
||||||
|
expect(enforcement()).toEqual(before);
|
||||||
|
await mutate(link(current.id, 2, 'restriction', sanction.id));
|
||||||
|
expect(await detail(current.id)).toEqual(data);
|
||||||
|
expect(
|
||||||
|
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_action_links').get()
|
||||||
|
).toEqual({ count: 1 });
|
||||||
|
const event = data.events.find((entry) => entry.action === 'case.action_linked')!;
|
||||||
|
expect(JSON.parse(event.detail)).toEqual({
|
||||||
|
reason: request.reason,
|
||||||
|
actionReference: { type: 'restriction', id: sanction.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
await mutate({
|
||||||
|
op: 'revoke-sanction',
|
||||||
|
id: sanction.id,
|
||||||
|
revision: 1,
|
||||||
|
reason: 'Review completed'
|
||||||
|
});
|
||||||
|
data = await detail(current.id);
|
||||||
|
expect(data.case.revision).toBe(3);
|
||||||
|
expect((await detail(original.id)).case.revision).toBe(3);
|
||||||
|
expect(data.linkedActions?.[0].state).toBe('revoked');
|
||||||
|
expect(data.events[0].action).toBe('restriction.revoked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links historical live actions to closed cases and exposes their real delivery state and timeline', async () => {
|
||||||
|
const warningId = action();
|
||||||
|
const failedId = action({ state: 'failed' });
|
||||||
|
action({ game: 't6' });
|
||||||
|
action({ guid: '456' });
|
||||||
|
action({ guid: null, kind: 'announce' });
|
||||||
|
const { id } = await create();
|
||||||
|
expect((await detail(id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
|
||||||
|
[warningId, failedId].sort()
|
||||||
|
);
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
status: 'no_action',
|
||||||
|
reason: 'Initial review complete'
|
||||||
|
});
|
||||||
|
const before = enforcement();
|
||||||
|
await mutate(link(id, 2, 'action', warningId));
|
||||||
|
const data = await detail(id, 'super_viewer');
|
||||||
|
expect(data.case).toMatchObject({ status: 'closed', revision: 3 });
|
||||||
|
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
|
||||||
|
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
|
||||||
|
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
|
||||||
|
expect(enforcement()).toEqual(before);
|
||||||
|
await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthorized, stale, missing and wrong-player or game links and permits a matching fleet restriction', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const right = action();
|
||||||
|
const otherPlayer = action({ guid: '456' });
|
||||||
|
const otherGame = action({ game: 't6' });
|
||||||
|
const wrongRestriction = await restriction({ guid: '456' });
|
||||||
|
for (const actor of ['super_viewer', 'viewer'])
|
||||||
|
await expect(mutate(link(id, 1, 'action', right), actor)).rejects.toMatchObject({
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
await expect(mutate(link(id, 2, 'action', right))).rejects.toMatchObject({ status: 409 });
|
||||||
|
for (const [type, source] of [
|
||||||
|
['action', otherPlayer],
|
||||||
|
['action', otherGame],
|
||||||
|
['action', 'missing'],
|
||||||
|
['restriction', wrongRestriction.id]
|
||||||
|
])
|
||||||
|
await expect(mutate(link(id, 1, type, source))).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect((await detail(id)).case.revision).toBe(1);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
|
||||||
|
|
||||||
|
for (const game of ['t5', 't6'])
|
||||||
|
handle
|
||||||
|
.prepare('INSERT INTO moderation_aliases VALUES(?,?,?,?,?)')
|
||||||
|
.run(game, '123', 'Player', clock, clock);
|
||||||
|
const fleet = await restriction({ scope: 'fleet' }, 'admin');
|
||||||
|
const t6 = await create({ game: 't6' });
|
||||||
|
expect((await detail(t6.id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
|
||||||
|
[fleet.id, otherGame].sort()
|
||||||
|
);
|
||||||
|
await mutate(link(t6.id, 1, 'restriction', fleet.id));
|
||||||
|
expect((await detail(t6.id)).sanctions).toMatchObject([
|
||||||
|
{ id: fleet.id, scope: 'fleet', game: 't5' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a linked confirmed warning for a cited warning outcome and preserves the actual reference', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const confirmed = action();
|
||||||
|
const failed = action({ state: 'failed' });
|
||||||
|
const kick = action({ kind: 'kick' });
|
||||||
|
const sanction = await restriction();
|
||||||
|
const resolve = (
|
||||||
|
revision: number,
|
||||||
|
type: string,
|
||||||
|
actionId: string,
|
||||||
|
status = 'warning_issued'
|
||||||
|
) => ({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
status,
|
||||||
|
reason: 'Decision after evidence review',
|
||||||
|
actionReference: { type, id: actionId }
|
||||||
|
});
|
||||||
|
await expect(mutate(resolve(1, 'action', confirmed))).rejects.toThrow(
|
||||||
|
'Link the recorded action'
|
||||||
|
);
|
||||||
|
await mutate(link(id, 1, 'action', failed));
|
||||||
|
await mutate(link(id, 2, 'action', kick));
|
||||||
|
await mutate(link(id, 3, 'restriction', sanction.id));
|
||||||
|
for (const [type, source] of [
|
||||||
|
['action', failed],
|
||||||
|
['action', kick],
|
||||||
|
['restriction', sanction.id]
|
||||||
|
])
|
||||||
|
await expect(mutate(resolve(4, type, source))).rejects.toThrow('confirmed warning');
|
||||||
|
await mutate(link(id, 4, 'action', confirmed));
|
||||||
|
await expect(mutate(resolve(5, 'action', confirmed, 'restriction_applied'))).rejects.toThrow(
|
||||||
|
'recorded restriction'
|
||||||
|
);
|
||||||
|
const before = enforcement();
|
||||||
|
await mutate(resolve(5, 'action', confirmed));
|
||||||
|
const data = await detail(id);
|
||||||
|
expect(data.case).toMatchObject({ status: 'closed', revision: 6 });
|
||||||
|
expect(JSON.parse(data.events[0].detail)).toEqual({
|
||||||
|
reason: 'Decision after evidence review',
|
||||||
|
actionReference: { type: 'action', id: confirmed }
|
||||||
|
});
|
||||||
|
expect(enforcement()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a historical expired restriction created from the original case to support its closure', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const sanction = await restriction({ caseId: id, caseRevision: 1 });
|
||||||
|
vi.setSystemTime(new Date(Date.parse(clock) + 120 * 60_000));
|
||||||
|
expect((await detail(id)).linkedActions).toMatchObject([{ id: sanction.id, state: 'expired' }]);
|
||||||
|
const before = enforcement();
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision: 2,
|
||||||
|
status: 'restriction_applied',
|
||||||
|
reason: 'Earlier mute addressed the incident',
|
||||||
|
actionReference: { type: 'restriction', id: sanction.id }
|
||||||
|
});
|
||||||
|
expect((await detail(id)).case.status).toBe('closed');
|
||||||
|
expect(enforcement()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back a link, case revision and idempotency record if its audit write fails', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const warning = action();
|
||||||
|
const before = await detail(id);
|
||||||
|
handle.exec(`CREATE TRIGGER reject_case_action_audit BEFORE INSERT ON moderation_events
|
||||||
|
WHEN NEW.action='case.action_linked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
|
||||||
|
const request = { ...link(id, 1, 'action', warning), idempotencyKey: randomUUID() };
|
||||||
|
await expect(mutate(request)).rejects.toThrow('audit unavailable');
|
||||||
|
expect(await detail(id)).toEqual(before);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
|
||||||
|
.all(request.idempotencyKey)
|
||||||
|
).toEqual([]);
|
||||||
|
handle.exec('DROP TRIGGER reject_case_action_audit');
|
||||||
|
await mutate(request);
|
||||||
|
expect((await detail(id)).linkedActions).toMatchObject([{ id: warning }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades existing databases without changing original case restrictions and enforces link identity', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const legacy = new Database(':memory:');
|
||||||
|
legacy.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
runMigrations(legacy, migrations.slice(0, 30));
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
|
||||||
|
VALUES('case','t5','123','Prior case','open','former','Former staff',?,?)`
|
||||||
|
)
|
||||||
|
.run(clock, clock);
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
|
||||||
|
VALUES('restriction','t5','123','mute','game','Prior restriction','','former','Former staff',?,'case')`
|
||||||
|
)
|
||||||
|
.run(clock);
|
||||||
|
const before = legacy.prepare('SELECT * FROM moderation_sanctions').all();
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
expect(legacy.prepare('SELECT * FROM moderation_sanctions').all()).toEqual(before);
|
||||||
|
const insert = legacy.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)');
|
||||||
|
insert.run('case', 'restriction', 'restriction', 'Moderator', clock);
|
||||||
|
expect(() => insert.run('case', 'restriction', 'restriction', 'Moderator', clock)).toThrow(
|
||||||
|
'UNIQUE'
|
||||||
|
);
|
||||||
|
expect(() => insert.run('missing', 'action', 'action', 'Moderator', clock)).toThrow(
|
||||||
|
'FOREIGN KEY'
|
||||||
|
);
|
||||||
|
expect(() => insert.run('case', 'other', 'action', 'Moderator', clock)).toThrow('CHECK');
|
||||||
|
expect(legacy.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture();
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
for (const id of ['first', 'second'])
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused','moderator',0,0)`
|
||||||
|
)
|
||||||
|
.run(id, id, id);
|
||||||
|
});
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
|
||||||
|
it('filters case queues by workflow, priority and ownership while prioritizing active work', async () => {
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
const { moderationOverview, moderationQuery } =
|
||||||
|
await import('../../src/lib/server/moderation/overview.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [title, priority, status, owner] of [
|
||||||
|
['Normal report', 'normal', 'open', null],
|
||||||
|
['Urgent review', 'urgent', 'in_review', 'first'],
|
||||||
|
['High escalation', 'high', 'in_review', 'second'],
|
||||||
|
['Closed urgent appeal', 'urgent', 'closed', 'first']
|
||||||
|
]) {
|
||||||
|
const { id } = mutateModeration('first', {
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title,
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
});
|
||||||
|
ids.push(id);
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE moderation_cases SET priority=?,status=?,assignee_id=? WHERE id=?')
|
||||||
|
.run(priority, status, owner, id);
|
||||||
|
}
|
||||||
|
const read = (extra = '') =>
|
||||||
|
moderationOverview('first', moderationQuery(new URLSearchParams(`view=cases&${extra}`)));
|
||||||
|
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
|
||||||
|
expect(read('caseStatus=active').total).toBe(3);
|
||||||
|
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
|
||||||
|
expect(read('caseStatus=in_review&priority=high').cases.map((item) => item.id)).toEqual([
|
||||||
|
ids[2]
|
||||||
|
]);
|
||||||
|
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
|
||||||
|
expect(read('assignedTo=unassigned').cases.map((item) => item.id)).toEqual([ids[0]]);
|
||||||
|
expect(read('assignedTo=second').cases[0].assignee).toBe('second');
|
||||||
|
expect(
|
||||||
|
read('caseStatus=active&assignedTo=me&priority=urgent&game=t5&guid=123&q=review&offset=100')
|
||||||
|
.query.offset
|
||||||
|
).toBe(0);
|
||||||
|
expect(read('caseStatus=active&assignedTo=me&priority=urgent&game=t6').total).toBe(0);
|
||||||
|
expect(read('caseStatus=invalid&priority=invalid').total).toBe(4);
|
||||||
|
expect(
|
||||||
|
moderationOverview(
|
||||||
|
'first',
|
||||||
|
moderationQuery(
|
||||||
|
new URLSearchParams('caseStatus=closed&assignedTo=unassigned&priority=urgent')
|
||||||
|
)
|
||||||
|
).total
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the latest assessment in case queues while preserving earlier findings', async () => {
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
const { moderationOverview, moderationQuery } =
|
||||||
|
await import('../../src/lib/server/moderation/overview.js');
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
const mutate = (input: Record<string, unknown>) =>
|
||||||
|
mutateModeration('first', { ...input, idempotencyKey: randomUUID() });
|
||||||
|
const { id } = mutate({
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Conversation review'
|
||||||
|
});
|
||||||
|
for (const [revision, finding, summary] of [
|
||||||
|
[1, 'inconclusive', 'Need more context'],
|
||||||
|
[2, 'substantiated', 'Full excerpt confirms targeted harassment']
|
||||||
|
] as const)
|
||||||
|
mutate({
|
||||||
|
op: 'assess-case',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
summary,
|
||||||
|
rules: ['Respect other players'],
|
||||||
|
findings: [{ category: 'harassment', finding, detail: summary }]
|
||||||
|
});
|
||||||
|
const queue = moderationOverview('first', moderationQuery(new URLSearchParams('view=cases')));
|
||||||
|
expect(queue.cases[0].assessment).toMatchObject({
|
||||||
|
summary: 'Full excerpt confirms targeted harassment',
|
||||||
|
findings: [{ category: 'harassment', finding: 'substantiated' }]
|
||||||
|
});
|
||||||
|
expect(moderationCaseDetail('first', id).assessmentHistory).toHaveLength(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
const clock = '2026-10-17T12:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date(clock));
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
handle = db();
|
||||||
|
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||||
|
}
|
||||||
|
async function detail(id: string, actor = 'moderator') {
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
return moderationCaseDetail(actor, id);
|
||||||
|
}
|
||||||
|
const create = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
mutate({
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Review incident',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
const link = (id: string, revision: number, relatedCaseId: string, relation = 'same_incident') => ({
|
||||||
|
op: 'link-case',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
relatedCaseId,
|
||||||
|
relation,
|
||||||
|
reason: 'Evidence connects these reviews.'
|
||||||
|
});
|
||||||
|
const unlink = (id: string, revision: number, relationshipId: string) => ({
|
||||||
|
op: 'unlink-case',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
relationshipId,
|
||||||
|
reason: 'The investigations concern different incidents.'
|
||||||
|
});
|
||||||
|
async function linkCurrent(id: string, related: string, relation: string) {
|
||||||
|
return mutate(link(id, (await detail(id)).case.revision, related, relation));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('records symmetric incident links once, preserving both players and advancing both case revisions', async () => {
|
||||||
|
const a = await create();
|
||||||
|
const b = await create({ game: 't6', guid: '456', title: 'Connected player' });
|
||||||
|
const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() };
|
||||||
|
await mutate(request);
|
||||||
|
await mutate(request);
|
||||||
|
const first = await detail(a.id);
|
||||||
|
const second = await detail(b.id, 'super_viewer');
|
||||||
|
expect(first.case).toMatchObject({ revision: 2, game: 't5', guid: '123', status: 'open' });
|
||||||
|
expect(second.case).toMatchObject({ revision: 2, game: 't6', guid: '456', status: 'open' });
|
||||||
|
expect(first.relatedCases).toMatchObject([
|
||||||
|
{ relation: 'same_incident', case: { id: b.id, title: 'Connected player' } }
|
||||||
|
]);
|
||||||
|
expect(second.relatedCases).toMatchObject([
|
||||||
|
{ id: first.relatedCases![0].id, case: { id: a.id } }
|
||||||
|
]);
|
||||||
|
expect(new Set([first.relatedCases![0].direction, second.relatedCases![0].direction])).toEqual(
|
||||||
|
new Set(['incoming', 'outgoing'])
|
||||||
|
);
|
||||||
|
await mutate(link(b.id, 2, a.id));
|
||||||
|
expect(await detail(a.id)).toEqual(first);
|
||||||
|
expect(await detail(b.id)).toEqual(second);
|
||||||
|
expect(
|
||||||
|
handle.prepare('SELECT case_id,target_case_id FROM moderation_case_relationships').all()
|
||||||
|
).toEqual([{ case_id: [a.id, b.id].sort()[0], target_case_id: [a.id, b.id].sort()[1] }]);
|
||||||
|
expect(JSON.parse(first.events[0].detail)).toMatchObject({
|
||||||
|
relation: 'same_incident',
|
||||||
|
relatedCaseId: b.id,
|
||||||
|
reason: request.reason
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'case-note', id: b.id, revision: 1, text: 'Stale note' })
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains directed appeal and duplicate relationships while rejecting cycles within each relation', async () => {
|
||||||
|
const a = await create();
|
||||||
|
const b = await create();
|
||||||
|
const c = await create();
|
||||||
|
await linkCurrent(a.id, b.id, 'duplicate_of');
|
||||||
|
await linkCurrent(b.id, c.id, 'duplicate_of');
|
||||||
|
await expect(linkCurrent(c.id, a.id, 'duplicate_of')).rejects.toThrow('cycle');
|
||||||
|
await expect(linkCurrent(b.id, a.id, 'duplicate_of')).rejects.toThrow('cycle');
|
||||||
|
await linkCurrent(b.id, a.id, 'appeal_of');
|
||||||
|
await expect(linkCurrent(a.id, b.id, 'appeal_of')).rejects.toThrow('cycle');
|
||||||
|
expect((await detail(a.id)).relatedCases).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
relation: 'duplicate_of',
|
||||||
|
direction: 'outgoing',
|
||||||
|
case: expect.objectContaining({ id: b.id })
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
relation: 'appeal_of',
|
||||||
|
direction: 'incoming',
|
||||||
|
case: expect.objectContaining({ id: b.id })
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const relationship = (await detail(b.id)).relatedCases!.find(
|
||||||
|
(entry) => entry.relation === 'duplicate_of' && entry.direction === 'outgoing'
|
||||||
|
)!;
|
||||||
|
await mutate(unlink(b.id, (await detail(b.id)).case.revision, relationship.id));
|
||||||
|
await linkCurrent(c.id, a.id, 'duplicate_of');
|
||||||
|
expect((await detail(c.id)).relatedCases).toMatchObject([
|
||||||
|
{ relation: 'duplicate_of', direction: 'outgoing', case: { id: a.id } }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces case existence, staff access, revisions, self-link rejection and subject boundaries', async () => {
|
||||||
|
const a = await create();
|
||||||
|
const b = await create({ guid: '456' });
|
||||||
|
const c = await create({ game: 't6' });
|
||||||
|
for (const actor of ['viewer', 'super_viewer'])
|
||||||
|
await expect(mutate(link(a.id, 1, b.id), actor)).rejects.toMatchObject({ status: 403 });
|
||||||
|
await expect(mutate(link(a.id, 2, b.id))).rejects.toMatchObject({ status: 409 });
|
||||||
|
await expect(mutate(link(a.id, 1, 'missing'))).rejects.toMatchObject({ status: 404 });
|
||||||
|
await expect(mutate(link(a.id, 1, a.id))).rejects.toThrow('itself');
|
||||||
|
await expect(mutate(link(a.id, 1, b.id, 'unknown'))).rejects.toMatchObject({ status: 400 });
|
||||||
|
for (const relation of ['duplicate_of', 'appeal_of'])
|
||||||
|
for (const other of [b.id, c.id])
|
||||||
|
await expect(mutate(link(a.id, 1, other, relation))).rejects.toThrow(
|
||||||
|
'same game and player'
|
||||||
|
);
|
||||||
|
expect((await detail(a.id)).case.revision).toBe(1);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]);
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id: a.id,
|
||||||
|
revision: 1,
|
||||||
|
status: 'no_action',
|
||||||
|
reason: 'Review complete'
|
||||||
|
});
|
||||||
|
await mutate(link(a.id, 2, b.id));
|
||||||
|
expect((await detail(a.id)).case).toMatchObject({ status: 'closed', revision: 3 });
|
||||||
|
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes relationships from either endpoint without erasing history and permits a fresh link later', async () => {
|
||||||
|
const a = await create();
|
||||||
|
const b = await create();
|
||||||
|
const unrelated = await create();
|
||||||
|
await mutate(link(a.id, 1, b.id, 'appeal_of'));
|
||||||
|
const relationship = (await detail(a.id)).relatedCases![0];
|
||||||
|
await expect(mutate(unlink(unrelated.id, 1, relationship.id))).rejects.toMatchObject({
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
await expect(mutate(unlink(b.id, 1, relationship.id))).rejects.toMatchObject({ status: 409 });
|
||||||
|
await mutate(unlink(b.id, 2, relationship.id));
|
||||||
|
for (const item of [a, b]) {
|
||||||
|
const data = await detail(item.id);
|
||||||
|
expect(data.case.revision).toBe(3);
|
||||||
|
expect(data.relatedCases).toEqual([]);
|
||||||
|
expect(data.events.map((entry) => entry.action)).toEqual([
|
||||||
|
'case.unlinked',
|
||||||
|
'case.linked',
|
||||||
|
'case.opened'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT removed_at FROM moderation_case_relationships WHERE id=?')
|
||||||
|
.get(relationship.id)
|
||||||
|
).toEqual({ removed_at: clock });
|
||||||
|
await mutate(unlink(a.id, 3, relationship.id));
|
||||||
|
expect((await detail(a.id)).case.revision).toBe(3);
|
||||||
|
await mutate(link(a.id, 3, b.id, 'appeal_of'));
|
||||||
|
expect((await detail(a.id)).relatedCases![0].id).not.toBe(relationship.id);
|
||||||
|
expect(
|
||||||
|
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_relationships').get()
|
||||||
|
).toEqual({ count: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back both case revisions, relationship changes and receipts when either audit fails', async () => {
|
||||||
|
const a = await create();
|
||||||
|
const b = await create();
|
||||||
|
handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events
|
||||||
|
WHEN NEW.action IN ('case.linked','case.unlinked') BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
|
||||||
|
const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() };
|
||||||
|
await expect(mutate(request)).rejects.toThrow('audit unavailable');
|
||||||
|
expect((await detail(a.id)).case.revision).toBe(1);
|
||||||
|
expect((await detail(b.id)).case.revision).toBe(1);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]);
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
|
||||||
|
.all(request.idempotencyKey)
|
||||||
|
).toEqual([]);
|
||||||
|
handle.exec('DROP TRIGGER fail_relationship_audit');
|
||||||
|
await mutate(request);
|
||||||
|
const relationship = (await detail(a.id)).relatedCases![0];
|
||||||
|
handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events
|
||||||
|
WHEN NEW.action='case.unlinked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
|
||||||
|
await expect(mutate(unlink(a.id, 2, relationship.id))).rejects.toThrow('audit unavailable');
|
||||||
|
expect((await detail(a.id)).case.revision).toBe(2);
|
||||||
|
expect((await detail(b.id)).case.revision).toBe(2);
|
||||||
|
expect(handle.prepare('SELECT removed_at FROM moderation_case_relationships').get()).toEqual({
|
||||||
|
removed_at: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests recent same-player cases and treats valid shared archived matches only as review hints', async () => {
|
||||||
|
const a = await create();
|
||||||
|
const shared = await create();
|
||||||
|
const different = await create();
|
||||||
|
const malformed = await create();
|
||||||
|
const wrongSubject = await create();
|
||||||
|
await create({ guid: '456' });
|
||||||
|
await create({ game: 't6' });
|
||||||
|
const snapshot = (matchId: number, guid = '123') => ({
|
||||||
|
matchId,
|
||||||
|
match: { game: 't5', server: 'removed-server', started_at: clock },
|
||||||
|
entry: { type: 'message', message: { guid, message: 'Preserved evidence' } }
|
||||||
|
});
|
||||||
|
const add = (caseId: string, value: string) =>
|
||||||
|
handle
|
||||||
|
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
|
||||||
|
.run(
|
||||||
|
randomUUID(),
|
||||||
|
caseId,
|
||||||
|
'Preserved evidence',
|
||||||
|
'Archived chat',
|
||||||
|
value,
|
||||||
|
'moderator',
|
||||||
|
'moderator',
|
||||||
|
clock
|
||||||
|
);
|
||||||
|
add(a.id, JSON.stringify(snapshot(12)));
|
||||||
|
add(shared.id, JSON.stringify(snapshot(12)));
|
||||||
|
add(different.id, JSON.stringify(snapshot(13)));
|
||||||
|
add(malformed.id, '{broken JSON');
|
||||||
|
add(malformed.id, JSON.stringify({ matchId: 12 }));
|
||||||
|
add(wrongSubject.id, JSON.stringify(snapshot(12, '456')));
|
||||||
|
const candidates = (await detail(a.id)).candidateCases!;
|
||||||
|
expect(candidates).toHaveLength(4);
|
||||||
|
expect(candidates.find((item) => item.id === shared.id)?.sharedMatch).toBe(true);
|
||||||
|
expect(
|
||||||
|
candidates.filter((item) => item.id !== shared.id).every((item) => !item.sharedMatch)
|
||||||
|
).toBe(true);
|
||||||
|
expect((await detail(a.id)).relatedCases).toEqual([]);
|
||||||
|
for (let index = 0; index < 30; index++) {
|
||||||
|
vi.setSystemTime(new Date(Date.parse(clock) + (index + 1) * 60_000));
|
||||||
|
await create({ title: `Later case ${index}` });
|
||||||
|
}
|
||||||
|
const recent = (await detail(a.id)).candidateCases!;
|
||||||
|
expect(recent).toHaveLength(25);
|
||||||
|
expect(recent[0].title).toBe('Later case 29');
|
||||||
|
expect(recent[24].title).toBe('Later case 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades relationship storage with foreign keys, symmetric identity and active-only uniqueness', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const legacy = new Database(':memory:');
|
||||||
|
legacy.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
runMigrations(legacy, migrations.slice(0, 32));
|
||||||
|
const insertCase =
|
||||||
|
legacy.prepare(`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
|
||||||
|
VALUES(?,'t5','123','Existing case','open','former','Former staff',?,?)`);
|
||||||
|
insertCase.run('a', clock, clock);
|
||||||
|
insertCase.run('b', clock, clock);
|
||||||
|
const before = legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all();
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toMatchObject(
|
||||||
|
before
|
||||||
|
);
|
||||||
|
const insert = legacy.prepare(
|
||||||
|
'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)'
|
||||||
|
);
|
||||||
|
insert.run('first', 'a', 'b', 'same_incident', 'former', 'Former staff', clock);
|
||||||
|
expect(() =>
|
||||||
|
insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock)
|
||||||
|
).toThrow('UNIQUE');
|
||||||
|
expect(() =>
|
||||||
|
insert.run('reverse', 'b', 'a', 'same_incident', 'former', 'Former staff', clock)
|
||||||
|
).toThrow('CHECK');
|
||||||
|
expect(() =>
|
||||||
|
insert.run('self', 'a', 'a', 'appeal_of', 'former', 'Former staff', clock)
|
||||||
|
).toThrow('CHECK');
|
||||||
|
expect(() =>
|
||||||
|
insert.run('missing', 'a', 'missing', 'appeal_of', 'former', 'Former staff', clock)
|
||||||
|
).toThrow('FOREIGN KEY');
|
||||||
|
legacy
|
||||||
|
.prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?')
|
||||||
|
.run(clock, 'first');
|
||||||
|
insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock);
|
||||||
|
expect(legacy.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import type { AuthUser } from '../../src/lib/auth.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer']) {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users
|
||||||
|
(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_cases
|
||||||
|
(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
|
||||||
|
VALUES('case-123','t5','123','Private investigation','open','moderator','moderator',?,?)`
|
||||||
|
)
|
||||||
|
.run('2026-10-17T12:00:00.000Z', '2026-10-17T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
|
||||||
|
async function api(user: AuthUser | null, id = 'case-123') {
|
||||||
|
const { GET } = await import('../../src/routes/api/moderation/cases/[id]/+server.js');
|
||||||
|
return GET({ locals: { user }, params: { id } } as Parameters<typeof GET>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function page(user: AuthUser | null, id = 'case-123') {
|
||||||
|
const { load } = await import('../../src/routes/moderation/cases/[id]/+page.server.js');
|
||||||
|
const setHeaders = vi.fn();
|
||||||
|
const result = await load({
|
||||||
|
locals: { user },
|
||||||
|
params: { id },
|
||||||
|
setHeaders
|
||||||
|
} as unknown as Parameters<typeof load>[0]);
|
||||||
|
return { result, setHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(['admin', 'moderator', 'super_viewer'])(
|
||||||
|
'serves private case detail without caching to %s',
|
||||||
|
async (role) => {
|
||||||
|
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||||
|
const user = getUser(role)!;
|
||||||
|
const response = await api(user);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
case: { id: 'case-123', title: 'Private investigation' }
|
||||||
|
});
|
||||||
|
const { result, setHeaders } = await page(user);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
moderationCase: { case: { id: 'case-123', title: 'Private investigation' } }
|
||||||
|
});
|
||||||
|
expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['operator', 'viewer'])(
|
||||||
|
'rejects %s reads before revealing case existence or content',
|
||||||
|
async (role) => {
|
||||||
|
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||||
|
const user = getUser(role)!;
|
||||||
|
for (const id of ['case-123', 'missing']) {
|
||||||
|
const response = await api(user, id);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||||
|
expect(await response.json()).toEqual({ error: 'Moderation access is required.' });
|
||||||
|
await expect(page(user, id)).rejects.toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
body: { message: 'Moderation access is required.' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
|
||||||
|
'rechecks current staff access when stale session locals have %s',
|
||||||
|
async (change) => {
|
||||||
|
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const user = getUser('moderator')!;
|
||||||
|
db().prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
|
||||||
|
const response = await api(user);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||||
|
await expect(page(user)).rejects.toMatchObject({ status: 403 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('preserves private not-found responses in the API and page loader', async () => {
|
||||||
|
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||||
|
const user = getUser('moderator')!;
|
||||||
|
const response = await api(user, 'missing');
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||||
|
expect(await response.json()).toEqual({ error: 'Case not found.' });
|
||||||
|
await expect(page(user, 'missing')).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
body: { message: 'Case not found.' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a signed-in user before looking up a case', async () => {
|
||||||
|
const response = await api(null);
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||||
|
expect(await response.json()).toEqual({ error: 'Sign in to continue.' });
|
||||||
|
await expect(page(null)).rejects.toMatchObject({
|
||||||
|
status: 401,
|
||||||
|
body: { message: 'Sign in to continue.' }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { moderationCaseOutcomes, moderationCaseStatuses } from '../../src/lib/moderation.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
});
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
|
||||||
|
it('migrates every legacy state while preserving assignments, decisions, evidence, actions and relationships', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const handle = new Database(':memory:');
|
||||||
|
handle.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
runMigrations(handle, migrations.slice(0, 35));
|
||||||
|
handle.exec(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES('staff','staff','Staff','unused','moderator',0,0)`);
|
||||||
|
const statuses = ['open', 'in_review', 'escalated', ...moderationCaseOutcomes];
|
||||||
|
for (const status of statuses) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision,assignee_id,review_at)
|
||||||
|
VALUES(?,'t5','123','Existing case',?,'staff','Staff','2026-10-17','2026-10-17',7,'staff','2026-11-01')`
|
||||||
|
)
|
||||||
|
.run(status, status);
|
||||||
|
}
|
||||||
|
handle.exec(`
|
||||||
|
INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||||
|
VALUES('t5','123','Staff','case.warning_issued','warning_issued','Original decision','2026-10-17');
|
||||||
|
INSERT INTO moderation_evidence VALUES('e','warning_issued','Original excerpt','Archived chat','{"entry":"unchanged"}','staff','Staff','2026-10-17');
|
||||||
|
INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
|
||||||
|
VALUES('s','t5','123','mute','game','Original action','','staff','Staff','2026-10-17','warning_issued');
|
||||||
|
UPDATE moderation_cases SET review_sanction_id='s' WHERE id='warning_issued';
|
||||||
|
INSERT INTO moderation_case_action_links VALUES('warning_issued','restriction','s','Staff','2026-10-17');
|
||||||
|
INSERT INTO moderation_case_relationships VALUES('r','open','warning_issued','same_incident','staff','Staff','2026-10-17',NULL);
|
||||||
|
INSERT INTO moderation_case_assessments VALUES('warning_issued',6,'{"summary":"Existing finding"}','staff','Staff','2026-10-17');
|
||||||
|
INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('staff','warning_issued','evidence','2026-10-17');
|
||||||
|
`);
|
||||||
|
const children = [
|
||||||
|
'moderation_evidence',
|
||||||
|
'moderation_sanctions',
|
||||||
|
'moderation_case_action_links',
|
||||||
|
'moderation_case_relationships',
|
||||||
|
'moderation_case_assessments',
|
||||||
|
'moderation_case_notifications'
|
||||||
|
];
|
||||||
|
const before = children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all());
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
expect(children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all())).toEqual(
|
||||||
|
before
|
||||||
|
);
|
||||||
|
for (const old of statuses) {
|
||||||
|
const expected =
|
||||||
|
old === 'open'
|
||||||
|
? 'open'
|
||||||
|
: ['in_review', 'escalated'].includes(old)
|
||||||
|
? 'in_review'
|
||||||
|
: 'closed';
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT status,revision,assignee_id,review_at FROM moderation_cases WHERE id=?'
|
||||||
|
)
|
||||||
|
.get(old)
|
||||||
|
).toEqual({
|
||||||
|
status: expected,
|
||||||
|
revision: old === expected ? 7 : 8,
|
||||||
|
assignee_id: 'staff',
|
||||||
|
review_at: '2026-11-01'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const events = handle
|
||||||
|
.prepare("SELECT detail FROM moderation_events WHERE action='case.status_migrated'")
|
||||||
|
.all() as { detail: string }[];
|
||||||
|
expect(events.map((event) => JSON.parse(event.detail).from).sort()).toEqual(
|
||||||
|
['escalated', ...moderationCaseOutcomes].sort()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare("SELECT detail FROM moderation_events WHERE action='case.warning_issued'")
|
||||||
|
.get()
|
||||||
|
).toEqual({ detail: 'Original decision' });
|
||||||
|
expect(() =>
|
||||||
|
handle.exec("UPDATE moderation_cases SET status='warning_issued' WHERE id='open'")
|
||||||
|
).toThrow('CHECK');
|
||||||
|
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
handle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes lifecycle independently of ownership, findings and enforcement with revision and retry protection', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
const handle = db();
|
||||||
|
for (const role of ['moderator', 'super_viewer'])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
const mutate = (input: Record<string, unknown>, actor = 'moderator') =>
|
||||||
|
mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||||
|
const { id } = mutate({
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Reported conduct',
|
||||||
|
assigneeId: 'moderator'
|
||||||
|
});
|
||||||
|
mutate({
|
||||||
|
op: 'assess-case',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
summary: 'Ambiguous context',
|
||||||
|
rules: [],
|
||||||
|
findings: [{ category: 'threats', finding: 'inconclusive', detail: 'Need more context' }]
|
||||||
|
});
|
||||||
|
const original = moderationCaseDetail('moderator', id);
|
||||||
|
let revision = 2;
|
||||||
|
for (const status of ['in_review', 'closed', 'open', 'closed', 'in_review']) {
|
||||||
|
const request = {
|
||||||
|
op: 'update-case',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
status,
|
||||||
|
reason: 'Workflow changed',
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
};
|
||||||
|
expect(() => mutate({ ...request, reason: '' })).toThrow('Status change reason');
|
||||||
|
expect(() => mutate({ ...request, revision: revision - 1 })).toThrow('changed');
|
||||||
|
expect(() => mutate(request, 'super_viewer')).toThrow('Moderation access');
|
||||||
|
mutate(request);
|
||||||
|
mutate(request);
|
||||||
|
revision++;
|
||||||
|
const data = moderationCaseDetail('moderator', id);
|
||||||
|
expect(data.case).toMatchObject({ status, revision, assigneeId: 'moderator' });
|
||||||
|
expect(data.assessmentHistory).toEqual(original.assessmentHistory);
|
||||||
|
expect(data.case.evidence).toEqual(original.case.evidence);
|
||||||
|
expect(data.sanctions).toEqual(original.sanctions);
|
||||||
|
expect(data.events[0]).toMatchObject({ action: 'case.updated' });
|
||||||
|
}
|
||||||
|
for (const status of [...moderationCaseOutcomes, 'assigned', 'escalated'])
|
||||||
|
expect(() =>
|
||||||
|
mutate({ op: 'update-case', id, revision, status, reason: 'Invalid state' })
|
||||||
|
).toThrow('Choose Open');
|
||||||
|
expect(moderationCaseStatuses).toEqual(['open', 'in_review', 'closed']);
|
||||||
|
expect(handle.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get()).toEqual({ n: 0 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { moderationCaseOutcomes } from '../../src/lib/moderation.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
const clock = '2026-10-17T12:00:00.000Z';
|
||||||
|
const server = 't5_zombie_theater_1';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date(clock));
|
||||||
|
fixture = await newFixture([server]);
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
handle = db();
|
||||||
|
for (const role of ['admin', 'moderator', 'viewer', 'operator', 'super_viewer'])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||||
|
}
|
||||||
|
async function detail(id: string, actor = 'moderator') {
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
return moderationCaseDetail(actor, id);
|
||||||
|
}
|
||||||
|
const create = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
mutate({
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Review chat conduct',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
const restriction = (
|
||||||
|
caseId: string,
|
||||||
|
caseRevision: number,
|
||||||
|
overrides: Record<string, unknown> = {}
|
||||||
|
) => ({
|
||||||
|
op: 'create-sanction',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
kind: 'mute',
|
||||||
|
scope: 'game',
|
||||||
|
reason: 'Repeated chat abuse',
|
||||||
|
durationMinutes: 60,
|
||||||
|
caseId,
|
||||||
|
caseRevision,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades legacy cases without changing evidence, events, requests, or restriction records', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const { migrateModerationCases } = await import('../../src/lib/server/moderation/schema.js');
|
||||||
|
const legacy = new Database(':memory:');
|
||||||
|
try {
|
||||||
|
legacy.pragma('foreign_keys = ON');
|
||||||
|
runMigrations(legacy, migrations.slice(0, 28));
|
||||||
|
for (const status of ['open', 'resolved', 'dismissed']) {
|
||||||
|
legacy
|
||||||
|
.prepare('INSERT INTO moderation_cases VALUES(?,?,?,?,?,?,?,?,?,?)')
|
||||||
|
.run(
|
||||||
|
status,
|
||||||
|
't5',
|
||||||
|
'123',
|
||||||
|
`Legacy ${status}`,
|
||||||
|
status,
|
||||||
|
'retired-user',
|
||||||
|
'Former staff',
|
||||||
|
clock,
|
||||||
|
clock,
|
||||||
|
7
|
||||||
|
);
|
||||||
|
legacy
|
||||||
|
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
|
||||||
|
.run(
|
||||||
|
`evidence-${status}`,
|
||||||
|
status,
|
||||||
|
'Original evidence',
|
||||||
|
'Archived chat',
|
||||||
|
'{"original":true}',
|
||||||
|
'retired-user',
|
||||||
|
'Former staff',
|
||||||
|
clock
|
||||||
|
);
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||||
|
VALUES('t5','123','Former staff','case.opened',?,'Original history',?)`
|
||||||
|
)
|
||||||
|
.run(status, clock);
|
||||||
|
}
|
||||||
|
legacy
|
||||||
|
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
|
||||||
|
.run(
|
||||||
|
'retired-user',
|
||||||
|
'original-request',
|
||||||
|
'fingerprint',
|
||||||
|
'{"ok":true,"id":"open"}',
|
||||||
|
clock
|
||||||
|
);
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at)
|
||||||
|
VALUES('old-sanction','t5','123','ban','game','Original reason','','retired-user','Former staff',?)`
|
||||||
|
)
|
||||||
|
.run(clock);
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
migrateModerationCases(legacy);
|
||||||
|
expect(legacy.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||||
|
expect(legacy.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
expect(
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
'SELECT status,revision,priority,assignee_id FROM moderation_cases ORDER BY id'
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
).toEqual(
|
||||||
|
['dismissed', 'open', 'resolved'].map((status) => ({
|
||||||
|
status: status === 'open' ? 'open' : 'closed',
|
||||||
|
revision: status === 'open' ? 7 : 8,
|
||||||
|
priority: 'normal',
|
||||||
|
assignee_id: null
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
legacy
|
||||||
|
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE snapshot=?')
|
||||||
|
.get('{"original":true}')
|
||||||
|
).toEqual({ count: 3 });
|
||||||
|
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
|
||||||
|
count: 5
|
||||||
|
});
|
||||||
|
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
|
||||||
|
result: '{"ok":true,"id":"open"}'
|
||||||
|
});
|
||||||
|
expect(legacy.prepare('SELECT id,case_id FROM moderation_sanctions').get()).toEqual({
|
||||||
|
id: 'old-sanction',
|
||||||
|
case_id: null
|
||||||
|
});
|
||||||
|
legacy.prepare("UPDATE moderation_cases SET status='in_review' WHERE id='open'").run();
|
||||||
|
expect(() =>
|
||||||
|
legacy.prepare("UPDATE moderation_sanctions SET case_id='missing'").run()
|
||||||
|
).toThrow('FOREIGN KEY');
|
||||||
|
expect(() => legacy.prepare("DELETE FROM moderation_cases WHERE id='open'").run()).toThrow(
|
||||||
|
'FOREIGN KEY'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires private read access and returns a complete case outside list pagination', async () => {
|
||||||
|
const { id } = await create({ note: 'Initial context' });
|
||||||
|
for (const actor of ['viewer', 'operator'])
|
||||||
|
await expect(detail(id, actor)).rejects.toThrow('Moderation access');
|
||||||
|
await expect(detail('missing')).rejects.toMatchObject({ status: 404 });
|
||||||
|
for (let i = 0; i < 30; i++) await mutate({ op: 'case-note', id, text: `Follow-up ${i}` });
|
||||||
|
const data = await detail(id, 'super_viewer');
|
||||||
|
expect(data.case).toMatchObject({
|
||||||
|
id,
|
||||||
|
priority: 'normal',
|
||||||
|
assigneeId: null,
|
||||||
|
assignee: null,
|
||||||
|
revision: 31
|
||||||
|
});
|
||||||
|
expect(data.case.evidence).toHaveLength(31);
|
||||||
|
expect(data.events).toHaveLength(31);
|
||||||
|
expect(data.player).toMatchObject({ game: 't5', guid: '123' });
|
||||||
|
expect(data.assignees).toEqual([
|
||||||
|
{ id: 'admin', username: 'admin' },
|
||||||
|
{ id: 'moderator', username: 'moderator' }
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'case-note', id, text: 'Forbidden write' }, 'super_viewer')
|
||||||
|
).rejects.toThrow('Moderation access');
|
||||||
|
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run();
|
||||||
|
await expect(detail(id)).rejects.toThrow('Moderation access');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits title, ownership, priority and active state updates with optimistic revisions', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'update-case', id, revision: 1, status: 'in_review' })
|
||||||
|
).rejects.toThrow('Status change reason');
|
||||||
|
await mutate({
|
||||||
|
op: 'update-case',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
title: 'Escalated chat report',
|
||||||
|
priority: 'urgent',
|
||||||
|
assigneeId: 'admin',
|
||||||
|
status: 'in_review',
|
||||||
|
reason: 'Reviewing archive'
|
||||||
|
});
|
||||||
|
const data = await detail(id);
|
||||||
|
expect(data.case).toMatchObject({
|
||||||
|
title: 'Escalated chat report',
|
||||||
|
priority: 'urgent',
|
||||||
|
assigneeId: 'admin',
|
||||||
|
assignee: 'admin',
|
||||||
|
status: 'in_review',
|
||||||
|
revision: 2
|
||||||
|
});
|
||||||
|
expect(JSON.parse(data.events[0].detail)).toMatchObject({
|
||||||
|
title: { from: 'Review chat conduct', to: 'Escalated chat report' },
|
||||||
|
assignee: { from: null, to: 'admin' },
|
||||||
|
status: { from: 'open', to: 'in_review' },
|
||||||
|
reason: 'Reviewing archive'
|
||||||
|
});
|
||||||
|
await expect(mutate({ op: 'update-case', id, revision: 1, priority: 'low' })).rejects.toThrow(
|
||||||
|
'changed'
|
||||||
|
);
|
||||||
|
for (const assigneeId of ['viewer', 'super_viewer', 'operator', 'missing'])
|
||||||
|
await expect(mutate({ op: 'update-case', id, revision: 2, assigneeId })).rejects.toThrow(
|
||||||
|
'active administrator or moderator'
|
||||||
|
);
|
||||||
|
handle.prepare("UPDATE auth_users SET must_change_password=1 WHERE id='admin'").run();
|
||||||
|
await mutate({
|
||||||
|
op: 'update-case',
|
||||||
|
id,
|
||||||
|
revision: 2,
|
||||||
|
assigneeId: 'admin',
|
||||||
|
title: 'Existing ownership retained'
|
||||||
|
});
|
||||||
|
await mutate({
|
||||||
|
op: 'update-case',
|
||||||
|
id,
|
||||||
|
revision: 3,
|
||||||
|
assigneeId: null,
|
||||||
|
status: 'open',
|
||||||
|
reason: 'Needs administrator review'
|
||||||
|
});
|
||||||
|
expect((await detail(id)).case).toMatchObject({
|
||||||
|
revision: 4,
|
||||||
|
status: 'open',
|
||||||
|
assigneeId: null
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'update-case', id, revision: 4, assigneeId: 'admin' })
|
||||||
|
).rejects.toThrow('active administrator or moderator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports every outcome and reopening while preserving each prior decision', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
let revision = 1;
|
||||||
|
for (const status of moderationCaseOutcomes) {
|
||||||
|
await expect(mutate({ op: 'resolve-case', id, revision, status })).rejects.toThrow(
|
||||||
|
'Decision reason'
|
||||||
|
);
|
||||||
|
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
|
||||||
|
revision++;
|
||||||
|
expect((await detail(id)).case.status).toBe('closed');
|
||||||
|
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
|
||||||
|
'Status change reason'
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
|
||||||
|
).rejects.toThrow('changed');
|
||||||
|
await expect(mutate({ op: 'reopen-case', id, revision })).rejects.toThrow(
|
||||||
|
'Reopening reason'
|
||||||
|
);
|
||||||
|
await mutate({ op: 'reopen-case', id, revision, reason: `New evidence after ${status}` });
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
const data = await detail(id);
|
||||||
|
expect(data.case).toMatchObject({ revision, status: 'open' });
|
||||||
|
for (const status of moderationCaseOutcomes) {
|
||||||
|
expect(data.case.evidence.some((item) => item.text === `Decision ${status}`)).toBe(true);
|
||||||
|
expect(data.events.some((event) => event.action === `case.${status}`)).toBe(true);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'reopen-case', id, revision, reason: 'Already open' })
|
||||||
|
).rejects.toThrow('already active');
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'resolve-case', id, revision, status: 'escalated', reason: 'Wrong operation' })
|
||||||
|
).rejects.toThrow('case outcome');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links restrictions atomically with case revisions and idempotent retries', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const input = { ...restriction(id, 1), idempotencyKey: randomUUID() };
|
||||||
|
for (const override of [
|
||||||
|
{ game: 't6' },
|
||||||
|
{ guid: '456' },
|
||||||
|
{ durationMinutes: null },
|
||||||
|
{ caseRevision: 0 }
|
||||||
|
])
|
||||||
|
await expect(mutate({ ...input, ...override })).rejects.toThrow();
|
||||||
|
expect((await detail(id)).case.revision).toBe(1);
|
||||||
|
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
|
||||||
|
count: 0
|
||||||
|
});
|
||||||
|
const applied = await mutate(input);
|
||||||
|
expect(await mutate(input)).toEqual(applied);
|
||||||
|
let data = await detail(id);
|
||||||
|
expect(data.case.revision).toBe(2);
|
||||||
|
expect(data.sanctions).toMatchObject([{ id: applied.id, caseId: id, active: true }]);
|
||||||
|
expect(data.events.filter((event) => event.action === 'case.restriction')).toHaveLength(1);
|
||||||
|
await expect(mutate(restriction(id, 1))).rejects.toThrow('changed');
|
||||||
|
await mutate({ op: 'revoke-sanction', id: applied.id, revision: 1, reason: 'Review revised' });
|
||||||
|
data = await detail(id);
|
||||||
|
expect(data.case.revision).toBe(3);
|
||||||
|
expect(data.sanctions[0].active).toBe(false);
|
||||||
|
expect(data.events[0]).toMatchObject({
|
||||||
|
action: 'restriction.revoked',
|
||||||
|
subjectId: applied.id,
|
||||||
|
detail: 'Review revised'
|
||||||
|
});
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision: 3,
|
||||||
|
status: 'restriction_applied',
|
||||||
|
reason: 'Finished review'
|
||||||
|
});
|
||||||
|
await expect(mutate(restriction(id, 4))).rejects.toThrow('Reopen this case');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back restriction, policy and case writes when a linked audit write fails', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const policy = handle.prepare('SELECT revision FROM moderation_policy').get();
|
||||||
|
handle.exec(`CREATE TRIGGER fail_case_audit BEFORE INSERT ON moderation_events
|
||||||
|
WHEN NEW.action='case.restriction' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
|
||||||
|
const input = { ...restriction(id, 1), idempotencyKey: randomUUID() };
|
||||||
|
await expect(mutate(input)).rejects.toThrow('audit unavailable');
|
||||||
|
expect((await detail(id)).case.revision).toBe(1);
|
||||||
|
expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual(policy);
|
||||||
|
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
|
||||||
|
count: 0
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT COUNT(*) AS count FROM moderation_requests WHERE request_key=?')
|
||||||
|
.get(input.idempotencyKey)
|
||||||
|
).toEqual({ count: 0 });
|
||||||
|
handle.exec('DROP TRIGGER fail_case_audit');
|
||||||
|
await mutate(input);
|
||||||
|
expect((await detail(id)).sanctions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches only archived evidence for the exact player and retains snapshots after cleanup', async () => {
|
||||||
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
saveTelemetry({
|
||||||
|
server,
|
||||||
|
map: 'zombie_theater',
|
||||||
|
event: 'game_start',
|
||||||
|
players: [{ guid: '123', name: 'Player' }],
|
||||||
|
receivedAt: clock
|
||||||
|
});
|
||||||
|
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
for (const [guid, message] of [
|
||||||
|
['123', 'Verified message'],
|
||||||
|
['123', ' Evidence with padding '],
|
||||||
|
['456', 'Another player']
|
||||||
|
])
|
||||||
|
handle
|
||||||
|
.prepare('INSERT INTO match_chat VALUES(?,?,?,?)')
|
||||||
|
.run(
|
||||||
|
matchId,
|
||||||
|
message,
|
||||||
|
0,
|
||||||
|
JSON.stringify({ type: 'message', message: { guid, message, player: 'Player' } })
|
||||||
|
);
|
||||||
|
const { id } = await create();
|
||||||
|
for (const message of ['Forged text', 'Another player', 'Evidence with padding'])
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, message } })
|
||||||
|
).rejects.toThrow('could not be verified');
|
||||||
|
const { id: otherGame } = await create({ game: 't6' });
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id: otherGame,
|
||||||
|
revision: 1,
|
||||||
|
evidence: { matchId, message: 'Verified message' }
|
||||||
|
})
|
||||||
|
).rejects.toThrow('source match is unavailable');
|
||||||
|
const input = {
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
evidence: { matchId, message: 'Verified message' },
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
};
|
||||||
|
await mutate(input);
|
||||||
|
await mutate(input);
|
||||||
|
await expect(mutate({ ...input, idempotencyKey: randomUUID() })).rejects.toThrow('changed');
|
||||||
|
await mutate({
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id,
|
||||||
|
revision: 2,
|
||||||
|
evidence: { matchId, message: ' Evidence with padding ' }
|
||||||
|
});
|
||||||
|
handle.prepare('DELETE FROM leaderboard_matches').run();
|
||||||
|
const data = await detail(id);
|
||||||
|
expect(data.case.revision).toBe(3);
|
||||||
|
expect(data.case.evidence).toMatchObject([
|
||||||
|
{ text: 'Verified message', snapshot: expect.stringContaining('Verified message') },
|
||||||
|
{
|
||||||
|
text: ' Evidence with padding ',
|
||||||
|
snapshot: expect.stringContaining(' Evidence with padding ')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(data.events[0].action).toBe('case.evidence_attached');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps decision history writable when a case has reached its evidence limit', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
for (let index = 0; index < 100; index++)
|
||||||
|
handle
|
||||||
|
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
|
||||||
|
.run(
|
||||||
|
randomUUID(),
|
||||||
|
id,
|
||||||
|
`Evidence ${index}`,
|
||||||
|
'Staff note',
|
||||||
|
null,
|
||||||
|
'moderator',
|
||||||
|
'moderator',
|
||||||
|
clock
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'case-note', id, revision: 1, text: 'Over the limit' })
|
||||||
|
).rejects.toThrow('100 evidence entries');
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
status: 'no_action',
|
||||||
|
reason: 'Review complete'
|
||||||
|
});
|
||||||
|
await mutate({ op: 'reopen-case', id, revision: 2, reason: 'Reconsider the decision' });
|
||||||
|
expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 });
|
||||||
|
expect((await detail(id)).case.evidence).toHaveLength(102);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves all participants and selected authors, distinguishes repeated messages, and deduplicates attachments', async () => {
|
||||||
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js');
|
||||||
|
saveTelemetry({
|
||||||
|
server,
|
||||||
|
map: 'zombie_theater',
|
||||||
|
event: 'game_start',
|
||||||
|
players: [{ guid: '123', name: 'Player' }],
|
||||||
|
receivedAt: clock
|
||||||
|
});
|
||||||
|
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
const insert = handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)');
|
||||||
|
for (let i = 0; i < 22; i++) {
|
||||||
|
const subject = i > 0 && i < 20;
|
||||||
|
insert.run(
|
||||||
|
matchId,
|
||||||
|
subject ? 'repeat' : `context-${i}`,
|
||||||
|
subject ? i : 1,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
context: { realTime: null, round: null, matchElapsedMs: null },
|
||||||
|
message: {
|
||||||
|
time: '1:01',
|
||||||
|
guid: subject ? '123' : '456',
|
||||||
|
player: subject ? 'Subject' : 'Witness',
|
||||||
|
slot: subject ? 1 : 2,
|
||||||
|
message: subject ? 'Repeated message' : `Context ${i}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const entries = matchChatTimeline(matchId);
|
||||||
|
expect(new Set(entries.map((entry) => entry.messageId)).size).toBe(22);
|
||||||
|
const { id } = await create();
|
||||||
|
await mutate({
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
evidence: { matchId, messageIds: entries.slice(1, 20).map((entry) => entry.messageId) }
|
||||||
|
});
|
||||||
|
let data = await detail(id);
|
||||||
|
expect(data.case.evidence).toHaveLength(1);
|
||||||
|
const snapshot = JSON.parse(data.case.evidence[0].snapshot!);
|
||||||
|
expect(snapshot.entries).toHaveLength(22);
|
||||||
|
expect(snapshot.selectedMessageIds).toHaveLength(19);
|
||||||
|
expect(snapshot.highlightedMessageIds).toHaveLength(19);
|
||||||
|
expect(snapshot.entries[0].message.player).toBe('Witness');
|
||||||
|
await mutate({
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id,
|
||||||
|
revision: 2,
|
||||||
|
evidence: { matchId, message: 'Repeated message' }
|
||||||
|
});
|
||||||
|
data = await detail(id);
|
||||||
|
expect(data.case.revision).toBe(2);
|
||||||
|
expect(data.case.evidence).toHaveLength(1);
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id,
|
||||||
|
revision: 2,
|
||||||
|
evidence: { matchId, messageIds: ['forged'] }
|
||||||
|
})
|
||||||
|
).rejects.toThrow('identifiers');
|
||||||
|
const legacy = await create();
|
||||||
|
await mutate({
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id: legacy.id,
|
||||||
|
revision: 1,
|
||||||
|
evidence: { matchId, message: 'Repeated message' }
|
||||||
|
});
|
||||||
|
expect((await detail(legacy.id)).case.evidence).toHaveLength(19);
|
||||||
|
handle.prepare('DELETE FROM leaderboard_matches').run();
|
||||||
|
expect(JSON.parse((await detail(id)).case.evidence[0].snapshot!).entries).toHaveLength(22);
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import type { AuthUser } from '../../src/lib/auth.js';
|
||||||
|
import {
|
||||||
|
matchApiParams,
|
||||||
|
matchHistoryHref,
|
||||||
|
readMatchQuery
|
||||||
|
} from '../../src/lib/match-history-query.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
const server = 't5_zombie_theater_1';
|
||||||
|
const clock = '2026-10-17T12:00:00.000Z';
|
||||||
|
const end = '2026-10-17T12:01:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
handle = db();
|
||||||
|
handle
|
||||||
|
.prepare("INSERT INTO servers(name,game,map) VALUES(?,'t5','zombie_theater')")
|
||||||
|
.run(server);
|
||||||
|
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer'])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES('t5','123','Player',?,?)"
|
||||||
|
)
|
||||||
|
.run(clock, end);
|
||||||
|
const insert =
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_matches(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_observed,highest_round,last_event,last_fingerprint)
|
||||||
|
VALUES(?,?,'t5','zombie_theater','t5:zombie_theater','Kino Der Toten',?,?,?,?,1,?,'game_end','test')`);
|
||||||
|
for (const [id, status, round] of [
|
||||||
|
[1, 'completed', 1],
|
||||||
|
[2, 'interrupted', 1],
|
||||||
|
[3, 'active', 0],
|
||||||
|
[4, 'end-report-missing', 2],
|
||||||
|
[5, 'completed', 8],
|
||||||
|
[6, 'completed', 1]
|
||||||
|
] as const) {
|
||||||
|
insert.run(id, id === 6 ? 'other-server' : server, clock, end, end, status, round);
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_match_players(match_id,guid,highest_round,has_stats,completed,first_seen_at,last_seen_at)
|
||||||
|
VALUES(?,'123',?,1,?,?,?)`
|
||||||
|
)
|
||||||
|
.run(id, round, status === 'completed' ? 1 : 0, clock, end);
|
||||||
|
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
|
||||||
|
id,
|
||||||
|
`message-${id}`,
|
||||||
|
1,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
message: {
|
||||||
|
time: '1:00',
|
||||||
|
guid: '123',
|
||||||
|
slot: 1,
|
||||||
|
player: 'Player',
|
||||||
|
message: `Message from match ${id}`
|
||||||
|
},
|
||||||
|
context: { realTime: null, matchElapsedMs: null, round: null }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
|
||||||
|
async function user(role: string): Promise<AuthUser> {
|
||||||
|
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||||
|
return getUser(role)!;
|
||||||
|
}
|
||||||
|
async function api(actor: AuthUser | null, scoped = false, archive = '1') {
|
||||||
|
const url = new URL(`http://localhost/api/matches?archive=${archive}`);
|
||||||
|
if (scoped) {
|
||||||
|
const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
|
||||||
|
return GET({ url, params: { name: server }, locals: { user: actor } });
|
||||||
|
}
|
||||||
|
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||||
|
return GET({ url, locals: { user: actor } });
|
||||||
|
}
|
||||||
|
async function page(
|
||||||
|
actor: AuthUser | null,
|
||||||
|
surface: 'list' | 'detail' | 'instance',
|
||||||
|
id = '1',
|
||||||
|
archive = '1'
|
||||||
|
) {
|
||||||
|
const url = new URL(`http://localhost/matches/${id}?matchArchive=${archive}`);
|
||||||
|
const setHeaders = vi.fn();
|
||||||
|
let result;
|
||||||
|
if (surface === 'list') {
|
||||||
|
const { load } = await import('../../src/routes/matches/+page.server.js');
|
||||||
|
result = load({ url, locals: { user: actor }, setHeaders });
|
||||||
|
} else if (surface === 'detail') {
|
||||||
|
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
|
||||||
|
result = load({ url, params: { id }, locals: { user: actor }, setHeaders });
|
||||||
|
} else {
|
||||||
|
const { load } = await import('../../src/routes/servers/[name]/+page.server.js');
|
||||||
|
result = load({ url, params: { name: server }, locals: { user: actor }, setHeaders });
|
||||||
|
}
|
||||||
|
return { result, setHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('makes archive selection explicit, strictly parses it and preserves it through namespaced navigation', () => {
|
||||||
|
expect(readMatchQuery(new URLSearchParams()).archive).toBe(false);
|
||||||
|
expect(readMatchQuery(new URLSearchParams('archive=0')).archive).toBe(false);
|
||||||
|
expect(readMatchQuery(new URLSearchParams('archive=1')).archive).toBe(true);
|
||||||
|
for (const value of ['true', 'yes', '2'])
|
||||||
|
expect(() => readMatchQuery(new URLSearchParams(`archive=${value}`))).toThrow(
|
||||||
|
'Invalid match history filters'
|
||||||
|
);
|
||||||
|
const url = new URL('http://localhost/matches?matchPage=3&matchStatus=interrupted');
|
||||||
|
const href = matchHistoryHref(url, { archive: '1' });
|
||||||
|
expect(href).toContain('matchArchive=1');
|
||||||
|
expect(href).not.toContain('matchPage');
|
||||||
|
expect(matchApiParams(new URL(href, url)).get('archive')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes saved round-one, interrupted, active and missing-end chat only in expanded queries without changing gameplay qualification', async () => {
|
||||||
|
const { getMatchDetail, serverMatchHistory } =
|
||||||
|
await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const publicBefore = getLeaderboard();
|
||||||
|
expect(publicBefore.entries[0].gamesPlayed).toBe(1);
|
||||||
|
expect(serverMatchHistory().total).toBe(2);
|
||||||
|
const archive = readMatchQuery(new URLSearchParams('archive=1'));
|
||||||
|
expect(serverMatchHistory(undefined, 25, archive).total).toBe(6);
|
||||||
|
expect(serverMatchHistory(server, 25, archive).total).toBe(5);
|
||||||
|
expect(
|
||||||
|
serverMatchHistory(
|
||||||
|
server,
|
||||||
|
25,
|
||||||
|
readMatchQuery(new URLSearchParams('archive=1&status=interrupted'))
|
||||||
|
).matches.map((item) => item.id)
|
||||||
|
).toEqual([2]);
|
||||||
|
for (const id of [1, 2, 3, 4, 6]) {
|
||||||
|
expect(getMatchDetail(id)).toBeNull();
|
||||||
|
expect(getMatchDetail(id, true)?.chat).toMatchObject([
|
||||||
|
{ message: { message: `Message from match ${id}` }, messageId: `${id}:message-${id}:1` }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const id of [2, 3, 4])
|
||||||
|
expect(getMatchDetail(id, true)).toMatchObject({
|
||||||
|
finalRound: null,
|
||||||
|
durationMs: null,
|
||||||
|
endedAt: null,
|
||||||
|
endPlayers: null,
|
||||||
|
players: [{ presentAtEnd: false }]
|
||||||
|
});
|
||||||
|
expect(getMatchDetail(5)).toMatchObject({ status: 'completed', finalRound: 8 });
|
||||||
|
expect(getLeaderboard()).toEqual(publicBefore);
|
||||||
|
handle.prepare('DELETE FROM servers WHERE name=?').run(server);
|
||||||
|
expect(getMatchDetail(2, true)?.chat).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['admin', 'moderator', 'super_viewer'])(
|
||||||
|
'allows %s archive access with private responses on every API and page surface',
|
||||||
|
async (role) => {
|
||||||
|
const actor = await user(role);
|
||||||
|
for (const scoped of [false, true]) {
|
||||||
|
const response = await api(actor, scoped);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||||
|
expect((await response.json()).total).toBe(scoped ? 5 : 6);
|
||||||
|
}
|
||||||
|
for (const surface of ['list', 'detail', 'instance'] as const) {
|
||||||
|
const loaded = await page(actor, surface);
|
||||||
|
expect(loaded.setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
|
||||||
|
if (surface === 'detail')
|
||||||
|
expect(loaded.result).toMatchObject({
|
||||||
|
match: { id: 1, status: 'completed', finalRound: 1 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['operator', 'viewer', null])(
|
||||||
|
'rejects %s expanded access before revealing match existence',
|
||||||
|
async (role) => {
|
||||||
|
const actor = role ? await user(role) : null;
|
||||||
|
for (const scoped of [false, true]) {
|
||||||
|
const response = await api(actor, scoped);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||||
|
}
|
||||||
|
for (const surface of ['list', 'detail', 'instance'] as const)
|
||||||
|
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
|
||||||
|
await expect(page(actor, 'detail', '999')).rejects.toMatchObject({ status: 403 });
|
||||||
|
expect((await api(actor, false, '0')).status).toBe(200);
|
||||||
|
await expect(page(actor, 'detail', '1', '0')).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect((await page(actor, 'detail', '5', '0')).result).toMatchObject({ match: { id: 5 } });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
|
||||||
|
'revalidates stale moderator locals after %s',
|
||||||
|
async (change) => {
|
||||||
|
const actor = await user('moderator');
|
||||||
|
handle.prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
|
||||||
|
expect((await api(actor)).status).toBe(403);
|
||||||
|
expect((await api(actor, true)).status).toBe(403);
|
||||||
|
for (const surface of ['list', 'detail', 'instance'] as const)
|
||||||
|
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('returns validation and not-found errors without silently falling back to the standard history', async () => {
|
||||||
|
const actor = await user('moderator');
|
||||||
|
expect((await api(actor, false, 'true')).status).toBe(400);
|
||||||
|
for (const surface of ['list', 'detail', 'instance'] as const)
|
||||||
|
await expect(page(actor, surface, '1', 'true')).rejects.toMatchObject({ status: 400 });
|
||||||
|
for (const id of ['0', '01', '-1', '1.5', '999'])
|
||||||
|
await expect(page(actor, 'detail', id)).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches interrupted-match archive evidence and keeps the snapshot after source cleanup', async () => {
|
||||||
|
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
const entry = getMatchDetail(2, true)!.chat[0];
|
||||||
|
const { id } = mutateModeration('moderator', {
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Interrupted match report',
|
||||||
|
evidence: { matchId: 2, messageIds: [entry.messageId] },
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
});
|
||||||
|
handle.prepare('DELETE FROM leaderboard_matches WHERE id=2').run();
|
||||||
|
const evidence = moderationCaseDetail('moderator', id).case.evidence[0];
|
||||||
|
expect(JSON.parse(evidence.snapshot!).entry.message.message).toBe('Message from match 2');
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user