diff --git a/agent/CHANGELOG.md b/agent/CHANGELOG.md index ad0ea03..170d01a 100644 --- a/agent/CHANGELOG.md +++ b/agent/CHANGELOG.md @@ -1,5 +1,18 @@ # 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 ### 1.11.0 — Private player moderation configuration diff --git a/agent/ec_agent.py b/agent/ec_agent.py index 347dd17..e609274 100644 --- a/agent/ec_agent.py +++ b/agent/ec_agent.py @@ -35,7 +35,7 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.11.0" +VERSION = "1.12.0" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 MANIFEST_MAX_ENTRIES = 10000 @@ -49,6 +49,7 @@ PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024 PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024 PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024 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_INCLUDE = b"exec electric_cherry_moderation.cfg\n" 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) +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: def __init__(self, config, docker=None): self.config = config @@ -1574,7 +1655,7 @@ class Agent: if line.startswith("PRETTY_NAME="): os_name = line.split("=", 1)[1].strip('"') 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() 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") except (ValueError, TypeError, KeyError, AttributeError): 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): unhealthy_recovery = payload.get("unhealthyRecovery") is True diff --git a/agent/tests/test_agent.py b/agent/tests/test_agent.py index bbdc0df..c3047bd 100644 --- a/agent/tests/test_agent.py +++ b/agent/tests/test_agent.py @@ -1832,6 +1832,118 @@ class AgentTests(unittest.TestCase): self.assertNotIn(saved["moderation"]["token"], row["result"]) 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): 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)] diff --git a/docs/README.md b/docs/README.md index c7ec0ac..66fa063 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ and launch your first server. - [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports. - [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments. - [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. - [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API. diff --git a/docs/guides/afk.md b/docs/guides/afk.md new file mode 100644 index 0000000..826a859 --- /dev/null +++ b/docs/guides/afk.md @@ -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 `. 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:`. 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. diff --git a/docs/guides/native-hosts.md b/docs/guides/native-hosts.md index a89649e..9af3bd3 100644 --- a/docs/guides/native-hosts.md +++ b/docs/guides/native-hosts.md @@ -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) to drain pending decisions before the new scripts start. See the [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. diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index cb17cca..3efb655 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -39,7 +39,9 @@ export function routePermission(routeId: string | null, method: string): RoutePe return 'admin'; 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/moderation/runtime' && method === 'POST') return 'machine'; if (routeId === '/api/leaderboard' && reading) return 'public'; diff --git a/src/lib/server/db/afk.ts b/src/lib/server/db/afk.ts new file mode 100644 index 0000000..a866825 --- /dev/null +++ b/src/lib/server/db/afk.ts @@ -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); + `); +} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 8402adf..ac519d4 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -25,6 +25,7 @@ import { migrateGameFileSchema } from '../game-files/schema.js'; import { migrateLeaderboardReports } from './leaderboard-reports.js'; import { migratePlayerScoreTotal } from './player-score-total.js'; import { migrateBank } from './bank.js'; +import { migrateAfk } from './afk.js'; import { migrateBankCredentials } from './bank-credentials.js'; import { migrateTemperatures } from '../hosts/temperatures.js'; import { migrateModeration, migrateModerationCases } from '../moderation/schema.js'; @@ -168,7 +169,8 @@ export const migrations: readonly Migration[] = [ name: 'moderation case workflow and linked restrictions', mode: 'foreign-keys-off', run: migrateModerationCases - } + }, + { version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk } ]; export const databaseVersion = migrations.length; diff --git a/src/lib/server/fleet/afk.ts b/src/lib/server/fleet/afk.ts new file mode 100644 index 0000000..9d667bb --- /dev/null +++ b/src/lib/server/fleet/afk.ts @@ -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 => { + 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; + 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; + 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(); +} diff --git a/src/lib/ui/components/AfkReserveCard.svelte b/src/lib/ui/components/AfkReserveCard.svelte new file mode 100644 index 0000000..bce1c5b --- /dev/null +++ b/src/lib/ui/components/AfkReserveCard.svelte @@ -0,0 +1,193 @@ + + +
+

AFK reserve

+

+ {duration(account.freeSeconds)} free and + {duration(account.savedSeconds)} saved, shared across Black Ops I and II. + Saved capacity: {duration(account.capacitySeconds)}. +

+ {#if account.freeRenewsAt} +

+ Free time next replenishes {$timeFormat.dateTime(account.freeRenewsAt)}. Unused free + time does not accumulate. +

+ {/if} + {#if account.reservedSeconds > 0} +

+ {duration(account.reservedSeconds)} is reserved for a session and unavailable to other servers. +

+ {/if} +
+
+
Recorded active play
+
{duration(account.activeSeconds)}
+
+
+
Confirmed AFK time
+
{duration(account.confirmedAfkSeconds)}
+
+
+
Manual returns
+
{account.manualReturns}
+
+
+
Expirations
+
{account.expiredSessions}
+
+
+
Purchases
+
{account.purchaseCount} ({duration(account.purchasedSeconds)})
+
+
+ {#if account.unresolvedSessions > 0} +

+ {account.unresolvedSessions} session(s) have an unconfirmed duration. Their time is excluded + from confirmed AFK usage. +

+ {/if} + {#if account.entries.length} +
+ Recent time ledger +
+ + + + + {#each account.entries as entry (entry.id)} + + + + + + + + {/each} + +
Recent AFK grants, reservations and consumption
Time / eventFree changeSaved changeBalances afterServer / match
{$timeFormat.dateTime(entry.eventAt)}{label(entry.kind)}{entry.operationId}{change(entry.freeDelta)}{change(entry.savedDelta)}{duration(entry.freeSeconds)} free{duration(entry.savedSeconds)} saved{serverTitle(entry.server)}{#if entry.matchAvailable}Match #{entry.matchId}{:else if entry.matchId}Match #{entry.matchId} (history expired){:else}—{/if}
+
+
+ {:else} +

No AFK activity recorded yet.

+ {/if} + {#if account.sessions.length} +
+ Recent AFK sessions +
+ + + + + {#each account.sessions as session (session.sessionId)} + + + + + + + {/each} + +
Accepted AFK transitions
StartedServerDurationOutcome
{session.startedAt + ? $timeFormat.dateTime(session.startedAt) + : 'Start unconfirmed'}{serverTitle(session.server)}{session.consumedSeconds === null + ? 'Unconfirmed' + : duration(session.consumedSeconds)} + {#if session.state === 'corrected' && session.chargedSeconds !== null} + Corrected charge: {duration( + session.chargedSeconds + )} + {/if}{label( + session.endReason ?? session.state + )}{#if session.endedAt}{$timeFormat.dateTime(session.endedAt)}{/if}
+
+
+ {/if} +
+ + diff --git a/src/routes/api/afk/[action]/+server.ts b/src/routes/api/afk/[action]/+server.ts new file mode 100644 index 0000000..415d878 --- /dev/null +++ b/src/routes/api/afk/[action]/+server.ts @@ -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 }); + } +} diff --git a/src/routes/api/afk/corrections/+server.ts b/src/routes/api/afk/corrections/+server.ts new file mode 100644 index 0000000..f377523 --- /dev/null +++ b/src/routes/api/afk/corrections/+server.ts @@ -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' } }); +} diff --git a/src/routes/api/agent/afk/settle/+server.ts b/src/routes/api/agent/afk/settle/+server.ts new file mode 100644 index 0000000..30cbf78 --- /dev/null +++ b/src/routes/api/agent/afk/settle/+server.ts @@ -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' } }); +} diff --git a/src/routes/leaderboard/players/[guid]/+page.server.ts b/src/routes/leaderboard/players/[guid]/+page.server.ts index f989c04..8a202f0 100644 --- a/src/routes/leaderboard/players/[guid]/+page.server.ts +++ b/src/routes/leaderboard/players/[guid]/+page.server.ts @@ -1,4 +1,5 @@ import { bankProfile } from '$lib/server/fleet/bank.js'; +import { afkProfile } from '$lib/server/fleet/afk.js'; import { error } from '@sveltejs/kit'; import { playerReport } from '$lib/server/reports/index.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 { profile, bank: bankProfile(params.guid), + afk: afkProfile(params.guid), returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')), scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry } }; diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index 77cf9e9..45c8615 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -1,5 +1,7 @@