This commit is contained in:
@@ -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,142 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
The existing player profile access controls protect 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,6 +25,7 @@ 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, migrateModerationCases } from '../moderation/schema.js';
|
import { migrateModeration, migrateModerationCases } from '../moderation/schema.js';
|
||||||
@@ -168,7 +169,8 @@ export const migrations: readonly Migration[] = [
|
|||||||
name: 'moderation case workflow and linked restrictions',
|
name: 'moderation case workflow and linked restrictions',
|
||||||
mode: 'foreign-keys-off',
|
mode: 'foreign-keys-off',
|
||||||
run: migrateModerationCases
|
run: migrateModerationCases
|
||||||
}
|
},
|
||||||
|
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const databaseVersion = migrations.length;
|
export const databaseVersion = migrations.length;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
let { account }: { account: ReturnType<typeof afkProfile> } = $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">
|
||||||
|
<h2 id="afk-title">AFK reserve</h2>
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
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>
|
||||||
@@ -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,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} />
|
||||||
<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
|
||||||
|
|||||||
+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 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCatJ0WhsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfqjWQD8CaV5ZH/kD1AhJ15B9czG
|
|
||||||
x69WnENeMXHMjHt0vnIlSuQBAKE+r6scq6M5Y2rbUDbgDFDjir5lR36qR8DGBi6A
|
|
||||||
VMQH
|
|
||||||
=V8dS
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, it } from 'vitest';
|
||||||
|
import { render } from 'svelte/server';
|
||||||
|
import AfkReserveCard from '../../src/lib/ui/components/AfkReserveCard.svelte';
|
||||||
|
import type { afkProfile } from '../../src/lib/server/fleet/afk.js';
|
||||||
|
|
||||||
|
it('separates active play, confirmed usage and corrected crash charges without linking expired matches', () => {
|
||||||
|
const account: ReturnType<typeof afkProfile> = {
|
||||||
|
freeSeconds: 1200,
|
||||||
|
savedSeconds: 600,
|
||||||
|
availableSeconds: 1800,
|
||||||
|
reservedSeconds: 180,
|
||||||
|
capacitySeconds: 21600,
|
||||||
|
freeRenewsAt: '2026-10-18T12:00:00Z',
|
||||||
|
activeSeconds: 3600,
|
||||||
|
confirmedAfkSeconds: 30,
|
||||||
|
unresolvedSessions: 1,
|
||||||
|
expiredSessions: 2,
|
||||||
|
purchaseCount: 1,
|
||||||
|
purchasedSeconds: 600,
|
||||||
|
manualReturns: 3,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
server: 't5_zombie_theater_1',
|
||||||
|
kind: 'purchase',
|
||||||
|
freeDelta: 0,
|
||||||
|
savedDelta: 600,
|
||||||
|
freeSeconds: 1200,
|
||||||
|
savedSeconds: 600,
|
||||||
|
sessionId: null,
|
||||||
|
operationId: 'purchase-1',
|
||||||
|
eventAt: '2026-10-17T12:00:00Z',
|
||||||
|
createdAt: '2026-10-17T12:00:00Z',
|
||||||
|
matchId: 77,
|
||||||
|
matchAvailable: 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
sessionId: 'crash',
|
||||||
|
server: 't5_zombie_theater_1',
|
||||||
|
state: 'corrected',
|
||||||
|
authorizedSeconds: 1800,
|
||||||
|
consumedSeconds: null,
|
||||||
|
chargedSeconds: 45,
|
||||||
|
confirmedSeconds: 40,
|
||||||
|
endReason: 'crash',
|
||||||
|
startedAt: '2026-10-17T12:00:00Z',
|
||||||
|
endedAt: '2026-10-17T12:00:45Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const { body } = render(AfkReserveCard, { props: { account } });
|
||||||
|
expect(body).toContain('Recorded active play');
|
||||||
|
expect(body).toContain('Confirmed AFK time');
|
||||||
|
expect(body).toContain('Unconfirmed');
|
||||||
|
expect(body).toContain('Corrected charge: 45s');
|
||||||
|
expect(body).toMatch(/excluded\s+from confirmed AFK usage/);
|
||||||
|
expect(body).toContain('purchase-1');
|
||||||
|
expect(body).toContain('history expired');
|
||||||
|
expect(body).not.toContain('href="/matches/77"');
|
||||||
|
});
|
||||||
@@ -4,6 +4,25 @@ import { writable } from 'svelte/store';
|
|||||||
import PlayerProfilePage from '../../src/routes/leaderboard/players/[guid]/+page.svelte';
|
import PlayerProfilePage from '../../src/routes/leaderboard/players/[guid]/+page.svelte';
|
||||||
import PlayerMapCards from '../../src/lib/ui/components/PlayerMapCards.svelte';
|
import PlayerMapCards from '../../src/lib/ui/components/PlayerMapCards.svelte';
|
||||||
import type { PlayerMapRecord, PlayerProfile, RecordEvidence } from '../../src/lib/leaderboard.js';
|
import type { PlayerMapRecord, PlayerProfile, RecordEvidence } from '../../src/lib/leaderboard.js';
|
||||||
|
import type { afkProfile } from '../../src/lib/server/fleet/afk.js';
|
||||||
|
|
||||||
|
const afk: ReturnType<typeof afkProfile> = {
|
||||||
|
freeSeconds: 0,
|
||||||
|
savedSeconds: 0,
|
||||||
|
availableSeconds: 0,
|
||||||
|
reservedSeconds: 0,
|
||||||
|
capacitySeconds: 21600,
|
||||||
|
freeRenewsAt: null,
|
||||||
|
activeSeconds: 0,
|
||||||
|
confirmedAfkSeconds: 0,
|
||||||
|
unresolvedSessions: 0,
|
||||||
|
expiredSessions: 0,
|
||||||
|
purchaseCount: 0,
|
||||||
|
purchasedSeconds: 0,
|
||||||
|
manualReturns: 0,
|
||||||
|
entries: [],
|
||||||
|
sessions: []
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('$app/stores', () => ({
|
vi.mock('$app/stores', () => ({
|
||||||
page: writable({
|
page: writable({
|
||||||
@@ -91,6 +110,7 @@ it('shows the same ranks in the desktop profile with lifetime map scope explaine
|
|||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
profile,
|
profile,
|
||||||
|
afk,
|
||||||
bank: { balance: 0, available: 0, pending: false, entries: [] },
|
bank: { balance: 0, available: 0, pending: false, entries: [] },
|
||||||
returnTo: '/leaderboard',
|
returnTo: '/leaderboard',
|
||||||
scope: { game: '', map: '', players: '2', metric: 'kills', entry: null }
|
scope: { game: '', map: '', players: '2', metric: 'kills', entry: null }
|
||||||
@@ -136,6 +156,7 @@ it('renders a known player without qualifying history with identity and unavaila
|
|||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
profile,
|
profile,
|
||||||
|
afk,
|
||||||
bank: { balance: 0, available: 0, pending: false, entries: [] },
|
bank: { balance: 0, available: 0, pending: false, entries: [] },
|
||||||
returnTo: '/leaderboard',
|
returnTo: '/leaderboard',
|
||||||
scope: { game: '', map: '', players: '', metric: 'highestRound', entry: null }
|
scope: { game: '', map: '', players: '', metric: 'highestRound', entry: null }
|
||||||
@@ -189,6 +210,7 @@ it('shows the shared balance and ledger with retained and expired match referenc
|
|||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
profile,
|
profile,
|
||||||
|
afk,
|
||||||
bank: {
|
bank: {
|
||||||
balance: 1500,
|
balance: 1500,
|
||||||
available: 1000,
|
available: 1000,
|
||||||
|
|||||||
Reference in New Issue
Block a user