Compare commits
129
Commits
b3fa486886
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f05be3a59 | ||
|
|
d715173719 | ||
|
|
fd00ef5357 | ||
|
|
f2b2541b46 | ||
|
|
a7f1c65d17 | ||
|
|
48653af3dc | ||
|
|
870f13b150 | ||
|
|
b678de2f6f | ||
|
|
90b94911c5 | ||
|
|
428aefdf24 | ||
|
|
aca728b557 | ||
|
|
ef96a2c308 | ||
|
|
83f1c91877 | ||
|
|
59f88ac33d | ||
|
|
d723f23d67 | ||
|
|
7f2205e9c4 | ||
|
|
4501eba873 | ||
|
|
f3b9699317 | ||
|
|
b9bc897d6e | ||
|
|
2e6eb0bdb0 | ||
|
|
d93a766578 | ||
|
|
ba9bed6a0d | ||
|
|
b5037a70fe | ||
|
|
4e04191330 | ||
|
|
8b372fa2c1 | ||
|
|
c91d49c298 | ||
|
|
c1758cce25 | ||
|
|
68616327ec | ||
|
|
1fc329b123 | ||
|
|
428df7f03a | ||
|
|
be7f6c77c1 | ||
|
|
dedf15d9af | ||
|
|
61dbfb2c5f | ||
|
|
171f657876 | ||
|
|
09396ab584 | ||
|
|
eceb38a6bc | ||
|
|
82e07ea33e | ||
|
|
e85dc58130 | ||
|
|
d6147077d7 | ||
|
|
d9fa15cdf6 | ||
|
|
4e3df81786 | ||
|
|
2932b74f55 | ||
|
|
537b9841a7 | ||
|
|
fa0baa8db5 | ||
|
|
54f4301994 | ||
|
|
95160de5b4 | ||
|
|
0a98ea319c | ||
|
|
d14c6a7ff4 | ||
|
|
e071e5b574 | ||
|
|
bcd3b99ee4 | ||
|
|
106d3a5ded | ||
|
|
1b908f891f | ||
|
|
272055a1ed | ||
|
|
f91ec4d152 | ||
|
|
2f52dcb1ef | ||
|
|
ffda04f276 | ||
|
|
81fa35b792 | ||
|
|
c296769b33 | ||
|
|
6059ebf2a5 | ||
|
|
76325b99ad | ||
|
|
785cc4616b | ||
|
|
4ea291d305 | ||
|
|
253d47300c | ||
|
|
d2b2958700 | ||
|
|
7442c6fd15 | ||
|
|
03e2d08a38 | ||
|
|
e3e740cea3 | ||
|
|
7fcf1cded2 | ||
|
|
d6041e40ef | ||
|
|
528ed7f37a | ||
|
|
0bfda267ec | ||
|
|
aebfc7a783 | ||
|
|
616b1937ed | ||
|
|
ea932ec563 | ||
|
|
33da4482a6 | ||
|
|
bd34c3c9e7 | ||
|
|
cc9c10226e | ||
|
|
8d1bb69591 | ||
|
|
a0663e43b8 | ||
|
|
3e0b4db736 | ||
|
|
44e755a166 | ||
|
|
a93ddb5ef9 | ||
|
|
6f71822d41 | ||
|
|
df0d395138 | ||
|
|
b09aff80b2 | ||
|
|
61d4c25ce6 | ||
|
|
ce6dba17e6 | ||
|
|
d1f581d6a8 | ||
|
|
596b160be3 | ||
|
|
f0afd6de67 | ||
|
|
bd16737dd8 | ||
|
|
4c1f2cdb8f | ||
|
|
15fb90200a | ||
|
|
9eb3aa84c3 | ||
|
|
8fcb8312a3 | ||
|
|
4059fc30d6 | ||
|
|
5cf284f413 | ||
|
|
16469e2d43 | ||
|
|
8ed059b8d3 | ||
|
|
967b2a1c8a | ||
|
|
15ab41d4a2 | ||
|
|
da623e8409 | ||
|
|
deb2d21f29 | ||
|
|
0abe5ba820 | ||
|
|
96d21d0514 | ||
|
|
ff589afe5e | ||
|
|
56b4043b6e | ||
|
|
c443ffa0dc | ||
|
|
43c1a32065 | ||
|
|
485a73c422 | ||
|
|
40ad5f01a9 | ||
|
|
29c2e3ba00 | ||
|
|
dbbe3749e2 | ||
|
|
6128a137cc | ||
|
|
cdc400c6bb | ||
|
|
4f5725ee74 | ||
|
|
e9be3f7177 | ||
|
|
c10f602e8e | ||
|
|
0cc4498b53 | ||
|
|
29d2d9634c | ||
|
|
d8fb7e1d66 | ||
|
|
2b155ded8f | ||
|
|
a18635fb78 | ||
|
|
63e084e38f | ||
|
|
dd29b41f95 | ||
|
|
62e3397d5c | ||
|
|
c4e2ff4384 | ||
|
|
e01b143a09 | ||
|
|
511f090552 |
@@ -10,3 +10,7 @@ archive
|
|||||||
*.log
|
*.log
|
||||||
test-fixtures
|
test-fixtures
|
||||||
data
|
data
|
||||||
|
# Offline release signing material must never enter the build context.
|
||||||
|
**/.gnupg
|
||||||
|
**/private-keys-v1.d
|
||||||
|
**/secring.gpg
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
|
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
|
||||||
EC_PUBLIC_URL=https://electric-cherry.example.com
|
EC_PUBLIC_URL=https://electric-cherry.example.com
|
||||||
|
# Client addresses for login throttling behind a trusted reverse proxy.
|
||||||
|
# Leave ADDRESS_HEADER empty for direct connections. Enable only when all access
|
||||||
|
# goes through a proxy that replaces untrusted forwarding headers.
|
||||||
|
# For one trusted proxy using X-Forwarded-For:
|
||||||
|
# ADDRESS_HEADER=x-forwarded-for
|
||||||
|
# XFF_DEPTH=1
|
||||||
|
# Set XFF_DEPTH to the number of trusted proxy hops; see the access guide.
|
||||||
|
ADDRESS_HEADER=
|
||||||
|
XFF_DEPTH=1
|
||||||
# Generate separate secrets with: openssl rand -hex 32
|
# Generate separate secrets with: openssl rand -hex 32
|
||||||
# Setup token authorizes creation of the first administrator only. Remove it
|
# Setup token authorizes creation of the first administrator only. Remove it
|
||||||
# after setup. Never reuse the telemetry token as a setup or host credential.
|
# after setup. Never reuse the telemetry token as a setup or host credential.
|
||||||
@@ -7,6 +16,15 @@ EC_SETUP_TOKEN=
|
|||||||
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
|
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
|
||||||
# Telemetry ingestion is disabled until a token of at least 32 chars is set.
|
# Telemetry ingestion is disabled until a token of at least 32 chars is set.
|
||||||
EC_TELEMETRY_TOKEN=
|
EC_TELEMETRY_TOKEN=
|
||||||
|
# Optional native Discord companion. Keep these in the controller environment;
|
||||||
|
# enable the integration and optionally exclude servers under Admin → Discord.
|
||||||
|
# The application ID and public key are in the Discord Developer Portal.
|
||||||
|
# The bot token is a secret: never put it in channel messages or screenshots.
|
||||||
|
EC_DISCORD_APPLICATION_ID=
|
||||||
|
EC_DISCORD_PUBLIC_KEY=
|
||||||
|
EC_DISCORD_BOT_TOKEN=
|
||||||
|
# Bank credentials are generated and injected automatically per native instance.
|
||||||
|
EC_BANK_MAX_BALANCE=250000
|
||||||
# Persistent SQLite metadata, game-file storage, and beta archives.
|
# Persistent SQLite metadata, game-file storage, and beta archives.
|
||||||
# For an existing deployment, retain its original data directory.
|
# For an existing deployment, retain its original data directory.
|
||||||
EC_DATA_DIR=/root/zombies/.electric-cherry
|
EC_DATA_DIR=/root/zombies/.electric-cherry
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Python and native build tools
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends python3 make g++ gpg gpg-agent gpgv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# Generates the SvelteKit config required by tests in a fresh checkout.
|
||||||
|
- name: Check styles and types
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Check formatting and lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run application tests
|
||||||
|
# Bound resource use for database-heavy fixtures on shared runners.
|
||||||
|
env:
|
||||||
|
VITEST_MAX_WORKERS: '2'
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Run agent tests
|
||||||
|
run: npm run test:agent
|
||||||
|
|
||||||
|
- name: Build production bundles
|
||||||
|
run: npm run build
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Prettier also respects .gitignore.
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Generated by agent/package_release.py, including its checksum manifest.
|
||||||
|
static/agent/
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": { "parser": "svelte" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@
|
|||||||
- Keep unrelated features or fixes in separate commits. If a request includes two unrelated features, complete, validate, and commit each one before starting the next.
|
- Keep unrelated features or fixes in separate commits. If a request includes two unrelated features, complete, validate, and commit each one before starting the next.
|
||||||
- Stage only changes belonging to the current task. Leave unrelated pre-existing changes out of the commit.
|
- Stage only changes belonging to the current task. Leave unrelated pre-existing changes out of the commit.
|
||||||
- Use a concise commit message that describes the change.
|
- Use a concise commit message that describes the change.
|
||||||
|
- When the user links a repository issue, reference its issue number in the corresponding commit message (for example, `Refs #59`).
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ RUN chmod +x /entrypoint.sh
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
|
# Legacy host imports stream whole game files; buffering routes enforce their own caps.
|
||||||
ENV BODY_SIZE_LIMIT=Infinity
|
ENV BODY_SIZE_LIMIT=Infinity
|
||||||
ENV SQLITE_TMPDIR=/data/.sqlite-tmp
|
ENV SQLITE_TMPDIR=/data/.sqlite-tmp
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ a small Python agent.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
*Fleet overview with sample data.*
|
_Fleet overview with sample data._
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Launch individual servers or saved collections of maps and lobbies.
|
- Launch individual servers or saved collections of maps and lobbies.
|
||||||
- Monitor players, host capacity, match history, and leaderboards.
|
- Monitor players, host capacity, match history, and leaderboards.
|
||||||
|
- Moderate players with scoped restrictions, live actions, preserved evidence and review history.
|
||||||
- Manage scripts, plugins, and configuration with beta testing and rollback.
|
- Manage scripts, plugins, and configuration with beta testing and rollback.
|
||||||
- Queue maintenance around active games and scale spare lobbies with opt-in policies.
|
- Queue maintenance around active games and scale spare lobbies with opt-in policies.
|
||||||
- Control access with administrator, operator, and viewer accounts.
|
- Control access with administrator, moderator, operator, and read-only accounts.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -58,11 +59,10 @@ doesn't need a Docker socket or direct access to your game-server files.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
The controller uses SvelteKit, TypeScript, and SQLite. The host agent uses the
|
The controller uses SvelteKit, TypeScript, and SQLite. The host agent uses the
|
||||||
Python 3 standard library. For local development, install Node.js 22 and Python 3:
|
Python 3 standard library. For local development, install Node.js 22.13+ and Python 3:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
npm run agent:package
|
|
||||||
export EC_SETUP_TOKEN=$(openssl rand -hex 32)
|
export EC_SETUP_TOKEN=$(openssl rand -hex 32)
|
||||||
export BETA_ARCHIVE_DIR=./data/beta-archive
|
export BETA_ARCHIVE_DIR=./data/beta-archive
|
||||||
EC_AUTH_ALLOW_HTTP=true npm run dev
|
EC_AUTH_ALLOW_HTTP=true npm run dev
|
||||||
@@ -70,10 +70,14 @@ EC_AUTH_ALLOW_HTTP=true npm run dev
|
|||||||
|
|
||||||
Open `http://localhost:3000` and use the setup token from your shell. The local
|
Open `http://localhost:3000` and use the setup token from your shell. The local
|
||||||
database defaults to `./data/cherry.db`; keep it separate from production data.
|
database defaults to `./data/cherry.db`; keep it separate from production data.
|
||||||
|
`npm run dev` packages the downloadable agent before starting the server.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npm run lint
|
||||||
npm run check
|
npm run check
|
||||||
npm test
|
npm test
|
||||||
npm run test:agent
|
npm run test:agent
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run `npm run format` to apply the repository's Prettier formatting.
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- Advertise `moderation.configure` and accept separate generation-bound game
|
||||||
|
credentials through private create/rebuild configuration or the typed
|
||||||
|
`instance.configure-moderation` operation. Existing servers preserve startup
|
||||||
|
through a fixed include in their verified private bank configuration; servers
|
||||||
|
without a verified bootstrap require rebuilding.
|
||||||
|
- Keep moderation credentials out of file reads, logs, manifests, staged changes,
|
||||||
|
shared recipes and operation receipts. Configure loads only the fixed private
|
||||||
|
file, never restarts gameplay, and does not replay RCON after an unknown result.
|
||||||
|
- Controller coverage remains unavailable until the game moderation runtime
|
||||||
|
connects and reports its capabilities. Older agents retain normal launches.
|
||||||
|
- Package release artifacts for offline signing before publication; do not
|
||||||
|
publish unsigned artifacts.
|
||||||
|
|
||||||
|
## 2026-10-14
|
||||||
|
|
||||||
|
### 1.10.1 — Retain verified rebuild artifacts (EC #154)
|
||||||
|
|
||||||
|
- Retain the complete verified replacement file set before stopping the current
|
||||||
|
container, including inline files and private bank configuration, within the
|
||||||
|
existing 64 MiB operation limit. Staged-file comparison, replacement validation
|
||||||
|
and writing reuse those bytes without further artifact downloads.
|
||||||
|
- Keep prepared bytes local to the operation. Failed and held attempts discard
|
||||||
|
their files; later operations prepare a fresh set. Immutable specifications,
|
||||||
|
journal requests and container identity labels remain unchanged.
|
||||||
|
- Preserve checksum, path, per-file size, player-safety, bank-settlement and
|
||||||
|
generation checks. Add regressions for preflight failures, lost connectivity
|
||||||
|
after preflight, file-set limits and operation isolation.
|
||||||
|
- Package release artifacts for offline signing before publication. No protocol
|
||||||
|
change or controller migration is required.
|
||||||
|
|
||||||
|
## 2026-10-13
|
||||||
|
|
||||||
|
### 1.10.0 — Host temperature telemetry
|
||||||
|
|
||||||
|
- Read supported Linux hwmon CPU package/core, AMD physical die/CCD, NVMe and
|
||||||
|
Intel PCH temperatures with the Python standard library. Sensor failures,
|
||||||
|
faults and disabled inputs are skipped independently; unsupported hosts report
|
||||||
|
an empty list. AMD Tctl control offsets are excluded from physical readings.
|
||||||
|
- Include labeled, bounded sensors in heartbeats with the `host.temperatures`
|
||||||
|
capability. Physical device/channel identities survive hwmon renumbering.
|
||||||
|
- The controller shows current CPU readings and expandable sensor details, and
|
||||||
|
adds temperature history to Capacity. Fleet history has one CPU line per host;
|
||||||
|
individual hosts can add NVMe composite and chipset lines. History retains
|
||||||
|
minute peaks using the host-resource retention setting; missing data stays
|
||||||
|
unavailable and no thermal alerts or placement rules are introduced.
|
||||||
|
- Adds database migration 23. Existing agents remain compatible. Release
|
||||||
|
artifacts are packaged and the stale signature is removed. Offline signing
|
||||||
|
and host rollout are still required; neither was performed here.
|
||||||
|
|
||||||
|
## 2026-10-12
|
||||||
|
|
||||||
|
### 1.9.3 — Protect private bank configuration (EC #152)
|
||||||
|
|
||||||
|
- Exclude the generated bank config from file manifests and reject file reads,
|
||||||
|
staged changes, stale activations, rollback snapshots and log reads targeting
|
||||||
|
it. Creation and rebuilding still inject the private instance credential.
|
||||||
|
- Package and offline-sign this release, then update agents before resuming
|
||||||
|
drift recovery. Old private-file stages fail safely and require a fresh plan.
|
||||||
|
|
||||||
|
### 1.9.1 — Public game-bank endpoint
|
||||||
|
|
||||||
|
- Companion to [TWS scripts #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13).
|
||||||
|
In `agent/ec_agent.py`, stop generating `tws_bank_url` in
|
||||||
|
`electric_cherry_bank.cfg`; continue injecting the private per-instance token.
|
||||||
|
Launch configuration still supplies `tws_server_id`. The host's enrolled
|
||||||
|
controller address and authentication remain unchanged.
|
||||||
|
- Recognize journals for `https://ec.thewrightserver.net/api/bank` and the legacy
|
||||||
|
bank URL derived from the enrolled controller. Reject other URLs and preserve
|
||||||
|
unresolved storage. Settlement still goes only through the enrolled controller's
|
||||||
|
authenticated, operation- and instance-scoped API. Original transaction IDs and
|
||||||
|
commit/cancel decisions are preserved across lost replies and retries.
|
||||||
|
- Compatibility: pair with T5/T6 bank scripts `2.0.0` and existing TWS plugins
|
||||||
|
`1.1.0+`. No new Python dependency, controller migration or protocol change.
|
||||||
|
Old scripts still fall back to the public endpoint when the dvar is absent.
|
||||||
|
- Deployment: package and offline-sign the release, update/restart host agents,
|
||||||
|
then rebuild through EC when a server's bank endpoint changes. Rebuild stops
|
||||||
|
the game and drains old decisions before starting the replacement or deleting
|
||||||
|
storage. An endpoint that already matches needs only a map restart for the
|
||||||
|
script update. Follow the [bank rollout procedure](../docs/guides/bank.md#upgrading-from-a-controller-derived-game-endpoint).
|
||||||
|
Packaging refreshes `static/agent/ec-agent.py` and `manifest.json` and removes
|
||||||
|
the old signature; a new offline signature is required before publishing.
|
||||||
|
No signing or deployment was performed.
|
||||||
|
- Regression coverage in `agent/tests/test_agent.py`: generated credentials,
|
||||||
|
public and legacy journals for both games, rebuild/removal, lost replies,
|
||||||
|
agent restart, unchanged identities and rejection of untrusted endpoints.
|
||||||
|
Live Windows/Plutonium endpoint migration and signed host rollout remain to
|
||||||
|
be validated.
|
||||||
+716
-41
File diff suppressed because it is too large
Load Diff
+82
-32
@@ -19,13 +19,16 @@ import re
|
|||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
UPDATER_PROTOCOL_VERSION = 1
|
UPDATER_PROTOCOL_VERSION = 2
|
||||||
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
||||||
|
# This repository is the trust authority, independent of controller configuration.
|
||||||
|
RELEASE_KEY_URL = "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg"
|
||||||
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
||||||
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
|
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
|
||||||
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
|
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
|
||||||
@@ -36,6 +39,8 @@ UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
|
|||||||
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
||||||
AGENT_SERVICE = "electric-cherry-agent.service"
|
AGENT_SERVICE = "electric-cherry-agent.service"
|
||||||
MAX_MANIFEST = 16 * 1024
|
MAX_MANIFEST = 16 * 1024
|
||||||
|
MAX_SIGNATURE = 16 * 1024
|
||||||
|
MAX_KEYRING = 256 * 1024
|
||||||
MAX_SOURCE = 8 * 1024 * 1024
|
MAX_SOURCE = 8 * 1024 * 1024
|
||||||
HEALTH_TIMEOUT = 90
|
HEALTH_TIMEOUT = 90
|
||||||
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
|
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
|
||||||
@@ -150,6 +155,72 @@ def source_version(source):
|
|||||||
return declarations[0]
|
return declarations[0]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url, limit, source):
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": "electric-cherry-updater/2", "Cache-Control": "no-cache"})
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
raise ContactError("Agent release redirects are not allowed")
|
||||||
|
try:
|
||||||
|
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
raise ContactError(f"The {source} did not return a complete download")
|
||||||
|
length = response.headers.get("Content-Length")
|
||||||
|
if length is not None and (not length.isdecimal() or int(length) > limit):
|
||||||
|
raise IntegrityError(f"The {source} exceeds its download size limit")
|
||||||
|
chunks, total = [], 0
|
||||||
|
deadline = time.monotonic() + 120
|
||||||
|
while True:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise ContactError(f"The {source} download timed out")
|
||||||
|
# HTTPResponse.read1 performs one socket read. read(n) can
|
||||||
|
# wait indefinitely for n bytes from a trickling server.
|
||||||
|
block = response.read1(min(64 * 1024, limit + 1 - total))
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
total += len(block)
|
||||||
|
if total > limit:
|
||||||
|
raise IntegrityError(f"The {source} exceeds its download size limit")
|
||||||
|
chunks.append(block)
|
||||||
|
if length is not None and total != int(length):
|
||||||
|
raise ContactError(f"The {source} download was interrupted")
|
||||||
|
return b"".join(chunks)
|
||||||
|
except (OSError, ValueError, http.client.HTTPException):
|
||||||
|
raise ContactError(f"The {source} is unavailable; the running agent was left unchanged") from None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_release_key():
|
||||||
|
# Fetch afresh before every release check, including checks with no new
|
||||||
|
# version. There is deliberately no persistent keyring or offline fallback.
|
||||||
|
key = fetch_url(RELEASE_KEY_URL, MAX_KEYRING, "release-signing repository")
|
||||||
|
if not key:
|
||||||
|
raise IntegrityError("The release-signing repository returned an empty keyring")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def verify_release_signature(manifest, signature, public_key):
|
||||||
|
if not public_key or len(public_key) > MAX_KEYRING:
|
||||||
|
raise IntegrityError("The repository release-signing keyring is empty or too large")
|
||||||
|
if not signature:
|
||||||
|
raise IntegrityError("The controller release requires a detached manifest signature")
|
||||||
|
trusted(UPDATE_DIR, directory=True)
|
||||||
|
# An isolated home excludes any ambient GnuPG keyring or configuration.
|
||||||
|
# The exact downloaded bytes are verified before manifest JSON is parsed.
|
||||||
|
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
|
||||||
|
home = Path(temporary)
|
||||||
|
keyring = home / "release-signing.gpg"
|
||||||
|
keyring.write_bytes(public_key)
|
||||||
|
manifest_path = home / "manifest.json"
|
||||||
|
signature_path = home / "manifest.json.sig"
|
||||||
|
manifest_path.write_bytes(manifest)
|
||||||
|
signature_path.write_bytes(signature)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["gpgv", "--homedir", str(home), "--keyring", str(keyring), str(signature_path), str(manifest_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=False)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
raise IntegrityError("Release signature verification is unavailable; install or repair gpgv on this host") from None
|
||||||
|
if result.returncode:
|
||||||
|
raise IntegrityError("The release manifest signature is invalid or is not signed by a trusted release key")
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def exclusive_lock(path):
|
def exclusive_lock(path):
|
||||||
trusted(path, missing=True)
|
trusted(path, missing=True)
|
||||||
@@ -242,40 +313,19 @@ class Updater:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def fetch(self, path, limit):
|
def fetch(self, path, limit):
|
||||||
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/1"})
|
return fetch_url(self.installation["controller"] + path, limit, "controller release")
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
||||||
raise ContactError("Agent release redirects are not allowed")
|
|
||||||
try:
|
|
||||||
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
raise ContactError("The controller did not return a complete agent release")
|
|
||||||
length = response.headers.get("Content-Length")
|
|
||||||
if length is not None and (not length.isdecimal() or int(length) > limit):
|
|
||||||
raise IntegrityError("The controller release exceeds its download size limit")
|
|
||||||
chunks, total = [], 0
|
|
||||||
deadline = time.monotonic() + 120
|
|
||||||
while True:
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
raise ContactError("The controller release download timed out")
|
|
||||||
# HTTPResponse.read1 performs one socket read. read(n) can
|
|
||||||
# wait indefinitely for n bytes from a trickling server.
|
|
||||||
block = response.read1(min(64 * 1024, limit + 1 - total))
|
|
||||||
if not block:
|
|
||||||
break
|
|
||||||
total += len(block)
|
|
||||||
if total > limit:
|
|
||||||
raise IntegrityError("The controller release exceeds its download size limit")
|
|
||||||
chunks.append(block)
|
|
||||||
if length is not None and total != int(length):
|
|
||||||
raise ContactError("The controller release download was interrupted")
|
|
||||||
return b"".join(chunks)
|
|
||||||
except (OSError, ValueError, http.client.HTTPException):
|
|
||||||
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
|
|
||||||
|
|
||||||
def manifest(self):
|
def manifest(self):
|
||||||
|
public_key = fetch_release_key()
|
||||||
|
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
|
||||||
try:
|
try:
|
||||||
value = json.loads(self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST))
|
signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE)
|
||||||
|
except ContactError:
|
||||||
|
raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None
|
||||||
|
self.ensure_directory()
|
||||||
|
verify_release_signature(manifest, signature, public_key)
|
||||||
|
try:
|
||||||
|
value = json.loads(manifest)
|
||||||
except (ValueError, UnicodeError):
|
except (ValueError, UnicodeError):
|
||||||
raise IntegrityError("The controller release manifest is invalid") from None
|
raise IntegrityError("The controller release manifest is invalid") from None
|
||||||
if not isinstance(value, dict) or type(value.get("protocolVersion")) is not int or value["protocolVersion"] != 1 or value.get("platform") != "debian-amd64":
|
if not isinstance(value, dict) or type(value.get("protocolVersion")) is not int or value["protocolVersion"] != 1 or value.get("platform") != "debian-amd64":
|
||||||
|
|||||||
+46
-6
@@ -33,7 +33,7 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
|
|||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux
|
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux gpgv
|
||||||
installation_preflight() {
|
installation_preflight() {
|
||||||
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
|
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
|
||||||
echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2
|
echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2
|
||||||
@@ -142,7 +142,7 @@ PY
|
|||||||
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
|
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
|
||||||
if [ -d "$data_dir" ]; then existing_data_root=true; fi
|
if [ -d "$data_dir" ]; then existing_data_root=true; fi
|
||||||
installation_preflight
|
installation_preflight
|
||||||
temporary_dir=$(mktemp -d)
|
temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
|
||||||
updater_timer_was_active=false
|
updater_timer_was_active=false
|
||||||
update_lock_held=false
|
update_lock_held=false
|
||||||
activity_lock_held=false
|
activity_lock_held=false
|
||||||
@@ -167,21 +167,61 @@ installation_cleanup() {
|
|||||||
exit "$ec_install_status"
|
exit "$ec_install_status"
|
||||||
}
|
}
|
||||||
trap installation_cleanup EXIT
|
trap installation_cleanup EXIT
|
||||||
|
# INSTALLATION_TRUST: always fetch directly from the repository, never the controller.
|
||||||
|
key_status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
|
||||||
|
--connect-timeout 25 --max-time 120 --max-filesize 262144 \
|
||||||
|
--header 'Cache-Control: no-cache' --write-out '%{http_code}' \
|
||||||
|
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg' \
|
||||||
|
-o "$temporary_dir/release-signing.gpg")
|
||||||
|
[ "$key_status" = 200 ] && [ -s "$temporary_dir/release-signing.gpg" ] || {
|
||||||
|
echo 'The release-signing repository did not return a public key. Installation stopped.' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# INSTALLATION_TRUST_END
|
||||||
controller="${controller%/}"
|
controller="${controller%/}"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
|
||||||
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
|
||||||
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'PY'
|
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" "$temporary_dir/release-signing.gpg" <<'PY'
|
||||||
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
|
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
|
||||||
import ast, hashlib, json, pathlib, re, sys
|
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
|
||||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
manifest_path = pathlib.Path(sys.argv[1])
|
||||||
|
manifest_bytes = manifest_path.read_bytes()
|
||||||
|
signature = manifest_path.with_name('manifest.json.sig')
|
||||||
|
keyring = pathlib.Path(sys.argv[4])
|
||||||
|
# The key lives in our private root-owned mktemp directory under sticky /tmp.
|
||||||
|
# /tmp itself is intentionally writable; require protection of the directory
|
||||||
|
# and file we created, rather than accepting a pre-existing host keyring.
|
||||||
|
for path in (keyring.parent, keyring):
|
||||||
|
info = path.lstat()
|
||||||
|
expected_type = stat.S_ISREG if path == keyring else stat.S_ISDIR
|
||||||
|
if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||||
|
raise SystemExit('Release signing key must be a regular root-owned file in protected directories.')
|
||||||
|
if not 0 < keyring.stat().st_size <= 256 * 1024:
|
||||||
|
raise SystemExit('Repository public key is empty or exceeds its size limit.')
|
||||||
|
if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024:
|
||||||
|
raise SystemExit('Release signing metadata exceeds its size limit.')
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix='gpgv-', dir=manifest_path.parent) as home:
|
||||||
|
verified = subprocess.run(['gpgv', '--homedir', home, '--keyring', str(keyring),
|
||||||
|
str(signature), '-'], input=manifest_bytes,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
timeout=30, check=False)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
raise SystemExit('Release signature verification requires a working gpgv installation.') from None
|
||||||
|
if verified.returncode:
|
||||||
|
raise SystemExit('Release signature verification failed. The installed agent was left unchanged.')
|
||||||
|
manifest = json.loads(manifest_bytes)
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
raise SystemExit('Unsupported agent or updater release manifest.')
|
||||||
agent = pathlib.Path(sys.argv[2]).read_bytes()
|
agent = pathlib.Path(sys.argv[2]).read_bytes()
|
||||||
updater = pathlib.Path(sys.argv[3]).read_bytes()
|
updater = pathlib.Path(sys.argv[3]).read_bytes()
|
||||||
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
|
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
|
||||||
raise SystemExit('Agent release checksum verification failed.')
|
raise SystemExit('Agent release checksum verification failed.')
|
||||||
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
|
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
|
||||||
raise SystemExit('Updater release checksum verification failed.')
|
raise SystemExit('Updater release checksum verification failed.')
|
||||||
if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1 or manifest.get('updaterProtocolVersion') != 1:
|
if manifest.get('platform') != 'debian-amd64' or type(manifest.get('protocolVersion')) is not int or manifest['protocolVersion'] != 1 or type(manifest.get('updaterProtocolVersion')) is not int or manifest['updaterProtocolVersion'] != 2:
|
||||||
raise SystemExit('Unsupported agent or updater release manifest.')
|
raise SystemExit('Unsupported agent or updater release manifest.')
|
||||||
versions = [node.value.value for node in ast.parse(agent).body
|
versions = [node.value.value for node in ast.parse(agent).body
|
||||||
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
|
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
|
||||||
|
|||||||
+26
-10
@@ -5,14 +5,11 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
|
|
||||||
|
|
||||||
def package(source, target):
|
def manifest_bytes(agent, updater, installer):
|
||||||
"""Read version metadata without importing or executing release code."""
|
"""Return the deterministic bytes signed by the offline release signer."""
|
||||||
source, target = Path(source), Path(target)
|
# Inspect source without importing or executing release code.
|
||||||
agent = (source / "ec_agent.py").read_bytes()
|
|
||||||
updater = (source / "ec_agent_update.py").read_bytes()
|
|
||||||
versions = [node.value.value for node in ast.parse(agent).body
|
versions = [node.value.value for node in ast.parse(agent).body
|
||||||
if isinstance(node, ast.Assign) and any(isinstance(name, ast.Name) and name.id == "VERSION" for name in node.targets)
|
if isinstance(node, ast.Assign) and any(isinstance(name, ast.Name) and name.id == "VERSION" for name in node.targets)
|
||||||
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)]
|
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)]
|
||||||
@@ -21,14 +18,33 @@ def package(source, target):
|
|||||||
compile(agent, "ec_agent.py", "exec")
|
compile(agent, "ec_agent.py", "exec")
|
||||||
compile(updater, "ec_agent_update.py", "exec")
|
compile(updater, "ec_agent_update.py", "exec")
|
||||||
manifest = {"version": versions[0], "protocolVersion": 1, "platform": "debian-amd64",
|
manifest = {"version": versions[0], "protocolVersion": 1, "platform": "debian-amd64",
|
||||||
"sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 1,
|
"sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 2,
|
||||||
"updaterSha256": hashlib.sha256(updater).hexdigest()}
|
"updaterSha256": hashlib.sha256(updater).hexdigest(),
|
||||||
|
"installerSha256": hashlib.sha256(installer).hexdigest()}
|
||||||
|
return (json.dumps(manifest, indent=2) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def package(source, target):
|
||||||
|
"""Read version metadata without importing or executing release code."""
|
||||||
|
source, target = Path(source), Path(target)
|
||||||
|
agent = (source / "ec_agent.py").read_bytes()
|
||||||
|
updater = (source / "ec_agent_update.py").read_bytes()
|
||||||
|
installer = (source / "install.sh").read_bytes()
|
||||||
|
manifest = manifest_bytes(agent, updater, installer)
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
previous_manifest = (target / "manifest.json").read_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
previous_manifest = None
|
||||||
|
if previous_manifest != manifest:
|
||||||
|
# Builds never sign. Keep an offline signature only across identical
|
||||||
|
# rebuilds; changed releases remain unsigned until reviewed and signed.
|
||||||
|
(target / "manifest.json.sig").unlink(missing_ok=True)
|
||||||
(target / "ec-agent.py").write_bytes(agent)
|
(target / "ec-agent.py").write_bytes(agent)
|
||||||
(target / "ec-agent-update.py").write_bytes(updater)
|
(target / "ec-agent-update.py").write_bytes(updater)
|
||||||
shutil.copyfile(source / "install.sh", target / "install.sh")
|
(target / "install.sh").write_bytes(installer)
|
||||||
temporary_manifest = target / ".manifest.json.tmp"
|
temporary_manifest = target / ".manifest.json.tmp"
|
||||||
temporary_manifest.write_text(json.dumps(manifest, indent=2) + "\n")
|
temporary_manifest.write_bytes(manifest)
|
||||||
temporary_manifest.replace(target / "manifest.json")
|
temporary_manifest.replace(target / "manifest.json")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sign a reviewed, packaged release using a separate offline GnuPG home.
|
||||||
|
|
||||||
|
This command is never invoked by controller builds or runtime. Keep
|
||||||
|
the signing key and its GnuPG home outside this checkout and Docker context.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from package_release import manifest_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def reviewed_manifest(release):
|
||||||
|
manifest = (release / "manifest.json").read_bytes()
|
||||||
|
expected = manifest_bytes((release / "ec-agent.py").read_bytes(),
|
||||||
|
(release / "ec-agent-update.py").read_bytes(),
|
||||||
|
(release / "install.sh").read_bytes())
|
||||||
|
if manifest != expected:
|
||||||
|
raise ValueError("Release manifest does not match packaged artifacts; package and review the release again")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def sign_release(release, gnupghome, fingerprint):
|
||||||
|
"""Sign exact manifest bytes, without importing any private key material."""
|
||||||
|
release, gnupghome = Path(release).resolve(), Path(gnupghome).resolve()
|
||||||
|
repository = Path(__file__).resolve().parent.parent
|
||||||
|
if not re.fullmatch(r"(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})", fingerprint):
|
||||||
|
raise ValueError("Signing requires a full OpenPGP key fingerprint (40 or 64 hexadecimal characters)")
|
||||||
|
if gnupghome.is_relative_to(repository) or gnupghome.is_relative_to(release):
|
||||||
|
raise ValueError("Keep the signing GnuPG home outside the repository and release directory")
|
||||||
|
if not gnupghome.is_dir():
|
||||||
|
raise ValueError("The offline signing GnuPG home must already exist")
|
||||||
|
manifest = reviewed_manifest(release)
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".sign-release-", dir=release) as directory:
|
||||||
|
staged_manifest = Path(directory) / "manifest.json"
|
||||||
|
signature = Path(directory) / "manifest.json.sig"
|
||||||
|
staged_manifest.write_bytes(manifest)
|
||||||
|
subprocess.run([
|
||||||
|
"gpg", "--no-options", "--homedir", str(gnupghome), "--batch", "--yes",
|
||||||
|
"--armor", "--detach-sign", "--digest-algo", "SHA512",
|
||||||
|
"--local-user", fingerprint.upper() + "!", "--output", str(signature),
|
||||||
|
str(staged_manifest),
|
||||||
|
], check=True)
|
||||||
|
if not signature.read_bytes().startswith(b"-----BEGIN PGP SIGNATURE-----\n"):
|
||||||
|
raise ValueError("GnuPG did not produce an ASCII-armored detached signature")
|
||||||
|
if reviewed_manifest(release) != manifest:
|
||||||
|
raise ValueError("Release changed during signing; review and sign the release again")
|
||||||
|
signature.chmod(0o644)
|
||||||
|
signature.replace(release / "manifest.json.sig")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--release-dir", type=Path,
|
||||||
|
default=Path(__file__).resolve().parent.parent / "static" / "agent")
|
||||||
|
parser.add_argument("--gnupghome", required=True, type=Path,
|
||||||
|
help="Existing offline GnuPG home outside the checkout/build context")
|
||||||
|
parser.add_argument("--fingerprint", required=True,
|
||||||
|
help="Full fingerprint of the exact signing key (not a key ID or email)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
sign_release(args.release_dir, args.gnupghome, args.fingerprint)
|
||||||
|
except (OSError, ValueError, SyntaxError, subprocess.CalledProcessError) as cause:
|
||||||
|
parser.exit(1, f"Release signing failed: {cause}\n")
|
||||||
|
print(f"Signed {args.release_dir / 'manifest.json'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+1184
-3
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
|
||||||
SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text()
|
SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text()
|
||||||
@@ -33,6 +33,8 @@ class InstallerTests(unittest.TestCase):
|
|||||||
self.root = self.base / "managed"
|
self.root = self.base / "managed"
|
||||||
self.config = self.base / "configuration" / "agent.json"
|
self.config = self.base / "configuration" / "agent.json"
|
||||||
self.config.parent.mkdir()
|
self.config.parent.mkdir()
|
||||||
|
self.keyring = self.config.with_name('release-signing.gpg')
|
||||||
|
self.keyring.write_bytes(b'publisher public key')
|
||||||
self.controller = "https://controller.example"
|
self.controller = "https://controller.example"
|
||||||
self.saved = {"root": str(self.root), "controller": self.controller,
|
self.saved = {"root": str(self.root), "controller": self.controller,
|
||||||
"hostId": "host-one", "credential": "private"}
|
"hostId": "host-one", "credential": "private"}
|
||||||
@@ -80,6 +82,10 @@ class InstallerTests(unittest.TestCase):
|
|||||||
self.preflight()
|
self.preflight()
|
||||||
self.assertEqual((self.root / "unrelated.txt").read_text(), "keep")
|
self.assertEqual((self.root / "unrelated.txt").read_text(), "keep")
|
||||||
|
|
||||||
|
def test_no_preprovisioned_signing_key_is_required(self):
|
||||||
|
self.keyring.unlink()
|
||||||
|
self.preflight()
|
||||||
|
|
||||||
def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self):
|
def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self):
|
||||||
self.legacy_installation()
|
self.legacy_installation()
|
||||||
original_config = self.config.read_bytes()
|
original_config = self.config.read_bytes()
|
||||||
@@ -334,23 +340,74 @@ docker() {
|
|||||||
self.assertNotIn("Docker is installed but unavailable", result.stderr)
|
self.assertNotIn("Docker is installed but unavailable", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryTrustTests(unittest.TestCase):
|
||||||
|
def fetch(self, status="200", body="public key", curl_exit=0):
|
||||||
|
block = SOURCE[SOURCE.index("# INSTALLATION_TRUST:"):SOURCE.index("# INSTALLATION_TRUST_END")]
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
script = r'''set -euo pipefail
|
||||||
|
curl() {
|
||||||
|
printf '%s\n' "$@" > "$temporary_dir/curl-args"
|
||||||
|
printf '%s' "$EC_TEST_BODY" > "$temporary_dir/release-signing.gpg"
|
||||||
|
printf '%s' "$EC_TEST_STATUS"
|
||||||
|
return "$EC_TEST_EXIT"
|
||||||
|
}
|
||||||
|
''' + block + "\nprintf 'verified-fetch'"
|
||||||
|
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||||
|
env={**os.environ, "temporary_dir": temporary, "EC_TEST_BODY": body,
|
||||||
|
"EC_TEST_STATUS": status, "EC_TEST_EXIT": str(curl_exit)})
|
||||||
|
args = (Path(temporary) / "curl-args").read_text().splitlines()
|
||||||
|
return result, args
|
||||||
|
|
||||||
|
def test_installer_fetches_fixed_repository_key_with_bounds_and_tls(self):
|
||||||
|
result, args = self.fetch()
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout, "verified-fetch")
|
||||||
|
self.assertIn("https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg", args)
|
||||||
|
for option, value in (("--proto", "=https"), ("--max-filesize", "262144"),
|
||||||
|
("--max-time", "120"), ("--connect-timeout", "25"),
|
||||||
|
("--header", "Cache-Control: no-cache")):
|
||||||
|
self.assertEqual(args[args.index(option) + 1], value)
|
||||||
|
self.assertNotIn("--location", args)
|
||||||
|
self.assertNotIn("--insecure", args)
|
||||||
|
self.assertLess(SOURCE.index("# INSTALLATION_TRUST:"), SOURCE.index('$controller/api/agent/release/manifest.json'))
|
||||||
|
|
||||||
|
def test_repository_failure_redirect_or_empty_key_stops_installation(self):
|
||||||
|
for status, body, code in (("302", "redirect", 0), ("404", "missing", 22),
|
||||||
|
("200", "", 0), ("200", "partial", 28), ("200", "oversized", 63)):
|
||||||
|
with self.subTest(status=status, body=body):
|
||||||
|
result, _ = self.fetch(status, body, code)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertNotIn("verified-fetch", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
class ReleaseTests(unittest.TestCase):
|
class ReleaseTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temporary = tempfile.TemporaryDirectory()
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
self.source = Path(self.temporary.name) / "source"
|
self.source = Path(self.temporary.name).resolve() / "source"
|
||||||
self.target = Path(self.temporary.name) / "release"
|
self.target = Path(self.temporary.name).resolve() / "release"
|
||||||
self.source.mkdir()
|
self.source.mkdir()
|
||||||
(self.source / "ec_agent.py").write_text('VERSION = "3.12.7"\nraise RuntimeError("must never execute during packaging")\n')
|
(self.source / "ec_agent.py").write_text('VERSION = "3.12.7"\nraise RuntimeError("must never execute during packaging")\n')
|
||||||
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("must never execute during verification")\n')
|
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("must never execute during verification")\n')
|
||||||
(self.source / "install.sh").write_text("#!/bin/bash\n")
|
(self.source / "install.sh").write_text("#!/bin/bash\n")
|
||||||
PACKAGER.package(self.source, self.target)
|
PACKAGER.package(self.source, self.target)
|
||||||
|
self.keyring = self.source.parent / 'release-signing.gpg'
|
||||||
|
self.keyring.write_bytes(b'publisher public key')
|
||||||
|
(self.target / 'manifest.json.sig').write_bytes(b'detached signature')
|
||||||
|
self.gpgv = Mock(return_value=subprocess.CompletedProcess([], 0))
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def verify(self):
|
def verify(self):
|
||||||
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py")]
|
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py"), str(self.keyring)]
|
||||||
with patch.object(sys, "argv", argv):
|
original_lstat = Path.lstat
|
||||||
|
def root_owned(path):
|
||||||
|
fields = list(original_lstat(path))
|
||||||
|
fields[4] = 0
|
||||||
|
if stat.S_ISDIR(fields[0]):
|
||||||
|
fields[0] &= ~0o022
|
||||||
|
return os.stat_result(fields)
|
||||||
|
with patch.object(sys, "argv", argv), patch.object(Path, 'lstat', root_owned), patch.object(subprocess, 'run', self.gpgv):
|
||||||
exec(compile(RELEASE, "installer-release", "exec"), {})
|
exec(compile(RELEASE, "installer-release", "exec"), {})
|
||||||
|
|
||||||
def manifest(self, **changes):
|
def manifest(self, **changes):
|
||||||
@@ -363,7 +420,7 @@ class ReleaseTests(unittest.TestCase):
|
|||||||
def test_package_derives_version_and_hashes_both_sources_without_executing_them(self):
|
def test_package_derives_version_and_hashes_both_sources_without_executing_them(self):
|
||||||
manifest = self.manifest()
|
manifest = self.manifest()
|
||||||
self.assertEqual(manifest["version"], "3.12.7")
|
self.assertEqual(manifest["version"], "3.12.7")
|
||||||
self.assertEqual(manifest["updaterProtocolVersion"], 1)
|
self.assertEqual(manifest["updaterProtocolVersion"], 2)
|
||||||
self.assertEqual(manifest["sha256"], hashlib.sha256((self.source / "ec_agent.py").read_bytes()).hexdigest())
|
self.assertEqual(manifest["sha256"], hashlib.sha256((self.source / "ec_agent.py").read_bytes()).hexdigest())
|
||||||
self.assertEqual(manifest["updaterSha256"], hashlib.sha256((self.source / "ec_agent_update.py").read_bytes()).hexdigest())
|
self.assertEqual(manifest["updaterSha256"], hashlib.sha256((self.source / "ec_agent_update.py").read_bytes()).hexdigest())
|
||||||
self.assertEqual((self.target / "ec-agent-update.py").read_bytes(), (self.source / "ec_agent_update.py").read_bytes())
|
self.assertEqual((self.target / "ec-agent-update.py").read_bytes(), (self.source / "ec_agent_update.py").read_bytes())
|
||||||
@@ -380,11 +437,45 @@ class ReleaseTests(unittest.TestCase):
|
|||||||
path.write_bytes(original)
|
path.write_bytes(original)
|
||||||
|
|
||||||
def test_installer_rejects_missing_or_unsupported_updater_protocol(self):
|
def test_installer_rejects_missing_or_unsupported_updater_protocol(self):
|
||||||
for protocol in (None, 0, 2, "1"):
|
for protocol in (None, 0, 1, True, "2"):
|
||||||
self.manifest(updaterProtocolVersion=protocol)
|
self.manifest(updaterProtocolVersion=protocol)
|
||||||
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
|
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
|
||||||
self.verify()
|
self.verify()
|
||||||
|
|
||||||
|
def test_empty_or_oversized_downloaded_key_cannot_authorize_installation(self):
|
||||||
|
for value in (b"", b"x" * (256 * 1024 + 1)):
|
||||||
|
self.keyring.write_bytes(value)
|
||||||
|
with self.subTest(size=len(value)), self.assertRaisesRegex(SystemExit, "public key is empty or exceeds"):
|
||||||
|
self.verify()
|
||||||
|
self.gpgv.assert_not_called()
|
||||||
|
|
||||||
|
def test_signature_is_verified_against_downloaded_key_before_manifest_parsing(self):
|
||||||
|
self.verify()
|
||||||
|
args, kwargs = self.gpgv.call_args
|
||||||
|
self.assertEqual(args[0][0], 'gpgv')
|
||||||
|
self.assertEqual(args[0][-4:], ['--keyring', str(self.keyring), str(self.target / 'manifest.json.sig'), '-'])
|
||||||
|
self.assertEqual(kwargs['input'], (self.target / 'manifest.json').read_bytes())
|
||||||
|
self.assertEqual(kwargs['timeout'], 30)
|
||||||
|
self.assertFalse(Path(args[0][2]).exists())
|
||||||
|
(self.target / 'manifest.json').write_bytes(b'not even JSON')
|
||||||
|
self.gpgv.return_value = subprocess.CompletedProcess([], 1)
|
||||||
|
with self.assertRaisesRegex(SystemExit, 'signature verification failed'):
|
||||||
|
self.verify()
|
||||||
|
|
||||||
|
def test_missing_signature_key_or_gpgv_prevents_installation(self):
|
||||||
|
for path in (self.keyring, self.target / 'manifest.json.sig'):
|
||||||
|
original = path.read_bytes()
|
||||||
|
path.unlink()
|
||||||
|
self.gpgv.reset_mock()
|
||||||
|
with self.subTest(path=path), self.assertRaises(FileNotFoundError):
|
||||||
|
self.verify()
|
||||||
|
self.gpgv.assert_not_called()
|
||||||
|
path.write_bytes(original)
|
||||||
|
for failure in (FileNotFoundError(), subprocess.TimeoutExpired('gpgv', 30)):
|
||||||
|
self.gpgv.side_effect = failure
|
||||||
|
with self.subTest(failure=failure), self.assertRaisesRegex(SystemExit, 'working gpgv'):
|
||||||
|
self.verify()
|
||||||
|
|
||||||
def test_installer_rejects_manifest_version_mismatch_and_invalid_code(self):
|
def test_installer_rejects_manifest_version_mismatch_and_invalid_code(self):
|
||||||
self.manifest(version="3.12.8")
|
self.manifest(version="3.12.8")
|
||||||
with self.assertRaisesRegex(SystemExit, "version does not match"):
|
with self.assertRaisesRegex(SystemExit, "version does not match"):
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Release packaging is unsigned; only the explicit offline command may sign."""
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
AGENT = Path(__file__).resolve().parents[1]
|
||||||
|
SPEC = importlib.util.spec_from_file_location("package_release", AGENT / "package_release.py")
|
||||||
|
PACKAGER = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(PACKAGER)
|
||||||
|
SPEC = importlib.util.spec_from_file_location("sign_release", AGENT / "sign_release.py")
|
||||||
|
SIGNER = importlib.util.module_from_spec(SPEC)
|
||||||
|
with patch.dict(sys.modules, {"package_release": PACKAGER}):
|
||||||
|
SPEC.loader.exec_module(SIGNER)
|
||||||
|
SPEC = importlib.util.spec_from_file_location("signed_pipeline_updater", AGENT / "ec_agent_update.py")
|
||||||
|
UPDATER = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(UPDATER)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseFixture:
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.base = Path(self.temporary.name).resolve()
|
||||||
|
self.source = self.base / "source"
|
||||||
|
self.source.mkdir()
|
||||||
|
self.target = self.base / "release"
|
||||||
|
self.gnupghome = self.base / "offline-keyring"
|
||||||
|
self.gnupghome.mkdir(mode=0o700)
|
||||||
|
self.fingerprint = "A" * 40
|
||||||
|
(self.source / "ec_agent.py").write_text('VERSION = "4.2.0"\nraise RuntimeError("Do not run")\n')
|
||||||
|
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("Do not run")\n')
|
||||||
|
(self.source / "install.sh").write_text("#!/bin/bash\nexit 1\n")
|
||||||
|
PACKAGER.package(self.source, self.target)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class PackagedReleaseTests(ReleaseFixture, unittest.TestCase):
|
||||||
|
def test_manifest_is_deterministic_and_covers_every_executable(self):
|
||||||
|
first = (self.target / "manifest.json").read_bytes()
|
||||||
|
PACKAGER.package(self.source, self.target)
|
||||||
|
self.assertEqual(first, (self.target / "manifest.json").read_bytes())
|
||||||
|
manifest = json.loads(first)
|
||||||
|
self.assertEqual(manifest["updaterProtocolVersion"], 2)
|
||||||
|
for field, filename in (("sha256", "ec-agent.py"),
|
||||||
|
("updaterSha256", "ec-agent-update.py"),
|
||||||
|
("installerSha256", "install.sh")):
|
||||||
|
self.assertEqual(manifest[field], hashlib.sha256((self.target / filename).read_bytes()).hexdigest())
|
||||||
|
self.assertFalse((self.target / "manifest.json.sig").exists())
|
||||||
|
|
||||||
|
def test_packaging_preserves_signature_bytes_only_for_identical_manifest(self):
|
||||||
|
signature = self.target / "manifest.json.sig"
|
||||||
|
for filename in ("ec_agent.py", "ec_agent_update.py", "install.sh"):
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
signature.write_bytes(b"offline signature")
|
||||||
|
with patch.object(subprocess, "run", side_effect=AssertionError("Builds must never sign")):
|
||||||
|
PACKAGER.package(self.source, self.target)
|
||||||
|
self.assertEqual(signature.read_bytes(), b"offline signature")
|
||||||
|
source = self.source / filename
|
||||||
|
source.write_bytes(source.read_bytes() + b"# reviewed change\n")
|
||||||
|
PACKAGER.package(self.source, self.target)
|
||||||
|
self.assertFalse(signature.exists())
|
||||||
|
|
||||||
|
def test_orphaned_signature_is_removed_when_manifest_was_missing(self):
|
||||||
|
(self.target / "manifest.json").unlink()
|
||||||
|
(self.target / "manifest.json.sig").write_bytes(b"orphaned signature")
|
||||||
|
PACKAGER.package(self.source, self.target)
|
||||||
|
self.assertFalse((self.target / "manifest.json.sig").exists())
|
||||||
|
|
||||||
|
def test_signer_requires_full_fingerprint_before_invoking_gpg(self):
|
||||||
|
for fingerprint in ("", "release@example.com", "A" * 16, "Z" * 40, "A" * 41):
|
||||||
|
with self.subTest(fingerprint=fingerprint), patch.object(subprocess, "run") as run:
|
||||||
|
with self.assertRaisesRegex(ValueError, "full OpenPGP key fingerprint"):
|
||||||
|
SIGNER.sign_release(self.target, self.gnupghome, fingerprint)
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_signer_rejects_private_key_homes_in_repository_or_release(self):
|
||||||
|
for home in (AGENT, self.target, self.target / "private"):
|
||||||
|
with self.subTest(home=home), patch.object(subprocess, "run") as run:
|
||||||
|
with self.assertRaisesRegex(ValueError, "outside the repository and release"):
|
||||||
|
SIGNER.sign_release(self.target, home, self.fingerprint)
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_signer_refuses_unreviewed_or_inconsistent_release_bytes(self):
|
||||||
|
for filename in ("ec-agent.py", "ec-agent-update.py", "install.sh", "manifest.json"):
|
||||||
|
with self.subTest(filename=filename), patch.object(subprocess, "run") as run:
|
||||||
|
path = self.target / filename
|
||||||
|
original = path.read_bytes()
|
||||||
|
path.write_bytes(original + b"\n")
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not match packaged artifacts"):
|
||||||
|
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
|
||||||
|
run.assert_not_called()
|
||||||
|
path.write_bytes(original)
|
||||||
|
|
||||||
|
def test_failed_signing_does_not_replace_existing_signature(self):
|
||||||
|
signature = self.target / "manifest.json.sig"
|
||||||
|
signature.write_bytes(b"previous valid signature")
|
||||||
|
with patch.object(subprocess, "run", side_effect=subprocess.CalledProcessError(2, "gpg")):
|
||||||
|
with self.assertRaises(subprocess.CalledProcessError):
|
||||||
|
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
|
||||||
|
self.assertEqual(signature.read_bytes(), b"previous valid signature")
|
||||||
|
self.assertEqual(list(self.target.glob(".sign-release-*")), [])
|
||||||
|
|
||||||
|
def test_release_mutation_during_signing_never_publishes_a_signature(self):
|
||||||
|
def mutate(command, **kwargs):
|
||||||
|
Path(command[command.index("--output") + 1]).write_bytes(b"-----BEGIN PGP SIGNATURE-----\nstaged\n")
|
||||||
|
(self.target / "ec-agent.py").write_bytes(b'VERSION = "9.0.0"\n')
|
||||||
|
with patch.object(subprocess, "run", side_effect=mutate):
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not match packaged artifacts"):
|
||||||
|
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
|
||||||
|
self.assertFalse((self.target / "manifest.json.sig").exists())
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("gpg") and shutil.which("gpgv"), "GnuPG required for real offline signing tests")
|
||||||
|
class OfflineSigningTests(ReleaseFixture, unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.keys = tempfile.TemporaryDirectory(prefix="ec-sign-")
|
||||||
|
cls.keyhome = Path(cls.keys.name)
|
||||||
|
cls.keyhome.chmod(0o700)
|
||||||
|
command = ["gpg", "--no-options", "--homedir", str(cls.keyhome), "--batch"]
|
||||||
|
subprocess.run(command + ["--pinentry-mode", "loopback", "--passphrase", "",
|
||||||
|
"--quick-generate-key", "Ephemeral release test <release@example.invalid>",
|
||||||
|
"ed25519", "sign", "0"], check=True, capture_output=True)
|
||||||
|
listing = subprocess.run(command + ["--with-colons", "--list-secret-keys"],
|
||||||
|
check=True, capture_output=True, text=True).stdout
|
||||||
|
cls.key_fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:"))
|
||||||
|
cls.public_key = subprocess.run(command + ["--export", cls.key_fingerprint],
|
||||||
|
check=True, capture_output=True).stdout
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
if shutil.which("gpgconf"):
|
||||||
|
subprocess.run(["gpgconf", "--homedir", str(cls.keyhome), "--kill", "gpg-agent"],
|
||||||
|
check=False, capture_output=True)
|
||||||
|
cls.keys.cleanup()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.keyring = self.base / "release-signing.gpg"
|
||||||
|
self.keyring.write_bytes(self.public_key)
|
||||||
|
self.update_dir = self.base / "update"
|
||||||
|
self.update_dir.mkdir(mode=0o700)
|
||||||
|
original_lstat = Path.lstat
|
||||||
|
protected_paths = {self.keyring, *self.keyring.parents, self.update_dir}
|
||||||
|
def root_owned(path):
|
||||||
|
info = original_lstat(path)
|
||||||
|
if path not in protected_paths:
|
||||||
|
return info
|
||||||
|
fields = list(info)
|
||||||
|
fields[4] = 0
|
||||||
|
if stat.S_ISDIR(fields[0]):
|
||||||
|
fields[0] &= ~0o022
|
||||||
|
return os.stat_result(fields)
|
||||||
|
self.root_owned = root_owned
|
||||||
|
|
||||||
|
def verify_installer(self):
|
||||||
|
blocks = re.findall(r"<<'PY'\n(.*?)\nPY", (AGENT / "install.sh").read_text(), re.DOTALL)
|
||||||
|
release = next(block for block in blocks if "# INSTALLATION_RELEASE:" in block)
|
||||||
|
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"),
|
||||||
|
str(self.target / "ec-agent-update.py"), str(self.keyring)]
|
||||||
|
# Only filesystem ownership is simulated; real GnuPG verifies the
|
||||||
|
# separately exported public key and the offline signer's exact bytes.
|
||||||
|
with patch.object(sys, "argv", argv), patch.object(Path, "lstat", self.root_owned):
|
||||||
|
exec(compile(release, "installer-release", "exec"), {})
|
||||||
|
|
||||||
|
def verify_updater(self):
|
||||||
|
with patch.object(UPDATER, "UPDATE_DIR", self.update_dir), \
|
||||||
|
patch.object(Path, "lstat", self.root_owned):
|
||||||
|
UPDATER.verify_release_signature((self.target / "manifest.json").read_bytes(),
|
||||||
|
(self.target / "manifest.json.sig").read_bytes(), self.public_key)
|
||||||
|
|
||||||
|
def verify(self):
|
||||||
|
return subprocess.run([
|
||||||
|
"gpg", "--no-options", "--homedir", str(self.keyhome), "--batch", "--verify",
|
||||||
|
str(self.target / "manifest.json.sig"), str(self.target / "manifest.json"),
|
||||||
|
], capture_output=True)
|
||||||
|
|
||||||
|
def test_offline_signature_verifies_exact_bytes_and_survives_identical_build(self):
|
||||||
|
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
|
||||||
|
signature = (self.target / "manifest.json.sig").read_bytes()
|
||||||
|
self.assertTrue(signature.startswith(b"-----BEGIN PGP SIGNATURE-----\n"))
|
||||||
|
self.assertEqual(self.verify().returncode, 0)
|
||||||
|
PACKAGER.package(self.source, self.target)
|
||||||
|
self.assertEqual((self.target / "manifest.json.sig").read_bytes(), signature)
|
||||||
|
self.assertEqual(self.verify().returncode, 0)
|
||||||
|
with (self.target / "manifest.json").open("ab") as manifest:
|
||||||
|
manifest.write(b" ")
|
||||||
|
self.assertNotEqual(self.verify().returncode, 0)
|
||||||
|
|
||||||
|
def test_unknown_signing_key_fails_without_publishing_a_signature(self):
|
||||||
|
with self.assertRaises(subprocess.CalledProcessError):
|
||||||
|
SIGNER.sign_release(self.target, self.keyhome, "F" * 40)
|
||||||
|
self.assertFalse((self.target / "manifest.json.sig").exists())
|
||||||
|
|
||||||
|
def test_offline_signed_release_is_accepted_by_installer_and_updater(self):
|
||||||
|
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
|
||||||
|
self.verify_installer()
|
||||||
|
self.verify_updater()
|
||||||
|
|
||||||
|
def test_controller_cannot_authorize_changed_code_by_rehashing_manifest(self):
|
||||||
|
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
|
||||||
|
for filename in ("ec-agent.py", "ec-agent-update.py"):
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
path = self.target / filename
|
||||||
|
original = path.read_bytes()
|
||||||
|
path.write_bytes(original + b"# controller's unauthorized change\n")
|
||||||
|
(self.target / "manifest.json").write_bytes(PACKAGER.manifest_bytes(
|
||||||
|
(self.target / "ec-agent.py").read_bytes(),
|
||||||
|
(self.target / "ec-agent-update.py").read_bytes(),
|
||||||
|
(self.target / "install.sh").read_bytes()))
|
||||||
|
with self.assertRaisesRegex(SystemExit, "signature verification failed"):
|
||||||
|
self.verify_installer()
|
||||||
|
with self.assertRaisesRegex(UPDATER.IntegrityError, "signature is invalid"):
|
||||||
|
self.verify_updater()
|
||||||
|
path.write_bytes(original)
|
||||||
|
|
||||||
|
def test_signed_manifest_rejects_changed_agent_and_updater_downloads(self):
|
||||||
|
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
|
||||||
|
for filename, message in (("ec-agent.py", "Agent release checksum"),
|
||||||
|
("ec-agent-update.py", "Updater release checksum")):
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
path = self.target / filename
|
||||||
|
original = path.read_bytes()
|
||||||
|
path.write_bytes(original + b"# controller's unauthorized change\n")
|
||||||
|
with self.assertRaisesRegex(SystemExit, message):
|
||||||
|
self.verify_installer()
|
||||||
|
path.write_bytes(original)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import errno
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("ec_agent_temperatures", Path(__file__).resolve().parents[1] / "ec_agent.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
|
||||||
|
class TemperatureTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.hwmon = self.root / "class"
|
||||||
|
self.hwmon.mkdir()
|
||||||
|
self.patch = patch.object(module, "HWMON_ROOT", self.hwmon)
|
||||||
|
self.patch.start()
|
||||||
|
self.addCleanup(self.patch.stop)
|
||||||
|
self.agent = object.__new__(module.Agent)
|
||||||
|
|
||||||
|
def chip(self, number, device, readings):
|
||||||
|
physical = self.root / (device + str(number)) / "hwmon" / ("hwmon" + str(number))
|
||||||
|
physical.mkdir(parents=True)
|
||||||
|
(self.hwmon / physical.name).symlink_to(physical, target_is_directory=True)
|
||||||
|
(physical / "name").write_text(device)
|
||||||
|
for channel, label, value in readings:
|
||||||
|
(physical / (channel + "_input")).write_text(str(value))
|
||||||
|
if label is not None:
|
||||||
|
(physical / (channel + "_label")).write_text(label)
|
||||||
|
return physical
|
||||||
|
|
||||||
|
def test_ec2_sensors_and_units_without_guessing_unknown_devices(self):
|
||||||
|
self.chip(0, "nvme", [("temp1", "Composite", 50900), ("temp2", "Sensor 1", 50900)])
|
||||||
|
self.chip(1, "pch_cometlake", [("temp1", None, 62000)])
|
||||||
|
self.chip(2, "coretemp", [("temp1", "Package id 0", 65000), ("temp2", "Core 0", 63000)])
|
||||||
|
self.chip(3, "iwlwifi_1", [("temp1", None, "unavailable")])
|
||||||
|
samples = self.agent.temperatures()
|
||||||
|
self.assertEqual([s["celsius"] for s in samples], [50.9, 50.9, 62.0, 65.0, 63.0])
|
||||||
|
self.assertEqual([s["kind"] for s in samples], ["nvme", "nvme", "chipset", "cpu-package", "cpu-core"])
|
||||||
|
self.assertEqual(len({s["id"] for s in samples}), len(samples))
|
||||||
|
|
||||||
|
def test_fault_disabled_bad_value_and_enodata_do_not_lose_other_sensors(self):
|
||||||
|
chip = self.chip(0, "coretemp", [("temp" + str(i), "Core " + str(i), value)
|
||||||
|
for i, value in enumerate([65000, 60000, "bad", 500000, 61000, 63000, 0], 1)])
|
||||||
|
(chip / "temp1_fault").write_text("1")
|
||||||
|
(chip / "temp2_enable").write_text("0")
|
||||||
|
original = Path.read_text
|
||||||
|
def read(path, *args, **kwargs):
|
||||||
|
if path.name == "temp5_input":
|
||||||
|
raise OSError(errno.ENODATA, "No data available")
|
||||||
|
return original(path, *args, **kwargs)
|
||||||
|
with patch.object(Path, "read_text", read):
|
||||||
|
self.assertEqual([s["celsius"] for s in self.agent.temperatures()], [63.0, 0.0])
|
||||||
|
|
||||||
|
def test_amd_keeps_physical_die_readings_and_excludes_control_offsets(self):
|
||||||
|
self.chip(0, "k10temp", [("temp1", "Tctl", 85000), ("temp2", "Tdie", 65000), ("temp3", "Tccd1", 64000)])
|
||||||
|
self.assertEqual([s["label"] for s in self.agent.temperatures()], ["Tdie", "Tccd1"])
|
||||||
|
|
||||||
|
def test_sensor_identity_survives_hwmon_renumbering_and_separates_drives(self):
|
||||||
|
physical = self.chip(1, "nvme", [("temp1", "Composite", 50000)])
|
||||||
|
self.chip(2, "nvme", [("temp1", "Composite", 51000)])
|
||||||
|
before = {s["celsius"]: s["id"] for s in self.agent.temperatures()}
|
||||||
|
renamed = physical.with_name("hwmon9")
|
||||||
|
physical.rename(renamed)
|
||||||
|
(self.hwmon / "hwmon1").unlink()
|
||||||
|
(self.hwmon / "hwmon9").symlink_to(renamed, target_is_directory=True)
|
||||||
|
self.assertEqual(before, {s["celsius"]: s["id"] for s in self.agent.temperatures()})
|
||||||
|
self.assertEqual(len(set(before.values())), 2)
|
||||||
|
|
||||||
|
def test_missing_hwmon_and_disappearing_devices_are_optional(self):
|
||||||
|
self.assertEqual(self.agent.temperatures(), [])
|
||||||
|
(self.hwmon / "hwmon0").symlink_to(self.root / "missing")
|
||||||
|
self.assertEqual(self.agent.temperatures(), [])
|
||||||
|
with patch.object(Path, "glob", side_effect=PermissionError()):
|
||||||
|
self.assertEqual(self.agent.temperatures(), [])
|
||||||
|
|
||||||
|
def test_direct_hwmon_child_keeps_identity_when_renumbered(self):
|
||||||
|
physical = self.chip(1, "nvme", [("temp1", "Composite", 50000)])
|
||||||
|
direct = physical.parent.parent / "hwmon8"
|
||||||
|
physical.rename(direct)
|
||||||
|
(self.hwmon / "hwmon1").unlink()
|
||||||
|
(self.hwmon / "hwmon8").symlink_to(direct, target_is_directory=True)
|
||||||
|
before = self.agent.temperatures()[0]["id"]
|
||||||
|
renamed = direct.with_name("hwmon9")
|
||||||
|
direct.rename(renamed)
|
||||||
|
(self.hwmon / "hwmon8").unlink()
|
||||||
|
(self.hwmon / "hwmon9").symlink_to(renamed, target_is_directory=True)
|
||||||
|
self.assertEqual(self.agent.temperatures()[0]["id"], before)
|
||||||
|
|
||||||
|
def test_inventory_size_is_bounded(self):
|
||||||
|
self.chip(0, "coretemp", [("temp" + str(i), "Core " + str(i), 65000) for i in range(1, 200)])
|
||||||
|
self.assertEqual(len(self.agent.temperatures()), 128)
|
||||||
+241
-6
@@ -5,7 +5,9 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
@@ -62,9 +64,16 @@ class UpdateTests(unittest.TestCase):
|
|||||||
if directory and not path.is_dir() or not directory and not path.is_file():
|
if directory and not path.is_dir() or not directory and not path.is_file():
|
||||||
raise module.UpdateError("Wrong file type")
|
raise module.UpdateError("Wrong file type")
|
||||||
return path
|
return path
|
||||||
self.patches.extend([patch.object(module, "trusted", side_effect=trusted_for_test), patch.object(module, "sync_parent"), patch.object(module, "systemctl", side_effect=self.systemctl)])
|
self.public_key = b"repository public key"
|
||||||
|
key_patch = patch.object(module, "fetch_release_key", return_value=self.public_key)
|
||||||
|
self.fetch_key = key_patch.start()
|
||||||
|
self.addCleanup(key_patch.stop)
|
||||||
|
self.signature_patch = patch.object(module, "verify_release_signature")
|
||||||
|
self.patches.extend([patch.object(module, "trusted", side_effect=trusted_for_test), patch.object(module, "sync_parent"), patch.object(module, "systemctl", side_effect=self.systemctl), self.signature_patch])
|
||||||
for item in self.patches:
|
for item in self.patches:
|
||||||
item.start()
|
mocked = item.start()
|
||||||
|
if item is self.signature_patch:
|
||||||
|
self.signature_verifier = mocked
|
||||||
self.config = {"hostId": "host-one", "root": str(self.root), "controller": "https://controller.example", "credential": "secret-enrollment-credential"}
|
self.config = {"hostId": "host-one", "root": str(self.root), "controller": "https://controller.example", "credential": "secret-enrollment-credential"}
|
||||||
self.config_bytes = json.dumps(self.config).encode()
|
self.config_bytes = json.dumps(self.config).encode()
|
||||||
module.AGENT_CONFIG.write_bytes(self.config_bytes)
|
module.AGENT_CONFIG.write_bytes(self.config_bytes)
|
||||||
@@ -78,7 +87,8 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.write_health = True
|
self.write_health = True
|
||||||
self.downloads = []
|
self.downloads = []
|
||||||
self.source = CANDIDATE
|
self.source = CANDIDATE
|
||||||
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 1, "platform": "debian-amd64"}
|
self.signature = b"detached signature"
|
||||||
|
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 2, "platform": "debian-amd64"}
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
for item in reversed(self.patches):
|
for item in reversed(self.patches):
|
||||||
@@ -108,6 +118,10 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.downloads.append(path)
|
self.downloads.append(path)
|
||||||
if path.endswith("manifest.json"):
|
if path.endswith("manifest.json"):
|
||||||
return json.dumps(self.release).encode()
|
return json.dumps(self.release).encode()
|
||||||
|
if path.endswith("manifest.json.sig"):
|
||||||
|
if isinstance(self.signature, Exception):
|
||||||
|
raise self.signature
|
||||||
|
return self.signature
|
||||||
if path.endswith("ec-agent.py"):
|
if path.endswith("ec-agent.py"):
|
||||||
return self.source
|
return self.source
|
||||||
self.fail("Unexpected download " + path)
|
self.fail("Unexpected download " + path)
|
||||||
@@ -128,6 +142,7 @@ class UpdateTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
|
def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
|
||||||
self.assertEqual(self.updater().run(), "updated")
|
self.assertEqual(self.updater().run(), "updated")
|
||||||
|
self.signature_verifier.assert_called_once_with(json.dumps(self.release).encode(), self.signature, self.public_key)
|
||||||
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
|
||||||
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
|
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
|
||||||
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
|
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
|
||||||
@@ -137,6 +152,36 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists())
|
self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists())
|
||||||
self.assertEqual(next(command for command in self.commands if command[0] != "show"), ("stop", module.AGENT_SERVICE))
|
self.assertEqual(next(command for command in self.commands if command[0] != "show"), ("stop", module.AGENT_SERVICE))
|
||||||
|
|
||||||
|
def test_invalid_signature_rejects_attacker_source_and_matching_manifest_checksum(self):
|
||||||
|
self.source = b'VERSION = "1.1.0"\nraise RuntimeError("attacker code")\n'
|
||||||
|
self.release["sha256"] = module.sha256(self.source)
|
||||||
|
self.signature_verifier.side_effect = module.IntegrityError("Invalid release signature")
|
||||||
|
self.assertEqual(self.updater().run(), "failed")
|
||||||
|
self.assert_unchanged()
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
|
||||||
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
||||||
|
self.assertFalse((module.UPDATE_DIR / "failed-release.json").exists())
|
||||||
|
self.assertNotIn("targetVersion", self.status())
|
||||||
|
|
||||||
|
def test_signature_is_verified_before_manifest_json_is_parsed(self):
|
||||||
|
updater = self.updater()
|
||||||
|
updater.fetch = lambda path, limit: b"untrusted invalid JSON" if path.endswith("manifest.json") else self.signature
|
||||||
|
self.signature_verifier.side_effect = module.IntegrityError("Invalid release signature")
|
||||||
|
with patch.object(module.json, "loads", side_effect=AssertionError("Parsed unsigned metadata")):
|
||||||
|
with self.assertRaisesRegex(module.IntegrityError, "Invalid release signature"):
|
||||||
|
updater.manifest()
|
||||||
|
self.signature_verifier.assert_called_once_with(b"untrusted invalid JSON", self.signature, self.public_key)
|
||||||
|
|
||||||
|
def test_missing_signature_never_downloads_code_or_stops_agent(self):
|
||||||
|
self.signature = module.ContactError("No signature is published")
|
||||||
|
self.assertEqual(self.updater().run(), "failed")
|
||||||
|
self.assertIn("signature", self.status()["error"])
|
||||||
|
self.assert_unchanged()
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
|
||||||
|
self.signature_verifier.assert_not_called()
|
||||||
|
|
||||||
def test_equal_or_older_versions_do_not_download_or_restart(self):
|
def test_equal_or_older_versions_do_not_download_or_restart(self):
|
||||||
for target in ("1.0.0", "0.9.9"):
|
for target in ("1.0.0", "0.9.9"):
|
||||||
with self.subTest(target=target):
|
with self.subTest(target=target):
|
||||||
@@ -146,6 +191,32 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
|
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
|
||||||
self.assertEqual(self.commands, [])
|
self.assertEqual(self.commands, [])
|
||||||
|
|
||||||
|
def test_every_check_fetches_key_before_controller_even_without_a_new_version(self):
|
||||||
|
self.release["version"] = "1.0.0"
|
||||||
|
keys = [b"first public key", b"replacement public key"]
|
||||||
|
self.fetch_key.side_effect = keys
|
||||||
|
updater = self.updater()
|
||||||
|
def controller_fetch(path, limit):
|
||||||
|
self.assertGreater(self.fetch_key.call_count, self.signature_verifier.call_count)
|
||||||
|
return self.fetch(path, limit)
|
||||||
|
updater.fetch = controller_fetch
|
||||||
|
for _ in keys:
|
||||||
|
self.assertEqual(updater.run(), "current")
|
||||||
|
self.assertEqual(self.fetch_key.call_count, 2)
|
||||||
|
self.assertEqual([call.args[2] for call in self.signature_verifier.call_args_list], keys)
|
||||||
|
self.assert_unchanged()
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
|
||||||
|
def test_repository_failure_never_uses_old_host_key_or_contacts_controller(self):
|
||||||
|
(self.config_dir / "release-signing.gpg").write_bytes(b"old trusted key")
|
||||||
|
self.fetch_key.side_effect = module.ContactError("Repository unavailable")
|
||||||
|
self.assertEqual(self.updater().run(), "deferred")
|
||||||
|
self.assert_unchanged()
|
||||||
|
self.assertEqual(self.downloads, [])
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.assertTrue(self.active)
|
||||||
|
self.signature_verifier.assert_not_called()
|
||||||
|
|
||||||
def test_busy_operation_defers_update_without_stopping_agent(self):
|
def test_busy_operation_defers_update_without_stopping_agent(self):
|
||||||
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
|
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
|
||||||
self.assertEqual(self.updater().run(), "deferred")
|
self.assertEqual(self.updater().run(), "deferred")
|
||||||
@@ -207,7 +278,7 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.downloads.clear()
|
self.downloads.clear()
|
||||||
self.source = CANDIDATE
|
self.source = CANDIDATE
|
||||||
self.assertEqual(self.updater().run(), "failed")
|
self.assertEqual(self.updater().run(), "failed")
|
||||||
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"])
|
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
|
||||||
|
|
||||||
def test_invalid_or_mismatched_python_version_is_never_installed(self):
|
def test_invalid_or_mismatched_python_version_is_never_installed(self):
|
||||||
for source in (b'VERSION = "1.1.0"\ninvalid syntax ?\n', b'VERSION = "5.0.0"\n'):
|
for source in (b'VERSION = "1.1.0"\ninvalid syntax ?\n', b'VERSION = "5.0.0"\n'):
|
||||||
@@ -220,7 +291,7 @@ class UpdateTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_incompatible_protocol_platform_or_version_never_downloads_code(self):
|
def test_incompatible_protocol_platform_or_version_never_downloads_code(self):
|
||||||
original = dict(self.release)
|
original = dict(self.release)
|
||||||
for key, value in (("protocolVersion", 2), ("protocolVersion", True), ("platform", "linux-arm64"), ("version", "1.1.0-rc1"), ("version", "01.1.0"), ("updaterProtocolVersion", 2)):
|
for key, value in (("protocolVersion", 2), ("protocolVersion", True), ("platform", "linux-arm64"), ("version", "1.1.0-rc1"), ("version", "01.1.0"), ("updaterProtocolVersion", 1), ("updaterProtocolVersion", 3)):
|
||||||
with self.subTest(key=key, value=value):
|
with self.subTest(key=key, value=value):
|
||||||
self.release = {**original, key: value}
|
self.release = {**original, key: value}
|
||||||
self.assertIn(self.updater().run(), ("failed", "deferred"))
|
self.assertIn(self.updater().run(), ("failed", "deferred"))
|
||||||
@@ -239,7 +310,7 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.assertEqual(sum(command == ("start", module.AGENT_SERVICE) for command in self.commands), 2)
|
self.assertEqual(sum(command == ("start", module.AGENT_SERVICE) for command in self.commands), 2)
|
||||||
self.downloads.clear()
|
self.downloads.clear()
|
||||||
self.assertEqual(self.updater().run(), "failed")
|
self.assertEqual(self.updater().run(), "failed")
|
||||||
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"])
|
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
|
||||||
|
|
||||||
def test_changed_artifact_of_failed_target_can_be_retried(self):
|
def test_changed_artifact_of_failed_target_can_be_retried(self):
|
||||||
updater = self.updater()
|
updater = self.updater()
|
||||||
@@ -266,9 +337,15 @@ class UpdateTests(unittest.TestCase):
|
|||||||
def test_crash_between_executable_replacements_recovers_from_backups(self):
|
def test_crash_between_executable_replacements_recovers_from_backups(self):
|
||||||
self.crash_during_second_replacement()
|
self.crash_during_second_replacement()
|
||||||
self.downloads.clear()
|
self.downloads.clear()
|
||||||
|
self.signature_verifier.reset_mock()
|
||||||
|
self.fetch_key.reset_mock()
|
||||||
|
self.fetch_key.side_effect = module.ContactError("Repository offline during recovery")
|
||||||
|
self.signature_verifier.side_effect = module.IntegrityError("Signing keyring unavailable during recovery")
|
||||||
self.assertEqual(self.updater().run(), "failed")
|
self.assertEqual(self.updater().run(), "failed")
|
||||||
self.assert_unchanged()
|
self.assert_unchanged()
|
||||||
self.assertEqual(self.downloads, [])
|
self.assertEqual(self.downloads, [])
|
||||||
|
self.signature_verifier.assert_not_called()
|
||||||
|
self.fetch_key.assert_not_called()
|
||||||
self.assertTrue(self.active)
|
self.assertTrue(self.active)
|
||||||
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
self.assertFalse(module.UPDATE_JOURNAL.exists())
|
||||||
|
|
||||||
@@ -414,5 +491,163 @@ class UpdateSecurityTests(unittest.TestCase):
|
|||||||
module.version(value)
|
module.version(value)
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryKeyDownloadTests(unittest.TestCase):
|
||||||
|
def test_fixed_https_url_no_credentials_no_cache_and_no_redirects(self):
|
||||||
|
with patch.object(module.urllib.request, "build_opener") as opener:
|
||||||
|
opener.return_value.open.return_value = Response(b"public keys")
|
||||||
|
self.assertEqual(module.fetch_release_key(), b"public keys")
|
||||||
|
request = opener.return_value.open.call_args.args[0]
|
||||||
|
self.assertEqual(request.full_url, "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg")
|
||||||
|
self.assertIsNone(request.get_header("Authorization"))
|
||||||
|
self.assertIsNone(request.get_header("Cookie"))
|
||||||
|
self.assertEqual(request.get_header("Cache-control"), "no-cache")
|
||||||
|
self.assertEqual(opener.return_value.open.call_args.kwargs["timeout"], 25)
|
||||||
|
redirect = opener.call_args.args[0]
|
||||||
|
for destination in ("https://attacker.example/key.gpg", "http://gitea.thewrightserver.net/key.gpg"):
|
||||||
|
with self.subTest(destination=destination), self.assertRaises(module.ContactError):
|
||||||
|
redirect.redirect_request(None, None, 302, "redirect", {}, destination)
|
||||||
|
|
||||||
|
def test_missing_empty_oversized_and_truncated_keys_fail_closed(self):
|
||||||
|
cases = [Response(b""), Response(b"x", {"Content-Length": str(module.MAX_KEYRING + 1)}),
|
||||||
|
Response(b"x" * (module.MAX_KEYRING + 1)),
|
||||||
|
Response(b"partial", {"Content-Length": "100"}),
|
||||||
|
Response(b"key", {"Content-Length": "invalid"})]
|
||||||
|
for status in (301, 304, 404, 500):
|
||||||
|
response = Response(b"public key")
|
||||||
|
response.status = status
|
||||||
|
cases.append(response)
|
||||||
|
for response in cases:
|
||||||
|
with self.subTest(status=response.status, headers=response.headers), patch.object(module.urllib.request, "build_opener") as opener:
|
||||||
|
opener.return_value.open.return_value = response
|
||||||
|
with self.assertRaises(module.UpdateError):
|
||||||
|
module.fetch_release_key()
|
||||||
|
with patch.object(module.urllib.request, "build_opener") as opener:
|
||||||
|
opener.return_value.open.side_effect = OSError("TLS or network failure")
|
||||||
|
with self.assertRaisesRegex(module.ContactError, "release-signing repository is unavailable"):
|
||||||
|
module.fetch_release_key()
|
||||||
|
|
||||||
|
def test_trickling_repository_download_has_a_deadline(self):
|
||||||
|
with patch.object(module.urllib.request, "build_opener") as opener:
|
||||||
|
opener.return_value.open.return_value = Response(b"public key")
|
||||||
|
with patch.object(module.time, "monotonic", side_effect=[0, 0, 121]):
|
||||||
|
with self.assertRaisesRegex(module.ContactError, "timed out"):
|
||||||
|
module.fetch_release_key()
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureVerificationFixture:
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.base = Path(self.temporary.name)
|
||||||
|
self.keyring = self.base / "release-signing.gpg"
|
||||||
|
self.keyring.write_bytes(b"trusted host public keys")
|
||||||
|
self.update_dir = self.base / "update"
|
||||||
|
self.update_dir.mkdir(mode=0o700)
|
||||||
|
entry = patch.object(module, "UPDATE_DIR", self.update_dir)
|
||||||
|
entry.start()
|
||||||
|
self.addCleanup(entry.stop)
|
||||||
|
# Test fixtures are owned by the test runner rather than system root.
|
||||||
|
# Root ownership and unsafe modes are checked independently above.
|
||||||
|
entry = patch.object(module, "trusted", side_effect=lambda path, **_: Path(path))
|
||||||
|
self.trusted = entry.start()
|
||||||
|
self.addCleanup(entry.stop)
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
|
||||||
|
def test_gpgv_uses_only_downloaded_keyring_and_exact_bytes_with_an_isolated_home(self):
|
||||||
|
manifest = b'{"version": "1.2.3"}\n'
|
||||||
|
signature = b"binary detached signature\x00\xff"
|
||||||
|
def verify(command, **kwargs):
|
||||||
|
home = Path(command[2])
|
||||||
|
self.assertEqual(command[:2], ["gpgv", "--homedir"])
|
||||||
|
self.assertEqual(command[3:5], ["--keyring", str(home / "release-signing.gpg")])
|
||||||
|
self.assertEqual(Path(command[4]).read_bytes(), self.keyring.read_bytes())
|
||||||
|
self.assertEqual(home.parent, self.update_dir)
|
||||||
|
self.assertEqual(stat.S_IMODE(home.stat().st_mode), 0o700)
|
||||||
|
self.assertEqual(Path(command[5]).read_bytes(), signature)
|
||||||
|
self.assertEqual(Path(command[6]).read_bytes(), manifest)
|
||||||
|
self.assertEqual(kwargs["timeout"], 30)
|
||||||
|
self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
|
||||||
|
return subprocess.CompletedProcess(command, 0)
|
||||||
|
with patch.object(module.subprocess, "run", side_effect=verify) as run:
|
||||||
|
module.verify_release_signature(manifest, signature, self.keyring.read_bytes())
|
||||||
|
run.assert_called_once()
|
||||||
|
self.assertEqual(self.trusted.call_args_list[0].args, (self.update_dir,))
|
||||||
|
self.assertEqual(list(self.update_dir.iterdir()), [])
|
||||||
|
|
||||||
|
def test_empty_or_oversized_keyring_or_missing_signature_never_runs_a_verifier(self):
|
||||||
|
with patch.object(module.subprocess, "run") as run:
|
||||||
|
for key in (b"", b"x" * (module.MAX_KEYRING + 1)):
|
||||||
|
with self.subTest(size=len(key)), self.assertRaisesRegex(module.IntegrityError, "keyring"):
|
||||||
|
module.verify_release_signature(b"manifest", b"signature", key)
|
||||||
|
with self.assertRaisesRegex(module.IntegrityError, "detached manifest signature"):
|
||||||
|
module.verify_release_signature(b"manifest", b"", self.keyring.read_bytes())
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_bad_unknown_or_unavailable_verifier_fails_closed_and_cleans_temporary_files(self):
|
||||||
|
for result in (subprocess.CompletedProcess([], 1), subprocess.CompletedProcess([], 2), FileNotFoundError("gpgv missing"), subprocess.TimeoutExpired("gpgv", 30)):
|
||||||
|
with self.subTest(result=result):
|
||||||
|
kwargs = {"side_effect": result} if isinstance(result, Exception) else {"return_value": result}
|
||||||
|
with patch.object(module.subprocess, "run", **kwargs):
|
||||||
|
with self.assertRaises(module.IntegrityError):
|
||||||
|
module.verify_release_signature(b"manifest", b"signature", self.keyring.read_bytes())
|
||||||
|
self.assertEqual(list(self.update_dir.iterdir()), [])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("gpg") and shutil.which("gpgv"), "GnuPG signing and verification tools are required")
|
||||||
|
class RealSignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.keys = tempfile.TemporaryDirectory()
|
||||||
|
cls.addClassCleanup(cls.keys.cleanup)
|
||||||
|
cls.homes = []
|
||||||
|
for index in range(2):
|
||||||
|
home = Path(cls.keys.name) / str(index)
|
||||||
|
home.mkdir(mode=0o700)
|
||||||
|
cls.homes.append(home)
|
||||||
|
subprocess.run(["gpg", "--homedir", str(home), "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", f"Release Test {index} <release-{index}@example.test>", "ed25519", "sign", "0"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||||
|
if shutil.which("gpgconf"):
|
||||||
|
cls.addClassCleanup(subprocess.run, ["gpgconf", "--homedir", str(home), "--kill", "gpg-agent"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
||||||
|
cls.public_key = subprocess.run(["gpg", "--homedir", str(cls.homes[0]), "--batch", "--export"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
|
||||||
|
|
||||||
|
def sign(self, manifest, index=0):
|
||||||
|
return subprocess.run(["gpg", "--homedir", str(self.homes[index]), "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--digest-algo", "SHA512", "--detach-sign"], input=manifest, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True).stdout
|
||||||
|
|
||||||
|
def test_repository_key_rotation_removes_old_signer_on_the_next_check(self):
|
||||||
|
replacement = subprocess.run(["gpg", "--homedir", str(self.homes[1]), "--batch", "--export"],
|
||||||
|
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
|
||||||
|
manifest = json.dumps({"version": "1.1.0", "sha256": module.sha256(CANDIDATE),
|
||||||
|
"protocolVersion": 1, "updaterProtocolVersion": 2,
|
||||||
|
"platform": "debian-amd64",
|
||||||
|
"keyUrl": "https://controller.example/attacker-key"}).encode()
|
||||||
|
old_signature, new_signature = self.sign(manifest), self.sign(manifest, 1)
|
||||||
|
updater = module.Updater()
|
||||||
|
updater.installation = {"controller": "https://controller.example"}
|
||||||
|
with patch.object(module.urllib.request, "build_opener") as opener:
|
||||||
|
opener.return_value.open.side_effect = [Response(value) for value in (
|
||||||
|
self.public_key, manifest, old_signature,
|
||||||
|
replacement, manifest, old_signature,
|
||||||
|
replacement, manifest, new_signature)]
|
||||||
|
self.assertEqual(updater.manifest()["version"], "1.1.0")
|
||||||
|
with self.assertRaisesRegex(module.IntegrityError, "not signed by a trusted release key"):
|
||||||
|
updater.manifest()
|
||||||
|
self.assertEqual(updater.manifest()["version"], "1.1.0")
|
||||||
|
urls = [call.args[0].full_url for call in opener.return_value.open.call_args_list]
|
||||||
|
self.assertEqual(urls[::3], [module.RELEASE_KEY_URL] * 3)
|
||||||
|
self.assertNotIn("https://controller.example/attacker-key", urls)
|
||||||
|
self.assertEqual(list(self.update_dir.iterdir()), [])
|
||||||
|
|
||||||
|
def test_real_signatures_authenticate_exact_manifest_and_reject_unknown_keys(self):
|
||||||
|
self.keyring.write_bytes(self.public_key)
|
||||||
|
manifest = b'{"version":"1.1.0","sha256":"trusted-code-checksum"}\n'
|
||||||
|
signature = self.sign(manifest)
|
||||||
|
module.verify_release_signature(manifest, signature, self.keyring.read_bytes())
|
||||||
|
for changed_manifest, changed_signature in ((manifest + b" ", signature), (manifest.replace(b"trusted-code-checksum", b"attacker-code-checksum"), signature), (manifest, signature[:-1]), (manifest, self.sign(manifest, 1))):
|
||||||
|
with self.subTest(manifest=changed_manifest, signature=changed_signature):
|
||||||
|
with self.assertRaises(module.IntegrityError):
|
||||||
|
module.verify_release_signature(changed_manifest, changed_signature, self.keyring.read_bytes())
|
||||||
|
self.assertEqual(list(self.update_dir.iterdir()), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+35
-28
@@ -1,29 +1,36 @@
|
|||||||
services:
|
services:
|
||||||
electric-cherry:
|
electric-cherry:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
image: electric-cherry:latest
|
image: electric-cherry:latest
|
||||||
container_name: electric-cherry
|
container_name: electric-cherry
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- '3000:3000'
|
||||||
volumes:
|
volumes:
|
||||||
# Preserve the existing controller database by default. Set EC_DATA_DIR
|
# Preserve the existing controller database by default. Set EC_DATA_DIR
|
||||||
# to a dedicated persistent directory for a new controller installation.
|
# to a dedicated persistent directory for a new controller installation.
|
||||||
- ${EC_DATA_DIR:-/root/zombies/.electric-cherry}:/data:rw
|
- ${EC_DATA_DIR:-/root/zombies/.electric-cherry}:/data:rw
|
||||||
environment:
|
environment:
|
||||||
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
|
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
|
||||||
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
|
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
|
||||||
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
|
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
|
||||||
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
|
EC_DISCORD_APPLICATION_ID: ${EC_DISCORD_APPLICATION_ID:-}
|
||||||
# Large game files are streamed with manifest size and checksum limits.
|
EC_DISCORD_PUBLIC_KEY: ${EC_DISCORD_PUBLIC_KEY:-}
|
||||||
BODY_SIZE_LIMIT: "Infinity"
|
EC_DISCORD_BOT_TOKEN: ${EC_DISCORD_BOT_TOKEN:-}
|
||||||
DB_PATH: /data/cherry.db
|
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
|
||||||
GAME_FILES_ROOT: /data/game-files
|
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
|
||||||
BETA_ARCHIVE_DIR: /data/beta-archive
|
# Enable only behind a trusted proxy that sets the client-address header.
|
||||||
# These are logical legacy paths; native instance files live on agents.
|
ADDRESS_HEADER: ${ADDRESS_HEADER:-}
|
||||||
SERVERS_ROOT: /data/legacy/t5
|
XFF_DEPTH: ${XFF_DEPTH:-1}
|
||||||
T6_SERVERS_ROOT: /data/legacy/t6
|
# Legacy host imports stream whole game files; buffering routes enforce their own caps.
|
||||||
PLUTAINER_IMAGE: ${PLUTAINER_IMAGE:-ghcr.io/ayymoss/plutainer:latest}
|
BODY_SIZE_LIMIT: 'Infinity'
|
||||||
# Optional. Native instances otherwise receive individual random secrets.
|
DB_PATH: /data/cherry.db
|
||||||
RCON_PASSWORD: ${RCON_PASSWORD:-}
|
GAME_FILES_ROOT: /data/game-files
|
||||||
|
BETA_ARCHIVE_DIR: /data/beta-archive
|
||||||
|
# These are logical legacy paths; native instance files live on agents.
|
||||||
|
SERVERS_ROOT: /data/legacy/t5
|
||||||
|
T6_SERVERS_ROOT: /data/legacy/t6
|
||||||
|
PLUTAINER_IMAGE: ${PLUTAINER_IMAGE:-ghcr.io/ayymoss/plutainer:latest}
|
||||||
|
# Optional. Native instances otherwise receive individual random secrets.
|
||||||
|
RCON_PASSWORD: ${RCON_PASSWORD:-}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ and launch your first server.
|
|||||||
- [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery.
|
- [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery.
|
||||||
- [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery.
|
- [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery.
|
||||||
- [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup.
|
- [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup.
|
||||||
|
- [Discord companion](guides/discord.md) — live server boards, private browsing, leaderboards, and availability alerts.
|
||||||
|
|
||||||
### Fleet operations
|
### Fleet operations
|
||||||
|
|
||||||
@@ -25,6 +26,9 @@ and launch your first server.
|
|||||||
- [Beta test plans](guides/beta-test-plans.md) — game reporters, test requirements, coverage, and outcomes.
|
- [Beta test plans](guides/beta-test-plans.md) — game reporters, test requirements, coverage, and outcomes.
|
||||||
- [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.
|
||||||
|
- [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.
|
- [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
+105
-20
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
The controller uses SvelteKit, TypeScript and SQLite (`better-sqlite3`), with the
|
The controller uses SvelteKit, TypeScript and SQLite (`better-sqlite3`), with the
|
||||||
Node adapter and server-sent events. The host agent uses Python 3's standard
|
Node adapter and server-sent events. The host agent uses Python 3's standard
|
||||||
library and runs as a systemd service; hosts do not need Node.js.
|
library and runs as a systemd service; release verification calls `gpgv`.
|
||||||
|
Hosts do not need Node.js.
|
||||||
|
|
||||||
Agents connect outbound to the controller over HTTPS and manage Docker/Plutainer
|
Agents connect outbound to the controller over HTTPS and manage Docker/Plutainer
|
||||||
and local RCON on each host. The controller owns fleet metadata and queues; it
|
and local RCON on each host. The controller owns fleet metadata and queues; it
|
||||||
@@ -10,11 +11,12 @@ does not require a Docker socket or direct access to host filesystems.
|
|||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
Use Node.js 22 (the container's version), npm and Python 3.
|
Use Node.js 22.13+ (the container uses Node.js 22), npm and Python 3. Install
|
||||||
|
GnuPG's `gpg`, `gpg-agent` and `gpgv` to run real release-signing integration tests; local test
|
||||||
|
runs skip those tests when either executable is absent.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
npm run agent:package
|
|
||||||
export EC_SETUP_TOKEN="$(openssl rand -hex 32)"
|
export EC_SETUP_TOKEN="$(openssl rand -hex 32)"
|
||||||
export BETA_ARCHIVE_DIR=./data/beta-archive
|
export BETA_ARCHIVE_DIR=./data/beta-archive
|
||||||
EC_AUTH_ALLOW_HTTP=true npm run dev
|
EC_AUTH_ALLOW_HTTP=true npm run dev
|
||||||
@@ -31,11 +33,15 @@ for access and recovery details.
|
|||||||
Create a disposable fixture without connecting game hosts:
|
Create a disposable fixture without connecting game hosts:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npx svelte-kit sync
|
||||||
fixture_parent=$(mktemp -d "${TMPDIR:-/tmp}/ec-ui.XXXXXX")
|
fixture_parent=$(mktemp -d "${TMPDIR:-/tmp}/ec-ui.XXXXXX")
|
||||||
npm run seed:fixture -- --db "$fixture_parent/preview/cherry.db" --watch
|
npm run seed:fixture -- --db "$fixture_parent/preview/cherry.db" --watch
|
||||||
```
|
```
|
||||||
|
|
||||||
The command prints the generated paths. Leave that terminal open to refresh
|
The sync step generates the SvelteKit TypeScript configuration that the seeder
|
||||||
|
needs in a fresh checkout.
|
||||||
|
|
||||||
|
The seeder prints the generated paths. Leave that terminal open to refresh
|
||||||
synthetic observations every 15 seconds. In a second terminal, load the printed
|
synthetic observations every 15 seconds. In a second terminal, load the printed
|
||||||
environment file and start the controller on localhost:
|
environment file and start the controller on localhost:
|
||||||
|
|
||||||
@@ -54,7 +60,7 @@ snapshots naturally show stale observations when viewed against today's clock.
|
|||||||
The fixture contains 3 hosts, 12 canonical Black Ops I/II servers, and 500 match
|
The fixture contains 3 hosts, 12 canonical Black Ops I/II servers, and 500 match
|
||||||
records: 494 historical and 6 live. It includes solo through four-player squads,
|
records: 494 historical and 6 live. It includes solo through four-player squads,
|
||||||
unknown starting counts, enough players and matches to exercise pagination,
|
unknown starting counts, enough players and matches to exercise pagination,
|
||||||
rosters, seven days of utilization and capacity samples, and retained Activity
|
rosters, seven days of occupancy samples with unknown player limits, and retained Activity
|
||||||
from campaigns, betas, host operations, maintenance, recovery, and autoscaling.
|
from campaigns, betas, host operations, maintenance, recovery, and autoscaling.
|
||||||
The fleet has 6 live matches, 2 empty lobbies, a preparing server, and 3 problems:
|
The fleet has 6 live matches, 2 empty lobbies, a preparing server, and 3 problems:
|
||||||
a stopped server, a server not responding, and a server on the offline host.
|
a stopped server, a server not responding, and a server on the offline host.
|
||||||
@@ -77,16 +83,79 @@ remove the generated fixture directory.
|
|||||||
## Checks and builds
|
## Checks and builds
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npm run lint
|
||||||
npm run check
|
npm run check
|
||||||
npm test
|
npm test
|
||||||
npm run test:agent
|
npm run test:agent
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Builds regenerate the downloadable agent, standalone updater and checksum
|
The [Gitea Actions workflow](../../.gitea/workflows/ci.yml) runs these checks on
|
||||||
manifest. They also bundle report workers under `build/reports` and launch
|
every push and pull request after `npm ci`. It uses a Node.js 22 Debian container
|
||||||
preparation under `build/launch`. Development and test commands compile those
|
with Python 3, `gpg`, `gpg-agent`, `gpgv` and native build tools for `better-sqlite3`. Signing
|
||||||
workers automatically. Restart `npm run dev` after changing worker code.
|
tests create disposable keys in temporary directories; CI never receives release
|
||||||
|
private keys. The runner must support Docker jobs with the `ubuntu-latest` label.
|
||||||
|
`npm run check` runs before the tests
|
||||||
|
to generate the SvelteKit configuration needed in a fresh checkout. Application
|
||||||
|
tests use two workers in CI to bound resource use for database-heavy fixtures.
|
||||||
|
The build step runs the same `npm run build` command used by the Dockerfile,
|
||||||
|
including agent packaging and the report and launch bundles.
|
||||||
|
|
||||||
|
Run `npm run format` to apply Prettier formatting before committing. It uses
|
||||||
|
four-space indentation and a 100-column target for supported source, config and
|
||||||
|
documentation files, including Svelte components. Python, shell and game scripts
|
||||||
|
are outside Prettier's supported languages. Generated agent downloads and the npm
|
||||||
|
lockfile keep their generator's formatting.
|
||||||
|
|
||||||
|
`npm run lint` checks formatting without changing files, then runs the recommended
|
||||||
|
JavaScript, TypeScript and Svelte ESLint rules. Generated build output and local
|
||||||
|
runtime data are excluded. Type checking remains part of `npm run check`.
|
||||||
|
The ESLint config documents exceptions for existing Svelte navigation, list and
|
||||||
|
reactivity patterns. Individual suppressions explain intentional validation and
|
||||||
|
test fixtures. Node.js 22.13 is the minimum supported 22.x version for the lint
|
||||||
|
toolchain.
|
||||||
|
|
||||||
|
The `package.json` overrides keep SvelteKit 2 on patched `devalue` 5.x (5.9.4 or
|
||||||
|
newer) and `cookie` 0.7.x releases until its dependency ranges include those fixes.
|
||||||
|
These dependencies ship in the server bundle even though SvelteKit is a
|
||||||
|
development dependency.
|
||||||
|
Dependency security checks must include development dependencies: use `npm audit`
|
||||||
|
without `--omit=dev`.
|
||||||
|
|
||||||
|
`npm run dev` and `npm run build` regenerate the downloadable agent, installer,
|
||||||
|
standalone updater and checksum manifest. Packaging preserves an existing
|
||||||
|
`static/agent/manifest.json.sig` only when its manifest bytes remain identical;
|
||||||
|
changes to the agent, updater or installer remove the stale signature. Builds
|
||||||
|
never sign, and hosts reject unsigned releases while retaining installed code.
|
||||||
|
Finalize and review release code, run `npm run agent:package`, and explicitly
|
||||||
|
sign it on the offline publisher machine:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 agent/sign_release.py --gnupghome /secure/electric-cherry-signing --fingerprint FULL_SIGNING_KEY_FINGERPRINT
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit or distribute the signed public artifacts before building the controller
|
||||||
|
image from the same source revision. Keep the signing home outside the repository, Docker context, controller
|
||||||
|
and CI. Follow [Publishing signed releases](../guides/native-hosts.md#publishing-signed-releases)
|
||||||
|
for key creation, host provisioning and rotation.
|
||||||
|
|
||||||
|
Builds also bundle report workers
|
||||||
|
under `build/reports` and launch preparation under `build/launch`. Development
|
||||||
|
and test commands compile those workers automatically. Restart `npm run dev`
|
||||||
|
after changing agent or worker code, or run `npm run agent:package` to refresh
|
||||||
|
only the downloadable agent assets while the development server is running.
|
||||||
|
|
||||||
|
To run the production build locally, run these commands from the repository root
|
||||||
|
with the controller environment configured as above:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
NODE_ENV=production node build
|
||||||
|
```
|
||||||
|
|
||||||
|
`NODE_ENV=production` makes the report and launch workers load from `build/reports`
|
||||||
|
and `build/launch`. Without it, they look under `.svelte-kit/`, where the production
|
||||||
|
build does not create worker bundles. The Docker image sets this variable already.
|
||||||
|
|
||||||
Automated tests simulate Docker and game hosts. Real Debian installation and
|
Automated tests simulate Docker and game hosts. Real Debian installation and
|
||||||
T5/T6 server validation use the [host acceptance checklist](../guides/native-hosts.md#development-and-validation).
|
T5/T6 server validation use the [host acceptance checklist](../guides/native-hosts.md#development-and-validation).
|
||||||
@@ -95,17 +164,17 @@ T5/T6 server validation use the [host acceptance checklist](../guides/native-hos
|
|||||||
|
|
||||||
Paths below are relative to the repository root.
|
Paths below are relative to the repository root.
|
||||||
|
|
||||||
| Path | Responsibility |
|
| Path | Responsibility |
|
||||||
| --- | --- |
|
| --------------------------------------- | ----------------------------------------------------------- |
|
||||||
| `src/routes` | Pages and API endpoints |
|
| `src/routes` | Pages and API endpoints |
|
||||||
| `src/lib/ui` | Shared components, stores and [UI patterns](ui-patterns.md) |
|
| `src/lib/ui` | Shared components, stores and [UI patterns](ui-patterns.md) |
|
||||||
| `src/lib/server/hosts` | Enrollment, readiness and command transport |
|
| `src/lib/server/hosts` | Enrollment, readiness and command transport |
|
||||||
| `src/lib/server/backends` | Instance identity and remote file access |
|
| `src/lib/server/backends` | Instance identity and remote file access |
|
||||||
| `src/lib/server/provisioning/native.ts` | Lifecycle and campaign queues connected to agents |
|
| `src/lib/server/provisioning/native.ts` | Lifecycle and campaign queues connected to agents |
|
||||||
| `src/lib/server/game-files` | Immutable uploads and streaming distribution |
|
| `src/lib/server/game-files` | Immutable uploads and streaming distribution |
|
||||||
| `src/lib/server/db` | SQLite schema, migrations and shared artifact storage |
|
| `src/lib/server/db` | SQLite schema, migrations and shared artifact storage |
|
||||||
| `src/lib/server/reports` | Metrics, leaderboard and capacity workers |
|
| `src/lib/server/reports` | Metrics, leaderboard and capacity workers |
|
||||||
| `agent` | Python service, installer, packaging and tests |
|
| `agent` | Python service, installer, packaging and tests |
|
||||||
|
|
||||||
## Performance diagnostics
|
## Performance diagnostics
|
||||||
|
|
||||||
@@ -151,6 +220,22 @@ retained across restarts; interrupted transactions roll back and retry on startu
|
|||||||
The one-time vacuum needs disk space for a temporary database copy. **Do not
|
The one-time vacuum needs disk space for a temporary database copy. **Do not
|
||||||
remove `cherry.db-wal` to recover from an interrupted upgrade.**
|
remove `cherry.db-wal` to recover from an interrupted upgrade.**
|
||||||
|
|
||||||
|
Database upgrades are ordered in `src/lib/server/db/migrations.ts` and tracked by
|
||||||
|
SQLite's `PRAGMA user_version`. Startup applies only pending versions, including
|
||||||
|
host, authentication and game-file schemas, before serving requests or starting
|
||||||
|
workers. Databases from older releases begin at version 0; the compatibility
|
||||||
|
steps honor their existing migration markers so completed backfills are preserved.
|
||||||
|
An application cannot open a database with a newer version than it supports.
|
||||||
|
|
||||||
|
For a schema or data upgrade, append a consecutive version to that list. Do not
|
||||||
|
edit an applied migration or rely on changing `schema.sql` to upgrade databases.
|
||||||
|
Each step and its version commit in one transaction. Table rebuilds can select
|
||||||
|
`foreign-keys-off`, which disables enforcement before the transaction and checks
|
||||||
|
references before committing. The resumable incremental-vacuum step uses
|
||||||
|
`autocommit` because SQLite forbids `VACUUM` inside a transaction; its version is
|
||||||
|
recorded only after the rebuild and host preparation boundary recovery succeed.
|
||||||
|
Connection tuning and query optimization still run on every open.
|
||||||
|
|
||||||
The controller uses a 128 MiB page cache; each reader worker uses 32 MiB.
|
The controller uses a 128 MiB page cache; each reader worker uses 32 MiB.
|
||||||
Main-database migrations use 16 MiB with memory mapping disabled. Temporary
|
Main-database migrations use 16 MiB with memory mapping disabled. Temporary
|
||||||
storage stays disk-backed. Containers use `/data/.sqlite-tmp` for scratch files;
|
storage stays disk-backed. Containers use `/data/.sqlite-tmp` for scratch files;
|
||||||
|
|||||||
@@ -8,7 +8,18 @@ Primitives live in its `primitives` directory.
|
|||||||
- Use `SectionHeader` for page titles and primary actions. The page owns spacing
|
- Use `SectionHeader` for page titles and primary actions. The page owns spacing
|
||||||
between sections.
|
between sections.
|
||||||
- Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors.
|
- Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors.
|
||||||
Give controls distinct `ariaLabel` values when several share one field.
|
Give controls distinct `ariaLabel` values when several share one field. Use
|
||||||
|
`hiddenLabel` on `Field` for compact filters with a visually hidden label.
|
||||||
|
`Input` and `Select` forward native validation, accessibility and event props;
|
||||||
|
use `bind:element` when a workflow needs to focus the native control. `Select`
|
||||||
|
accepts either `options` or child `option` / `optgroup` elements and preserves
|
||||||
|
the bound value's type. Keep layout on the field or its parent; controls own
|
||||||
|
their appearance.
|
||||||
|
- `npm run check:styles` rejects raw selects and text-like inputs outside their
|
||||||
|
primitives, including search, password, numeric and date/time fields. Native
|
||||||
|
checkboxes, radios, file pickers, hidden inputs, range/color pickers and input
|
||||||
|
buttons are allowed. The check parses Svelte markup, so comments and examples
|
||||||
|
in scripts are ignored.
|
||||||
- Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists.
|
- Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists.
|
||||||
- Use `StatusBadge` for workflow states and `ServerIdentity` for server names.
|
- Use `StatusBadge` for workflow states and `ServerIdentity` for server names.
|
||||||
Keep raw IDs available for copying and troubleshooting.
|
Keep raw IDs available for copying and troubleshooting.
|
||||||
@@ -39,6 +50,29 @@ and action targets. Avoid nested buttons or placing a button inside a link.
|
|||||||
Allow long server identities to wrap and tab/filter rows to scroll on narrow
|
Allow long server identities to wrap and tab/filter rows to scroll on narrow
|
||||||
screens.
|
screens.
|
||||||
|
|
||||||
|
### Responsive widths
|
||||||
|
|
||||||
|
Use the shared breakpoint scale in `src/lib/ui/styles/tokens.css`:
|
||||||
|
|
||||||
|
| Token | Width | Use |
|
||||||
|
| ----------------- | ------- | ------------------------------------------ |
|
||||||
|
| `--breakpoint-xs` | 360 px | Extra-small screen adjustments |
|
||||||
|
| `--breakpoint-sm` | 640 px | Compact controls, cards and page summaries |
|
||||||
|
| `--breakpoint-md` | 960 px | Sidebar switches to the navigation drawer |
|
||||||
|
| `--breakpoint-lg` | 1100 px | Dense, multi-column layouts reflow |
|
||||||
|
|
||||||
|
CSS custom properties cannot be used inside media conditions. Write the literal
|
||||||
|
value, for example `@media (max-width: 640px)`, and choose the shared width that
|
||||||
|
fits the layout. Related page and component changes should use the same width.
|
||||||
|
For JavaScript behavior, use the identical query and invert `matches` when you
|
||||||
|
need its complement; avoid adding a pixel to the boundary.
|
||||||
|
|
||||||
|
`npm run check:styles` reads this scale and rejects other widths in Svelte and
|
||||||
|
CSS `@media` queries. The file-and-width allowlist in `scripts/check-ui-styles.mjs`
|
||||||
|
records existing layouts awaiting migration. Remove entries as those layouts
|
||||||
|
move to the shared scale; do not add exceptions for new components. The app
|
||||||
|
shell, Servers page and server cards use only shared widths.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Use the shared navigation metadata for Fleet, Releases, Activity, Players, and
|
Use the shared navigation metadata for Fleet, Releases, Activity, Players, and
|
||||||
@@ -74,16 +108,16 @@ logs. Static values use text or state colors; the accent marks links and control
|
|||||||
|
|
||||||
### Product language
|
### Product language
|
||||||
|
|
||||||
| Concept | Visible label |
|
| Concept | Visible label |
|
||||||
| --- | --- |
|
| -------------------------- | ------------------------------------------------------- |
|
||||||
| A managed game instance | Server; use the friendly map and lobby name |
|
| A managed game instance | Server; use the friendly map and lobby name |
|
||||||
| Creating a server | Create server |
|
| Creating a server | Create server |
|
||||||
| Removing a server | Remove server |
|
| Removing a server | Remove server |
|
||||||
| Favourite server | Pin |
|
| Favourite server | Pin |
|
||||||
| Game does not answer | Not responding |
|
| Game does not answer | Not responding |
|
||||||
| Games | Black Ops I / Black Ops II; BO1 / BO2 in compact labels |
|
| Games | Black Ops I / Black Ops II; BO1 / BO2 in compact labels |
|
||||||
| Releasing file changes | Campaigns |
|
| Releasing file changes | Campaigns |
|
||||||
| Player completion evidence | Present in final report / Not present in final report |
|
| Player completion evidence | Present in final report / Not present in final report |
|
||||||
|
|
||||||
Keep internal values such as `t5`, `t6`, `destroy`, instance IDs, and filenames in
|
Keep internal values such as `t5`, `t6`, `destroy`, instance IDs, and filenames in
|
||||||
API fields and technical details. User-facing actions use the labels above.
|
API fields and technical details. User-facing actions use the labels above.
|
||||||
@@ -98,6 +132,14 @@ timestamps without an offset are UTC; never parse them as browser-local time.
|
|||||||
Receipt freshness uses the browser clock and shows Paused or a retry reason
|
Receipt freshness uses the browser clock and shows Paused or a retry reason
|
||||||
when updates are unavailable. These clocks are not live regions.
|
when updates are unavailable. These clocks are not live regions.
|
||||||
|
|
||||||
|
Provisioning detail pages show the requester, absolute start time and duration
|
||||||
|
in the summary. Keep the finish time, raw IDs and copy controls in collapsed
|
||||||
|
Technical details. Show freshness only while the operation is active. Poll active
|
||||||
|
operations and finished operations whose recovery capacity is still held.
|
||||||
|
Use `OperationTime` for steps, checks and activity: offsets from
|
||||||
|
the operation start (`+m:ss` or `+h:mm:ss`), with negative offsets for earlier
|
||||||
|
evidence. Missing timestamps stay **Not recorded**.
|
||||||
|
|
||||||
Use `plural` or `countLabel` from `src/lib/ui/plural.ts` for count-dependent
|
Use `plural` or `countLabel` from `src/lib/ui/plural.ts` for count-dependent
|
||||||
language, including verbs: `plural(count, 'needs', 'need')`. The helper uses
|
language, including verbs: `plural(count, 'needs', 'need')`. The helper uses
|
||||||
`Intl.PluralRules`; supply irregular plural forms explicitly. Keep separators
|
`Intl.PluralRules`; supply irregular plural forms explicitly. Keep separators
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Persistent AFK time
|
||||||
|
|
||||||
|
AFK time is a GUID account shared by T5 and T6, separate from the points bank. The scripts own `.afk`, `.back`, explicit purchase confirmation, actual gameplay contribution detection, protection and controls, the center-screen countdown, and all-player-AFK / match-ending behavior. Electric Cherry owns policy, durable receipts, atomic points purchases, reservations, and reporting. The immutable native instance's existing bank credential authenticates both APIs; the shared telemetry token and browser sessions cannot authorize game requests.
|
||||||
|
|
||||||
|
## Policy and accounting
|
||||||
|
|
||||||
|
All balances are integer seconds. Defaults:
|
||||||
|
|
||||||
|
| Setting | Environment variable | Default |
|
||||||
|
| ---------------------------------------------- | ---------------------------------------------------------------- | ----------- |
|
||||||
|
| Free rolling allowance | `EC_AFK_FREE_SECONDS` | 1800 |
|
||||||
|
| Combined earned/purchased capacity | `EC_AFK_CAPACITY_SECONDS` | 21600 |
|
||||||
|
| Active play interval / reward | `EC_AFK_ACTIVE_INTERVAL_SECONDS`, `EC_AFK_ACTIVE_REWARD_SECONDS` | 300 / 60 |
|
||||||
|
| Participating round reward | `EC_AFK_ROUND_REWARD_SECONDS` | 15 |
|
||||||
|
| Kill interval / reward | `EC_AFK_KILL_INTERVAL`, `EC_AFK_KILL_REWARD_SECONDS` | 100 / 30 |
|
||||||
|
| Headshot interval / reward | `EC_AFK_HEADSHOT_INTERVAL`, `EC_AFK_HEADSHOT_REWARD_SECONDS` | 25 / 15 |
|
||||||
|
| Successful teammate revive | `EC_AFK_REVIVE_REWARD_SECONDS` | 30 |
|
||||||
|
| Performance reward cap per rolling active hour | `EC_AFK_BONUS_HOURLY_SECONDS` | 480 |
|
||||||
|
| Points / seconds per purchase pack | `EC_AFK_PURCHASE_POINTS`, `EC_AFK_PURCHASE_SECONDS` | 10000 / 600 |
|
||||||
|
|
||||||
|
Free time is spent first. Its available amount is the allowance minus outstanding free reservations and confirmed free consumption charged within the preceding 24 hours. Consumption is charged at the controller's receipt of a durable stop, so free time replenishes 24 hours after that receipt. This deliberately conservative rolling window cannot grant more than the allowance in a rolling day, even with delayed delivery or inaccurate server clocks. Each charge expires independently; unused days never accumulate. Reads, reconnects, map changes, and changing game/server do not reset anything. An unclosed reservation never expires, even after 24 hours. Profile `freeRenewsAt` is the next known charge expiration, not a promise to release unresolved authorization.
|
||||||
|
|
||||||
|
Active seconds and partial kill/headshot thresholds persist by GUID across matches and servers. Cumulative snapshots contribute only their increase over the last accepted snapshot for that originating immutable instance, match key, and GUID. Active play rewards use every completed interval. Participating rounds, kill thresholds, and headshot thresholds share one rolling 3600-_active-second_ budget: each performance grant occupies budget until another 3600 verified active seconds elapses. Kill and headshot rewards can stack. Spectating, AFK, idle connections, wall-clock waiting, and raw score increases do not advance this clock. Each legitimate successful teammate revive earns the full configured revive reward, including repeated revives of the same teammate; there is no revive count limit or performance cap. Only overall saved capacity applies.
|
||||||
|
|
||||||
|
Capacity includes saved seconds currently reserved by sessions. Overflow rewards and performance bonuses above the rolling budget are discarded, while partial threshold progress persists; capacity does not bank deferred whole rewards. The scripts report only actual gameplay events, eligible active seconds, and rounds with participation. The backend requires `activity:"playing"`, rejects current reward submissions while a session is reserved or started (an unresolved, stopped crash may resume gameplay), enforces monotonic counters, and rejects cumulative active time above the match's wall duration plus a 60-second telemetry tolerance. This scoped trusted-server API cannot independently observe a game engine's successful revive or verify its eligibility. The native/GSC collection paths are part of the trust boundary.
|
||||||
|
|
||||||
|
## Machine protocol
|
||||||
|
|
||||||
|
Use `POST /api/afk/{balance,event,purchase,reserve,start,settle}` with `Content-Type: application/json` and `Authorization: Bearer <instance bank credential>`. The body limit is 4096 bytes; responses have `Cache-Control: no-store`. Purchases, reservations, start acknowledgments, and balance reads require the exact current active telemetry match and a present GUID on the authenticated server. Gameplay backlog can additionally reference a retained historical match containing that GUID when the immutable instance lifetime proves origin. Historical active time is bounded to the recorded match end or last received telemetry, and its grants still respect held saved capacity. Gameplay whose historical membership evidence has been removed is rejected without granting time. Settlement remains valid for the original instance after disconnect, match completion, or match/server history deletion.
|
||||||
|
|
||||||
|
All requests contain `server`, `guid`, and `match` (the existing telemetry `reporter_match_key`, not a separate AFK match identifier). Mutations additionally require `request_id`. GUID, match, request and session IDs are 1–64 characters from `[A-Za-z0-9_.:-]`. Optional `event_time` is a parseable timestamp no more than five minutes in the future; it records the reported event time, while `created_at` records controller receipt time. Keep the complete body immutable across retries, including `event_time`.
|
||||||
|
|
||||||
|
| Action | Additional request fields |
|
||||||
|
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `balance` | None |
|
||||||
|
| `event` | `seq` positive integer; `active_seconds`, `kills`, `headshots`, `rounds`, `revives` nonnegative cumulative integer counters (maximum 2147483647); `activity:"playing"` |
|
||||||
|
| `purchase` | `packs` integer 1–1000; `confirmed:true`; `expected_purchase_points` and `expected_purchase_seconds`, copied from the displayed quote, per pack |
|
||||||
|
| `reserve` | `session_id`; `seconds` integer 1–604800, the requested maximum authorization |
|
||||||
|
| `start` | `session_id` |
|
||||||
|
| `settle` | `session_id`; `consumed_seconds` integer 0–604800 or `null`; `end_reason`; optional `confirmed_seconds` checkpoint |
|
||||||
|
|
||||||
|
`end_reason` is one of `manual`, `disconnect`, `death`, `match_end`, `expired`, `never_started`, or `crash`. `expired` requires exact consumption of the authorization. `never_started` requires zero. An unknown crash duration uses `consumed_seconds:null,end_reason:"crash",confirmed_seconds:<last durable elapsed lower bound>`. Confirmed consumption must fit the original authorization and cannot fall below an already-recorded checkpoint.
|
||||||
|
|
||||||
|
Every application result has:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"state": "reserved",
|
||||||
|
"code": "ok",
|
||||||
|
"request_id": "request-123",
|
||||||
|
"free_seconds": 0,
|
||||||
|
"saved_seconds": 600,
|
||||||
|
"available_seconds": 600,
|
||||||
|
"reserved_seconds": 1800,
|
||||||
|
"capacity_seconds": 21600,
|
||||||
|
"authorized_seconds": 1800,
|
||||||
|
"bank_balance": 20000,
|
||||||
|
"purchase_points": 10000,
|
||||||
|
"purchase_seconds": 600,
|
||||||
|
"max_purchase_packs": 2,
|
||||||
|
"session_id": "session-123",
|
||||||
|
"consumed_seconds": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`available_seconds` excludes held authorizations; `reserved_seconds` is held time for this GUID across all servers. `authorized_seconds` is meaningful for session results and is the original authorization, never an extension. `session_id` and `consumed_seconds` appear on session results; `awarded_seconds` appears on reward and purchase results. `max_purchase_packs` is the smaller of affordable packs and capacity, or zero when a bank transfer is prepared. Recheck the quote through `balance` before confirmation; changed pricing rejects with `price_changed` without spending points. There is no automatic purchase.
|
||||||
|
|
||||||
|
States are `balance`, `rewarded`, `purchased`, `reserved`, `started`, `settled`, `unresolved`, `corrected`, and `rejected`. HTTP 200 can contain an application rejection (`ok:false`), such as `empty`, `session_pending`, `bank_pending`, `insufficient_balance`, `reserve_capacity`, `price_changed`, `invalid_active_time`, `invalid_consumption`, `progress_regression`, or `progress_conflict`. Authentication failures are 401/403; invalid syntax/amounts are 400. `not_in_match` is a nonreceipted HTTP 409 for unknown/current membership publication lag on new work, allowing the same immutable request to retry after telemetry catches up. Known completed/obsolete matches and recorded players who have departed (`present=0`) are terminal HTTP 200 rejections for new work. Only never-observed membership remains eligible for the publication-lag retry. Gameplay missing historical membership/origin proof is also a terminal HTTP 200 rejection so an unrecoverable old snapshot cannot block the durable queue; subsequent cumulative snapshots can still include its counters if membership is established. Changed request identity or ownership is HTTP 409 `request_conflict`/`session_conflict`. Transaction failures are 503 and can retry unchanged. Early authentication/body failures may contain only `ok` and `code`.
|
||||||
|
|
||||||
|
Purchase debits the existing bank account and grants saved time in the same immediate SQLite transaction, with linked point/time ledger entries. A prepared bank transfer blocks purchase until its own existing commit/cancel workflow completes. No second points bank is created.
|
||||||
|
|
||||||
|
## Ordering, authorization, and outages
|
||||||
|
|
||||||
|
1. Native code durably writes the immutable request before sending it. Use one stable request ID per operation, with separate IDs for reserve, start, and settle. Stable session IDs bind all session operations.
|
||||||
|
2. `reserve` atomically takes free time first and saved time second, authorizing `min(requested,available)` seconds. Only one outstanding session can hold a GUID's funds. An identical retry returns the saved result. A new operation referring to an already-known session cannot reset or extend it.
|
||||||
|
3. Durable native state records the reservation and the start intent. `start` must return the `started` acknowledgment before protection is enabled. Scripts additionally recheck current player/map eligibility and activate only once. A delayed acknowledgment cannot reactivate an ended local session. The countdown uses the locally recorded authorization and elapsed clock; it does not require heartbeat renewal.
|
||||||
|
4. Manual return, disconnect, death, match end, or zero countdown removes protection and restores controls before producing a durable stop. Native code records the confirmed elapsed seconds and immutable `settle` request, then retries delivery until acknowledged. Round elapsed time up to whole seconds and cap it to authorization. At zero the scripts immediately restore normal vulnerability and controls; there is no grace, automatic buy, or spectator transition. Manual `.back` retains its separate gameplay behavior.
|
||||||
|
5. `settle` records confirmed consumption and releases unused reserved seconds exactly once. It accepts legitimate delayed reports after membership disappears. `never_started` settlement can arrive before its reserve and installs a tombstone; delayed reserve/start cannot reopen that session. Completed/corrected sessions remain terminal. A retry of an earlier operation returns that operation's original receipt, which may predate settlement; native terminal journal state must win over such an old response.
|
||||||
|
6. If the controller is unavailable, an already-started session keeps its locally authorized countdown to zero. If reserve/start was never acknowledged, do not enable protection. A crashed process must first be proven stopped before recovery. A checkpoint is only a lower bound on elapsed time; an absent heartbeat or old checkpoint is never evidence for a refund. Recover unknown elapsed time as `unresolved`, retaining authorization until reliable stop evidence or an explicit administrator correction resolves it.
|
||||||
|
|
||||||
|
Receipts bind the complete body and action to server, immutable instance, and request ID. Reusing an ID with any changed parameter is a conflict. Accepted same/lower-sequence gameplay counters award no additional time; same sequence with changed counters conflicts, a later sequence with a decreased total is rejected, and a lower sequence cannot introduce a higher counter. Native/GSC code must preserve cumulative per-GUID totals across reconnects within the same match. A distinct revive increments `revives` even for the same teammate; retrying a snapshot never increments it.
|
||||||
|
|
||||||
|
For host teardown, `POST /api/agent/afk/settle` uses the assigned host credential and `{operationId,action,transfer}`. Only an unfinished `instance.remove`, `instance.rebuild`, or `instance.cancel-create` for that host/immutable instance is accepted. `action:"settle"` reconciles session evidence; `event` and `purchase` are receipt-only recovery and return `not_applied` if the original request never committed. Teardown cannot create a new purchase or reward. Instance retirement revokes normal game API credentials, including reads; the assigned host recovery path permits settlement after retirement without granting a replacement instance ownership of old sessions.
|
||||||
|
|
||||||
|
## Crash correction and reporting
|
||||||
|
|
||||||
|
An administrator can reconcile an explicitly stopped `unresolved` session with `POST /api/afk/corrections` using their browser session and same-origin protections:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "session-123",
|
||||||
|
"request_id": "admin-case-456",
|
||||||
|
"consumed_seconds": 120,
|
||||||
|
"reason": "Confirmed process stopped; reviewed native checkpoint and host logs.",
|
||||||
|
"stopped": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The selected charge must be at least the confirmed checkpoint and no more than authorization. Correction cannot release a live reserved/started session, and does not convert an uncertain duration into an exact one: `state` becomes `corrected`, `consumedSeconds` remains null, `chargedSeconds` records the accounting decision, and `confirmedSeconds` retains the evidence lower bound. The audit ledger records the actor, reason, charge, and release. The correction ID is idempotent and rejects changed parameters. A true confirmed stop report can instead settle an unresolved session with its verified exact duration.
|
||||||
|
|
||||||
|
Open **Players → AFK** (`/afk`) to browse all AFK accounts, sessions, and time-ledger entries. The accounts view supports player-name and exact-GUID searches and links each account to its player profile. Session history includes unresolved reservations; session and ledger history can be filtered by game, server, and date. Filters and pagination remain in the URL when opening a player or retained match, so the back link restores the same overview. Refresh reads current balances without issuing grants, releasing reservations, or otherwise changing accounts. This reporting page has no purchase or correction controls.
|
||||||
|
|
||||||
|
The AFK overview and player profiles use the same signed-in viewer access as Bank. Player profiles also retain balances and recent time-ledger entries alongside bank information. Reports distinguish cumulative verified active seconds, confirmed AFK duration, unresolved sessions, expiration count, manual returns, purchases, and corrected charges. Session starts, end reasons, operation/session IDs, server, match when retained, reported event time, receipt time, resulting balances, free grants, rewards, point-linked purchases, reservations, consumption, releases, and corrections survive match retention and server deletion.
|
||||||
|
|
||||||
|
## Coordinated rollout and validation
|
||||||
|
|
||||||
|
Back up the controller database and persistent game journals. Deploy EC first;
|
||||||
|
migration 30 creates the AFK accounts, operations, progress, sessions, bonus budget
|
||||||
|
and ledger tables without match/server cascading deletion. Install host agent
|
||||||
|
**1.12.0+**, including its `afk.settlement` capability, before activating the game
|
||||||
|
scripts. Package and offline-sign the agent release using the normal host update
|
||||||
|
workflow; a local build does not sign or deploy it.
|
||||||
|
|
||||||
|
Install TWS AFK scripts **2.0.0** with matching native plugins **1.4.0+** for both
|
||||||
|
engines, common **1.4.0+**, telemetry **1.0.0+**, and Plutonium **r5334+**. Fully
|
||||||
|
restart each game-server process for the plugin update. Keep the existing private
|
||||||
|
bank credential configuration and persistent `tws-afk/t5/` / `tws-afk/t6/` storage.
|
||||||
|
See the [scripts AFK guide](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/src/branch/main/docs/afk.md)
|
||||||
|
for commands, active-play criteria, protection ownership, game dvars and the
|
||||||
|
in-game validation matrix.
|
||||||
|
|
||||||
|
Run `npx vitest run tests/unit/afk.test.ts tests/unit/bank.test.ts tests/unit/ui-afk-profile.test.ts`,
|
||||||
|
the normal EC checks/build, and `python3 -m unittest discover -s agent/tests -v`.
|
||||||
|
The scripts repository supplies `node tools/check_afk.mjs` and
|
||||||
|
`python3 tools/test_plugin_afk.py` with the pinned native JSON dependency headers.
|
||||||
|
Portable native tests use sanitizers and engine adapters; script tests use engine
|
||||||
|
shims. Neither proves Windows DLL linking or live game behavior.
|
||||||
|
|
||||||
|
Before production rollout, exercise both T5 and T6 with a short test allowance:
|
||||||
|
manual return, immediate zero-time expiry, downed/dead/spectator states, `.hud`
|
||||||
|
hidden, repeated revives, partial reward thresholds across reconnects, explicit
|
||||||
|
purchase confirmation and capacity rejection. Compare the center countdown to
|
||||||
|
the authorized seconds, ensure controls/weapons and normal vulnerability return
|
||||||
|
at zero, and confirm active-play metrics exclude AFK time (manual `.back` restores reward eligibility immediately, including during its separate protection grace). Test last-active
|
||||||
|
death/disconnect and stock afterlife/Grief recovery on deployed maps.
|
||||||
|
|
||||||
|
Drop reserve/start/purchase/settle responses, reorder deliveries, disconnect EC
|
||||||
|
after authorization and restart game/controller/host processes. Confirm the local
|
||||||
|
deadline survives the outage, durable stops release unused time once, and ambiguous
|
||||||
|
crashes remain unconfirmed until an audited correction. Rebuild/remove an instance
|
||||||
|
with both games' journals and a retired game credential; host recovery must retain
|
||||||
|
files until acknowledged and must never create a new purchase or gameplay reward.
|
||||||
|
Live T5/T6 gameplay and deployment validation require the actual game servers.
|
||||||
+110
-22
@@ -31,22 +31,46 @@ guessable token. An unset setup token holds setup closed.
|
|||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
| Capability | Viewer | Operator | Administrator |
|
The Moderator role has Viewer-level fleet access plus private player moderation.
|
||||||
| --- | --- | --- | --- |
|
It permits live player actions, case review and server/game restrictions lasting
|
||||||
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes |
|
up to seven days, without granting fleet operations or access to raw configs and
|
||||||
| Raw configs, file diffs, logs and telemetry diagnostics | No | Yes | Yes |
|
credentials. Administrators also control permanent/fleet restrictions, integration
|
||||||
| Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes |
|
configuration and revoking another moderator's restriction. Super Viewers can
|
||||||
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage |
|
read moderation; Operators and Viewers cannot. See [Player moderation](moderation.md).
|
||||||
| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage |
|
|
||||||
| Server keys and global settings/maintenance policies | No | No | Manage |
|
| Capability | Viewer | Super Viewer | Operator | Administrator |
|
||||||
| Create users, change roles, disable accounts and reset passwords | No | No | Yes |
|
| --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- |
|
||||||
| Own password and sign out | Yes | Yes | Yes |
|
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes |
|
||||||
|
| Raw configs, file diffs, logs, saved RCON and telemetry diagnostics | No | Read | Read | Read |
|
||||||
|
| Deploy/start/stop/restart/rebuild/remove servers and run diagnostic probes | No | No | Yes | Yes |
|
||||||
|
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Read | Manage | Manage |
|
||||||
|
| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Read status | Manage |
|
||||||
|
| Server keys and global settings/maintenance policies | No | Read | No | Manage |
|
||||||
|
| Users and account audit activity | No | Read | No | Manage |
|
||||||
|
| Create users, change roles, disable accounts and reset passwords | No | No | No | Yes |
|
||||||
|
| Own password and sign out | Yes | Yes | Yes | Yes |
|
||||||
|
|
||||||
|
Super Viewer is a read-only administrator. It includes the Admin navigation and
|
||||||
|
access to sensitive configuration and diagnostic content, but cannot change fleet
|
||||||
|
state, settings, keys, or other accounts. Cleanup previews remain available because
|
||||||
|
they do not save settings or run cleanup. Assign it only to people trusted to read
|
||||||
|
administrative information. Existing accounts and sessions are preserved on upgrade.
|
||||||
|
|
||||||
Operators can launch using available game-pool keys already saved by an administrator. They
|
Operators can launch using available game-pool keys already saved by an administrator. They
|
||||||
cannot save keys indirectly through a launch request. An operator's content
|
cannot save keys indirectly through a launch request. An operator's content
|
||||||
permissions include executable scripts and plugins; assign that role only to
|
permissions include executable scripts and plugins; assign that role only to
|
||||||
people trusted to operate the game fleet.
|
people trusted to operate the game fleet.
|
||||||
|
|
||||||
|
Drift previews (`/api/drift`) and GitHub release lookups
|
||||||
|
(`/api/plugins/github/release`) require operator or administrator access, including
|
||||||
|
`GET` and `HEAD` requests. These endpoints initiate fleet file scans or use GitHub
|
||||||
|
API quota; Viewers and Super Viewers cannot trigger them.
|
||||||
|
|
||||||
|
Saved RCON failure replies are available to Super Viewers, operators and administrators in the
|
||||||
|
server's Diagnostics tab. Player addresses are redacted before diagnostics are
|
||||||
|
saved; successful checks clear the saved reply. Fleet snapshots and live status
|
||||||
|
streams contain no reply snippets. Upgrades discard older saved RCON snippets.
|
||||||
|
|
||||||
Permissions are checked on the server for API calls, normal pages and Svelte
|
Permissions are checked on the server for API calls, normal pages and Svelte
|
||||||
page-data requests. Hiding UI controls is a convenience, not the access boundary.
|
page-data requests. Hiding UI controls is a convenience, not the access boundary.
|
||||||
The controller prevents disabling or demoting the last enabled administrator.
|
The controller prevents disabling or demoting the last enabled administrator.
|
||||||
@@ -59,17 +83,61 @@ passwords created or reset by administrators must be changed before any fleet
|
|||||||
access. Password changes require the current password.
|
access. Password changes require the current password.
|
||||||
|
|
||||||
Sessions use random tokens in HTTP-only cookies, with only token hashes stored in
|
Sessions use random tokens in HTTP-only cookies, with only token hashes stored in
|
||||||
SQLite. HTTPS uses a Secure `__Host-` cookie. Sessions expire after 12 hours, or
|
SQLite. HTTPS uses a Secure `__Host-` cookie. By default, sessions expire after
|
||||||
30 minutes without authenticated requests; background dashboard polling counts
|
12 hours, or 30 minutes without authenticated requests; background dashboard
|
||||||
as activity. Login replaces the existing browser session. Logout revokes it.
|
polling counts as activity.
|
||||||
|
|
||||||
|
Select **Keep me signed in on this device** at sign-in to stay signed in for
|
||||||
|
30 days, including between visits from a phone's home-screen app. This option is
|
||||||
|
unchecked by default and applies only to that browser or home-screen app. Remembered
|
||||||
|
sessions have no inactivity timeout, but still expire 30 days after sign-in;
|
||||||
|
activity does not extend that deadline. Use this option only on a device you trust.
|
||||||
|
Existing sessions keep their original limits after upgrading.
|
||||||
|
|
||||||
|
Password changes always require the current password. Remembered administrator
|
||||||
|
sessions also require the administrator's current password when creating accounts
|
||||||
|
or editing users, including access changes and password resets. A successful
|
||||||
|
password change replaces the current session, preserving its remember-device
|
||||||
|
choice with a fresh lifetime, and revokes all other sessions.
|
||||||
|
|
||||||
|
For API clients, `POST /api/auth/login` accepts an optional boolean `rememberMe`
|
||||||
|
(omitted or `false` keeps the default limits). When using a remembered session,
|
||||||
|
`POST /api/users` and `PATCH /api/users/:id` require the administrator's password
|
||||||
|
in `currentPassword`; the `password` field remains the target user's temporary
|
||||||
|
password. The password-change endpoint preserves the current session's choice.
|
||||||
|
|
||||||
|
Login replaces the existing browser session. Logout revokes it.
|
||||||
Changing a password, changing a role, disabling an account, or revoking that
|
Changing a password, changing a role, disabling an account, or revoking that
|
||||||
user's sessions invalidates their existing sessions. Live event streams check
|
user's sessions invalidates their existing sessions. Live event streams check
|
||||||
revocation every 15 seconds and then close.
|
revocation every 15 seconds and then close.
|
||||||
|
|
||||||
Login, initial setup and password verification are rate-limited with persistent
|
Login and password changes for enabled Administrators and Super Viewers are exempt
|
||||||
15-minute windows. The database stores audit entries for login results, account
|
from per-account and per-address rate limits, including existing exhausted windows.
|
||||||
changes and successful management requests without recording passwords, tokens
|
Their attempts do not consume either limit. Other accounts use persistent
|
||||||
or request bodies. Administrators can review recent entries in Users.
|
15-minute login windows of 10 attempts per username-and-address pair and 30 per
|
||||||
|
address across accounts. Attempts from one address do not exhaust another
|
||||||
|
address's allowance for the same username. Initial setup and password changes
|
||||||
|
retain their limits of 10 per account and 30 per address. Exemptions use the
|
||||||
|
account's current database role; password verification is still required.
|
||||||
|
|
||||||
|
A successful password login also sets a separate, HTTP-only login-device cookie
|
||||||
|
for 30 days. This cookie lets that browser skip its username-and-address limit,
|
||||||
|
while the password and shared address limit still apply. It does not sign you in,
|
||||||
|
and is independent of **Keep me signed in**. It survives ordinary sign-out;
|
||||||
|
password changes/resets, role changes, disabling an account, and revoking all of
|
||||||
|
its sessions invalidate its login-device cookies. Only token hashes are stored in
|
||||||
|
SQLite. Reusing a valid cookie does not extend its original expiry.
|
||||||
|
|
||||||
|
The database stores audit entries for login
|
||||||
|
results, account changes and successful management requests without recording
|
||||||
|
passwords, tokens or request bodies. Administrators and Super Viewers can review
|
||||||
|
recent entries in Users.
|
||||||
|
|
||||||
|
For every role, the password service runs at most two scrypt operations at once
|
||||||
|
and queues up to 16 more for up to two seconds. If that queue fills or the wait
|
||||||
|
expires, the request returns HTTP 429 with `Retry-After: 2`. The persistent rate
|
||||||
|
limits return `Retry-After: 900` when a non-exempt account or address reaches its
|
||||||
|
attempt limit.
|
||||||
|
|
||||||
An administrator can reset another user's password from Users. If the only
|
An administrator can reset another user's password from Users. If the only
|
||||||
administrator forgets their password, an operator with local controller/container
|
administrator forgets their password, an operator with local controller/container
|
||||||
@@ -83,8 +151,10 @@ DB_PATH=./data/cherry.db node scripts/reset-admin.mjs admin
|
|||||||
|
|
||||||
Replace `admin` with the existing administrator's username. The command prompts
|
Replace `admin` with the existing administrator's username. The command prompts
|
||||||
without echoing the password, resets that enabled administrator only, revokes
|
without echoing the password, resets that enabled administrator only, revokes
|
||||||
their sessions and records the recovery. It does not expose a remote recovery
|
their sessions and trusted login devices, clears their account-specific login and
|
||||||
endpoint or accept passwords as command arguments.
|
password-change rate limits, and records the recovery. Other users' limits and
|
||||||
|
shared address limits remain intact. It does not expose a remote recovery endpoint
|
||||||
|
or accept passwords as command arguments.
|
||||||
|
|
||||||
## Agent and telemetry connections
|
## Agent and telemetry connections
|
||||||
|
|
||||||
@@ -126,9 +196,27 @@ JSON requests and multipart uploads. Do not configure a proxy to replace arbitra
|
|||||||
incoming origins with your trusted origin. Machine endpoints use their own token
|
incoming origins with your trusted origin. Machine endpoints use their own token
|
||||||
checks and do not require a browser Origin.
|
checks and do not require a browser Origin.
|
||||||
|
|
||||||
Use the adapter's real client-address configuration only with a trusted proxy
|
Login throttling uses the address returned by SvelteKit's adapter. Without
|
||||||
that strips spoofed forwarding headers; login throttling otherwise uses the direct
|
`ADDRESS_HEADER`, this is the direct peer address, so users behind a reverse proxy
|
||||||
peer address. Forwarded host or user headers do not authenticate users.
|
share the proxy's address limit. Compose passes `ADDRESS_HEADER` and `XFF_DEPTH`
|
||||||
|
from `.env` to the controller; direct Node deployments must set them in the
|
||||||
|
server's environment. For one trusted proxy that sets `X-Forwarded-For`, use:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
ADDRESS_HEADER=x-forwarded-for
|
||||||
|
XFF_DEPTH=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Recreate the Compose controller after changing these values with
|
||||||
|
`docker compose up -d`. For a proxy chain, set `XFF_DEPTH` to the number of trusted
|
||||||
|
proxies: the adapter selects that many positions from the right of the header.
|
||||||
|
For example, `client, proxy1` with two trusted proxies requires `XFF_DEPTH=2`.
|
||||||
|
The public-facing proxy must replace untrusted forwarding headers, and every
|
||||||
|
trusted hop must supply the expected address chain. Restrict direct access to
|
||||||
|
port 3000 so requests cannot bypass those proxies. Do not enable these settings
|
||||||
|
for an untrusted proxy or copy a depth that does not match your deployment.
|
||||||
|
See the [adapter's client-address settings](https://svelte.dev/docs/kit/adapter-node#Environment-variables-ADDRESS_HEADER-and-XFF_DEPTH).
|
||||||
|
Forwarded host or user headers do not authenticate users.
|
||||||
|
|
||||||
Local HTTP browser development requires `EC_AUTH_ALLOW_HTTP=true` and a loopback
|
Local HTTP browser development requires `EC_AUTH_ALLOW_HTTP=true` and a loopback
|
||||||
hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development
|
hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development
|
||||||
|
|||||||
+20
-10
@@ -10,22 +10,32 @@ T5 game-wide policies cover the ten standard Zombies maps; T6 policies cover nin
|
|||||||
|
|
||||||
Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Off, with a minimum of one running lobby, one empty spare, and a 15-minute empty interval. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum.
|
Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Off, with a minimum of one running lobby, one empty spare, and a 15-minute empty interval. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum.
|
||||||
|
|
||||||
Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show lobby counts, key availability, and the current status. An active job appears once with its latest stage and a **View job** link. Use **Lobbies** for per-map spare coverage and occupancy evidence, **Activity** for decision changes grouped by job, and **Metrics** for observed capacity and provisioning measurements.
|
Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show lobby counts, key availability, and the current status. An active job appears once with its latest stage and a **View job** link. Use **Lobbies** for per-map spare coverage and occupancy evidence and **Activity** for decision changes grouped by job. [**Metrics**](metrics.md) shows observed lobby counts and empty spare hours, plus autoscaling additions, removals, failed or cancelled operations, and average/maximum request-to-ready time for the selected period, game, and map. The policy card retains its separate 31-day measurement totals, including spare deficits.
|
||||||
|
|
||||||
| Autoscaling | Behavior |
|
| Autoscaling | Behavior |
|
||||||
| --- | --- |
|
| ----------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
| Off | No new actions. Unsubmitted work is withdrawn and delivered operations remain tracked until settled. |
|
| Off | No new actions. Unsubmitted work is withdrawn and delivered operations remain tracked until settled. |
|
||||||
| On | Adds lobbies and removes surplus lobbies after continuous empty evidence and all removal checks pass. |
|
| On | Adds lobbies and removes surplus lobbies after continuous empty evidence and all removal checks pass. |
|
||||||
|
|
||||||
Plutonium keys belong to a game pool shared by every map, manual launch, collection, beta, and autoscaling policy for that game. An eligible creation uses the lowest available lobby number from 1–20 and reserves any free game key atomically. Key labels do not select a map or lobby. The policy shows saved and available game keys; policy maxima can exceed pool size, and a policy can be saved before any keys are added. An empty or exhausted pool holds new growth with a clear reason; it does not prevent surplus removal. Queued launches reserve their key, stopped instances retain it, and confirmed cleanup returns it to the pool. Failed or uncertain launches keep their reservation until safely reconciled. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host.
|
Plutonium keys belong to a game pool shared by every map, manual launch, collection, beta, and autoscaling policy for that game. An eligible creation uses the lowest available lobby number from 1–20 and reserves any free game key atomically. Key labels do not select a map or lobby. The policy shows saved and available game keys; policy maxima can exceed pool size, and a policy can be saved before any keys are added. An empty or exhausted pool holds new growth with a clear reason; it does not prevent surplus removal. Queued launches reserve their key, stopped instances retain it, and confirmed cleanup returns it to the pool. Failed or uncertain launches keep their reservation until safely reconciled. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host.
|
||||||
|
|
||||||
The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. The controller evaluates policies every 15 seconds and requests at most one unresolved operation per policy. Game-wide policies allow one outstanding operation across their maps, prioritizing additions before removals. Once an operation settles, the next evaluation can act immediately on another eligible lobby. Surplus lobbies must still satisfy the configured continuous empty duration before removal. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins.
|
The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. Each occupied lobby must reach **round 2 or greater**, confirmed by fresh telemetry for its current match, before it increases the creation target. Brief joins, round-one matches, and unavailable round evidence do not trigger an additional spare. The minimum and baseline spare target can still bootstrap a map without an active match. Existing spares remain protected by the ordinary desired count even when a match restarts or round evidence becomes unavailable.
|
||||||
|
|
||||||
|
The controller evaluates policies every 15 seconds and requests at most one unresolved operation per policy. Game-wide policies apply the round requirement independently to each map and allow one outstanding operation across their maps, prioritizing additions before removals. Once an operation settles, the next evaluation can act immediately on another eligible lobby. Unsubmitted creates are withdrawn if they no longer meet the round or capacity requirements. Surplus lobbies must still satisfy the configured continuous empty duration before removal. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins.
|
||||||
|
|
||||||
|
When trimming a map, autoscaling keeps occupied lobbies and the lowest-numbered
|
||||||
|
empty lobbies needed for its minimum and spare targets. It selects the
|
||||||
|
highest-numbered eligible surplus lobby for removal. The required empty lobbies
|
||||||
|
are protected before checking idle timers, so Lobby 1 stays available while a
|
||||||
|
higher surplus lobby completes its empty interval. Existing numbering gaps remain
|
||||||
|
until normal growth needs another lobby; autoscaling does not replace healthy
|
||||||
|
lobbies just to renumber them.
|
||||||
|
|
||||||
Existing policies saved in the removed Dry run or Create only modes switch to Off on upgrade. Review and turn autoscaling On to resume; pending work remains tracked until settled.
|
Existing policies saved in the removed Dry run or Create only modes switch to Off on upgrade. Review and turn autoscaling On to resume; pending work remains tracked until settled.
|
||||||
|
|
||||||
## Ownership and blockers
|
## Ownership and blockers
|
||||||
|
|
||||||
A live policy owns the lobby counts for its selected maps. Overlapping live game-wide and map-specific policies are rejected. Game-wide rules include every catalog map and apply each map’s targets independently while sharing the game’s key pool. Pool capacity is a game-wide limit; targets across maps are not guaranteed when all keys are in use. Competing collection launches and manual Create, Start, Stop, or Destroy requests are rejected with a policy link. Restart and Rebuild remain available through the usual provisioning checks. Their pending work and unresolved recovery hold scaling decisions for the affected map, so temporary downtime does not trigger replacement lobbies. Exact collection matching also checks managed maps absent from the collection. To hand control back, set the policy to Off and wait for unsettled operations to finish.
|
A live policy owns the lobby counts for its selected maps. Overlapping live game-wide and map-specific policies are rejected. Game-wide rules include every catalog map and apply each map’s targets independently while sharing the game’s key pool. Pool capacity is a game-wide limit; targets across maps are not guaranteed when all keys are in use. While autoscaling is active, competing collection launches and manual Create, Start, Stop, or Destroy requests are rejected with a policy link. Pausing globally or pausing the policy allows these manual actions through their usual provisioning checks. Restart and Rebuild remain available through the usual provisioning checks. Their pending work and unresolved recovery hold scaling decisions for the affected map, so temporary downtime does not trigger replacement lobbies. Exact collection matching also checks managed maps absent from the collection.
|
||||||
|
|
||||||
Unknown or stale occupancy, stopped or unhealthy members, active betas, campaigns, maintenance, host drains, and unresolved recovery hold the policy's decisions. Autoscaling does not repair these conditions itself. The page and linked Provisioning operation explain the current blocker, including capacity and game key waits.
|
Unknown or stale occupancy, stopped or unhealthy members, active betas, campaigns, maintenance, host drains, and unresolved recovery hold the policy's decisions. Autoscaling does not repair these conditions itself. The page and linked Provisioning operation explain the current blocker, including capacity and game key waits.
|
||||||
|
|
||||||
@@ -38,7 +48,7 @@ Current positive occupancy from either telemetry or RCON vetoes an empty claim.
|
|||||||
|
|
||||||
## Pause and cancellation
|
## Pause and cancellation
|
||||||
|
|
||||||
Use **Pause all** to stop new decisions globally, or **Pause** on one policy. Use **Resume** to enable it again; the usual activation checks still apply. A global pause retains each live policy's ownership; setting it Off releases ownership once its outstanding operation settles. Resuming the global switch preserves each policy's On or Off setting.
|
Use **Pause all** to stop new decisions globally, or **Pause** on one policy. Use **Resume** to enable it again; the usual activation checks still apply. A global pause retains each live policy's ownership to prevent overlapping policies, but allows manual fleet, server, and collection actions. Policies paused after a failure also allow manual actions. Work already in progress still uses the normal per-server provisioning checks. Setting a policy Off releases ownership once its outstanding operation settles. Resuming the global switch preserves each policy's On or Off setting and restores the manual-action guard for active policies.
|
||||||
|
|
||||||
Unsubmitted creates and queued removals are withdrawn where possible. Cancelling an autoscaling operation from Provisioning also turns its policy Off so it cannot immediately request a replacement.
|
Unsubmitted creates and queued removals are withdrawn where possible. Cancelling an autoscaling operation from Provisioning also turns its policy Off so it cannot immediately request a replacement.
|
||||||
|
|
||||||
@@ -60,12 +70,12 @@ The production observation period and live pilot have not been run as part of im
|
|||||||
|
|
||||||
1. Select one or two representative maps and record the baseline lineup, keys, versions, host CPU and memory, and player-hours.
|
1. Select one or two representative maps and record the baseline lineup, keys, versions, host CPU and memory, and player-hours.
|
||||||
2. Select On after resolving blockers and checking agent capability. Set the minimum, maximum, and spare target, then choose Save policy.
|
2. Select On after resolving blockers and checking agent capability. Set the minimum, maximum, and spare target, then choose Save policy.
|
||||||
3. Monitor request-to-ready time from Provisioning, browser visibility, successful external joins, empty lobby-hours, spare deficits, and removals. Increase the spare target if creation latency leaves new groups without a suitable lobby.
|
3. Use Metrics to monitor request-to-ready time, empty lobby-hours, and autoscaling removals for the selected period and scope. Check the policy card's 31-day spare deficits and individual Provisioning operations for delays; verify browser visibility and successful external joins separately. Increase the spare target if creation latency leaves new groups without a suitable lobby.
|
||||||
4. Expand one map at a time when spare availability and gameplay meet your requirements. Pause if they regress.
|
4. Expand one map at a time when spare availability and gameplay meet your requirements. Pause if they regress.
|
||||||
|
|
||||||
The page retains up to 31 days of decision evidence and observations, with the 30 most recent decision changes shown. Repeated unchanged evaluations update the decision's last-seen time instead of adding a row. Unresolved intent survives retention. Provisioning history retains the operation and policy backlink separately.
|
The page retains up to 31 days of decision evidence and observations, with the 30 most recent decision changes shown. Repeated unchanged evaluations update the decision's last-seen time instead of adding a row. Unresolved intent survives retention. Provisioning history retains the operation and policy backlink separately.
|
||||||
|
|
||||||
Measurement totals exclude collection gaps. Request-to-verified-readiness summaries include queue time and only completed creates; they do not establish browser visibility or successful joins. “Time with players” in Metrics can rise simply because idle lobbies leave its denominator. Use host CPU and memory measurements alongside player-hours and spare availability when assessing savings.
|
Measurement totals exclude collection gaps. Request-to-verified-readiness summaries include queue time and only completed creates; they do not establish browser visibility or successful joins. [Metrics](metrics.md) shows average and peak lobby counts, empty spare hours, and map time with no reported empty lobby. Its autoscaling signals compare historical counts with current policy targets. Use host CPU and memory measurements alongside player-hours and spare availability when assessing savings.
|
||||||
|
|
||||||
## API and implementation
|
## API and implementation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Player bank
|
||||||
|
|
||||||
|
The bank stores one points balance per exact GUID across T5 and T6. Players use
|
||||||
|
`.b`, `.d <amount|all>` and `.w <amount|all>` with the TWS bank scripts. Deposits
|
||||||
|
move spendable match points into the bank; withdrawals return bank points to
|
||||||
|
the current match. Neither changes earned `score_total`. Bank-assisted games
|
||||||
|
still count toward the existing leaderboards; the ledger records their match
|
||||||
|
IDs. This release does not introduce separate bank-assisted rankings.
|
||||||
|
|
||||||
|
## Setup and rollout
|
||||||
|
|
||||||
|
1. Back up the controller database. Update every host agent to **1.9.1 or later**
|
||||||
|
before installing the public-endpoint bank scripts. This version settles both
|
||||||
|
public and legacy controller-derived journals before removing runtime storage.
|
||||||
|
Package and offline-sign the agent
|
||||||
|
release using the [host update workflow](native-hosts.md).
|
||||||
|
2. Deploy Electric Cherry and restart the controller. Migration 20 creates
|
||||||
|
accounts, requests and the ledger; migration 21 adds instance credentials and
|
||||||
|
binds existing requests to their currently active native instance, if any.
|
||||||
|
3. Set `EC_BANK_MAX_BALANCE` (default 250000, range 1–1000000). Invalid configuration
|
||||||
|
refuses new deposits. Lowering the limit preserves existing balances and
|
||||||
|
prepared deposits; withdrawals remain available.
|
||||||
|
4. Build/install TWS plugins `t5-plugin`/`t6-plugin` 1.1.0 or later and restart
|
||||||
|
the **whole game-server process**. Install `tws_t5_bank.gsc` or
|
||||||
|
`tws_t6_bank.gsc` 2.0.0, common 1.0.0+ and telemetry 1.0.0+.
|
||||||
|
For existing servers changing endpoints, use the rebuild procedure below.
|
||||||
|
5. Create or rebuild the servers through EC. Launch preparation generates a
|
||||||
|
cryptographically random `ec_bank_` credential for each immutable instance,
|
||||||
|
stores it in the private controller database, and injects it through
|
||||||
|
`configs/electric_cherry_bank.cfg`. The launch command executes this private
|
||||||
|
file and sets `tws_server_id`. The host agent writes only `tws_bank_token` in
|
||||||
|
that private file; it no longer generates `tws_bank_url`.
|
||||||
|
|
||||||
|
Both game scripts always send bank requests to
|
||||||
|
`https://ec.thewrightserver.net/api/bank`, even if an old `tws_bank_url` remains
|
||||||
|
in configuration. The host agent's enrolled HTTPS controller URL is separate
|
||||||
|
and continues to carry agent traffic, including authenticated bank settlement.
|
||||||
|
It may differ from the public endpoint reachable by game containers.
|
||||||
|
|
||||||
|
There is **no `EC_BANK_TOKENS` environment map** and no per-server secret setup.
|
||||||
|
The private configuration is sent only in that host's authenticated operation
|
||||||
|
payload. It is excluded from shared recipes, promoted files and blob downloads.
|
||||||
|
Do not upload `electric_cherry_bank.cfg`: its filename is reserved. Protect the
|
||||||
|
controller database, host operation journal and per-instance runtime directory,
|
||||||
|
which contain private credentials.
|
||||||
|
|
||||||
|
Drift scans exclude the exact generated configuration path for both games.
|
||||||
|
A maintenance rescan resolves old bank-only incidents and retains other file
|
||||||
|
changes in mixed incidents. Private-file campaign plans saved before this fix
|
||||||
|
are rejected at activation; cancel them and rescan rather than retrying the old
|
||||||
|
plan. Upgrade host agents to **1.9.3 or later** before resuming drift recovery
|
||||||
|
so stages already saved on a host also reject private-file reads and changes.
|
||||||
|
Legacy private bank-file campaign diffs and their blob downloads are blocked;
|
||||||
|
this does not erase previously captured bytes from databases or backups.
|
||||||
|
|
||||||
|
Retries, rebuilds and host moves of the same instance reuse its credential.
|
||||||
|
Retiring the instance immediately revokes authentication. Creating another
|
||||||
|
server with the same display name produces a new instance ID and secret; it
|
||||||
|
cannot finish the old instance's requests. Accounts remain attached to player
|
||||||
|
GUIDs, so balances survive the servers. Telemetry tokens and browser sessions
|
||||||
|
cannot authorize bank requests. Host credentials can only settle decisions
|
||||||
|
through the separate, operation-scoped teardown endpoint.
|
||||||
|
|
||||||
|
For an upgrade from the initial static-token implementation, drain outstanding
|
||||||
|
transfers while the old controller/configuration still runs, then deploy and
|
||||||
|
rebuild. Remove the obsolete environment map and any manually supplied token.
|
||||||
|
Do not replace identities or discard pending journals to force an upgrade.
|
||||||
|
|
||||||
|
### Upgrading from a controller-derived game endpoint
|
||||||
|
|
||||||
|
Update agents to `1.9.1+` first. If an existing server's `tws_bank_url` differs
|
||||||
|
from `https://ec.thewrightserver.net/api/bank`, rebuild that server through EC
|
||||||
|
with bank scripts `2.0.0`. The agent stops the old game, settles its pending
|
||||||
|
commit/cancel decisions with their original request IDs, then starts the
|
||||||
|
replacement and removes old storage. The server identity and instance credential
|
||||||
|
are preserved. Lost acknowledgments can replay the same decisions; EC's retained
|
||||||
|
request IDs prevent duplicate ledger changes. An outage or invalid journal
|
||||||
|
retains storage and blocks replacement until recovery succeeds.
|
||||||
|
|
||||||
|
Do not deploy the new scripts with only a map restart when the endpoint changes:
|
||||||
|
native journals are keyed by server and endpoint, and the new scripts cannot load
|
||||||
|
the old scope. Do not rename, rewrite or delete pending journals. Keep the
|
||||||
|
agent's enrolled controller URL unchanged until legacy journals drain; the
|
||||||
|
agent accepts only the fixed public endpoint and the legacy `/api/bank` URL
|
||||||
|
derived from that enrolled controller. Other legacy URLs require recovery with
|
||||||
|
their original configuration before switching. If the old URL already matches
|
||||||
|
the exact public endpoint, existing journals retain their identity and a map
|
||||||
|
restart is sufficient for the script upgrade. No new native plugin build is
|
||||||
|
required beyond `1.1.0`.
|
||||||
|
|
||||||
|
## Machine API
|
||||||
|
|
||||||
|
All routes below are `POST /api/bank/<action>`, with `Content-Type:
|
||||||
|
application/json` and `Authorization: Bearer <server-bank-token>`. Responses
|
||||||
|
are never cached. Bodies are capped at 4096 bytes. GUIDs and request IDs stay
|
||||||
|
strings; never convert them to numbers.
|
||||||
|
|
||||||
|
| Action | Body | Meaning |
|
||||||
|
| ---------- | ------------------------- | -------------------------------------------------------------------- |
|
||||||
|
| `balance` | `server`, `guid` | Read the shared balance and currently available points |
|
||||||
|
| `deposit` | Transfer fields below | Reserve deposit capacity without crediting the account yet |
|
||||||
|
| `withdraw` | Transfer fields below | Reserve bank funds without debiting the account yet |
|
||||||
|
| `commit` | Identical transfer fields | Apply a prepared transfer once and append its ledger entry |
|
||||||
|
| `cancel` | Identical transfer fields | Release a reservation; also prevents a delayed prepare from applying |
|
||||||
|
|
||||||
|
Transfer example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": "t5_zombie_theater_1",
|
||||||
|
"guid": "18446744073709551615",
|
||||||
|
"match": "reporter-match-key",
|
||||||
|
"request_id": "unique-process-token-17",
|
||||||
|
"operation": "deposit",
|
||||||
|
"amount": 2000,
|
||||||
|
"capacity": 995000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`match` is the reporter's telemetry match key, **not** Electric Cherry's numeric
|
||||||
|
match ID. `request_id` is unique for the server and at most 64 characters.
|
||||||
|
`amount` is a positive integer up to 1000000; withdrawal also accepts `"all"`.
|
||||||
|
The script resolves deposit `all` to its captured spendable points. `capacity`
|
||||||
|
is the current room below the game's bank withdrawal cap of 1000000 points.
|
||||||
|
An `all` withdrawal that exceeds this room is refused, rather than truncated.
|
||||||
|
|
||||||
|
Preparation requires the GUID to be present in the server's latest active match,
|
||||||
|
with the exact telemetry match key. Late joiners may need to wait for the next
|
||||||
|
30-second heartbeat. Unknown/destroyed servers, absent players and old matches
|
||||||
|
cannot prepare transfers. Settlement of an existing decision remains possible
|
||||||
|
after match completion or retention. Host teardown can finish saved decisions
|
||||||
|
after retirement; the retired game credential itself no longer authenticates.
|
||||||
|
|
||||||
|
Successful application responses include `ok`, `state`, `code`, `request_id`,
|
||||||
|
`amount` and `balance`. States are `balance`, `prepared`, `committed`, `cancelled`
|
||||||
|
or `rejected`. For balance reads, `amount` is the available balance. A business
|
||||||
|
rejection uses HTTP 200 with `ok: false`, `state: "rejected"` and a code such as
|
||||||
|
`insufficient_balance`, `empty`, `bank_limit`, `points_limit`, `not_in_match` or
|
||||||
|
`pending`. It never changes points. Authentication failures use 401/403,
|
||||||
|
malformed input 400/413/415, identity conflicts 409, and unavailable service 503.
|
||||||
|
|
||||||
|
Repeated requests return the saved result/state without applying a second
|
||||||
|
change. Settled IDs never reopen; a committed result retains its original
|
||||||
|
resulting balance even if later transfers change the account. Reusing an ID
|
||||||
|
with different transfer fields returns 409. A GUID has at most one prepared
|
||||||
|
transfer across all servers. SQLite transactions serialize checks, account
|
||||||
|
updates and ledger insertion, so concurrent requests cannot overspend funds or
|
||||||
|
deposit past the configured maximum.
|
||||||
|
|
||||||
|
## Lost responses and recovery
|
||||||
|
|
||||||
|
The game saves a cancellation decision **before** sending a prepare request.
|
||||||
|
Only a validated prepared reply lets it recheck the player's life, current
|
||||||
|
spendable points and withdrawal room. It then records a commit decision and
|
||||||
|
updates spendable points without yielding. The sender retries settlement with
|
||||||
|
the original identity, at delays of 1, 2, 4, 8, 16, then 30 seconds. Invalid,
|
||||||
|
truncated or uncorrelated replies never authorize points changes.
|
||||||
|
|
||||||
|
A failed/uncertain prepare leaves game points unchanged and queues cancellation.
|
||||||
|
Cancellation can arrive first: its tombstone blocks the delayed prepare. Death,
|
||||||
|
respawn, disconnect or match end before application also cancels. After a commit
|
||||||
|
decision, retries only finish the ledger; they never change game points again.
|
||||||
|
These points now belong to the current match, so an applied withdrawal is not
|
||||||
|
automatically refunded if that match ends or its server crashes.
|
||||||
|
|
||||||
|
Keep `tws-bank/t5/` or `tws-bank/t6/`, relative to the game process working
|
||||||
|
directory, on persistent writable storage with a
|
||||||
|
single process per server/endpoint. Journals contain transfer data, not secrets.
|
||||||
|
On a map/process restart, saved decisions settle before new bank commands.
|
||||||
|
Unreadable storage disables the bank; failed writes prevent points changes.
|
||||||
|
Pending decisions remain until acknowledged, even on credential errors. Bank
|
||||||
|
holds do not expire automatically, because expiring a saved commit can lose a
|
||||||
|
deposit or create a free withdrawal. Restore connectivity/configuration and the
|
||||||
|
original journal to resolve holds; never delete a pending journal to unblock
|
||||||
|
the bank. If storage is permanently lost, reconcile against the retained
|
||||||
|
request and ledger records before any manual repair.
|
||||||
|
|
||||||
|
During removal, rebuild, host migration or launch cancellation, agent 1.9.1+
|
||||||
|
stops the game and drains its journals **before** deleting runtime files or
|
||||||
|
starting a replacement. It uses its host credential at
|
||||||
|
`POST /api/agent/bank/settle`; EC checks the assigned host, immutable instance,
|
||||||
|
unfinished teardown operation, server and original request identity. This
|
||||||
|
endpoint permits only commit/cancel, never new deposits or withdrawals. Journal
|
||||||
|
URLs must match the public game-bank endpoint or that host's legacy
|
||||||
|
controller-derived bank endpoint; the agent never follows a journal URL.
|
||||||
|
Corruption, an unrecognized endpoint, failed acknowledgment or an
|
||||||
|
outage retains storage and leaves the operation unresolved for retry. An
|
||||||
|
interrupted rebuild uses the existing rebuild recovery workflow to retry with
|
||||||
|
a fresh operation after settlement. Do not manually erase its old directory.
|
||||||
|
|
||||||
|
The journal provides recovery from map/process restart. It cannot make live
|
||||||
|
game memory and SQLite one atomic transaction or preserve match points after a
|
||||||
|
machine/storage failure. Back up persistent storage and verify crash behavior
|
||||||
|
in the actual Windows/Plutonium deployment before rollout.
|
||||||
|
|
||||||
|
## History and validation
|
||||||
|
|
||||||
|
The **Players → Bank** page shows fleet-wide balances, funded accounts, completed
|
||||||
|
transfers in the last 24 hours and pending transfers. Accounts can be searched by
|
||||||
|
player name or GUID, sorted by balance or recent completed activity, and filtered
|
||||||
|
to funded accounts, accounts at or above the configured limit, or pending transfers.
|
||||||
|
Balances always remain shared across both games.
|
||||||
|
|
||||||
|
Transactions can be filtered by player, game, server, direction and inclusive UTC
|
||||||
|
dates. Pending transfers show oldest first, with an attention indicator after five
|
||||||
|
minutes and details explaining the reservation. This indicator does not expire or
|
||||||
|
cancel a transfer. Both views retain historical server identities and match IDs
|
||||||
|
after retention; unavailable matches have no live link.
|
||||||
|
|
||||||
|
Selecting a player opens a drawer with their balance, available points, pending
|
||||||
|
transfer and paginated history. Filters and pagination are retained in the URL,
|
||||||
|
including when following a match or player-profile link. The page refreshes every
|
||||||
|
30 seconds while visible. Summary counts always cover the whole fleet. Bank is
|
||||||
|
read-only for all browser roles; transfers continue to originate in game.
|
||||||
|
|
||||||
|
Player profiles show the shared balance, pending status and three newest ledger
|
||||||
|
entries, with a **View bank history** link to the drawer. Entries include signed
|
||||||
|
amounts, resulting balances, server, time and match.
|
||||||
|
Accounts, request IDs and ledger entries have no cascading match/server foreign
|
||||||
|
keys and are not deleted by match retention or server deletion. Retained IDs
|
||||||
|
also keep old retries idempotent. Expired match links become plain historical
|
||||||
|
IDs. Include these tables in normal database backups.
|
||||||
|
|
||||||
|
Run `npx vitest run tests/unit/bank.test.ts tests/unit/bank-overview.test.ts tests/client/bank-page.test.ts`, then the usual lint, type checks,
|
||||||
|
tests and build. TWS supplies `node tools/check_bank.mjs` and
|
||||||
|
`python3 tools/test_plugin_bank.py`. Live validation must cover both games:
|
||||||
|
deposit/withdraw amounts and `all`, insufficient funds and caps, joining before
|
||||||
|
telemetry, `.help`, score HUD updates, death/respawn/disconnect during requests,
|
||||||
|
lost prepare/commit replies, controller outage, disk failures and map/process
|
||||||
|
restart with pending work. Confirm earned score and leaderboard behavior, and
|
||||||
|
verify that another server's credential and the telemetry credential fail.
|
||||||
|
For the endpoint upgrade, also test a different enrolled controller address and
|
||||||
|
a stale `tws_bank_url`, `.b` with an empty balance, `.w all` with no funds, and
|
||||||
|
rebuild/removal with both legacy and public journals. Drop a settlement reply,
|
||||||
|
restart the agent and verify the original IDs settle without duplicate ledger
|
||||||
|
changes before the replacement starts or runtime storage is removed.
|
||||||
+165
-67
@@ -12,6 +12,10 @@ file applies to all participants. Every file needs at least one matching test
|
|||||||
participant. For example, a T5 script and a T6 script can share one beta without
|
participant. For example, a T5 script and a T6 script can share one beta without
|
||||||
either script being installed on the other game's servers.
|
either script being installed on the other game's servers.
|
||||||
|
|
||||||
|
Choose **Add all eligible files** to add the latest unpromoted version of every
|
||||||
|
remaining eligible file at once. Files and exact versions you already selected
|
||||||
|
stay unchanged.
|
||||||
|
|
||||||
Setup defaults to automatic selection. Enter one **Instances per game** number,
|
Setup defaults to automatic selection. Enter one **Instances per game** number,
|
||||||
which defaults to 1 and applies to every game represented by the selected files
|
which defaults to 1 and applies to every game represented by the selected files
|
||||||
and their matching servers. Every automatically selected instance must pass
|
and their matching servers. Every automatically selected instance must pass
|
||||||
@@ -29,8 +33,8 @@ servers. Servers held by another active beta or with pending deployment work
|
|||||||
are excluded, as are instances on unavailable, draining, or disabled hosts.
|
are excluded, as are instances on unavailable, draining, or disabled hosts.
|
||||||
Unknown or stale player counts are not treated as empty. If the
|
Unknown or stale player counts are not treated as empty. If the
|
||||||
requested counts cannot be met, including coverage of every selected map-scoped
|
requested counts cannot be met, including coverage of every selected map-scoped
|
||||||
file, the beta does not start. Deployment and restarts still wait for confirmed
|
file, the beta does not start. File deployment rebuilds servers after waiting
|
||||||
empty lobbies and restart capacity.
|
for confirmed empty lobbies and rebuild capacity.
|
||||||
|
|
||||||
Open **Advanced options → Choose specific servers** for manual selection. The
|
Open **Advanced options → Choose specific servers** for manual selection. The
|
||||||
picker includes servers matching any file in the bundle; each file must have a
|
picker includes servers matching any file in the bundle; each file must have a
|
||||||
@@ -51,24 +55,44 @@ Send JSON to `POST /api/telemetry` with `Content-Type: application/json` and eit
|
|||||||
`X-EC-Telemetry-Token: <EC_TELEMETRY_TOKEN>`. If both headers are present, they must
|
`X-EC-Telemetry-Token: <EC_TELEMETRY_TOKEN>`. If both headers are present, they must
|
||||||
contain the same secret. Keep the secret out of URLs, payloads and browser code,
|
contain the same secret. Keep the secret out of URLs, payloads and browser code,
|
||||||
and use a different secret from host-agent credentials or account passwords.
|
and use a different secret from host-agent credentials or account passwords.
|
||||||
The JSON event format is unchanged.
|
Request bodies are limited to 1 MiB (1,048,576 bytes), including requests without
|
||||||
|
`Content-Length`. The limit applies to the UTF-8 JSON body before parsing.
|
||||||
There are four events. Send them whether or not a beta is active.
|
There are four events. Send them whether or not a beta is active.
|
||||||
|
|
||||||
### Event fields
|
### Event fields
|
||||||
|
|
||||||
| Event | Fields |
|
| Event | Fields |
|
||||||
| --- | --- |
|
| -------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `game_start` | `server`, `map`, `players` with each player's `name` and `guid` |
|
| `game_start` | `server`, `map`, `players` with each player's `name` and `guid` |
|
||||||
| `round_change` | `server`, `map`, `round`, `players` with each player's `name`, `guid`, `kills`, `headshots`, `downs`, `revives`, and `score` |
|
| `round_change` | `server`, `map`, `round`, `players` with each player's `name`, `guid`, `kills`, `headshots`, `downs`, `revives`, and `score` |
|
||||||
| `heartbeat` | Same fields as `round_change`; sent periodically during a round to refresh the current round and player stats |
|
| `heartbeat` | Same fields as `round_change`; sent periodically during a round to refresh the current round and player stats |
|
||||||
| `game_end` | `server`, `map`, `round`, `players` with each player's `name`, `guid`, `kills`, `headshots`, `downs`, `revives`, and `score` |
|
| `game_end` | `server`, `map`, `round`, `players` with each player's `name`, `guid`, `kills`, `headshots`, `downs`, `revives`, and `score` |
|
||||||
|
|
||||||
`server` is the exact registered Electric Cherry server name. `map` is the
|
`server` is the exact registered Electric Cherry server name. `map` is the
|
||||||
current map. `players` is the full player array, including an empty array when
|
current map. `players` is the full player array, including an empty array when
|
||||||
there are no players. Names and GUIDs are strings; keep GUIDs as strings to
|
there are no players. `server`, `map`, and each player's `name` and `guid` must be
|
||||||
preserve their precision. Round and stat values are nonnegative JSON integers.
|
nonempty strings of at most 255 UTF-16 code units (JavaScript string length).
|
||||||
|
Values over the limit reject the entire report; accepted values are preserved
|
||||||
|
without trimming or truncation. Keep GUIDs as strings to preserve their precision
|
||||||
|
and leading zeros. Round and stat values are nonnegative JSON integers.
|
||||||
Each GUID must appear only once within a report's player array.
|
Each GUID must appear only once within a report's player array.
|
||||||
|
|
||||||
|
Every event also accepts these optional top-level fields:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| `match` | Reporter-generated game identity: a nonblank string of at most 64 UTF-16 code units. |
|
||||||
|
| `seq` | Positive JSON safe integer, at most 9,007,199,254,740,991, identifying this report within the game. |
|
||||||
|
|
||||||
|
Send both fields for reliable match boundaries, ordering, and retry handling.
|
||||||
|
Choose a fresh `match` value for every game, including restarts on the same map,
|
||||||
|
and keep it unchanged throughout that game. Match keys are scoped to the exact
|
||||||
|
`server` name. Start `seq` at 1 and increment it once per event, including
|
||||||
|
heartbeats. Retry a report with its original `match`, `seq`, and payload; do not
|
||||||
|
allocate a new sequence number for a retry. Either field may be omitted for
|
||||||
|
compatibility, but sequence-based deduplication requires both fields. Existing
|
||||||
|
reporters without these fields remain valid.
|
||||||
|
|
||||||
Every event also accepts an optional per-player `ping`: the game server's current
|
Every event also accepts an optional per-player `ping`: the game server's current
|
||||||
latency measurement in milliseconds, as a nonnegative JSON integer (for example,
|
latency measurement in milliseconds, as a nonnegative JSON integer (for example,
|
||||||
`"ping": 42`). Send `null` or omit the field when unavailable; do not substitute
|
`"ping": 42`). Send `null` or omit the field when unavailable; do not substitute
|
||||||
@@ -89,13 +113,15 @@ Send when the game starts. Round and stats are not required.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"event": "game_start",
|
"event": "game_start",
|
||||||
"server": "t6_zombie_town_1",
|
"server": "t6_zombie_town_1",
|
||||||
"map": "zm_transit",
|
"match": "town-20261012-001",
|
||||||
"players": [
|
"seq": 1,
|
||||||
{ "name": "Josh", "guid": "12345678901234567" },
|
"map": "zm_transit",
|
||||||
{ "name": "Alex", "guid": "23456789012345678" }
|
"players": [
|
||||||
]
|
{ "name": "Josh", "guid": "12345678901234567" },
|
||||||
|
{ "name": "Alex", "guid": "23456789012345678" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -105,14 +131,32 @@ Send the new round and each player's current stats.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"event": "round_change",
|
"event": "round_change",
|
||||||
"server": "t6_zombie_town_1",
|
"server": "t6_zombie_town_1",
|
||||||
"map": "zm_transit",
|
"match": "town-20261012-001",
|
||||||
"round": 25,
|
"seq": 2,
|
||||||
"players": [
|
"map": "zm_transit",
|
||||||
{ "name": "Josh", "guid": "12345678901234567", "kills": 420, "headshots": 160, "downs": 1, "revives": 3, "score": 32000 },
|
"round": 25,
|
||||||
{ "name": "Alex", "guid": "23456789012345678", "kills": 380, "headshots": 140, "downs": 3, "revives": 1, "score": 28000 }
|
"players": [
|
||||||
]
|
{
|
||||||
|
"name": "Josh",
|
||||||
|
"guid": "12345678901234567",
|
||||||
|
"kills": 420,
|
||||||
|
"headshots": 160,
|
||||||
|
"downs": 1,
|
||||||
|
"revives": 3,
|
||||||
|
"score": 32000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alex",
|
||||||
|
"guid": "23456789012345678",
|
||||||
|
"kills": 380,
|
||||||
|
"headshots": 140,
|
||||||
|
"downs": 3,
|
||||||
|
"revives": 1,
|
||||||
|
"score": 28000
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,14 +166,32 @@ Send the final round and each player's final stats when the game ends.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"event": "game_end",
|
"event": "game_end",
|
||||||
"server": "t6_zombie_town_1",
|
"server": "t6_zombie_town_1",
|
||||||
"map": "zm_transit",
|
"match": "town-20261012-001",
|
||||||
"round": 26,
|
"seq": 3,
|
||||||
"players": [
|
"map": "zm_transit",
|
||||||
{ "name": "Josh", "guid": "12345678901234567", "kills": 445, "headshots": 170, "downs": 2, "revives": 3, "score": 33500 },
|
"round": 26,
|
||||||
{ "name": "Alex", "guid": "23456789012345678", "kills": 401, "headshots": 146, "downs": 4, "revives": 1, "score": 29750 }
|
"players": [
|
||||||
]
|
{
|
||||||
|
"name": "Josh",
|
||||||
|
"guid": "12345678901234567",
|
||||||
|
"kills": 445,
|
||||||
|
"headshots": 170,
|
||||||
|
"downs": 2,
|
||||||
|
"revives": 3,
|
||||||
|
"score": 33500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alex",
|
||||||
|
"guid": "23456789012345678",
|
||||||
|
"kills": 401,
|
||||||
|
"headshots": 146,
|
||||||
|
"downs": 4,
|
||||||
|
"revives": 1,
|
||||||
|
"score": 29750
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,23 +201,51 @@ Success returns HTTP 200 with `{ "ok": true, "telemetry": { ... } }`.
|
|||||||
The returned telemetry contains the event and an Electric Cherry `receivedAt`
|
The returned telemetry contains the event and an Electric Cherry `receivedAt`
|
||||||
timestamp. A missing/invalid machine token returns 401; an unset or invalid
|
timestamp. A missing/invalid machine token returns 401; an unset or invalid
|
||||||
controller `EC_TELEMETRY_TOKEN` returns 503. Authentication happens before payload
|
controller `EC_TELEMETRY_TOKEN` returns 503. Authentication happens before payload
|
||||||
validation. Authenticated invalid JSON or fields return 400, an unknown/destroyed
|
validation. Authenticated bodies over 1 MiB return 413 before JSON parsing.
|
||||||
server 404, and a non-JSON content type 415.
|
Invalid JSON or fields return 400, an unknown/destroyed server 404, and a non-JSON
|
||||||
|
content type 415.
|
||||||
|
|
||||||
The script sends each event once, in gameplay order, directly to Electric Cherry.
|
Send events in gameplay order directly to Electric Cherry. There is no discovery
|
||||||
There is no discovery request, beta token, boot/session ID, sequence number,
|
request, beta token, custom feature event, external helper, or separate beta
|
||||||
custom feature event, external helper, or separate beta acknowledgement.
|
acknowledgement. Basic payload checks happen in the receiver.
|
||||||
Basic payload checks happen in the receiver.
|
|
||||||
|
|
||||||
Electric Cherry saves the latest complete report, including player stats, and
|
With `match` and `seq`, repeated sequences are ignored even after an application
|
||||||
|
restart. A lower sequence that arrives late can fill a missing-report gap without
|
||||||
|
rolling back the current round, roster, or counters. A delayed `game_start` can
|
||||||
|
record the starting roster without resetting the game's observed progression.
|
||||||
|
A known older match key cannot replace a newer game's live state. When an unseen
|
||||||
|
key starts a new game before the previous keyed game's `game_end` arrives,
|
||||||
|
Matches labels the previous game **End report missing**. A later `game_end`
|
||||||
|
completes its own match without changing the newer game. Missing sequence counts
|
||||||
|
are diagnostic: they do not invent absent rounds, rosters, or an ending.
|
||||||
|
Duplicate sequences, lower-sequence reports, and reports for known older keys do
|
||||||
|
not advance beta requirements or replace the current scoreboard. A successful
|
||||||
|
response acknowledges the submitted report; it does not mean that report became
|
||||||
|
the current game state.
|
||||||
|
|
||||||
|
Preserve delivery order across games as well: keep queued retries ahead of
|
||||||
|
newer games' reports. An unseen key is always treated as the next game, even if
|
||||||
|
its first report was delayed from an older game. `seq` cannot resolve this because
|
||||||
|
it orders reports only within a game. A persistent per-server game counter or
|
||||||
|
equivalent ordering metadata would require a protocol extension. See the
|
||||||
|
[first-report ordering limitation](leaderboard.md#persistence-and-match-boundaries)
|
||||||
|
for its effect on match history and the live scoreboard.
|
||||||
|
|
||||||
|
Electric Cherry saves the latest accepted report, including player stats, and
|
||||||
streams it to connected clients. A new start clears the previous round display;
|
streams it to connected clients. A new start clears the previous round display;
|
||||||
an end closes the match. Receipt order determines the latest report. This small
|
an end closes the match. Without a match key, legacy receipt-order and
|
||||||
protocol does not attempt to identify delayed events from earlier games or
|
map/round heuristics remain in effect. Those reports cannot reliably distinguish
|
||||||
provide exactly-once delivery across games.
|
delayed events from earlier games; send them once in order.
|
||||||
|
|
||||||
|
Match timing is independent of player count: an empty `game_start` still records
|
||||||
|
the start, and empty rosters or RCON polls do not erase it. Fleet can display a
|
||||||
|
fresh reported round even when the start was missed; match duration stays unknown
|
||||||
|
in that case. When match history identifies a new match from subsequent gameplay,
|
||||||
|
Fleet clears the previous match's timing rather than carrying it into the new one.
|
||||||
|
|
||||||
Reports also feed the persistent [Leaderboard](leaderboard.md), independently
|
Reports also feed the persistent [Leaderboard](leaderboard.md), independently
|
||||||
of beta activity. That page documents cumulative stat accounting, round credit,
|
of beta activity. That page documents cumulative stat accounting, round credit,
|
||||||
and the limits of identifying matches from this protocol.
|
and match identity and ordering behavior.
|
||||||
|
|
||||||
### Reporter deployment
|
### Reporter deployment
|
||||||
|
|
||||||
@@ -175,7 +265,8 @@ name. Each entry includes the Electric Cherry receipt time, HTTP result, error
|
|||||||
reason, event, map, round, player count, content type, and submitted JSON. Rejected
|
reason, event, map, round, player count, content type, and submitted JSON. Rejected
|
||||||
reports are included, even when the content type is wrong but the body is readable
|
reports are included, even when the content type is wrong but the body is readable
|
||||||
JSON after authentication. Unauthorized requests are rejected before their bodies
|
JSON after authentication. Unauthorized requests are rejected before their bodies
|
||||||
are read and are not retained in per-server diagnostics. Authentication headers
|
are read and are not retained in per-server diagnostics. Oversized bodies are
|
||||||
|
rejected before parsing and are not retained either. Authentication headers
|
||||||
and their secrets are never stored. Processing failures return 500 and record the internal error when diagnostic
|
and their secrets are never stored. Processing failures return 500 and record the internal error when diagnostic
|
||||||
storage is available; failed database transactions do not update the scoreboard.
|
storage is available; failed database transactions do not update the scoreboard.
|
||||||
|
|
||||||
@@ -192,8 +283,9 @@ newest first, with caching disabled.
|
|||||||
Check the reporter's destination and server field. Calls that never reach
|
Check the reporter's destination and server field. Calls that never reach
|
||||||
Electric Cherry, malformed JSON, and bodies without a usable server name
|
Electric Cherry, malformed JSON, and bodies without a usable server name
|
||||||
cannot appear in this server's log. Earlier requests are not backfilled.
|
cannot appear in this server's log. Earlier requests are not backfilled.
|
||||||
Missing/invalid telemetry credentials also produce no retained request; inspect
|
Missing/invalid telemetry credentials and oversized bodies also produce no
|
||||||
the reporter's HTTP result for 401 or an unconfigured-controller 503.
|
retained request; inspect the reporter's HTTP result for 401, 413, or an
|
||||||
|
unconfigured-controller 503.
|
||||||
- **400 / 415:** inspect the error and payload. One invalid player's stats or a
|
- **400 / 415:** inspect the error and payload. One invalid player's stats or a
|
||||||
duplicate GUID rejects the entire report. These requests do not update the
|
duplicate GUID rejects the entire report. These requests do not update the
|
||||||
scoreboard.
|
scoreboard.
|
||||||
@@ -213,18 +305,18 @@ an occupied server can still have no scoreboard report.
|
|||||||
|
|
||||||
Requirements on a qualifying server are ANDed:
|
Requirements on a qualifying server are ANDed:
|
||||||
|
|
||||||
| Requirement | Evidence |
|
| Requirement | Evidence |
|
||||||
| --- | --- |
|
| --------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| Reach round X | A round change, heartbeat, or game end reports round >= X with the required player count |
|
| Reach round X | A round change, heartbeat, or game end reports round >= X with the required player count |
|
||||||
| Complete qualifying matches | Game end at round 2 or later, optionally qualified by a higher round, player count, and duration |
|
| Complete qualifying matches | Game end at round 2 or later, optionally qualified by a higher round, player count, and duration |
|
||||||
| Populated minutes | Time between consecutive game reports with enough players at both ends |
|
| Populated minutes | Time between consecutive game reports with enough players at both ends |
|
||||||
| Player-hours | Populated minutes multiplied by the lower reported player count, divided by 60 |
|
| Player-hours | Populated minutes multiplied by the lower reported player count, divided by 60 |
|
||||||
| Reach player count | Player array length in a game report |
|
| Reach player count | Player array length in a game report |
|
||||||
| Sustained population | Consecutive report intervals above the player threshold within one game |
|
| Sustained population | Consecutive report intervals above the player threshold within one game |
|
||||||
| Continuous healthy minutes | Consecutive fresh container/RCON observations |
|
| Continuous healthy minutes | Consecutive fresh container/RCON observations |
|
||||||
| Maximum health outages | Transitions from observed healthy to unhealthy |
|
| Maximum health outages | Transitions from observed healthy to unhealthy |
|
||||||
| Successful recovery cycles | Repeat the other requirements after each additional empty-server restart |
|
| Successful recovery cycles | Repeat the other requirements after each additional empty-server restart |
|
||||||
| Tester checklist | Tester name and observation entered on the beta detail page |
|
| Tester checklist | Tester name and observation entered on the beta detail page |
|
||||||
|
|
||||||
All gameplay requirements use the script reports. Match counts do not read game
|
All gameplay requirements use the script reports. Match counts do not read game
|
||||||
logs. The reported round and required player count must occur together in the
|
logs. The reported round and required player count must occur together in the
|
||||||
@@ -261,7 +353,7 @@ Health monitoring continues after a pass until promotion.
|
|||||||
|
|
||||||
For failures, select hold as failed, pause for investigation, or automatic
|
For failures, select hold as failed, pause for investigation, or automatic
|
||||||
rollback. Rollback queues restoration only for files touched by the beta.
|
rollback. Rollback queues restoration only for files touched by the beta.
|
||||||
Restoration and restart both wait for an empty server and restart capacity.
|
Restoration rebuilds the server after waiting for an empty lobby and rebuild capacity.
|
||||||
The manual option holds those changes in a rollback campaign until you select
|
The manual option holds those changes in a rollback campaign until you select
|
||||||
**Resume when empty**. Promotion is always manual.
|
**Resume when empty**. Promotion is always manual.
|
||||||
|
|
||||||
@@ -273,9 +365,9 @@ Review the promotion preview before confirming. API clients fetch it with
|
|||||||
409 and requires a fresh preview.
|
409 and requires a fresh preview.
|
||||||
|
|
||||||
Confirming promotion creates a campaign without reading the existing files on
|
Confirming promotion creates a campaign without reading the existing files on
|
||||||
additional servers. Each server waits for an empty lobby and restart capacity,
|
additional servers. Each server waits for an empty lobby and rebuild capacity,
|
||||||
then captures rollback snapshots immediately before applying its queued files
|
then captures rollback snapshots before rebuilding with its queued files.
|
||||||
and restarting. Campaign history records snapshot capture, and file comparisons
|
Campaign history records snapshot capture, and file comparisons
|
||||||
become available once those snapshots are confirmed. Tested servers retain their
|
become available once those snapshots are confirmed. Tested servers retain their
|
||||||
original pre-beta snapshots; their composed configuration is still checked at
|
original pre-beta snapshots; their composed configuration is still checked at
|
||||||
promotion to determine whether another activation is needed.
|
promotion to determine whether another activation is needed.
|
||||||
@@ -305,9 +397,9 @@ binary and large files show their checksums. Files must actually change.
|
|||||||
|
|
||||||
The beta keeps its ID, servers, scope and test policy. Each revision creates new
|
The beta keeps its ID, servers, scope and test policy. Each revision creates new
|
||||||
file versions, clears current test progress and checklist sign-offs, repeats the
|
file versions, clears current test progress and checklist sign-offs, repeats the
|
||||||
configured waves, and starts a fresh testing window at submission. Files install
|
configured waves, and starts a fresh testing window at submission. Servers rebuild
|
||||||
and servers restart through the normal empty-server and capacity gates. Promotion
|
with the requested files through the normal empty-server and capacity gates.
|
||||||
requires the new revision to pass. Pause and retry retain the current revision's
|
Promotion requires the new revision to pass. Pause and retry retain the current revision's
|
||||||
deadline; they do not create a revision.
|
deadline; they do not create a revision.
|
||||||
|
|
||||||
Revision history retains downloadable bundle files and previous test results,
|
Revision history retains downloadable bundle files and previous test results,
|
||||||
@@ -319,7 +411,7 @@ new revision is waiting to deploy. Files originally absent are removed.
|
|||||||
Updates are available until promotion or rollback, including paused, failed and
|
Updates are available until promotion or rollback, including paused, failed and
|
||||||
expired tests. An activation already in progress must settle first; interrupted
|
expired tests. An activation already in progress must settle first; interrupted
|
||||||
file writes and deleted participants must be resolved before an update. Pending,
|
file writes and deleted participants must be resolved before an update. Pending,
|
||||||
unsubmitted restarts are superseded. Adding/removing files, changing targets or
|
unsubmitted activations are superseded. Adding/removing files, changing targets or
|
||||||
changing the policy still requires a new beta.
|
changing the policy still requires a new beta.
|
||||||
|
|
||||||
## API and storage
|
## API and storage
|
||||||
@@ -341,7 +433,13 @@ recent evidence under `test`.
|
|||||||
copied into a beta; later edits do not change an active plan.
|
copied into a beta; later edits do not change an active plan.
|
||||||
|
|
||||||
`POST /api/betas/:id/revisions` accepts multipart `expectedRevision`, optional
|
`POST /api/betas/:id/revisions` accepts multipart `expectedRevision`, optional
|
||||||
`notes`, and replacement uploads named `file:<scriptId>`. A stale revision is
|
`notes`, and replacement uploads named `file:<scriptId>`. Each script replacement
|
||||||
|
also requires `release:<scriptId>` in `MAJOR.MINOR.PATCH` form. The update dialog
|
||||||
|
accepts manual versions or `versions.json` to fill empty fields by exact filename.
|
||||||
|
A changed script cannot reuse a previous release version. Missing or invalid
|
||||||
|
versions reject the entire update without modifying beta history or deployments.
|
||||||
|
Plugin release tags remain optional. See [script release versions](getting-started.md#script-release-versions).
|
||||||
|
A stale revision is
|
||||||
rejected. `GET /api/betas/:id` includes the current `revision` and `revisions`
|
rejected. `GET /api/betas/:id` includes the current `revision` and `revisions`
|
||||||
history; `GET /api/betas/:id/revisions/:revision/files/:scriptId` downloads an
|
history; `GET /api/betas/:id/revisions/:revision/files/:scriptId` downloads an
|
||||||
archived file (or the original pinned file before its first update).
|
archived file (or the original pinned file before its first update).
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ and cleanup recovery where applicable.
|
|||||||
|
|
||||||
### Collection limits and editing
|
### Collection limits and editing
|
||||||
|
|
||||||
Collections support up to 40 distinct game/map selections, 1–20 lobbies per map,
|
Collections support up to 40 distinct game/map selections, 1–15 lobbies per map,
|
||||||
and 100 servers total. The host's configured port range and existing fleet may
|
and 100 servers total. The host's configured port range and existing fleet may
|
||||||
allow fewer. A collection can be saved even when there is insufficient capacity
|
allow fewer. A collection can be saved even when there is insufficient capacity
|
||||||
to launch it. Saving a collection does not capture or pin managed file versions.
|
to launch it. Saving a collection does not capture or pin managed file versions.
|
||||||
@@ -287,12 +287,43 @@ server launch until the health checks pass.
|
|||||||
|
|
||||||
For failed or interrupted creations, **Resume health verification** appears only
|
For failed or interrupted creations, **Resume health verification** appears only
|
||||||
when the original server is still registered on its assigned port and no later
|
when the original server is still registered on its assigned port and no later
|
||||||
lifecycle operation has targeted that ID. It resumes observation only; it never
|
lifecycle operation has targeted that ID. Native commands must have completed
|
||||||
recreates or restarts a container. Missing servers and later destruction attempts
|
before readiness verification can resume. If the container is unhealthy, the
|
||||||
require inspection rather than replaying provisioning automatically.
|
operation may continue its remaining automatic restart and rebuild recovery steps.
|
||||||
After an application restart, the worker automatically resumes observation of
|
|
||||||
eligible interrupted startups so their occupied slots are retained. It never
|
When the host conclusively reports that the initial creation command failed,
|
||||||
repeats container creation or deployment as part of this recovery.
|
**Retry with rebuild** starts a new provisioning operation for the same lobby and
|
||||||
|
port. It deletes the container and instance files and uses the latest promoted
|
||||||
|
setup. Normal player safety, capacity, and protected work checks still apply.
|
||||||
|
The original failure stays in history and links to the recovery operation.
|
||||||
|
This retry is unavailable for unknown command results, replacement instances,
|
||||||
|
cancelled creation cleanup, or a later operation on the same server.
|
||||||
|
|
||||||
|
After an application restart, the worker reconciles the original host command
|
||||||
|
before continuing eligible interrupted startups. Unknown command results retain
|
||||||
|
their original command ID and occupied slots until the host confirms the outcome.
|
||||||
|
Missing server registrations and later destruction attempts require inspection.
|
||||||
|
|
||||||
|
### Recovering a completed rebuild generation
|
||||||
|
|
||||||
|
A completed rebuild records the replacement generation before EC verifies health.
|
||||||
|
If fresh host inventory disagrees, verification pauses with the controller and
|
||||||
|
host generations in the operation's recovery message.
|
||||||
|
|
||||||
|
For an existing mismatch, open the **completed rebuild's Provisioning detail**
|
||||||
|
(even if that operation was later cancelled), then choose **Reconcile replacement
|
||||||
|
generation**. EC requires a completed command for the original host and immutable
|
||||||
|
instance ID, the command's requested replacement generation, and matching host
|
||||||
|
inventory observed within the last 30 seconds. Wait for that host to reconnect
|
||||||
|
and report fresh inventory if the action is unavailable. A server name alone is
|
||||||
|
not sufficient evidence.
|
||||||
|
|
||||||
|
Reconciliation preserves cancellation and submitted commands. Use **Resume health
|
||||||
|
verification** when offered, or request a new lifecycle operation after resolving
|
||||||
|
any remaining active operation. Fresh health checks, log reads, and new lifecycle
|
||||||
|
commands use the replacement generation. EC requires fresh container and game
|
||||||
|
evidence before reporting the server online. The agent continues to reject stale
|
||||||
|
commands; an admin player-safety override does not bypass generation checks.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -339,7 +370,12 @@ there while preserving query parameters.
|
|||||||
- `POST /api/servers`: existing request plus optional `previewId`; the game and
|
- `POST /api/servers`: existing request plus optional `previewId`; the game and
|
||||||
map must match that preview. `expectedName` remains supported.
|
map must match that preview. `expectedName` remains supported.
|
||||||
- `GET /api/deployments/:id`: includes `recipe`, `blueprintName`, and
|
- `GET /api/deployments/:id`: includes `recipe`, `blueprintName`, and
|
||||||
`canResumeVerification` for lifecycle operations.
|
`canResumeVerification` and `canRetryWithRebuild` for lifecycle operations.
|
||||||
- `POST /api/deployments/:id/resume`: resume eligible creation health checks.
|
- `POST /api/deployments/:id/resume`: resume eligible creation health checks.
|
||||||
|
- `POST /api/provisioning/:id/reconcile-generation`: reconcile a completed rebuild
|
||||||
|
using fresh inventory from its original host. Returns 409 when identity or
|
||||||
|
completion evidence cannot be validated; repeated application is safe.
|
||||||
|
- `POST /api/provisioning/:id/retry-rebuild`: request a new rebuild for an eligible
|
||||||
|
failed creation and link it from the original operation.
|
||||||
|
|
||||||
Collection launches begin processing immediately after all requests are queued.
|
Collection launches begin processing immediately after all requests are queued.
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Discord companion
|
||||||
|
|
||||||
|
Electric Cherry can publish live Black Ops I and Black Ops II server boards in
|
||||||
|
Discord, answer commands privately, and send requested map availability alerts by
|
||||||
|
DM. Configure it under **Admin → Discord**. Administrators can make changes;
|
||||||
|
Super Viewers can inspect configuration, the saved preview, and delivery health.
|
||||||
|
The integration is disabled by default. Once enabled, it publishes all current
|
||||||
|
and newly created game servers unless an administrator excludes them.
|
||||||
|
|
||||||
|
## Install the application
|
||||||
|
|
||||||
|
1. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications).
|
||||||
|
Copy its Application ID and Public Key, and create a bot token.
|
||||||
|
2. Set the controller environment variables below. Keep the token private; the
|
||||||
|
settings page reports whether it is configured without displaying its value.
|
||||||
|
With Docker Compose, put the values in the deployment's `.env` file and recreate
|
||||||
|
the controller to apply them. Do not commit that file.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
EC_DISCORD_APPLICATION_ID=
|
||||||
|
EC_DISCORD_PUBLIC_KEY=
|
||||||
|
EC_DISCORD_BOT_TOKEN=
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install the application into your Discord server using the `bot` and
|
||||||
|
`applications.commands` scopes. Grant **View Channel**, **Send Messages**, and
|
||||||
|
**Read Message History** in each board channel. No Administrator permission or
|
||||||
|
privileged Gateway intents are needed. Channel overrides must also permit the
|
||||||
|
bot to read and write those channels.
|
||||||
|
4. In the Developer Portal, set **Interactions Endpoint URL** to
|
||||||
|
`https://your-controller.example/api/discord/interactions`. Use your actual
|
||||||
|
public controller origin with a trusted HTTPS certificate. Discord verifies
|
||||||
|
the endpoint before accepting it. The controller checks Discord's request
|
||||||
|
signatures; this endpoint does not use the Electric Cherry login session.
|
||||||
|
5. In Discord, enable Developer Mode, then copy the Discord server ID and the
|
||||||
|
board channel IDs. Open **Admin → Discord**, enter those IDs, and uncheck any
|
||||||
|
game servers you want to exclude. Either game may omit its board channel.
|
||||||
|
6. Review the information-sharing switches, enable the companion, and save.
|
||||||
|
Select **Register commands** to install the commands in the saved Discord
|
||||||
|
server. Saving configuration and registering commands are separate actions.
|
||||||
|
|
||||||
|
The deployment's reverse proxy must route the interaction endpoint to the
|
||||||
|
controller and preserve Discord's signature headers and request body. No Gateway
|
||||||
|
connection is required. Installation scopes and channel permissions follow
|
||||||
|
Discord's [OAuth2 documentation](https://docs.discord.com/developers/topics/oauth2)
|
||||||
|
and [permissions reference](https://docs.discord.com/developers/topics/permissions).
|
||||||
|
See [HTTP interactions](https://docs.discord.com/developers/interactions/receiving-and-responding)
|
||||||
|
for Discord's endpoint verification and response requirements.
|
||||||
|
|
||||||
|
Use a dedicated Discord application for this controller. The command registration
|
||||||
|
action updates that application's command set in the configured Discord server.
|
||||||
|
|
||||||
|
## Choose what is published
|
||||||
|
|
||||||
|
All current and newly created game servers are included by default. Uncheck a
|
||||||
|
server to exclude it from boards, private server browsing, server details, and
|
||||||
|
availability watches. Exclusions are retained if a game server is removed, so a
|
||||||
|
server with the same name remains excluded if it returns. Check an excluded
|
||||||
|
server again to remove that exclusion.
|
||||||
|
|
||||||
|
Player names in server details and public connection addresses are separate,
|
||||||
|
disabled-by-default options. Connection instructions use an explicitly configured
|
||||||
|
native host public endpoint; internal host addresses and RCON credentials are not
|
||||||
|
published. Capacity, player counts, map, round, and observation state can appear
|
||||||
|
without either switch enabled.
|
||||||
|
|
||||||
|
**Publish fleet leaderboards** is a separate decision. It exposes recorded player
|
||||||
|
names and statistics from the whole fleet, including historical matches on
|
||||||
|
excluded servers. Leave it disabled if those
|
||||||
|
statistics should remain available only to Electric Cherry users.
|
||||||
|
|
||||||
|
The settings page's preview uses the **saved settings**. Pending form edits do
|
||||||
|
not change the preview or Discord. The preview remains available while the
|
||||||
|
integration is disabled.
|
||||||
|
|
||||||
|
## Boards and browsing
|
||||||
|
|
||||||
|
Each configured game channel receives a persistent server board, split into
|
||||||
|
additional messages when needed. The controller revisits delivery about every
|
||||||
|
30 seconds and edits changed content. Stored message IDs survive controller
|
||||||
|
restarts. Periodic existence checks recreate messages that were removed.
|
||||||
|
|
||||||
|
Boards show observed occupancy, capacity and current round where available,
|
||||||
|
with separate empty, in-progress, full, starting, offline, not-responding, and
|
||||||
|
unknown states. Freshness is part of the observation. **Empty slots do not prove
|
||||||
|
that a match accepts new players**; this release does not label games joinable.
|
||||||
|
Capacity defaults to four players, matching the fleet UI, until a fresh reported
|
||||||
|
capacity overrides it. A default capacity does not make stale player counts
|
||||||
|
current or allow a stale observation to trigger an availability alert.
|
||||||
|
|
||||||
|
Members can use the board controls or slash commands. Browsing, filters, details,
|
||||||
|
leaderboards, and command confirmations are visible only to the member who
|
||||||
|
requested them. They do not rearrange the shared board.
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| ------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `/servers` | Browse published servers; filter by game, map and state, and page through results. |
|
||||||
|
| `/server name` | Inspect one published server, including optional roster names and connection instructions. |
|
||||||
|
| `/leaderboard` | Browse recorded fleet rankings by game, map, metric, squad size and page, when enabled. |
|
||||||
|
| `/watch add game map condition` | Request a one-shot DM when a map has an empty lobby or observed space. |
|
||||||
|
| `/watch list` | See your active watches and their IDs. |
|
||||||
|
| `/watch cancel id` | Cancel one of your own watches. |
|
||||||
|
| `/help` | Explain the commands and their limits. |
|
||||||
|
|
||||||
|
Commands are limited to the configured Discord server. Map options preserve the
|
||||||
|
game's map variants. Leaderboard results reflect recorded gameplay, not a new
|
||||||
|
claim that a match was won or that a player survived continuously.
|
||||||
|
|
||||||
|
## Availability alerts
|
||||||
|
|
||||||
|
A watch lasts up to 24 hours and sends one DM when its condition is satisfied.
|
||||||
|
Each member may have up to ten active watches. Requesting the same map and
|
||||||
|
condition again reuses the existing watch. Members can cancel only their own
|
||||||
|
watches.
|
||||||
|
|
||||||
|
An **empty** watch needs an observed empty lobby. A **space** watch needs fewer
|
||||||
|
players than the current capacity, using the four-player default when no fresh
|
||||||
|
capacity override is available. Neither condition promises that
|
||||||
|
joining is allowed. Unknown, stale, and offline observations never satisfy a
|
||||||
|
watch. The worker requires two qualifying observations at least 15 seconds apart
|
||||||
|
before queueing a notification.
|
||||||
|
|
||||||
|
Watches and pending notifications persist across controller restarts. Delivery
|
||||||
|
handles transient failures and Discord rate limits. A permanent DM failure, such
|
||||||
|
as a member blocking messages from the bot, ends that watch. Members should allow
|
||||||
|
DMs from the bot before creating a watch and can inspect active watches with
|
||||||
|
`/watch list`.
|
||||||
|
|
||||||
|
## Delivery and troubleshooting
|
||||||
|
|
||||||
|
The settings page shows the last attempt, last success, current error, active
|
||||||
|
watches, pending notifications, and links to recorded board channels. Refreshing
|
||||||
|
that page preserves pending edits. A failure keeps the last loaded information
|
||||||
|
visible.
|
||||||
|
|
||||||
|
- **Credentials need attention:** check all three environment variables and
|
||||||
|
restart the controller after changing them. A readiness indicator checks local
|
||||||
|
configuration; it does not establish that Discord has accepted the token.
|
||||||
|
- **Discord rejects the interaction endpoint:** verify HTTPS routing, the
|
||||||
|
application's Public Key, and that the reverse proxy preserves signed
|
||||||
|
requests. Discord must reach the endpoint without an interactive login.
|
||||||
|
- **Buttons time out without a private loading message:** inspect the controller
|
||||||
|
logs while clicking once (`docker compose logs --since=5m electric-cherry`).
|
||||||
|
A `[discord] Deferring interaction` entry means the signed request reached the
|
||||||
|
handler and it prepared a private acknowledgement before database work. A
|
||||||
|
rejection entry identifies signature, body, or application-ID validation
|
||||||
|
failures. An application-ID mismatch also returns a private explanation;
|
||||||
|
`EC_DISCORD_APPLICATION_ID` must identify the same application as the Public
|
||||||
|
Key and bot token. If no entry appears, check the proxy and saved Interactions
|
||||||
|
Endpoint URL. A verified endpoint and working boards alone do not confirm
|
||||||
|
that button clicks are reaching the running controller.
|
||||||
|
- **Commands are missing:** save the correct Discord server ID, install the bot
|
||||||
|
with `applications.commands`, then select **Register commands**. Register again
|
||||||
|
after upgrading to a release that changes the command schema.
|
||||||
|
- **Board delivery is denied:** check the bot's permissions and channel overrides.
|
||||||
|
Authorization failures pause retries. After correcting access, select **Save
|
||||||
|
and retry** in Electric Cherry.
|
||||||
|
- **A board was deleted:** the controller's periodic check recreates it. Keep
|
||||||
|
Read Message History enabled so those checks can verify existing messages.
|
||||||
|
- **Discord rate limits or temporary outages:** delivery honors retry delays and
|
||||||
|
retries transient failures with increasing delays. Fleet operations continue
|
||||||
|
independently.
|
||||||
|
- **Old board messages remain after a channel change or disable:** the controller
|
||||||
|
attempts to remove old messages. If permissions were removed first, an
|
||||||
|
administrator may need to delete those messages in Discord.
|
||||||
|
|
||||||
|
Disabling the companion stops new publication and notifications. Cleanup may
|
||||||
|
still remove previously recorded board messages. Data already seen or copied by
|
||||||
|
members cannot be recalled by changing publication settings.
|
||||||
|
|
||||||
|
This release does not link Discord accounts to game identities, expose personal
|
||||||
|
bank or AFK accounts, mutate servers or moderation state, or announce
|
||||||
|
achievements. Those workflows require separate authorization and implementation.
|
||||||
@@ -9,6 +9,8 @@ HTTPS. Running game containers continue through controller or agent outages.
|
|||||||
|
|
||||||
- A controller machine with Docker Compose and an HTTPS reverse proxy.
|
- A controller machine with Docker Compose and an HTTPS reverse proxy.
|
||||||
- Debian 12 or 13 amd64 game hosts with storage for game files and instances.
|
- Debian 12 or 13 amd64 game hosts with storage for game files and instances.
|
||||||
|
- A signed agent release and its public key published in the trusted repository.
|
||||||
|
- Outbound HTTPS access from game hosts to the controller and public repository.
|
||||||
- Your complete T5 or T6 game folders, including the DLC you intend to host.
|
- Your complete T5 or T6 game folders, including the DLC you intend to host.
|
||||||
- Plutonium server keys for each game, with one unused key per new concurrent lobby.
|
- Plutonium server keys for each game, with one unused key per new concurrent lobby.
|
||||||
|
|
||||||
@@ -26,25 +28,42 @@ openssl rand -hex 32
|
|||||||
|
|
||||||
Set these values in `.env`:
|
Set these values in `.env`:
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
| `EC_PUBLIC_URL` | The external HTTPS address of the dashboard. |
|
| `EC_PUBLIC_URL` | The external HTTPS address of the dashboard. |
|
||||||
| `EC_SETUP_TOKEN` | The generated secret, used to create the first administrator. |
|
| `EC_SETUP_TOKEN` | The generated secret, used to create the first administrator. |
|
||||||
| `EC_DATA_DIR` | A persistent directory for the database, game files, and beta archives. |
|
| `EC_DATA_DIR` | A persistent directory for the database, game files, and beta archives. |
|
||||||
| `EC_TELEMETRY_TOKEN` | A separate secret of at least 32 characters for game reporters. Generate it independently with the same command. |
|
| `EC_TELEMETRY_TOKEN` | A separate secret of at least 32 characters for game reporters. Generate it independently with the same command. |
|
||||||
|
|
||||||
For an existing installation, retain its original data directory. Compose
|
For an existing installation, retain its original data directory. Compose
|
||||||
defaults to `/root/zombies/.electric-cherry`; choose a dedicated directory for a
|
defaults to `/root/zombies/.electric-cherry`; choose a dedicated directory for a
|
||||||
new controller. See [.env.example](../../.env.example) for the other settings.
|
new controller. See [.env.example](../../.env.example) for the other settings.
|
||||||
|
|
||||||
|
Before building for game hosts, obtain the publisher's signed `static/agent`
|
||||||
|
artifacts and matching source revision, or follow
|
||||||
|
[Publishing signed releases](native-hosts.md#publishing-signed-releases)
|
||||||
|
for your own build. Normal builds preserve signatures for identical artifacts;
|
||||||
|
source changes require a new offline signature. An unsigned controller can run,
|
||||||
|
but hosts reject its agent installation and updates.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Proxy the HTTPS address to controller port 3000. Open the dashboard and follow
|
Proxy the HTTPS address to controller port 3000. Enable gzip or Brotli compression
|
||||||
`/setup` to create the first administrator. Then remove `EC_SETUP_TOKEN` from
|
at the HTTPS reverse proxy for HTML (`text/html`) and JSON (`application/json`),
|
||||||
`.env` and run `docker compose up -d` to recreate the controller. Setup stays
|
including server-rendered pages, page data, and API responses. The build already
|
||||||
closed once an account exists.
|
precompresses static JavaScript and CSS assets; dynamically generated responses
|
||||||
|
need compression at the proxy.
|
||||||
|
|
||||||
|
Configure `ADDRESS_HEADER` and `XFF_DEPTH` for your trusted proxy so login limits
|
||||||
|
use each client's address. Leave `ADDRESS_HEADER` empty for direct connections;
|
||||||
|
see the [proxy configuration guide](authentication.md#proxy-and-local-development)
|
||||||
|
before enabling forwarded addresses.
|
||||||
|
|
||||||
|
Open the dashboard and follow `/setup` to create the first administrator. Then
|
||||||
|
remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the
|
||||||
|
controller. Setup stays closed once an account exists.
|
||||||
|
|
||||||
Add other users under **Admin → Users**. The
|
Add other users under **Admin → Users**. The
|
||||||
[access guide](authentication.md) explains roles, proxy configuration, and
|
[access guide](authentication.md) explains roles, proxy configuration, and
|
||||||
@@ -55,15 +74,23 @@ password recovery.
|
|||||||
1. Open **Releases → Game files** and upload the complete local folder for T5 or
|
1. Open **Releases → Game files** and upload the complete local folder for T5 or
|
||||||
T6, including DLC. Uploads can run before any host is enrolled. The latest
|
T6, including DLC. Uploads can run before any host is enrolled. The latest
|
||||||
verified upload becomes that game's default files.
|
verified upload becomes that game's default files.
|
||||||
2. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
|
2. Ensure the release publisher has [published its public key and signed release](native-hosts.md#publishing-signed-releases).
|
||||||
bind address, and run the generated enrollment command on the host.
|
Hosts fetch the key automatically during installation and before every update
|
||||||
3. Wait for automatic setup to prepare the Plutainer runtime and available game
|
check; there is no manual key-copy step.
|
||||||
|
3. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
|
||||||
|
bind address, and run the generated enrollment command. It downloads the
|
||||||
|
installer from the fixed public repository URL before running it. Check that
|
||||||
|
the installer source is the known repository, independent of the controller.
|
||||||
|
4. Wait for automatic setup to prepare the Plutainer runtime and available game
|
||||||
files. The host page reports download, verification, and readiness progress.
|
files. The host page reports download, verification, and readiness progress.
|
||||||
|
|
||||||
The installer sets up the Python agent as a systemd service and enables automatic
|
The installer sets up the Python agent as a systemd service and enables automatic
|
||||||
agent updates. It installs Docker if needed. Multiple instances share read-only
|
agent updates. It installs Docker if needed. Multiple instances share read-only
|
||||||
base game files; each instance has its own writable data. See
|
base game files; each instance has its own writable data. See
|
||||||
[Native hosts](native-hosts.md) for storage layout, network access, and recovery.
|
[Native hosts](native-hosts.md) for storage layout, network access, and recovery.
|
||||||
|
Existing hosts using updater protocol 1 need a
|
||||||
|
[trusted installer migration](native-hosts.md#automatic-agent-updates) before
|
||||||
|
controller-independent release verification takes effect.
|
||||||
|
|
||||||
## Launch your first server
|
## Launch your first server
|
||||||
|
|
||||||
@@ -86,6 +113,35 @@ servers. Disruptive operations normally wait for a fresh empty lobby or a
|
|||||||
verified stopped container. A rebuild removes the instance's writable files.
|
verified stopped container. A rebuild removes the instance's writable files.
|
||||||
See [Provisioning](provisioning.md) and [Collections](collections.md) for details.
|
See [Provisioning](provisioning.md) and [Collections](collections.md) for details.
|
||||||
|
|
||||||
|
## Script release versions
|
||||||
|
|
||||||
|
Every new script upload needs a release version in `MAJOR.MINOR.PATCH` form,
|
||||||
|
for example `1.2.0`. Enter a version for each file in the upload drawer, script
|
||||||
|
page, or beta replacement dialog. Versions belong to individual scripts; a batch
|
||||||
|
can contain different versions.
|
||||||
|
|
||||||
|
To fill empty fields automatically, choose `versions.json` from the same source
|
||||||
|
checkout as your scripts. Cherry matches exact, case-sensitive filenames (such as
|
||||||
|
`tws_t5_hud.gsc`) and leaves entered values intact. Review the values before
|
||||||
|
uploading. A manifest identifies source releases; it does not verify file contents.
|
||||||
|
|
||||||
|
Changing a script requires a new release version. Reuploading identical bytes with
|
||||||
|
the same version reuses the saved release; different bytes with that version are
|
||||||
|
rejected. Older release versions can be imported. Cherry keeps its upload number
|
||||||
|
separately, displaying labels such as `1.2.0 (upload 7)`.
|
||||||
|
|
||||||
|
Historical scripts without a release label display `Unversioned (upload N)` and
|
||||||
|
remain available for testing, promotion, and rollback. Cherry does not infer old
|
||||||
|
versions from today's manifest or rewrite historical records. Uploading the same
|
||||||
|
bytes with an explicit version creates a separate labeled record when needed.
|
||||||
|
Plugins retain optional publisher tags; configurations retain automatic upload
|
||||||
|
numbers.
|
||||||
|
|
||||||
|
API clients must include multipart `releaseVersion` when creating a script with
|
||||||
|
`POST /api/scripts` or uploading with `POST /api/scripts/:id/versions`. Missing or
|
||||||
|
invalid versions return HTTP 400; conflicting contents return HTTP 409. The
|
||||||
|
version upload response reports `reused: true` for an existing identical release.
|
||||||
|
|
||||||
## Connect game telemetry
|
## Connect game telemetry
|
||||||
|
|
||||||
Configure your game reporter or plugin to send `EC_TELEMETRY_TOKEN` in an
|
Configure your game reporter or plugin to send `EC_TELEMETRY_TOKEN` in an
|
||||||
|
|||||||
+159
-86
@@ -13,10 +13,11 @@ only under All squad sizes. The JSON API also defaults to all maps.
|
|||||||
## Qualifying matches
|
## Qualifying matches
|
||||||
|
|
||||||
Matches only appear in server/global history once they reach round 2.
|
Matches only appear in server/global history once they reach round 2.
|
||||||
Leaderboards, summaries, map options, profiles and player history include only
|
Leaderboards, summaries, map options, profile statistics and player history include only
|
||||||
completed matches with a received `game_end`. Active and interrupted matches do
|
completed matches with a received `game_end`. Active, interrupted, and
|
||||||
not contribute. A player must also be observed at round 2 or later; players who
|
**End report missing** matches do not contribute. A player must also be observed
|
||||||
left before a completed game's end retain their recorded stats.
|
at round 2 or later; players who left before a completed game's end retain their
|
||||||
|
recorded stats.
|
||||||
This also excludes existing round 1 records. Early telemetry is retained so
|
This also excludes existing round 1 records. Early telemetry is retained so
|
||||||
qualifying games keep their start time, roster and cumulative counters.
|
qualifying games keep their start time, roster and cumulative counters.
|
||||||
|
|
||||||
@@ -24,6 +25,12 @@ qualifying games keep their start time, roster and cumulative counters.
|
|||||||
|
|
||||||
### Profiles and filters
|
### Profiles and filters
|
||||||
|
|
||||||
|
Players have a profile as soon as they are first reported, even before they have
|
||||||
|
qualifying match history. These profiles show their identity, an explanation of
|
||||||
|
when statistics become available, and dashes for personal bests with no recorded
|
||||||
|
match. The page fills in on refresh after a qualifying game ends. Unknown player
|
||||||
|
GUIDs still return 404.
|
||||||
|
|
||||||
Click a player's name to open their shareable profile at
|
Click a player's name to open their shareable profile at
|
||||||
`/leaderboard/players/[guid]`. Profiles combine Black Ops I (T5) and Black Ops II
|
`/leaderboard/players/[guid]`. Profiles combine Black Ops I (T5) and Black Ops II
|
||||||
(T6) records for the same GUID. Existing `/leaderboard/players/[game]/[guid]`
|
(T6) records for the same GUID. Existing `/leaderboard/players/[game]/[guid]`
|
||||||
@@ -68,9 +75,22 @@ Completed matches link to their own results page at `/matches/[id]` from global
|
|||||||
Match History, server history, and player profiles. The page shows the recorded
|
Match History, server history, and player profiles. The page shows the recorded
|
||||||
map and server, final round, start/end times, duration when the start was observed,
|
map and server, final round, start/end times, duration when the start was observed,
|
||||||
player counts, and the full saved roster. Players missing from the final report
|
player counts, and the full saved roster. Players missing from the final report
|
||||||
retain their earlier counters; players with no stats display dashes. Counters and
|
retain their earlier counters; players with no stats display dashes. Counters are
|
||||||
score are the highest reported values within the match, not necessarily its final
|
the highest reported values within the match, not necessarily its final scoreboard.
|
||||||
scoreboard. Active and interrupted matches remain listed without results links.
|
|
||||||
|
Each match shows one score column. If any player has a recorded total (including
|
||||||
|
zero), **Total score** shows each player's highest recorded cumulative points
|
||||||
|
earned in that match. Players absent from the final report retain their latest
|
||||||
|
recorded total. A dash in this column means no total was recorded for that player;
|
||||||
|
their points balance is never substituted. Historical matches where all totals
|
||||||
|
are missing retain **Highest reported score**, the highest recorded points
|
||||||
|
balance, not total points earned or necessarily the final score. This choice is
|
||||||
|
based on recorded data, not the match date. It does not change leaderboard or
|
||||||
|
profile score rankings, which still use balances.
|
||||||
|
|
||||||
|
Active, interrupted, and **End report missing** matches remain listed
|
||||||
|
without results links. Matches can filter each status and show known gaps in the
|
||||||
|
reporter's sequence; a delayed report can reduce that missing-report count.
|
||||||
Match pages remain available after the original server is removed.
|
Match pages remain available after the original server is removed.
|
||||||
|
|
||||||
### Player identity
|
### Player identity
|
||||||
@@ -87,15 +107,34 @@ of inferring one. GUIDs stay strings.
|
|||||||
|
|
||||||
## Recorded statistics
|
## Recorded statistics
|
||||||
|
|
||||||
| Statistic | Meaning |
|
Telemetry stat reports (`round_change`, `heartbeat`, and `game_end`) may include
|
||||||
| --- | --- |
|
`score_total`, a nonnegative integer read from the game's cumulative match score.
|
||||||
| Highest round | Highest round reported with that player present, including players who joined mid-game. |
|
`score` remains the spendable balance. Updated T5/T6 reporters capture game end
|
||||||
| Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. |
|
at the first end signal, before stock intermission replaces the balance with the
|
||||||
| Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. |
|
total. `game_start` remains identity-only. Deploy the receiver before the updated
|
||||||
| Games played | Completed games in which a stats report at round 2 or later contains the player, even if they left before the end. Start-only and round 1 participation do not count. |
|
reporters so it retains the new field.
|
||||||
| Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. |
|
|
||||||
| Best game kills | Highest kill count recorded in any one game. |
|
The receiver accepts older reports without `score_total`. Latest telemetry
|
||||||
| Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. |
|
preserves the field when supplied; match history retains the highest observed
|
||||||
|
total independently of the balance and exposes it as `scoreTotal` on each player
|
||||||
|
in the match-detail API. Missing totals and pre-upgrade history are unknown
|
||||||
|
(`null`), not zero, and are never inferred from balances. Totals are per match;
|
||||||
|
repeated snapshots are not added together. Existing score rankings continue to
|
||||||
|
use the highest reported balance.
|
||||||
|
|
||||||
|
| Statistic | Meaning |
|
||||||
|
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Highest round | Highest round reported with that player present, including players who joined mid-game. |
|
||||||
|
| Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. |
|
||||||
|
| Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. |
|
||||||
|
| Games played | Completed games in which a stats report at round 2 or later contains the player, excluding players who left in the same round they joined. Staying through game end counts even without a round change. Legacy participation with an unknown joining round still counts. Start-only and round 1 participation do not count. |
|
||||||
|
| Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. |
|
||||||
|
| Best game kills | Highest kill count recorded in any one game. |
|
||||||
|
| Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. |
|
||||||
|
|
||||||
|
Same-round departures retain their recorded stats, personal bests, and match
|
||||||
|
history; only the games-played count excludes them. This also changes the game
|
||||||
|
count used for efficiency qualification and per-game averages.
|
||||||
|
|
||||||
Every ranking sorts highest first, including downs (a count, not an efficiency
|
Every ranking sorts highest first, including downs (a count, not an efficiency
|
||||||
rating). Equal values share a rank; name and GUID provide a stable display order.
|
rating). Equal values share a rank; name and GUID provide a stable display order.
|
||||||
@@ -116,8 +155,9 @@ rate also requires 100 kills; kills per down requires five downs. The board show
|
|||||||
the numerator, denominator and game count beside each ratio. Ratios are computed
|
the numerator, denominator and game count beside each ratio. Ratios are computed
|
||||||
from aggregate counters, not an average of individual match percentages.
|
from aggregate counters, not an average of individual match percentages.
|
||||||
Completion rate is the share of those ended games whose final report contained
|
Completion rate is the share of those ended games whose final report contained
|
||||||
the player; it is not a win rate. Active and interrupted games do not enter any
|
the player; it is not a win rate. Games without a received `game_end`, including
|
||||||
denominator. Zero-denominator ratios remain unknown and are not ranked.
|
**End report missing** games, do not enter any denominator. Zero-denominator ratios
|
||||||
|
remain unknown and are not ranked.
|
||||||
|
|
||||||
Qualification is applied before ranking, search and pagination. Summary cards
|
Qualification is applied before ranking, search and pagination. Summary cards
|
||||||
still describe all players in the game/map selection, including those below the
|
still describe all players in the game/map selection, including those below the
|
||||||
@@ -139,12 +179,41 @@ They persist across app restarts and server deletion, and are not subject to
|
|||||||
campaign history retention. The latest scoreboard and leaderboard update in the
|
campaign history retention. The latest scoreboard and leaderboard update in the
|
||||||
same transaction. Each match keeps player counters even after someone leaves.
|
same transaction. Each match keeps player counters even after someone leaves.
|
||||||
|
|
||||||
A `game_start` opens a match; `game_end` completes it. A changed map, a lower round,
|
A `game_start` opens a match; `game_end` completes it. Reporters can send an optional
|
||||||
or a round report after a completed game can also establish a new match when the
|
`match` key and per-game `seq` on every event; see the
|
||||||
start was missed. Superseded active matches remain in history as interrupted.
|
[reporter protocol](beta-test-plans.md#event-fields) for field limits and retry rules.
|
||||||
Repeated starts before any stats belong to the same match. Identical consecutive
|
A key identifies one game on one server, including when the start report was
|
||||||
payloads (ignoring receipt time and roster order) do not add records. Repeated
|
missed. An unseen key opens a new game and labels the previous active keyed game
|
||||||
snapshots in a match cannot increase counters or rounds twice.
|
**End report missing**. This records missing evidence; it does not establish
|
||||||
|
whether the game finished or was interrupted. A late `game_end` for a previously
|
||||||
|
seen key can still complete its own match; reports for known older keys do not
|
||||||
|
replace the newer game's live state.
|
||||||
|
|
||||||
|
Keys are opaque, and `seq` orders reports only within one game. Electric Cherry
|
||||||
|
therefore orders games by their first processed report. If game `new` is already
|
||||||
|
reporting when the first report for game `old` arrives, the unseen `old` key
|
||||||
|
supersedes `new` and marks it **End report missing**. Later `new` heartbeats update
|
||||||
|
its history but cannot restore it as the live game. If `old` arrived as a
|
||||||
|
heartbeat, it can remain active even after `new` ends. Reporters should preserve
|
||||||
|
delivery order across games, keeping queued retries ahead of newer games' reports.
|
||||||
|
Reliably ordering unseen keys would require additional reporter metadata, such
|
||||||
|
as a persistent, increasing per-server game counter; the current protocol has no
|
||||||
|
such field.
|
||||||
|
|
||||||
|
With both fields present, each sequence is counted once, including across app
|
||||||
|
restarts. Lower sequences fill missing-report gaps without rolling back newer
|
||||||
|
rounds, counters, or rosters. A delayed `game_start` may fill the recorded starting
|
||||||
|
roster without resetting progression. The history API exposes the highest
|
||||||
|
sequence minus the count of unique received sequences as `missingReports`, or
|
||||||
|
`null` when sequencing is unavailable. A missing report count does not grant
|
||||||
|
credit for unobserved rounds or complete a game without `game_end`.
|
||||||
|
|
||||||
|
Without a match key, the legacy heuristics remain: a changed map, a lower round,
|
||||||
|
or a round report after a completed game can establish a new match when the
|
||||||
|
start was missed. Superseded active matches remain interrupted. Repeated starts
|
||||||
|
before any stats belong to the same match. Identical consecutive payloads
|
||||||
|
(ignoring receipt time and roster order) do not add records. Repeated snapshots
|
||||||
|
in a match cannot increase counters or rounds twice.
|
||||||
|
|
||||||
The first upgrade imports the latest stored full scoreboard from each server
|
The first upgrade imports the latest stored full scoreboard from each server
|
||||||
once. That provides the available counters and highest round, but not discarded
|
once. That provides the available counters and highest round, but not discarded
|
||||||
@@ -157,14 +226,13 @@ explicitly identified instead of being labeled as a known selection. A match
|
|||||||
retains the game/map identity recorded at ingestion even if the server is later
|
retains the game/map identity recorded at ingestion even if the server is later
|
||||||
reconfigured or removed.
|
reconfigured or removed.
|
||||||
|
|
||||||
The current protocol sends events in gameplay order and has no match ID, event
|
Legacy reports without match identity cannot reliably distinguish a late report
|
||||||
ID, or source sequence. A late report from an older game cannot be reliably
|
from an older game from a new game. Likewise, identical starts before any stats
|
||||||
distinguished from a new game. Likewise, identical starts before any stats are
|
are indistinguishable from retries. Send those reports once in order. Within a
|
||||||
indistinguishable from retries. Send reports once in order. Within a match,
|
match, counter decreases use the previous high-water mark; if a reconnect resets
|
||||||
counter decreases use the previous high-water mark; if a reconnect resets a
|
a player's counters, subsequent activity may be undercounted until those counters
|
||||||
player's counters, subsequent activity may be undercounted until those counters
|
pass their earlier maxima. Match keys and sequence numbers do not identify
|
||||||
pass their earlier maxima. Exact replay handling and counter-reset accounting
|
player counter resets.
|
||||||
would require additional reporter identity/ordering information.
|
|
||||||
|
|
||||||
## Read API
|
## Read API
|
||||||
|
|
||||||
@@ -207,15 +275,15 @@ example above works for command-line or server-side clients. Successful response
|
|||||||
All parameters are optional. Names and enumerated values are case-sensitive.
|
All parameters are optional. Names and enumerated values are case-sensitive.
|
||||||
Use URL encoding for map keys and search text.
|
Use URL encoding for map keys and search text.
|
||||||
|
|
||||||
| Parameter | Default | Accepted values and behavior |
|
| Parameter | Default | Accepted values and behavior |
|
||||||
| --- | --- | --- |
|
| --------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `game` | Both games | Exactly `t5` or `t6`. An empty value is invalid. |
|
| `game` | Both games | Exactly `t5` or `t6`. An empty value is invalid. |
|
||||||
| `map` | All maps | Opaque `maps[].key`, at most 1024 characters. Empty means no map filter. An unknown key or a game/map mismatch returns an empty selection, not an error. |
|
| `map` | All maps | Opaque `maps[].key`, at most 1024 characters. Empty means no map filter. An unknown key or a game/map mismatch returns an empty selection, not an error. |
|
||||||
| `players` | All squad sizes | Exactly `1`, `2`, `3`, or `4`, matching the recorded starting player count. Unknown counts contribute only when this filter is absent. |
|
| `players` | All squad sizes | Exactly `1`, `2`, `3`, or `4`, matching the recorded starting player count. Unknown counts contribute only when this filter is absent. |
|
||||||
| `metric` | `highestRound` | One of the metric keys below. An empty or unknown value is invalid. |
|
| `metric` | `highestRound` | One of the metric keys below. An empty or unknown value is invalid. |
|
||||||
| `q` | No search | At most 100 characters before trimming. Matches a literal substring of the current name or GUID, ignoring ASCII letter case. Surrounding whitespace is trimmed; whitespace-only input means no search. `%` and `_` are literal characters, not wildcards. |
|
| `q` | No search | At most 100 characters before trimming. Matches a literal substring of the current name or GUID, ignoring ASCII letter case. Surrounding whitespace is trimmed; whitespace-only input means no search. `%` and `_` are literal characters, not wildcards. |
|
||||||
| `limit` | `50` | Integer from 1 to 100. |
|
| `limit` | `50` | Integer from 1 to 100. |
|
||||||
| `offset` | `0` | Integer from 0 to 1,000,000; counts matching entries to skip, not ranks or pages. |
|
| `offset` | `0` | Integer from 0 to 1,000,000; counts matching entries to skip, not ranks or pages. |
|
||||||
|
|
||||||
`limit` and `offset` accept decimal digits only. Empty strings, signs, spaces,
|
`limit` and `offset` accept decimal digits only. Empty strings, signs, spaces,
|
||||||
fractions and exponent notation are rejected rather than clamped. String
|
fractions and exponent notation are rejected rather than clamped. String
|
||||||
@@ -231,40 +299,40 @@ Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives
|
|||||||
HTTP `200` returns the following top-level fields directly, with no `ok` or
|
HTTP `200` returns the following top-level fields directly, with no `ok` or
|
||||||
`data` wrapper:
|
`data` wrapper:
|
||||||
|
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
| --- | --- | --- |
|
| ----------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `entries` | Entry array | Requested page, ordered by the selected metric descending. |
|
| `entries` | Entry array | Requested page, ordered by the selected metric descending. |
|
||||||
| `maps` | Map array | Available completed-match map options, filtered only by `game`, independent of `map`, `q`, metric qualification and pagination. |
|
| `maps` | Map array | Available completed-match map options, filtered only by `game`, independent of `map`, `q`, metric qualification and pagination. |
|
||||||
| `total` | number | Number of matching GUIDs after metric qualification and search, before pagination. |
|
| `total` | number | Number of matching GUIDs after metric qualification and search, before pagination. |
|
||||||
| `limit`, `offset` | number | Accepted pagination values. |
|
| `limit`, `offset` | number | Accepted pagination values. |
|
||||||
| `metric` | string | Selected metric key, including the default when omitted. |
|
| `metric` | string | Selected metric key, including the default when omitted. |
|
||||||
| `summary` | object | Totals for all qualifying completed participation in the game/map scope, independent of `q`, metric qualification and pagination. |
|
| `summary` | object | Totals for all qualifying completed participation in the game/map scope, independent of `q`, metric qualification and pagination. |
|
||||||
|
|
||||||
Each entry combines one GUID's qualifying participation across the selected
|
Each entry combines one GUID's qualifying participation across the selected
|
||||||
games/maps. It contains all of these fields regardless of the selected metric:
|
games/maps. It contains all of these fields regardless of the selected metric:
|
||||||
|
|
||||||
| Entry field | Type | Meaning |
|
| Entry field | Type | Meaning |
|
||||||
| --- | --- | --- |
|
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `guid` | string | Player identity. Preserve it as a string, including leading zeros. |
|
| `guid` | string | Player identity. Preserve it as a string, including leading zeros. |
|
||||||
| `name` | string | Latest reported name across both games, even if that report is outside the selected scope or from an unfinished match. |
|
| `name` | string | Latest reported name across both games, even if that report is outside the selected scope or from an unfinished match. |
|
||||||
| `rank` | number | Rank within the scope after metric qualification, before search/pagination. Ties share rank and leave gaps: 1, 1, 3. |
|
| `rank` | number | Rank within the scope after metric qualification, before search/pagination. Ties share rank and leave gaps: 1, 1, 3. |
|
||||||
| `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`, `downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore` | number | The ten counters defined under [Recorded statistics](#recorded-statistics). |
|
| `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`, `downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore` | number | The ten counters defined under [Recorded statistics](#recorded-statistics). |
|
||||||
| `headshotRate`, `killsPerDown`, `averageKills`, `averageRounds`, `completionRate` | number or null | Ratios defined below; `null` if the denominator is zero. |
|
| `headshotRate`, `killsPerDown`, `averageKills`, `averageRounds`, `completionRate` | number or null | Ratios defined below; `null` if the denominator is zero. |
|
||||||
| `lastSeen` | string | Latest player observation among the qualifying participation in this scope, not the latest global identity update. |
|
| `lastSeen` | string | Latest player observation among the qualifying participation in this scope, not the latest global identity update. |
|
||||||
| `records` | object | Personal-best evidence keyed by `highestRound`, `bestKills` and `bestScore`. |
|
| `records` | object | Personal-best evidence keyed by `highestRound`, `bestKills` and `bestScore`. |
|
||||||
|
|
||||||
Entries have no `game` field. Equal metric values are displayed in name order
|
Entries have no `game` field. Equal metric values are displayed in name order
|
||||||
(ASCII case-insensitive, then exact name), then GUID order. `q` is a substring
|
(ASCII case-insensitive, then exact name), then GUID order. `q` is a substring
|
||||||
search, not an exact-GUID lookup; a tool looking up one GUID must check equality
|
search, not an exact-GUID lookup; a tool looking up one GUID must check equality
|
||||||
on returned `guid` values and account for pagination.
|
on returned `guid` values and account for pagination.
|
||||||
|
|
||||||
| Ratio | Calculation | Requirement to rank by this metric |
|
| Ratio | Calculation | Requirement to rank by this metric |
|
||||||
| --- | --- | --- |
|
| ---------------- | ------------------------------ | ------------------------------------- |
|
||||||
| `headshotRate` | `headshots / kills` | `gamesPlayed >= 5` and `kills >= 100` |
|
| `headshotRate` | `headshots / kills` | `gamesPlayed >= 5` and `kills >= 100` |
|
||||||
| `killsPerDown` | `kills / downs` | `gamesPlayed >= 5` and `downs >= 5` |
|
| `killsPerDown` | `kills / downs` | `gamesPlayed >= 5` and `downs >= 5` |
|
||||||
| `averageKills` | `kills / gamesPlayed` | `gamesPlayed >= 5` |
|
| `averageKills` | `kills / gamesPlayed` | `gamesPlayed >= 5` |
|
||||||
| `averageRounds` | `roundsSurvived / gamesPlayed` | `gamesPlayed >= 5` |
|
| `averageRounds` | `roundsSurvived / gamesPlayed` | `gamesPlayed >= 5` |
|
||||||
| `completionRate` | `gamesCompleted / gamesPlayed` | `gamesPlayed >= 5` |
|
| `completionRate` | `gamesCompleted / gamesPlayed` | `gamesPlayed >= 5` |
|
||||||
|
|
||||||
These are unrounded ratios, not formatted strings. For `headshotRate` and
|
These are unrounded ratios, not formatted strings. For `headshotRate` and
|
||||||
`completionRate`, `0.5` means 50%. Requirements use the selected game/map scope
|
`completionRate`, `0.5` means 50%. Requirements use the selected game/map scope
|
||||||
@@ -279,14 +347,14 @@ engine map identifier; `name` is the display name. Pass `key` as the request's
|
|||||||
completed matches reaching round 2, so an option can exist without any qualifying
|
completed matches reaching round 2, so an option can exist without any qualifying
|
||||||
players. Removed servers' maps remain available.
|
players. Removed servers' maps remain available.
|
||||||
|
|
||||||
| Summary field | Type | Meaning |
|
| Summary field | Type | Meaning |
|
||||||
| --- | --- | --- |
|
| ------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `players` | number | Unique qualifying GUIDs, combined across games. |
|
| `players` | number | Unique qualifying GUIDs, combined across games. |
|
||||||
| `matches` | number | Distinct completed matches with qualifying player participation; not the sum of `gamesPlayed`. |
|
| `matches` | number | Distinct completed matches with qualifying player participation; not the sum of `gamesPlayed`. |
|
||||||
| `kills`, `roundsSurvived` | number | Sums across qualifying players; rounds are player-rounds. |
|
| `kills`, `roundsSurvived` | number | Sums across qualifying players; rounds are player-rounds. |
|
||||||
| `highestRound` | number | Highest qualifying player's recorded round. |
|
| `highestRound` | number | Highest qualifying player's recorded round. |
|
||||||
| `firstTrackedAt` | string or null | Earliest match `startedAt` among those matches. |
|
| `firstTrackedAt` | string or null | Earliest match `startedAt` among those matches. |
|
||||||
| `updatedAt` | string or null | Latest match report receipt time among those matches; not a response-generation time or a complete data-change token. |
|
| `updatedAt` | string or null | Latest match report receipt time among those matches; not a response-generation time or a complete data-change token. |
|
||||||
|
|
||||||
For an empty scope, numeric summary fields are zero and both timestamps are
|
For an empty scope, numeric summary fields are zero and both timestamps are
|
||||||
`null`. A search with no results or an offset beyond `total` returns
|
`null`. A search with no results or an offset beyond `total` returns
|
||||||
@@ -297,15 +365,15 @@ Each `records` value has the following fields. Evidence uses the same game/map
|
|||||||
scope as the entry, and ties choose the earliest completion, then lowest match
|
scope as the entry, and ties choose the earliest completion, then lowest match
|
||||||
ID. The three records can refer to different matches.
|
ID. The three records can refer to different matches.
|
||||||
|
|
||||||
| Evidence field | Type | Meaning |
|
| Evidence field | Type | Meaning |
|
||||||
| --- | --- | --- |
|
| ------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------- |
|
||||||
| `matchId`, `value` | number | Completed match ID and that personal-best value. Link to `/matches/{matchId}` for its results page. |
|
| `matchId`, `value` | number | Completed match ID and that personal-best value. Link to `/matches/{matchId}` for its results page. |
|
||||||
| `game`, `mapKey`, `mapName`, `server` | string | Recorded game, opaque map key, map display name and server name. |
|
| `game`, `mapKey`, `mapName`, `server` | string | Recorded game, opaque map key, map display name and server name. |
|
||||||
| `startedAt` | string | First recorded match observation; only the game start when `startObserved` is true. |
|
| `startedAt` | string | First recorded match observation; only the game start when `startObserved` is true. |
|
||||||
| `endedAt` | string or null | Recorded completion time; can be unknown in older stored records. |
|
| `endedAt` | string or null | Recorded completion time; can be unknown in older stored records. |
|
||||||
| `startObserved` | boolean | Whether a `game_start` was received for the match. |
|
| `startObserved` | boolean | Whether a `game_start` was received for the match. |
|
||||||
| `presentAtStart` | boolean or null | Whether the player was first observed at the recorded start; `null` for unknown older participation. |
|
| `presentAtStart` | boolean or null | Whether the player was first observed at the recorded start; `null` for unknown older participation. |
|
||||||
| `firstObservedRound` | number or null | Round of the player's first observation, or unknown. This does not prove continuous survival. |
|
| `firstObservedRound` | number or null | Round of the player's first observation, or unknown. This does not prove continuous survival. |
|
||||||
|
|
||||||
Timestamps reflect controller receipt/observation times, normally ISO 8601 UTC
|
Timestamps reflect controller receipt/observation times, normally ISO 8601 UTC
|
||||||
strings such as `2026-10-05T12:00:00.000Z`, not reporter gameplay-clock times.
|
strings such as `2026-10-05T12:00:00.000Z`, not reporter gameplay-clock times.
|
||||||
@@ -339,10 +407,15 @@ an identity rename can change results without changing those timestamps.
|
|||||||
### Related routes and current limits
|
### Related routes and current limits
|
||||||
|
|
||||||
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
|
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
|
||||||
summaries as `{ "matches": [...], "total": number }`. These routes still require
|
summaries as `{ "matches": [...], "total": number, "page": number, "limit": number, "maps": [...] }`.
|
||||||
session authentication. Both accept only `limit` (default 25, range 1–10,000); there is
|
These routes still require session authentication. Both accept `limit` (default
|
||||||
no offset pagination. Results are newest first by first observation, then match
|
25, range 1–10,000), `page`, `game`, `map`, `q` (server name), `days` (0, 7, 30, or
|
||||||
ID, and include active/interrupted matches that reached round 2. Their
|
90), `status`, `sort` (`started`, `round`, `duration`, or `players`), and `direction`
|
||||||
|
(`asc` or `desc`). Results default to newest first by first observation, then
|
||||||
|
match ID, and include unfinished matches that reached round 2. Status values are
|
||||||
|
`active`, `completed`, `interrupted`, and `end-report-missing`; omit the filter for
|
||||||
|
all statuses. `reporterMatchKey` is the supplied match key or `null`;
|
||||||
|
`missingReports` is the known sequence-gap count or `null`. Their
|
||||||
[ServerMatch fields](../../src/lib/match-history.ts) differ from leaderboard entries
|
[ServerMatch fields](../../src/lib/match-history.ts) differ from leaderboard entries
|
||||||
and do not include player counters or a roster. Unknown server names return an
|
and do not include player counters or a roster. Unknown server names return an
|
||||||
empty list. URL-encode server names in paths.
|
empty list. URL-encode server names in paths.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Campaigns retain managed-file deployment evidence; Collections define lineups.
|
|||||||
when the running revision is unverified.
|
when the running revision is unverified.
|
||||||
- **Policies & schedules** controls health recovery, configuration drift, automatic
|
- **Policies & schedules** controls health recovery, configuration drift, automatic
|
||||||
Plutonium updates, and the optional daily restart schedule independently.
|
Plutonium updates, and the optional daily restart schedule independently.
|
||||||
- **Activity** uses the shared Activity log, scoped to maintenance and recovery.
|
- **Activity** shows retained history scoped to maintenance and recovery.
|
||||||
Filter retained findings, update results, and scheduled restarts in one timeline.
|
Filter retained findings, update results, and scheduled restarts in one timeline.
|
||||||
Follow a finding for evidence or an operation for execution progress. Use
|
Follow a finding for evidence or an operation for execution progress. Use
|
||||||
Overview’s **Active work & attention** to cancel waiting work or retry failed
|
Overview’s **Active work & attention** to cancel waiting work or retry failed
|
||||||
|
|||||||
+241
-68
@@ -5,98 +5,271 @@ Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute.
|
|||||||
|
|
||||||
## Overview and filters
|
## Overview and filters
|
||||||
|
|
||||||
The overview shows **Time with players** (occupied server-hours divided by known
|
**Lobbies running** shows the average and peak number of recorded lobbies in the
|
||||||
server-hours) and **Player-hours**. Known and unknown status time remain visible
|
selected scope. The average divides captured lobby-hours, including unknown
|
||||||
alongside the totals. **Last collected** is the newest recorded batch, including
|
status, by captured wall time. Empty spares are deliberate supply for new groups:
|
||||||
unknown checks; **Report refreshed** is when this report was calculated. Collection
|
the card shows their hours and share of all captured lobby-hours, rather than
|
||||||
stops when no servers are registered. Earliest retained observations do not prove
|
treating them as failed utilization. Unknown status can include lobbies starting
|
||||||
continuous history or the original installation date.
|
or unavailable, so this count does not guarantee every lobby was ready to join.
|
||||||
|
|
||||||
|
**Player-hours** integrates reported player counts over time. **No empty lobby**
|
||||||
|
shows the share of known map time when no tracked lobby reported zero players.
|
||||||
|
Each game/map is measured separately, then its durations are pooled. This is not
|
||||||
|
elapsed fleet time or a count of rejected joins. A partly occupied Zombies lobby
|
||||||
|
does not count as an empty spare, even if player slots remain.
|
||||||
|
|
||||||
|
**Last collected** is the newest recorded batch, including unknown checks;
|
||||||
|
**Report refreshed** is when this report was calculated. Collection stops when no
|
||||||
|
servers are registered. Earliest retained observations do not prove continuous
|
||||||
|
history or the original installation date.
|
||||||
|
|
||||||
Background refresh keeps the last successful report visible on failure and offers
|
Background refresh keeps the last successful report visible on failure and offers
|
||||||
Retry. Returning to a visible tab refreshes immediately. The selected range is
|
Retry. Returning to a visible tab refreshes immediately. Game and map filters apply
|
||||||
stored in the URL, and refreshes preserve table controls and chart selection.
|
to the whole report, including comparisons, signals, and the heatmap. Map choices
|
||||||
|
preserve the exact recorded game/map pair and include retained occupancy, match,
|
||||||
|
and autoscaling Provisioning history. Range and scope are shareable through the URL; refreshes preserve table
|
||||||
|
controls and chart selection.
|
||||||
|
|
||||||
Game and map filters apply to the whole report, including its comparisons,
|
## Autoscaling operations
|
||||||
recommendations, and heatmap. Map choices preserve the exact recorded game/map
|
|
||||||
pair. Range and scope are shareable through the URL. Table searches remain local.
|
The **Autoscaling** section follows the selected period, game, and map and links
|
||||||
|
to the policies. **Lobbies added** and **Lobbies removed** count successful create
|
||||||
|
and remove operations, including completions with warnings. Failed and cancelled
|
||||||
|
operations appear separately. Counts come from retained Provisioning records
|
||||||
|
linked to autoscaling, independently of occupancy samples; manual operations and
|
||||||
|
changes in sampled lobby counts do not contribute.
|
||||||
|
|
||||||
|
An operation belongs to the period containing its finish time, including both
|
||||||
|
endpoints. A request made before the period still counts if it finishes within it;
|
||||||
|
pending operations and finishes outside the period do not count. **Request to
|
||||||
|
ready** shows the average and maximum request-to-finish duration of successful
|
||||||
|
creates with valid timestamps, including queue time. Each create has equal weight.
|
||||||
|
Without valid completed creates, these timings are unavailable, not zero. Verified
|
||||||
|
readiness does not establish browser visibility or successful external joins.
|
||||||
|
|
||||||
|
Scope uses the operation's recorded server name, which encodes its game and map
|
||||||
|
variant. Game-wide policies do not combine different variants when filtering by
|
||||||
|
map. Friendly map names, slugs, and unambiguous canonical map names are supported;
|
||||||
|
an ambiguous engine label such as `zm_transit` is not assigned to a variant.
|
||||||
|
History survives server removal and policy deletion, subject to Provisioning
|
||||||
|
retention. Missing or purged operations are not estimated. The policy card's
|
||||||
|
31-day spare-deficit measurements remain separate from this section.
|
||||||
|
|
||||||
|
## Matches and groups
|
||||||
|
|
||||||
|
**Matches started** counts retained matches whose recorded start falls within the
|
||||||
|
selected period, including both endpoints. A missing start event uses the first
|
||||||
|
recorded match observation, which may be later than the actual start. Active,
|
||||||
|
completed, and interrupted matches all count, including matches that never reach
|
||||||
|
round 2 and matches on removed servers. These counts do not use the leaderboard's
|
||||||
|
completed-match or round qualification rules.
|
||||||
|
|
||||||
|
**Average group size** is the average number of distinct recorded player GUIDs per
|
||||||
|
match. A player counts once across all reports for that match, including late
|
||||||
|
joins and players who left before its end. Each match has equal weight in the
|
||||||
|
fleet average; the average is not an average of map averages. This measures
|
||||||
|
recorded participation over the match's lifetime, not its starting squad or peak
|
||||||
|
simultaneous occupancy. Active matches can change as more players are recorded.
|
||||||
|
|
||||||
|
**Solo games** counts matches with exactly one distinct recorded player. Its share
|
||||||
|
uses only matches with at least one recorded player. Matches with no recorded
|
||||||
|
players still count as started, but their group size is unknown and they are
|
||||||
|
excluded from both the average and solo-share denominator. If no matches have
|
||||||
|
recorded players, those two values are unavailable rather than zero.
|
||||||
|
|
||||||
|
The map table includes these same match statistics. A map with match history in
|
||||||
|
the selected period remains visible even without occupancy samples; its match
|
||||||
|
statistics do not establish lobby-hours, availability, or an autoscaling shortage.
|
||||||
|
Match statistics cover the selected period only and have no previous-period
|
||||||
|
comparison.
|
||||||
|
|
||||||
|
## Players over time
|
||||||
|
|
||||||
|
The player chart uses the lobby chart's periods: hourly for 24 hours, and UTC-aligned
|
||||||
|
24-hour periods for 7 or 30 days. Labels use the report timezone. **Unique players**
|
||||||
|
counts distinct player GUIDs in every period overlapped by their recorded match
|
||||||
|
participation, from first to last sighting. Multiple matches in one period count
|
||||||
|
once, and the same GUID across Black Ops I and II is one player. Matches that began
|
||||||
|
before the range still contribute participation inside it. These are recorded
|
||||||
|
participants, not concurrent player counts or the lobby chart's player-hours.
|
||||||
|
|
||||||
|
**New players** counts a GUID at its earliest recorded sighting across both games.
|
||||||
|
Game and Map filters limit unique players to matching retained matches; new players
|
||||||
|
count only when their original sighting was in a matching match. A regular's first
|
||||||
|
visit to another map or game does not make them new again. Period boundaries belong
|
||||||
|
to the following period; a sighting exactly at the range's end counts in the final
|
||||||
|
period. Range totals count distinct GUIDs across the entire window independently:
|
||||||
|
daily unique counts cannot be added to get the range total.
|
||||||
|
|
||||||
|
Periods before the earliest retained match are gaps, not zero players. This history
|
||||||
|
boundary is fleet-wide, so filtering to a quiet map does not hide recorded zeroes.
|
||||||
|
The chart notes limited history and marks periods clipped by the selected range or
|
||||||
|
the retained-history boundary with hollow points. Totals cover available history
|
||||||
|
only. Select a point, use Left/Right or Home/End, or open **Inspect a period** for
|
||||||
|
the exact interval and counts. **View exact time data** provides the same values
|
||||||
|
in a keyboard-accessible table, with missing history shown as a dash.
|
||||||
|
|
||||||
|
History begins when leaderboard recording started; earlier play cannot be recovered.
|
||||||
|
Players already playing then count as new when first recorded. Participation spans
|
||||||
|
do not prove continuous presence between reports, and unrecorded matches are absent.
|
||||||
|
Match retention removes participation, which can shorten or leave holes in this
|
||||||
|
history. The durable first-seen player identities survive retention, so removing an
|
||||||
|
old match cannot make a returning player new again. The original map cannot be
|
||||||
|
recovered from an expired match. The chart remains available without occupancy samples.
|
||||||
|
|
||||||
## Timeline and tables
|
## Timeline and tables
|
||||||
|
|
||||||
The timeline defaults to **Share of captured time**: each stack includes occupied,
|
The timeline defaults to **Average lobbies running**, split into **With players**,
|
||||||
idle, and unknown server time, normalized to 100%. This denominator differs from the
|
**Empty spare**, and **Unknown**. Each bucket divides those lobby-hours by its
|
||||||
headline utilization percentage, which excludes unknown time. Switch to **Hours**
|
captured wall time, so changing lobby counts remain visible. Collection gaps are
|
||||||
to compare absolute server time. Periods without captured time remain gaps.
|
excluded from that denominator. The average-lobby axis uses whole-number ticks;
|
||||||
The timeline has labelled axes and marks partial edge periods. Select a period,
|
period details preserve fractional averages. Switch to the share view to compare proportions,
|
||||||
use Previous/Next, or select a bar to inspect its exact interval and values. The
|
or hours to compare absolute lobby time. Periods without captured time remain gaps.
|
||||||
time table supplies the same figures for comparison and assistive technology.
|
|
||||||
Zero captured time is distinct from an observed empty server. Short durations use
|
|
||||||
seconds or minutes so a real observation is not rounded to zero.
|
|
||||||
|
|
||||||
Map and server tables support numeric sorting and local search. Their searches
|
The timeline has labelled axes and marks partial edge periods. Select a bar or use
|
||||||
apply only to the named table. Known-capacity duration stays next to all-full time.
|
Previous/Next to inspect its exact interval and values. The time table supplies
|
||||||
Server identities retain the technical name; rows describe history grouped by
|
the same figures for comparison and assistive technology. Zero captured time is
|
||||||
name, which may include different instances if a name was reused. **Open current
|
distinct from an observed empty lobby. Short durations use seconds or minutes so
|
||||||
server** appears only when that name is currently registered; its destination
|
a real observation is not rounded to zero.
|
||||||
represents the current registration rather than every instance in the history.
|
|
||||||
|
|
||||||
## Comparisons and hosting experiments
|
The map table supports numeric sorting and local search. **Lobbies (avg · peak)**
|
||||||
|
shows the time-weighted average and peak concurrent lobby count for each recorded
|
||||||
|
game/map pair, with empty lobby-hours beneath. Sort this column by average count.
|
||||||
|
The average divides all captured lobby-hours, including unknown occupancy, by
|
||||||
|
that map's captured wall time; collection gaps and periods without recorded lobbies
|
||||||
|
for that map are excluded. Peak is the largest captured concurrent count, not the
|
||||||
|
number of distinct lobby names. Empty lobby-hours sum time with zero reported
|
||||||
|
players across the map's lobbies. Unknown occupancy is not counted as empty;
|
||||||
|
empty time is unavailable when no occupancy is known. Lobby counts are unavailable
|
||||||
|
without captured time. Per-lobby match and activity history is available on server pages.
|
||||||
|
|
||||||
**Explore this period** adds experimental decision aids below the main timeline:
|
The map table's
|
||||||
|
**No empty lobby** column uses occupancy alone and retains the known map-time
|
||||||
|
denominator in its details. Unknown map availability stays distinct from zero
|
||||||
|
time without a spare. Evidence can be limited by short observations or unknown
|
||||||
|
occupancy, never by missing player limits.
|
||||||
|
|
||||||
- **Map capacity pressure** pools all-full map-hours divided by known-capacity
|
## Comparisons and activity
|
||||||
map-hours across the selected map groups. Overlapping map intervals are counted
|
|
||||||
separately. The score is not elapsed fleet time or a fleet health score; its
|
|
||||||
denominator and contributing map count remain visible.
|
|
||||||
- **Previous-period comparisons** use the preceding equally long window with
|
|
||||||
identical filters. Utilization changes are percentage points; player-hours
|
|
||||||
changes are relative percentages. A previous observed zero can show **New**.
|
|
||||||
Missing evidence remains unavailable. Partial retained history is labelled;
|
|
||||||
a 30-day comparison usually has little previous history because default retention is
|
|
||||||
31 days. Both periods' known time is shown. Bounds cannot establish that
|
|
||||||
collection was uninterrupted, and changes are not adjusted for fleet size.
|
|
||||||
- **Activity heatmap** groups captured time by weekday and hour in its displayed
|
|
||||||
timezone. Color represents occupied / known server time. Exact cell values
|
|
||||||
include unknown time and contributing dates. No-data cells differ from observed
|
|
||||||
zero utilization; repeated DST hours contribute their actual elapsed time.
|
|
||||||
- **Hosting experiments** are generated automatically from the selected map
|
|
||||||
observations. They are advisory and never create, restart, or destroy a server.
|
|
||||||
|
|
||||||
Hosting Rules v1 deliberately offer experiments even with small samples:
|
Previous-period comparisons use the preceding equally long window with identical
|
||||||
|
filters. Average lobby counts compare recorded supply, while player-hours changes
|
||||||
|
are relative percentages. A previous observed zero can show **New**. Missing
|
||||||
|
evidence remains unavailable. Partial retained history is labelled; a 30-day
|
||||||
|
comparison usually has little previous history because default retention is
|
||||||
|
31 days. Collection gaps are excluded, and comparisons do not prove uninterrupted
|
||||||
|
collection or adjust player-hours for fleet size.
|
||||||
|
|
||||||
| Recorded condition | Suggested experiment |
|
The activity heatmap groups captured time by weekday and hour in its displayed
|
||||||
| --- | --- |
|
timezone. Color represents occupied / known lobby time. Exact cell values include
|
||||||
| At least 20% of known capacity time had every lobby full | Trial one additional lobby during busy hours. |
|
unknown time and contributing dates. No-data cells differ from observed zero;
|
||||||
| Capacity unavailable and players present for at least 80% of known server time | Tentatively trial an extra lobby using occupancy as a proxy. |
|
repeated DST hours contribute their actual elapsed time. Occupancy share describes
|
||||||
| Players present for at most 10% of known time, with under 1% measured pressure or unavailable capacity | Review one idle lobby or a shorter hosting window while preserving access to the map. |
|
how the fleet was used, but can change simply because a policy adds or removes
|
||||||
| Occupancy unavailable | Check telemetry/RCON reporting and watch for known player counts. |
|
spares. Read it alongside player-hours, lobby counts, and empty-lobby availability.
|
||||||
| Other observations | Check missing capacity or keep the lineup and watch peaks. |
|
|
||||||
|
|
||||||
Each suggestion includes evidence and an outcome to watch. Unknown capacity,
|
## Autoscaling signals
|
||||||
less than one hour of relevant known evidence, or substantial unknown captured
|
|
||||||
status makes the suggestion tentative. These thresholds are product hypotheses;
|
Signals compare each map's observations with its **current** applicable policy.
|
||||||
occupancy does not establish failed joins or a need for additional capacity.
|
Game-wide policies apply independently to each supported map variant. Each signal
|
||||||
Suggestions use historical observations. Check the current lineup before trying
|
links to the policy on **Fleet → Autoscaling**; a map without a policy gets a link
|
||||||
a change; a retained map may no longer have a hosted lobby.
|
to add one. Signals are advisory and never change a policy or a lobby.
|
||||||
|
|
||||||
|
A global pause appears once above the signals. Historical shortage signals remain
|
||||||
|
visible while the fleet is paused, including suggestions to review the maximum
|
||||||
|
or spare target. Review these findings and current settings before resuming.
|
||||||
|
An individual policy's own pause or disabled state still prompts a policy review.
|
||||||
|
|
||||||
|
| Recorded condition | Suggested action |
|
||||||
|
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| No empty lobby for at least 2% of known map time, mostly at or above the current maximum | Review raising the policy maximum. |
|
||||||
|
| No empty lobby for at least 2% of known map time, mostly below the current maximum | Review the spare target and check creation time or blockers. |
|
||||||
|
| An applicable policy is off or paused | Review the policy and its pause or activation conditions. |
|
||||||
|
| No applicable policy and known occupancy is available | Consider adding an autoscaling policy to keep spares available. |
|
||||||
|
| Occupancy unavailable | Check telemetry/RCON reporting and wait for known player counts. |
|
||||||
|
| No shortage signal | Keep watching empty-lobby availability. |
|
||||||
|
|
||||||
|
Evidence includes no-empty map time and how it splits above/below the current
|
||||||
|
maximum. Less than one hour of known map availability, less than 80% known map
|
||||||
|
availability, or less than 80% known lobby time makes advice tentative. Player limits are not needed. These
|
||||||
|
thresholds are advisory rules, not demand forecasts.
|
||||||
|
|
||||||
|
Policy settings may have changed during the selected period. Comparing historical
|
||||||
|
counts against today's maximum does not establish which settings applied then or
|
||||||
|
whether autoscaling was enabled. No reported empty lobby also does not prove
|
||||||
|
failed joins or unmet demand. Review provisioning delays, blockers, host resources,
|
||||||
|
and game-key availability before changing targets.
|
||||||
|
|
||||||
## Definitions and collection
|
## Definitions and collection
|
||||||
|
|
||||||
- Utilized means a successful status response reported at least one player.
|
- **With players:** a successful observation reported at least one player.
|
||||||
- Idle means a successful response reported zero players.
|
- **Empty spare:** an observation reported zero players.
|
||||||
- Unknown covers failed checks, provisioning, and unavailable containers; it is excluded from utilization percentages.
|
- **Unknown:** failed checks, provisioning, or otherwise unavailable occupancy.
|
||||||
- Player-hours integrate the reported player count over observed time. Bots are included if the server counts them as players.
|
- **Player-hours:** reported players × observed time. Bots count if the server includes them.
|
||||||
- All lobbies full measures map wall time when every tracked lobby in that game/map group has a known capacity and is full. Missing capacity or status makes that interval unknown. This is a capacity-pressure indicator, not a count of rejected joins or unmet demand.
|
- **Empty lobby available:** at least one lobby on the game/map reported zero players.
|
||||||
|
- **No empty lobby:** at least one lobby reported occupancy, and none reported zero. Mixed positive and unknown counts mean no empty lobby was reported; an unknown lobby might actually have been empty.
|
||||||
|
- **Unknown map availability:** every lobby on the game/map had unknown occupancy. This interval is excluded from the known-map denominator and is never counted as a spare shortage.
|
||||||
|
|
||||||
Observations are recorded by the existing status worker. Each remains valid until the next collection, up to the 90-second freshness limit. Longer manager outages are excluded. Charts split time at UTC hour/day boundaries; partial periods remain partial. Map variants use the configured variant when it matches the reported map.
|
Observations come from the existing status worker. Each complete collection stays
|
||||||
|
valid until the next collection, up to the 90-second freshness limit. Longer gaps
|
||||||
|
are excluded, not counted as unknown or zero lobbies. Captured wall time includes
|
||||||
|
intervals with at least one recorded lobby in the selected scope. Unknown lobby
|
||||||
|
time and unknown map time use different units: several unknown lobbies on one map
|
||||||
|
contribute several lobby-hours but only one interval of unknown map availability.
|
||||||
|
|
||||||
Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity.
|
Charts split time at UTC hour/day boundaries; partial periods remain partial.
|
||||||
|
Map variants use the configured variant when it matches the reported map. Player
|
||||||
|
capacity may be retained in raw samples, but no metric or signal requires
|
||||||
|
`sv_maxclients` or an assumed four-player limit.
|
||||||
|
|
||||||
Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples default to 31 days of retention; Admin → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup.
|
Occupancy collection begins after this feature is installed; earlier occupancy cannot be
|
||||||
|
reconstructed. Samples default to 31 days of retention; **Admin → Settings** can
|
||||||
|
change this period. Cleanup runs during collection and in the background. Samples
|
||||||
|
survive removal of a server and contain no player identities.
|
||||||
|
|
||||||
|
Match statistics use existing saved match history, independently of occupancy
|
||||||
|
samples and raw telemetry diagnostics. **Match history retention** under
|
||||||
|
**Admin → Settings → Matches & players** defaults to indefinite. Expiring a
|
||||||
|
completed or interrupted match also removes its player participation and changes
|
||||||
|
these statistics; active matches are protected. Increasing retention cannot
|
||||||
|
restore expired matches. Saved match history survives removal of a server.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
`GET /api/metrics` supplies the same report as the page and disables response
|
`GET /api/metrics` supplies the same report as the page and disables HTTP response
|
||||||
caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game),
|
caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game),
|
||||||
and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults;
|
and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults;
|
||||||
invalid scope syntax is normalized. Fleet collection timestamps remain separate
|
invalid scope syntax is normalized. Fleet collection timestamps remain separate
|
||||||
from selected-scope evidence timestamps. Collection boundaries are determined
|
from selected-scope evidence timestamps. Collection boundaries are determined
|
||||||
before scope filtering, so samples from other groups still end a batch interval.
|
before scope filtering, so samples from other groups still end a batch interval.
|
||||||
|
|
||||||
|
`running` contains `average`, `peak`, and `capturedHours`; timeline points include
|
||||||
|
their own `capturedHours`. Report-level `matches` and each map row's `matches`
|
||||||
|
contain `started`, `withRecordedPlayers`, `averageGroupSize`, `solo`, and
|
||||||
|
`soloShare`. `withRecordedPlayers` is the denominator for the group average and
|
||||||
|
solo share; `soloShare` is a percentage from 0 to 100. When that denominator is
|
||||||
|
zero, `averageGroupSize` and `soloShare` are `null`, `solo` is zero, and `started`
|
||||||
|
retains the recorded number of starts. Match scope uses the exact saved `game` and `map_name`
|
||||||
|
labels, and its time window uses the saved `started_at` timestamp. Occupancy
|
||||||
|
collection and evidence timestamps remain independent of match history.
|
||||||
|
|
||||||
|
`players` contains the separately deduplicated range totals `uniquePlayers` and
|
||||||
|
`newPlayers`, plus `historyStartAt` (the earliest retained match, in epoch
|
||||||
|
milliseconds). Every `points` entry also has `uniquePlayers`, `newPlayers`, and
|
||||||
|
`playerHistoryPartial`. Counts before retained history are `null`; known periods
|
||||||
|
with no scoped participation are zero. Player charts treat either `partial` or
|
||||||
|
`playerHistoryPartial` as partial. Participation timestamp changes and durable
|
||||||
|
first-seen identity changes invalidate the report cache.
|
||||||
|
|
||||||
|
Map rows expose `noEmpty`, `availabilityObserved`, and
|
||||||
|
`availabilityUnknown` durations, plus current `policy` context and
|
||||||
|
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
|
||||||
|
map durations and coverage. `autoscalingPaused` reports the current global pause
|
||||||
|
separately from each policy's own `pausedReason`. Policy edits and global pause changes invalidate
|
||||||
|
cached metrics reports, as do changes to the match and player participation data
|
||||||
|
used by the report.
|
||||||
|
|
||||||
|
`autoscaling` contains `added`, `removed`, `failed`, `cancelled`, `readinessSamples`,
|
||||||
|
`readinessMeanSeconds`, and `readinessMaxSeconds`. Counts use `finished_at` in the
|
||||||
|
selected window; readiness uses `finished_at - started_at` for successful creates
|
||||||
|
with nonnegative, valid durations. Both timing values are `null` when there are
|
||||||
|
no readiness samples. Changes to Provisioning outcomes or autoscaling source
|
||||||
|
links invalidate cached reports, including when no new occupancy is collected.
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# Player moderation
|
||||||
|
|
||||||
|
**Players → Moderation** provides player search, warnings, private messages,
|
||||||
|
kicks, announcements, bans, mutes, case records and private history. Restrictions
|
||||||
|
can cover a server, a game or the fleet. Saving a restriction records the policy;
|
||||||
|
the coverage table shows whether game runtimes have synchronized it.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
Administrators manage all moderation controls and configure integrations.
|
||||||
|
Moderators can read private history, create notes/cases, send live actions, and
|
||||||
|
issue server/game restrictions lasting at most seven days. They can revoke their
|
||||||
|
own server/game restrictions originally lasting at most seven days. Applying or
|
||||||
|
revoking permanent, longer or fleet restrictions, and revoking another staff
|
||||||
|
member's restriction, require Administrator access. These limits also apply after
|
||||||
|
an administrator becomes a Moderator. Super Viewers can inspect
|
||||||
|
moderation but cannot change it. Operators and Viewers cannot read private
|
||||||
|
moderation data. Moderator does not grant fleet operation, script, plugin,
|
||||||
|
credential or account-management privileges.
|
||||||
|
|
||||||
|
Assign Moderator through **Admin → Users**. Existing users retain their roles;
|
||||||
|
changing a role revokes that user's sessions. Each service rechecks current
|
||||||
|
authorization, including immediately before delivering a queued live action.
|
||||||
|
|
||||||
|
## Player identity and restrictions
|
||||||
|
|
||||||
|
Select a player from Moderation, a live scoreboard or a player profile. Search
|
||||||
|
matches known aliases as well as GUIDs. A display name never identifies a
|
||||||
|
restriction target. Manually entered GUIDs can have case records and restrictions
|
||||||
|
before gameplay is observed; their first/last seen dates remain **Not recorded**.
|
||||||
|
|
||||||
|
IDs remain strings. Numeric game IDs are normalized without losing precision;
|
||||||
|
T6 RCON hexadecimal IDs have a separate conversion rule. Historical chat links
|
||||||
|
search for the raw log identity because its encoding is not assumed. Confirm a
|
||||||
|
known player identity before applying a restriction. Fleet restrictions require
|
||||||
|
the same numeric account ID to have been observed in both games. Otherwise use
|
||||||
|
game scope.
|
||||||
|
|
||||||
|
Every restriction has a player-visible reason, optional private note, scope,
|
||||||
|
expiry, creator and revision. Reasons and live messages must fit in 240 UTF-8
|
||||||
|
bytes. The native adapter replaces console control characters in displayed text.
|
||||||
|
Overlapping restrictions remain independent: revoking one mute does not cancel
|
||||||
|
another active mute. Revocation requires a reason and the current revision;
|
||||||
|
concurrent changes require refreshing before another decision.
|
||||||
|
|
||||||
|
Server scope follows a logical server registration across instance replacement
|
||||||
|
or rebuild. Deleting and later registering the same server name creates a new
|
||||||
|
registration, which does not inherit the old server-scoped restriction.
|
||||||
|
Game/fleet policies apply to every connected compatible runtime in that scope,
|
||||||
|
including newly provisioned servers. A new runtime must synchronize before it
|
||||||
|
has a complete policy; provisioning readiness alone does not prove moderation
|
||||||
|
coverage. The initial limit is 1,000 active restrictions across the controller.
|
||||||
|
|
||||||
|
## Enable the game integration
|
||||||
|
|
||||||
|
The controller requires `EC_PUBLIC_URL` to be a public HTTPS origin. The host
|
||||||
|
agent must advertise `moderation.configure` (agent 1.11.0+). Install the matching
|
||||||
|
TWS moderation script 1.0.0, common 1.2.0+, and TWS native plugin 1.3.0+ from
|
||||||
|
`tws-zombies-scripts` for each game. New native plugins require a full
|
||||||
|
game-server process restart. Use the normal release/beta workflow and validate
|
||||||
|
one T5 Zombies and one T6 Zombies instance before fleet rollout.
|
||||||
|
|
||||||
|
New launches/rebuilds on capable agents receive a private moderation config when
|
||||||
|
`EC_PUBLIC_URL` is configured. Existing instances can use **Enable integration**
|
||||||
|
in Moderation. This queues a typed operation that writes the fixed private config
|
||||||
|
and loads its dvars, without restarting gameplay. Existing startup persistence
|
||||||
|
uses the verified private bank config; older instances without that bootstrap
|
||||||
|
must be rebuilt. Installed moderation scripts retry missing configuration.
|
||||||
|
|
||||||
|
The private file `configs/electric_cherry_moderation.cfg` contains the HTTPS
|
||||||
|
endpoint, generation and a separate per-instance credential. It is excluded from
|
||||||
|
shared artifacts, file reads, drift scans, logs and operation receipts. Game
|
||||||
|
credentials are distinct from bank, telemetry and host credentials. They only
|
||||||
|
authorize the active immutable instance generation; retirement/rebuild revokes
|
||||||
|
the previous generation's access. Older agents retain ordinary server launches,
|
||||||
|
but cannot provide this integration.
|
||||||
|
|
||||||
|
Coverage states describe runtime communication:
|
||||||
|
|
||||||
|
- **Policy current:** a fresh runtime supports both bans and mutes and has
|
||||||
|
acknowledged the current policy revision.
|
||||||
|
- **Pending:** the runtime is connected but has not acknowledged the latest revision
|
||||||
|
or does not support both bans and mutes.
|
||||||
|
- **Offline:** no runtime synchronization has arrived for more than 15 seconds.
|
||||||
|
- **Unavailable:** this instance generation has not connected.
|
||||||
|
|
||||||
|
Policy current is not proof that an engine mute command took effect. The runtime
|
||||||
|
reports supported capabilities; unsupported live actions stay unavailable.
|
||||||
|
Native kick/mute command behavior and voice/text suppression require testing on
|
||||||
|
the deployed Plutonium build. A disconnected host cannot receive a new ban or
|
||||||
|
revocation until it reconnects.
|
||||||
|
|
||||||
|
## Live actions and offline enforcement
|
||||||
|
|
||||||
|
Live actions expire after 30 seconds and target the runtime's exact player
|
||||||
|
connection. A reconnect, reused slot, changed generation or revoked moderator
|
||||||
|
access prevents stale dispatch. Browser retries preserve the request identity.
|
||||||
|
The native adapter journals one-time actions before dispatch and never executes
|
||||||
|
the same action ID twice, including after restart.
|
||||||
|
|
||||||
|
Queued actions can become delivered, confirmed, failed, expired or unknown.
|
||||||
|
An expired undelivered action did not run. Unknown means delivery occurred but
|
||||||
|
the result could not be established; do not assume the action failed. A durable
|
||||||
|
late receipt can resolve an unknown outcome. Confirmation of a message means
|
||||||
|
server dispatch, not proof the recipient read it. Kick confirmation requires an
|
||||||
|
observed disconnect.
|
||||||
|
|
||||||
|
The native runtime caches the complete applicable policy on persistent storage,
|
||||||
|
scoped to the server, endpoint, generation and credential fingerprint. Cached
|
||||||
|
bans/mutes continue through controller outages and expire using the host clock.
|
||||||
|
Keep host time synchronized. A ban disconnects a player after connecting; it is
|
||||||
|
not a pre-authentication admission filter. The shared connection handler and a
|
||||||
|
500 ms reconciliation loop enforce it, retrying still-connected banned players
|
||||||
|
every five seconds. No native `ban.txt` entries are rewritten.
|
||||||
|
|
||||||
|
Preserve `tws-moderation/t5/` or `tws-moderation/t6/` in the game working directory.
|
||||||
|
The cache includes an increasing runtime session counter that prevents an older
|
||||||
|
process from replacing the active player roster. Losing or rolling back this
|
||||||
|
cache requires a generation rebuild with a new credential before synchronization
|
||||||
|
can resume; re-enabling the same integration does not reset the counter.
|
||||||
|
Corrupt caches and failed durable writes stop new actions and require operator
|
||||||
|
investigation. A controller restored to an older policy revision cannot silently
|
||||||
|
replace a newer cached revision; review the restore and integration before
|
||||||
|
resetting its cache or credentials.
|
||||||
|
|
||||||
|
## Cases, evidence and review
|
||||||
|
|
||||||
|
Create a case for a report, investigation or appeal. Each case has a dedicated,
|
||||||
|
shareable page with the player identity, evidence, staff notes, linked restrictions,
|
||||||
|
and an activity timeline. These pages require moderation access. Give a case a
|
||||||
|
priority and assign it to an eligible administrator or moderator. Filter the case
|
||||||
|
queue by status, priority, or assignment; active cases appear first, ordered by
|
||||||
|
priority and most recent update.
|
||||||
|
|
||||||
|
Use **Change status** to move a case between **Open**, **In progress**, and
|
||||||
|
**Closed**, recording a reason for each transition. Assignment is separate:
|
||||||
|
a case can have an owner at any stage. To resume a closed case, change its status
|
||||||
|
to Open or In progress. Evidence, assessments, actions, and earlier decisions
|
||||||
|
remain in its history. Editing case details does not change its status.
|
||||||
|
|
||||||
|
Existing closed outcomes are migrated to Closed. Previously escalated cases
|
||||||
|
become In progress. Their previous statuses are recorded in history; existing
|
||||||
|
decision reasons, action references, assignments, and follow-ups are preserved.
|
||||||
|
|
||||||
|
Use **Edit assessment** to keep a concise current summary, list the applicable
|
||||||
|
rules, and record a finding for each concern: hate speech, harassment, threats,
|
||||||
|
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
|
||||||
|
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
|
||||||
|
the evidence; case status tracks whether work remains. Record the decision and
|
||||||
|
its rationale in the assessment summary.
|
||||||
|
Every saved assessment retains its author, timestamp, and complete previous
|
||||||
|
revision. Assessments can be revised after closure without reopening the case.
|
||||||
|
|
||||||
|
Before opening a case, review existing cases for the same game and GUID. A case
|
||||||
|
also shows same-player candidates; a shared archived match is a useful potential
|
||||||
|
duplicate signal, not a finding that two reports are identical. Link cases as
|
||||||
|
**Same incident**, **Appeal of**, or **Duplicate of**, recording why they belong
|
||||||
|
together. Same-incident links may include different players; appeals and duplicates
|
||||||
|
must concern the same game and player. Links appear from both cases with their
|
||||||
|
direction made explicit. Self-links and circular appeal/duplicate chains are
|
||||||
|
rejected. Removing a mistaken relationship requires a reason and preserves its
|
||||||
|
history. Relationship changes do not close or reopen either case.
|
||||||
|
|
||||||
|
Schedule a follow-up using a date and time, or **Review when a linked mute expires**.
|
||||||
|
Custom date entry uses your browser's local time; displayed dates follow your time
|
||||||
|
zone preference. Reviews remain scheduled after a case closes or reopens. Use
|
||||||
|
**Overdue reviews** to find due work across open and closed cases, and clear the
|
||||||
|
follow-up with a reason when the review is complete.
|
||||||
|
|
||||||
|
In-app case updates notify the assignee and case creator when another staff member
|
||||||
|
adds evidence or a staff note. Assignment changes notify the previous and new assignees
|
||||||
|
and the creator, excluding the person making the change. **Your unread updates**
|
||||||
|
is personal to your account. Open a case to review and mark existing updates
|
||||||
|
read; notifications arriving afterward stay unread. Queue and case pages refresh
|
||||||
|
every 15 seconds while visible. These are in-app notifications, with no email or
|
||||||
|
external messaging delivery.
|
||||||
|
|
||||||
|
Apply a ban or mute directly from an active case. The case fixes the target game
|
||||||
|
and GUID and records the resulting restriction alongside the evidence. The same
|
||||||
|
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
|
||||||
|
restriction leaves the case active so staff can finish reviewing it. Changing case status
|
||||||
|
records workflow progress; it does not send a warning, create a restriction, or
|
||||||
|
revoke one. Linked restrictions can be revoked separately with
|
||||||
|
the required permission and reason, including after a case closes. Reopening a
|
||||||
|
case also leaves its restrictions in place. Check enforcement coverage after
|
||||||
|
applying or revoking a restriction.
|
||||||
|
|
||||||
|
Use **Link existing action** to associate a warning, kick, private message, ban, or
|
||||||
|
mute already recorded for this player with the case. This preserves the original
|
||||||
|
action, author, time, delivery state, and expiry; it does not issue another action
|
||||||
|
or extend a restriction. The same action can support more than one related case.
|
||||||
|
Linking requires the current case revision and a reason, and appears in its timeline.
|
||||||
|
Linked records show what actually happened, including failed or uncertain delivery.
|
||||||
|
They remain visible when a case closes; status changes do not require choosing
|
||||||
|
or re-entering an action.
|
||||||
|
|
||||||
|
Staff notes are explicitly labeled as notes. In match chat, select up to 100
|
||||||
|
messages and choose **Attach to case** to open a case or add them to an existing
|
||||||
|
case for the selected subject. Select at least one message from that subject;
|
||||||
|
messages from other participants can be selected alongside it. Each attachment
|
||||||
|
preserves selected messages and, by default, three surrounding messages on either side, with
|
||||||
|
names, GUIDs, original timing, and highlighted subject messages. The selection
|
||||||
|
preview shows the excerpt before saving. Server verification uses stable archived
|
||||||
|
message identifiers; repeated text remains separate occurrences, and attaching
|
||||||
|
the same selected messages again does not duplicate evidence. Exact-text
|
||||||
|
attachments remain available and include every matching occurrence, without the
|
||||||
|
former ten-occurrence limit.
|
||||||
|
|
||||||
|
Snapshots remain readable after source-match cleanup. Unknown or estimated
|
||||||
|
timestamps remain such; the snapshot does not turn them into exact observations.
|
||||||
|
Unarchived or already deleted chat cannot be attached as verified evidence.
|
||||||
|
|
||||||
|
Open **Chat archive** from Moderation to include all recorded matches, including
|
||||||
|
round-one games, interrupted games, and games missing an end report. Archive
|
||||||
|
access requires moderation read permission. Filter by server, game, map, status,
|
||||||
|
or date and open a match to select its preserved messages. Incomplete matches
|
||||||
|
show their observed state rather than final results. Ordinary match history and
|
||||||
|
leaderboard qualification are unchanged.
|
||||||
|
|
||||||
|
Both public (`say`) and team (`sayteam`) log messages are captured. Team messages
|
||||||
|
carry a **Team chat** label in chat and saved excerpts. Collection runs without a live
|
||||||
|
viewer and checks surviving logs once after startup, including idle servers.
|
||||||
|
Archive coverage still depends on the source log and accepted match telemetry:
|
||||||
|
logs that were deleted or rotated before collection, or old clock segments that
|
||||||
|
cannot safely be associated with a match, cannot be reconstructed. The collector
|
||||||
|
retains a bounded log tail (up to 2,000 lines / 256 KiB); it is not a full historical
|
||||||
|
log import. Already saved case excerpts survive later source cleanup.
|
||||||
|
|
||||||
|
Case decisions, edits, and restrictions check the current revision; refresh before
|
||||||
|
retrying a decision if another staff member has changed the case. Evidence,
|
||||||
|
restrictions and append-only moderation events survive source-match cleanup and
|
||||||
|
server deletion. They currently have no automatic retention cleanup.
|
||||||
|
Authentication audit cleanup does not remove moderation history. Correct decisions
|
||||||
|
by reopening, adding notes, or revoking restrictions; there is no history-erasing
|
||||||
|
delete endpoint.
|
||||||
|
|
||||||
|
Leaderboard disqualification, bank balance changes, automatic escalation and a
|
||||||
|
public report/appeal portal are separate features. This workflow supports staff
|
||||||
|
review of reports and appeals without exposing private evidence publicly.
|
||||||
|
|
||||||
|
## Development and acceptance
|
||||||
|
|
||||||
|
Controller tests cover permission boundaries, migrations, idempotency, exact
|
||||||
|
instance credentials, stale connections, expired actions, late receipts,
|
||||||
|
restriction expiry/revocation, policy coverage and evidence preservation. Agent
|
||||||
|
tests cover private configuration delivery and secret redaction. Companion
|
||||||
|
native tests exercise real policy/journal code and adapters with engine shims.
|
||||||
|
|
||||||
|
Before deploying, build the x86 native plugins on Windows and validate both
|
||||||
|
games: duplicate names, account reconnects, slot reuse, kick reasons, voice/text
|
||||||
|
mute/unmute, overlapping restrictions, map/process restart, new server policy
|
||||||
|
sync, expiry, revocation, controller outage and persistent-storage failure.
|
||||||
|
Portable tests cannot establish the live Plutonium ABI or engine behavior.
|
||||||
|
Package and offline-sign the agent release before publishing it; local builds
|
||||||
|
remove any stale manifest signature and do not sign or deploy the release.
|
||||||
+341
-53
@@ -12,9 +12,11 @@ the agent owns local execution and its durable operation journal.
|
|||||||
and roles; agent endpoints authenticate their own host credentials. See
|
and roles; agent endpoints authenticate their own host credentials. See
|
||||||
[Users and access](authentication.md) for setup and password recovery.
|
[Users and access](authentication.md) for setup and password recovery.
|
||||||
2. Open **Hosts → Add Host**, choose a name, local game-port range and bind address,
|
2. Open **Hosts → Add Host**, choose a name, local game-port range and bind address,
|
||||||
and generate an enrollment. Run the displayed command on Debian 12/13 amd64.
|
and generate an enrollment. Run the displayed command on the Debian 12/13
|
||||||
3. The installer validates the platform, installs Python 3 and an absent Docker
|
amd64 host. It downloads the installer directly from the public repository;
|
||||||
installation, verifies the downloaded agent and updater SHA-256 over HTTPS,
|
[release trust](#provision-release-trust) is fetched automatically.
|
||||||
|
3. The installer validates the platform, installs Python 3, `gpgv` and an absent Docker
|
||||||
|
installation, verifies the release signature and agent/updater SHA-256 checksums,
|
||||||
enrolls it and enables `electric-cherry-agent.service` and its automatic update
|
enrolls it and enables `electric-cherry-agent.service` and its automatic update
|
||||||
timer. It needs no Git, Node.js or local build.
|
timer. It needs no Git, Node.js or local build.
|
||||||
4. In **Game Files**, upload your complete local folder under T5 or T6. Include
|
4. In **Game Files**, upload your complete local folder under T5 or T6. Include
|
||||||
@@ -41,6 +43,47 @@ the agent owns local execution and its durable operation journal.
|
|||||||
A fresh destination host downloads centrally stored game files without manual
|
A fresh destination host downloads centrally stored game files without manual
|
||||||
copying or per-container setup. EC does not supply the game content itself.
|
copying or per-container setup. EC does not supply the game content itself.
|
||||||
|
|
||||||
|
### Provision release trust
|
||||||
|
|
||||||
|
The public Electric Cherry repository is the authority for both the installer
|
||||||
|
and release-signing public keys. The enrollment command downloads `agent/install.sh`
|
||||||
|
from that repository over HTTPS, saves it locally, and runs it only after the
|
||||||
|
download succeeds. No manual key transfer is required.
|
||||||
|
|
||||||
|
During installation and **before every update check**, the host downloads the
|
||||||
|
binary OpenPGP public keyring directly from this fixed URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg
|
||||||
|
```
|
||||||
|
|
||||||
|
The URL is built into the trusted installer and updater. Controller configuration,
|
||||||
|
release metadata and enrollment credentials cannot change it. The host verifies
|
||||||
|
HTTPS certificates, rejects redirects and incomplete, empty or oversized key
|
||||||
|
responses, and requests revalidation with `Cache-Control: no-cache`. It uses the
|
||||||
|
fresh keyring only for that check and deletes its temporary copy afterward.
|
||||||
|
There is no fallback to a previous host keyring, cached download or controller key.
|
||||||
|
If the repository is unavailable, new installations stop and automatic updates
|
||||||
|
wait for the next check; the installed agent and game containers keep running.
|
||||||
|
Local recovery of an interrupted update does not require the repository.
|
||||||
|
|
||||||
|
This deliberately trusts the repository account, its `main` branch and HTTPS
|
||||||
|
hosting as authorities for root code on game hosts. They must be outside the
|
||||||
|
controller's write access: a compromised controller must not also have a token,
|
||||||
|
mount or server access that can change these repository files. A repository
|
||||||
|
compromise can replace the key or installer. Before initial enrollment, check that
|
||||||
|
the command downloads from
|
||||||
|
`https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/install.sh`,
|
||||||
|
since a compromised controller UI could display a different command. Host provisioning systems can use that known URL directly.
|
||||||
|
|
||||||
|
Before deploying the first signed release, the publisher must export and commit
|
||||||
|
its **public** key as `agent/release-signing.gpg` on `main`, following the
|
||||||
|
[signing workflow below](#publishing-signed-releases). No production key is
|
||||||
|
created by packaging. Until the public key and signed release are published,
|
||||||
|
installation and updates fail closed. Forks must change the fixed repository URLs
|
||||||
|
in the installer, updater and enrollment command to their own trusted repository;
|
||||||
|
there is intentionally no controller setting to override them.
|
||||||
|
|
||||||
### Docker installation
|
### Docker installation
|
||||||
|
|
||||||
The installer uses Debian's Docker packages: `docker.io` on Debian 12, plus
|
The installer uses Debian's Docker packages: `docker.io` on Debian 12, plus
|
||||||
@@ -76,7 +119,22 @@ successful monitoring and file-read records. The default is one day; zero keeps
|
|||||||
them indefinitely. Actions, failures, negative checks, and unresolved operations
|
them indefinitely. Actions, failures, negative checks, and unresolved operations
|
||||||
are retained. Agent 1.4.9 also cleans up successful routine journal records after
|
are retained. Agent 1.4.9 also cleans up successful routine journal records after
|
||||||
the controller confirms their final receipt. Older unconfirmed journal entries
|
the controller confirms their final receipt. Older unconfirmed journal entries
|
||||||
remain until a replay supplies that confirmation. Cleanup runs in bounded batches.
|
remain until a replay supplies that confirmation. Controller cleanup runs every
|
||||||
|
five minutes and drains eligible records in batches, yielding between batches.
|
||||||
|
|
||||||
|
Log-read operation history stores metadata only. The waiting reader receives the
|
||||||
|
log content directly; saved match chat remains with its match. Background cleanup
|
||||||
|
also removes log bodies left in controller history by earlier versions.
|
||||||
|
|
||||||
|
Agent 1.8.5 adds incremental game-log reads for chat collection. After the initial
|
||||||
|
tail, the controller requests only appended bytes, handles rotation and truncation,
|
||||||
|
and buffers incomplete lines. Unchanged logs need no content transfer or chat
|
||||||
|
parsing. Background collection skips offline hosts, stopped servers and known
|
||||||
|
empty servers, with one final read after players leave. Opening chat reuses the
|
||||||
|
worker's saved reader state and only starts an on-demand read when the previous
|
||||||
|
read completed at least 15 seconds ago. This limit is shared across viewers and also
|
||||||
|
applies to missing logs and failed reads. Older agents use tail reads until they
|
||||||
|
update.
|
||||||
|
|
||||||
### New game files and cached versions
|
### New game files and cached versions
|
||||||
|
|
||||||
@@ -155,45 +213,135 @@ directory, while file writes reject symlinks and paths outside that directory.
|
|||||||
## Automatic agent updates
|
## Automatic agent updates
|
||||||
|
|
||||||
New installations enable automatic agent updates by default. Each host checks
|
New installations enable automatic agent updates by default. Each host checks
|
||||||
its controller's release over HTTPS about every five minutes, beginning about
|
the repository public key and then its controller's release over HTTPS about
|
||||||
two minutes after boot, with up to 30 seconds of jitter. Updating the controller
|
every five minutes, beginning about two minutes after boot, with up to 30 seconds
|
||||||
therefore makes its compatible, newer agent release available to enrolled hosts.
|
of jitter. Updating the controller
|
||||||
|
therefore makes its compatible, newer signed agent release available to enrolled hosts.
|
||||||
There is no controller command to schedule and no update button to press.
|
There is no controller command to schedule and no update button to press.
|
||||||
|
|
||||||
**Existing hosts need the current installer once.** From the host's **Agent
|
**Existing hosts must migrate to updater protocol 2 with the trusted installer.**
|
||||||
access**, generate an installer command and rerun it on the same machine, with
|
Protocol 1 bootstraps trust controller-provided checksums and remain exposed to
|
||||||
the same controller and data directory. Do not use `--reenroll` for this upgrade.
|
controller-forged updates until this migration is complete. Publishing a signed
|
||||||
The installer preserves host identity, credentials, game files and running
|
release does not upgrade those bootstraps: they request an installer rerun when
|
||||||
containers. Agent version 1.2.0 includes automatic update support; the host page
|
they see protocol 2, but a compromised controller could still serve protocol 1.
|
||||||
shows **Setup required** until a fresh heartbeat confirms the updater is installed.
|
|
||||||
|
From the host's **Agent access**, generate and run the command, which fetches
|
||||||
|
the repository installer and public key automatically. Use the same controller
|
||||||
|
and data directory. Preserve a custom root with `--data-dir`;
|
||||||
|
do not use `--reenroll` for this upgrade. To pause old automatic checks during
|
||||||
|
migration, run `sudo systemctl stop electric-cherry-update.timer`. This does not
|
||||||
|
interrupt a running update worker; let any active update or journal recovery
|
||||||
|
finish before installing. The installer preserves host identity, credentials,
|
||||||
|
game files and running containers, replaces the bootstrap, and enables its timer
|
||||||
|
on success. Hosts without an updater show **Setup required** until a heartbeat
|
||||||
|
confirms installation. Hosts using an earlier protocol 2 bootstrap with a
|
||||||
|
manually provisioned keyring also need this installer rerun to adopt repository
|
||||||
|
refresh: the updater does not replace itself automatically. An old local
|
||||||
|
`/etc/electric-cherry/release-signing.gpg` file is ignored by the new bootstrap.
|
||||||
|
|
||||||
The **Automatic agent updates** panel reports the running version, target
|
The **Automatic agent updates** panel reports the running version, target
|
||||||
version, last check, progress and any error. Offline hosts show their last
|
version, last check, progress and any error. Offline hosts show their last
|
||||||
reported status. Deferred updates report why they are waiting: for example an
|
reported status. Deferred updates report why they are waiting: for example an
|
||||||
active agent operation, an unavailable controller or a newer updater bootstrap.
|
active agent operation, an unavailable repository/controller or a newer updater bootstrap.
|
||||||
Checks also defer while the agent service is stopped; an update does not start a
|
Checks also defer while the agent service is stopped; an update does not start a
|
||||||
service that was stopped outside the update workflow.
|
service that was stopped outside the update workflow.
|
||||||
The independent updater holds the same execution lock, verifies the release
|
The independent updater holds the same execution lock, authenticates the exact
|
||||||
checksum, version and supported protocol, replaces agent code, and restarts the
|
manifest bytes with the freshly fetched repository keyring before parsing them,
|
||||||
agent service. It never restarts Docker or game containers. It confirms an
|
verifies the release checksum, version and supported protocol, replaces agent
|
||||||
|
code, and restarts the agent service. It never restarts Docker or game containers. It confirms an
|
||||||
authenticated heartbeat from the new agent process before accepting the update;
|
authenticated heartbeat from the new agent process before accepting the update;
|
||||||
failure restores the previous agent code. Its durable journal permits recovery
|
failure restores the previous agent code. Its durable journal permits recovery
|
||||||
after a host reboot or interrupted update.
|
after a host reboot or interrupted update.
|
||||||
|
|
||||||
The updater only installs newer compatible versions; rolling the controller back
|
The updater only installs newer compatible versions; replaying an older signed
|
||||||
does not downgrade agents. A rejected version/checksum pair stays blocked until
|
release or rolling the controller back does not downgrade agents. Signing cannot
|
||||||
|
force a controller to publish newer releases or prevent it withholding updates.
|
||||||
|
A rejected version/checksum pair stays blocked until
|
||||||
the controller publishes a different pair that is still newer than the installed
|
the controller publishes a different pair that is still newer than the installed
|
||||||
agent. Inspect the reported error when a host
|
agent. Inspect the reported error when a host
|
||||||
shows **Needs attention**. The updater itself is a stable bootstrap component:
|
shows **Needs attention**. The updater itself is a stable bootstrap component:
|
||||||
it changes only when the installer is rerun. If a future release requires an
|
it changes only when the installer is rerun. If a future release requires an
|
||||||
unsupported updater protocol, rerun that release's installer on the host.
|
unsupported updater protocol, rerun that release's installer on the host.
|
||||||
|
|
||||||
### Publishing releases and inspecting updates
|
### Publishing signed releases
|
||||||
|
|
||||||
When publishing changed agent code, bump its numeric `VERSION` in
|
When publishing changed agent code, bump its numeric `VERSION` in
|
||||||
`agent/ec_agent.py` and rebuild the controller. Packaging derives the manifest
|
`agent/ec_agent.py`. Finalize and review all source changes before packaging and
|
||||||
version and checksums from the sources; an already installed version is never
|
signing. An already installed version is never replaced just because its bytes
|
||||||
replaced just because its bytes changed.
|
changed.
|
||||||
|
|
||||||
|
Use a separate offline publisher machine with GnuPG. Keep the private key and its
|
||||||
|
GnuPG home outside the checkout, Docker build context, controller, and CI. For
|
||||||
|
example, create a dedicated signing key and inspect its full fingerprint:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ec_signing_home=/secure/electric-cherry-signing
|
||||||
|
install -d -m 0700 "$ec_signing_home"
|
||||||
|
gpg --homedir "$ec_signing_home" --quick-generate-key 'Electric Cherry Release Signing' ed25519 sign 2y
|
||||||
|
gpg --homedir "$ec_signing_home" --list-keys --with-fingerprint
|
||||||
|
```
|
||||||
|
|
||||||
|
Protect the key with a passphrase and keep its backup and revocation certificate
|
||||||
|
outside release artifacts. Set the full fingerprint of the signing key, then
|
||||||
|
export only its public key into the reviewed checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ec_signing_fingerprint=REPLACE_WITH_FULL_SIGNING_KEY_FINGERPRINT
|
||||||
|
gpg --homedir "$ec_signing_home" --export "$ec_signing_fingerprint" > agent/release-signing.gpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect the export with `gpg --show-keys --with-fingerprint agent/release-signing.gpg`
|
||||||
|
and confirm the expected publisher fingerprint. Commit and publish that public
|
||||||
|
file to the repository's `main` branch before enrolling hosts or releasing code
|
||||||
|
signed by this key. Never export secret keys into the repository. A YubiKey-held
|
||||||
|
signing key works with the same workflow: export its public certificate from your
|
||||||
|
GnuPG home and use the full signing key/subkey fingerprint with the signer. Keep
|
||||||
|
the YubiKey on the publisher workstation, outside controller and CI access.
|
||||||
|
|
||||||
|
From the reviewed checkout on the publisher machine, package and explicitly sign:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run agent:package
|
||||||
|
python3 agent/sign_release.py --gnupghome "$ec_signing_home" --fingerprint "$ec_signing_fingerprint"
|
||||||
|
```
|
||||||
|
|
||||||
|
The signer defaults to `static/agent`; `--release-dir` selects another packaged
|
||||||
|
release directory. It checks that the manifest matches the agent, updater and
|
||||||
|
installer bytes, then produces the ASCII-armored detached `manifest.json.sig`.
|
||||||
|
The manifest records their SHA-256 hashes as `sha256`, `updaterSha256`, and
|
||||||
|
`installerSha256`, along with version, platform and protocol metadata. The
|
||||||
|
installer hash lets recipients check a publisher-distributed installer against
|
||||||
|
the authenticated manifest before running it.
|
||||||
|
|
||||||
|
Commit or distribute the finalized `static/agent` public artifacts together,
|
||||||
|
including the signature, before building the controller image from the same
|
||||||
|
reviewed source revision. Never include private keys or the signing home.
|
||||||
|
Normal `npm run build`, development startup,
|
||||||
|
and Docker builds never sign: packaging preserves a signature only when the
|
||||||
|
manifest remains byte-for-byte identical and removes it after any covered source
|
||||||
|
change. Repackage, review and sign again after such a change. Unsigned builds can
|
||||||
|
run the controller, but hosts fail closed and leave their installed code intact.
|
||||||
|
|
||||||
|
### Signing-key rotation and removal
|
||||||
|
|
||||||
|
Review new fingerprints on the publisher workstation, then replace
|
||||||
|
`agent/release-signing.gpg` on the repository's `main` branch. Every host uses the
|
||||||
|
new keyring on its next update check; no host visits or controller changes are
|
||||||
|
needed. For a planned transition, export both approved public keys into this file,
|
||||||
|
publish the combined keyring, then publish releases signed by the new key. Remove
|
||||||
|
the retired key once the controller serves the new signatures. Checks already in
|
||||||
|
progress use the keyring they fetched at the start of that check.
|
||||||
|
|
||||||
|
For a compromised key, remove it from the repository keyring immediately and
|
||||||
|
publish releases signed by a replacement key. Hosts with the refreshed keyring
|
||||||
|
reject the old signer even if the controller still serves its release. Publishing
|
||||||
|
a revocation certificate or allowing a key to expire is insufficient: [GnuPG's
|
||||||
|
`gpgv` documentation](https://www.gnupg.org/documentation/manuals/gnupg/gpgv.html)
|
||||||
|
states that it does not check key expiration or revocation. Keep the keyring's
|
||||||
|
contents restricted to currently trusted keys. Removing all keys blocks new
|
||||||
|
installations and updates; it does not stop or remove already installed agents.
|
||||||
|
|
||||||
|
### Inspecting updates
|
||||||
|
|
||||||
The installer places the updater at
|
The installer places the updater at
|
||||||
`/usr/local/libexec/electric-cherry-update.py` and enables
|
`/usr/local/libexec/electric-cherry-update.py` and enables
|
||||||
@@ -209,6 +357,40 @@ journalctl -u electric-cherry-update.service -n 100
|
|||||||
|
|
||||||
## Placement and resource capacity
|
## Placement and resource capacity
|
||||||
|
|
||||||
|
### Host temperature
|
||||||
|
|
||||||
|
Agent **1.10.0** reports temperature sensors exposed by supported Linux hwmon
|
||||||
|
drivers without installing additional packages. **Hosts** shows CPU temperature
|
||||||
|
beneath CPU usage. A host's **Overview → Inventory** shows the selected reading
|
||||||
|
and expandable **Temperature sensors**, including per-core and storage details.
|
||||||
|
Stale readings are marked as last reported; absent or failed readings are
|
||||||
|
**Unavailable**, never zero or a readiness failure.
|
||||||
|
|
||||||
|
**Fleet → Capacity → Temperature history** uses the existing host selector and
|
||||||
|
Last hour / 24 hours / 7 days controls. **All hosts** displays a separate CPU line
|
||||||
|
for each host. Select one host to add its NVMe composite and chipset lines using
|
||||||
|
the legend. Hover, tap, or use the arrow keys on the chart to inspect a period.
|
||||||
|
Each host page's **Capacity** button opens the view filtered to that host.
|
||||||
|
The game filter affects launch estimates, not temperatures.
|
||||||
|
|
||||||
|
The CPU reading is the hottest reported package, falling back to a physical
|
||||||
|
AMD die/CCD and then a core reading. AMD Tctl cooling-control values are excluded
|
||||||
|
because they may include an offset. Supported device drivers are `coretemp`,
|
||||||
|
`k10temp`, `nvme` and `pch_*`; sensor availability depends on hardware and drivers.
|
||||||
|
Virtual machines and older agents may have no supported readings. Missing,
|
||||||
|
faulty and disabled sensors are skipped individually.
|
||||||
|
|
||||||
|
Temperature history starts after the agent upgrade. The controller stores one
|
||||||
|
peak per sensor per minute, limited to the CPU summary and up to sixteen NVMe
|
||||||
|
composite/chipset inputs per host. Longer chart periods show the maximum of
|
||||||
|
those peaks; a period without readings remains a gap. Brief gaps within an
|
||||||
|
aggregated period may still leave a peak for that period. **Host resource history
|
||||||
|
retention** applies to CPU, memory and temperature samples (seven days by default;
|
||||||
|
zero keeps them indefinitely). Series colors identify hosts/sensors and do not
|
||||||
|
indicate thermal safety. Temperature does not change placement or trigger alerts.
|
||||||
|
|
||||||
|
### CPU and memory capacity
|
||||||
|
|
||||||
### Host inventory and eligibility
|
### Host inventory and eligibility
|
||||||
|
|
||||||
The Inventory panel shows CPU usage with agent 1.4.2 and later. It measures
|
The Inventory panel shows CPU usage with agent 1.4.2 and later. It measures
|
||||||
@@ -231,10 +413,21 @@ servers. Existing API clients may still explicitly constrain a launch to a host.
|
|||||||
Queued jobs retain their accepted game-file version. Automatic host preparation
|
Queued jobs retain their accepted game-file version. Automatic host preparation
|
||||||
also downloads the game files needed by unplaced jobs; a newer upload does not
|
also downloads the game files needed by unplaced jobs; a newer upload does not
|
||||||
remove files still needed by the queue. Only the configured default runtime is
|
remove files still needed by the queue. Only the configured default runtime is
|
||||||
pulled automatically. Custom images must already be cached on the selected host,
|
pulled automatically. Custom images must have been prepared by an admin from the
|
||||||
or on an active host for automatic placement, before a launch is accepted. Admins
|
selected host's page, or on an active host for automatic placement, before a launch
|
||||||
can prepare them from the host page. Queued launches and migrations never trigger
|
is accepted. An image cached by another Docker workload does not qualify. Agent
|
||||||
custom image pulls, and container creation cannot implicitly pull a missing image.
|
1.8.6 reports these exact prepared references from `runtime-images.json` while
|
||||||
|
their image IDs remain cached; the controller also trusts its configured default
|
||||||
|
when cached. Host pages and APIs expose only prepared runtimes. Older agents can
|
||||||
|
still run the configured default, but must update before accepting custom runtimes.
|
||||||
|
Queued launches and migrations never trigger custom image pulls, and container
|
||||||
|
creation cannot implicitly pull a missing image.
|
||||||
|
|
||||||
|
Agent 1.8.7 also checks a runtime's current image ID against its recorded preparation
|
||||||
|
before creating or rebuilding a container. If another program retags a prepared
|
||||||
|
image, an admin must prepare it again from the host page before launching it.
|
||||||
|
Containers are created using the validated immutable image ID, so a tag change
|
||||||
|
after validation cannot substitute another image. Existing containers keep running.
|
||||||
|
|
||||||
### Resource samples and learned allowances
|
### Resource samples and learned allowances
|
||||||
|
|
||||||
@@ -347,22 +540,32 @@ The agent uses HTTPS, operating-system certificate validation, outbound heartbea
|
|||||||
and polling; Docker has no exposed remote API. Agent endpoints reject HTTP unless
|
and polling; Docker has no exposed remote API. Agent endpoints reject HTTP unless
|
||||||
`EC_AGENT_ALLOW_HTTP=true` is explicitly enabled for development. The agent's
|
`EC_AGENT_ALLOW_HTTP=true` is explicitly enabled for development. The agent's
|
||||||
insecure development option is limited to loopback. Public release assets contain
|
insecure development option is limited to loopback. Public release assets contain
|
||||||
no credentials. The release checksum is authenticated by the controller's HTTPS
|
no credentials. HTTPS protects transport; an offline publisher signature over the
|
||||||
origin; there is no separate offline release-signing infrastructure.
|
release manifest authenticates agent and updater code using the public keyring
|
||||||
|
fetched directly from the trusted repository before each check. Controller
|
||||||
|
credentials do not grant release-signing authority or control the key source.
|
||||||
|
|
||||||
Browser sessions do not authorize agents. Similarly, host credentials never grant
|
Browser sessions do not authorize agents. Similarly, host credentials never grant
|
||||||
access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`,
|
access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`,
|
||||||
configured in each reporter's authorization header; see the
|
configured in each reporter's authorization header; see the
|
||||||
[telemetry contract](beta-test-plans.md#game-reporter-protocol).
|
[telemetry contract](beta-test-plans.md#game-reporter-protocol).
|
||||||
|
|
||||||
Hosts offers credential rotation and revocation. Rotation persists the new secret
|
Hosts offers credential rotation and revocation. Replacing a credential requires
|
||||||
on the agent before switching controller credentials, so a lost response can be
|
an open administrator-requested rotation operation for that host. The controller
|
||||||
retried using the same proposed secret. Revocation affects one host only and does
|
replaces the credential and completes that operation in one transaction. Rotation
|
||||||
not stop its running game containers. Re-enrollment requires a fresh token and an
|
persists the new secret on the agent before switching controller credentials, so
|
||||||
explicit installer `--reenroll` when local credentials already exist.
|
a lost response can be retried using the same proposed secret. A retry of the
|
||||||
|
active secret only acknowledges it; it cannot authorize another replacement.
|
||||||
|
Revocation affects one host only and does not stop its running game containers.
|
||||||
|
Re-enrollment requires a fresh token and an explicit installer `--reenroll` when
|
||||||
|
local credentials already exist.
|
||||||
|
|
||||||
## Host and game-file API
|
## Host and game-file API
|
||||||
|
|
||||||
|
Open **Fleet → Hosts → Operation history** for retained operations across all
|
||||||
|
hosts, including deleted hosts. A host's **Operation history** action scopes the
|
||||||
|
same history to that host. Filters and pagination retain the selected scope.
|
||||||
|
|
||||||
- `GET /api/hosts/:id`: host details and deletion blockers.
|
- `GET /api/hosts/:id`: host details and deletion blockers.
|
||||||
- `GET /api/hosts/operations?page=1&filter=important`: operation history across
|
- `GET /api/hosts/operations?page=1&filter=important`: operation history across
|
||||||
hosts, newest first, with 25 summaries per page and filtered total/page counts.
|
hosts, newest first, with 25 summaries per page and filtered total/page counts.
|
||||||
@@ -374,9 +577,11 @@ explicit installer `--reenroll` when local credentials already exist.
|
|||||||
or `409` when the agent needs updating or unresolved ownership remains.
|
or `409` when the agent needs updating or unresolved ownership remains.
|
||||||
- `POST /api/agent/enroll`: consume enrollment token and return host credentials.
|
- `POST /api/agent/enroll`: consume enrollment token and return host credentials.
|
||||||
- `GET /api/agent/release/manifest.json`: public agent version, protocol and
|
- `GET /api/agent/release/manifest.json`: public agent version, protocol and
|
||||||
checksum plus `updaterProtocolVersion` and `updaterSha256` for installer bootstrap.
|
checksum plus `updaterProtocolVersion`, `updaterSha256`, and `installerSha256`.
|
||||||
|
- `GET /api/agent/release/manifest.json.sig`: detached publisher signature over
|
||||||
|
the exact manifest bytes; absent for an unsigned build.
|
||||||
- `GET /api/agent/release/ec-agent.py` and `ec-agent-update.py`: public release
|
- `GET /api/agent/release/ec-agent.py` and `ec-agent-update.py`: public release
|
||||||
source, verified against that HTTPS manifest before installation.
|
source, verified against the signed manifest before installation.
|
||||||
- `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches.
|
- `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches.
|
||||||
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
|
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
|
||||||
- `POST /api/agent/operations/:id`: authenticated acceptance, progress or result.
|
- `POST /api/agent/operations/:id`: authenticated acceptance, progress or result.
|
||||||
@@ -415,6 +620,16 @@ Otherwise the result remains **Unknown** and holds dependent work. Running alone
|
|||||||
is insufficient evidence that a particular restart happened. EC neither assumes
|
is insufficient evidence that a particular restart happened. EC neither assumes
|
||||||
an unreachable server stopped nor launches a duplicate replacement elsewhere.
|
an unreachable server stopped nor launches a duplicate replacement elsewhere.
|
||||||
|
|
||||||
|
An interrupted rebuild can settle as **Failed** when its last checkpoint is
|
||||||
|
`disrupting`, Docker confirms both the old and replacement containers are absent,
|
||||||
|
and any existing generation marker still matches the original instance. The agent removes
|
||||||
|
only the partial replacement files, preserves the old generation's files and
|
||||||
|
marker, and retains the original diagnostic in its failure report. It does not
|
||||||
|
replay the interrupted rebuild. A separately queued rebuild can then proceed
|
||||||
|
through normal eligibility checks. A later creation checkpoint, a changed
|
||||||
|
generation, an inspection error, or failed partial-file cleanup leaves the result
|
||||||
|
**Unknown** and keeps dependent work waiting.
|
||||||
|
|
||||||
### Player checks and activation
|
### Player checks and activation
|
||||||
|
|
||||||
Campaign bytes are staged and verified while players remain connected. Only
|
Campaign bytes are staged and verified while players remain connected. Only
|
||||||
@@ -442,6 +657,13 @@ restart and create complete only after post-operation health evidence; creation
|
|||||||
and rebuild also verify the selected map and running Plutonium revision. The
|
and rebuild also verify the selected map and running Plutonium revision. The
|
||||||
base-game library and runtime revision are independent records.
|
base-game library and runtime revision are independent records.
|
||||||
|
|
||||||
|
Agent 1.10.1 prepares and verifies the complete replacement file set before
|
||||||
|
stopping the existing container, within the 64 MiB operation limit. Staged-file
|
||||||
|
comparison, replacement validation and writing reuse those bytes, so losing
|
||||||
|
artifact connectivity after preflight does not prevent replacement. A preflight
|
||||||
|
download failure leaves the original container running. Prepared bytes last only
|
||||||
|
for that operation attempt; held or failed work must prepare its files again.
|
||||||
|
|
||||||
## Rebalance instances
|
## Rebalance instances
|
||||||
|
|
||||||
Open **Fleet → Capacity → Rebalance** to spread existing instances across active
|
Open **Fleet → Capacity → Rebalance** to spread existing instances across active
|
||||||
@@ -543,10 +765,11 @@ and invalidates unused installer links. A separate cleanup service removes the
|
|||||||
host's downloaded game libraries, instance files, agent journal and configuration,
|
host's downloaded game libraries, instance files, agent journal and configuration,
|
||||||
owned containers, and recorded runtime images unused by other containers. It
|
owned containers, and recorded runtime images unused by other containers. It
|
||||||
stops and removes the agent service and executable, automatic update timer,
|
stops and removes the agent service and executable, automatic update timer,
|
||||||
update service, updater executable and update state, then removes its own files.
|
update service, updater executable and update state (including any legacy local
|
||||||
|
signing keyring), then removes its own files.
|
||||||
The controller hides the host and revokes its ordinary credentials only after
|
The controller hides the host and revokes its ordinary credentials only after
|
||||||
receiving the final uninstall receipt. Adding the machine again requires a new
|
receiving the final uninstall receipt. Adding the machine again requires a new
|
||||||
host enrollment.
|
host enrollment; the installer fetches release trust from the repository again.
|
||||||
|
|
||||||
Game folders uploaded through Electric Cherry belong to the controller. Those
|
Game folders uploaded through Electric Cherry belong to the controller. Those
|
||||||
original uploads remain available for other hosts and future deployments; only
|
original uploads remain available for other hosts and future deployments; only
|
||||||
@@ -635,10 +858,28 @@ an open Game Files page.
|
|||||||
|
|
||||||
### Proxy limits and older uploads
|
### Proxy limits and older uploads
|
||||||
|
|
||||||
The controller and reverse proxy must accept 8 MiB upload requests and folder
|
The container and Compose retain `BODY_SIZE_LIMIT=Infinity` for legacy source-host
|
||||||
manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes
|
imports, which stream whole game files directly to disk and verify manifest sizes
|
||||||
still enforce these limits, declared file sizes and checksums. Set a proxy body
|
and checksums. Routes that buffer request bodies enforce limits before parsing:
|
||||||
limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx).
|
ordinary JSON requests (including telemetry) are limited to 1 MiB, authentication
|
||||||
|
and user-management JSON to 8 KiB, game-file upload chunks to 8 MiB, and folder
|
||||||
|
manifests to 32 MiB. Existing agent endpoints retain their individual limits, up
|
||||||
|
to 128 MiB for operation results. Game-file uploads also enforce declared sizes
|
||||||
|
and checksums.
|
||||||
|
|
||||||
|
Script, plugin and config artifacts are limited to 32 MiB per file, matching the
|
||||||
|
host agent. Multipart uploads allow 33 MiB total, including fields and framing;
|
||||||
|
beta revisions allow 65 MiB total for multiple replacement files. Both the declared
|
||||||
|
request length and actual streamed bytes are checked before multipart parsing.
|
||||||
|
Oversized ordinary JSON and artifact uploads receive HTTP 413. GitHub plugin imports use
|
||||||
|
the same 32 MiB artifact limit.
|
||||||
|
|
||||||
|
Set a proxy body limit of at least 128 MiB (for example
|
||||||
|
`client_max_body_size 128m` in nginx) for buffered endpoints. If legacy source-host
|
||||||
|
imports are still in use, allow the largest game file on
|
||||||
|
`/api/agent/imports/*/files/*`; these transfers do not use the browser's 8 MiB
|
||||||
|
chunks. Route limits apply even when connecting directly to the controller.
|
||||||
|
|
||||||
Existing uploads, host caches and server references remain usable. Previously
|
Existing uploads, host caches and server references remain usable. Previously
|
||||||
named libraries are grouped under T5 or T6, and the most recently published
|
named libraries are grouped under T5 or T6, and the most recently published
|
||||||
upload for each game becomes its latest files. Their immutable IDs and stored
|
upload for each game becomes its latest files. Their immutable IDs and stored
|
||||||
@@ -783,14 +1024,19 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
The build packages the agent, standalone updater and installer into the static
|
The build packages the agent, standalone updater and installer into the static
|
||||||
release, generates the agent/updater checksum manifest and serves those same
|
release, generates their checksum manifest and serves those same assets in the
|
||||||
assets in the Node-adapter image. Development serves the freshly packaged static
|
Node-adapter image. Sign production artifacts using the
|
||||||
release even when an older production build exists. The Python programs use only
|
[offline workflow](#publishing-signed-releases) before building. Development
|
||||||
the standard library.
|
serves the freshly packaged static release even when an older production build
|
||||||
|
exists. The Python programs use only the standard library; signature verification
|
||||||
|
also requires the host's `gpgv` executable. Real signing tests require `gpg` and
|
||||||
|
`gpgv`, installed by CI; local runs skip those tests when either tool is absent.
|
||||||
|
|
||||||
Automated tests cover enrollment/authentication, rotation/revocation, host
|
Automated tests cover enrollment/authentication, rotation/revocation, host
|
||||||
readiness, scoped ports, immutable library publication, streaming/ranges,
|
readiness, scoped ports, immutable library publication, streaming/ranges,
|
||||||
checksums, interrupted downloads, instance generation ownership, local occupancy
|
signed manifests, forged checksums, unknown signing keys, repository key refresh
|
||||||
|
and rotation, repository failures, interrupted downloads,
|
||||||
|
instance generation ownership, local occupancy
|
||||||
holds, controller restart/reconnect, ambiguous results, occupied campaign staging,
|
holds, controller restart/reconnect, ambiguous results, occupied campaign staging,
|
||||||
activation before images, collections and draining. Host deletion tests cover
|
activation before images, collections and draining. Host deletion tests cover
|
||||||
ownership guards, interrupted cleanup, uninstall receipts, credential revocation
|
ownership guards, interrupted cleanup, uninstall receipts, credential revocation
|
||||||
@@ -801,13 +1047,18 @@ A real Debian/Docker/Plutonium acceptance run is still required before productio
|
|||||||
rollout. Automated simulations do not establish successful deployment on real
|
rollout. Automated simulations do not establish successful deployment on real
|
||||||
game hosts. Run:
|
game hosts. Run:
|
||||||
|
|
||||||
- Installer on Debian 12 and 13 amd64, then rerun without changing identity/data.
|
- Publish the public key in the repository and run the generated enrollment
|
||||||
- Bootstrap an existing host with the current installer, then publish a newer
|
command on Debian 12 and 13 amd64 without a local keyring, then rerun without
|
||||||
compatible agent release. Verify a timer-driven update, its authenticated
|
changing identity/data.
|
||||||
|
- Migrate a protocol 1 host with the trusted installer, then publish a newer
|
||||||
|
compatible signed agent release. Verify a timer-driven update, its authenticated
|
||||||
heartbeat and unchanged game containers. Exercise an active-operation hold,
|
heartbeat and unchanged game containers. Exercise an active-operation hold,
|
||||||
bad checksum, failed startup/heartbeat rollback, controller downgrade, network
|
missing/invalid signatures, an unknown key, a forged manifest matching changed
|
||||||
loss and host reboot during update. Verify updater protocol mismatch requests
|
code, bad checksum, failed startup/heartbeat rollback, controller downgrade,
|
||||||
an installer rerun.
|
network loss and host reboot during update. Verify updater protocol mismatch
|
||||||
|
requests an installer rerun. Replace the repository keyring and confirm the
|
||||||
|
next check rejects the removed signer and accepts the new signer; block access
|
||||||
|
to the repository and confirm no cached key is used and local rollback still works.
|
||||||
- T5 and T6 deployments and a mixed collection from a fresh destination host.
|
- T5 and T6 deployments and a mixed collection from a fresh destination host.
|
||||||
- Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies;
|
- Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies;
|
||||||
verify the running revision and compare first-start downloads and launch times
|
verify the running revision and compare first-start downloads and launch times
|
||||||
@@ -832,3 +1083,40 @@ game hosts. Run:
|
|||||||
- Controller restart, agent restart and network loss before execution, during
|
- Controller restart, agent restart and network loss before execution, during
|
||||||
execution and after execution but before acknowledgement. Verify the same
|
execution and after execution but before acknowledgement. Verify the same
|
||||||
operation IDs and absence of duplicate containers/restarts.
|
operation IDs and absence of duplicate containers/restarts.
|
||||||
|
|
||||||
|
### Bank journals during server teardown
|
||||||
|
|
||||||
|
Agent 1.9.0 introduced `bank.settlement`: removal, rebuild, migration and creation
|
||||||
|
cancellation finish pending TWS bank decisions before deleting the old runtime.
|
||||||
|
An unavailable controller or corrupt journal leaves the operation unresolved
|
||||||
|
and preserves storage. Update all agents before enabling the
|
||||||
|
[player bank](bank.md). EC generates and injects bank credentials per instance;
|
||||||
|
no token map or manual server-secret provisioning is required.
|
||||||
|
|
||||||
|
Agent **1.9.1+** recognizes both the fixed public game-bank endpoint
|
||||||
|
`https://ec.thewrightserver.net/api/bank` and its own legacy controller-derived
|
||||||
|
bank endpoint in journals. It stops generating `tws_bank_url` in the private
|
||||||
|
game configuration. Agent transport still uses the enrolled controller URL and
|
||||||
|
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.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ GitHub release is imported from its detail page.
|
|||||||
## Limits
|
## Limits
|
||||||
|
|
||||||
- Public repositories on github.com only; no GitHub credentials are needed.
|
- Public repositories on github.com only; no GitHub credentials are needed.
|
||||||
- Direct `.dll` release assets up to 64 MB; archives and source-code downloads
|
- Direct `.dll` release assets up to 32 MiB; archives and source-code downloads
|
||||||
are not extracted. Other formats can still use the manual upload workflow.
|
are not extracted. Other formats can still use the manual upload workflow.
|
||||||
- Release tags have the same 80-character limit as manually entered versions.
|
- Release tags have the same 80-character limit as manually entered versions.
|
||||||
- GitHub rate limits, missing releases, download failures and checksum failures
|
- GitHub rate limits, missing releases, download failures and checksum failures
|
||||||
@@ -52,7 +52,7 @@ GitHub release is imported from its detail page.
|
|||||||
|
|
||||||
- `GET /api/plugins/github/release?url=...` previews a release and its DLL assets.
|
- `GET /api/plugins/github/release?url=...` previews a release and its DLL assets.
|
||||||
- `POST /api/plugins/github/import` accepts JSON `{ url, assetId, name,
|
- `POST /api/plugins/github/import` accepts JSON `{ url, assetId, name,
|
||||||
scopeType?, scopeValue? }` for a new plugin, or `{ url, assetId, pluginId }`
|
scopeType?, scopeValue? }` for a new plugin, or `{ url, assetId, pluginId }`
|
||||||
for an existing plugin. Use the preview's exact release URL, not a mutable
|
for an existing plugin. Use the preview's exact release URL, not a mutable
|
||||||
`latest` URL. Returns plugin/version IDs, checksum and the beta builder URL.
|
`latest` URL. Returns plugin/version IDs, checksum and the beta builder URL.
|
||||||
- `GET /api/plugins/updates` checks all plugins; `?pluginId=123` checks one.
|
- `GET /api/plugins/updates` checks all plugins; `?pluginId=123` checks one.
|
||||||
|
|||||||
+76
-24
@@ -1,8 +1,7 @@
|
|||||||
# Provisioning
|
# Provisioning
|
||||||
|
|
||||||
Open **Activity → Activity log** for retained lifecycle, host, campaign, and
|
Open **Activity → Provisioning** for server queues, bulk cancellation, lifecycle
|
||||||
maintenance history. Use **Manage servers** from that page or command search
|
actions, and retained lifecycle history. **Fleet → Collections** owns reusable lineups and collection launch
|
||||||
for server queues, bulk cancellation, and lifecycle actions. **Fleet → Collections** owns reusable lineups and collection launch
|
|
||||||
progress; their individual operations remain visible in Provisioning.
|
progress; their individual operations remain visible in Provisioning.
|
||||||
Provisioning owns the queues, safety checks, execution, and
|
Provisioning owns the queues, safety checks, execution, and
|
||||||
recovery for all six actions. Create a server using **Create server**. For an existing
|
recovery for all six actions. Create a server using **Create server**. For an existing
|
||||||
@@ -12,12 +11,22 @@ are shown. Confirmations name the server and its current players and round;
|
|||||||
disruptive actions still wait for a fresh empty-lobby check. **Manage server**
|
disruptive actions still wait for a fresh empty-lobby check. **Manage server**
|
||||||
on the operations page offers the same actions for a selected server.
|
on the operations page offers the same actions for a selected server.
|
||||||
|
|
||||||
The **Active** tab shows jobs verifying health first, then jobs executing work,
|
Provisioning badges show the recorded lifecycle state consistently on the list
|
||||||
waiting jobs, and queued jobs. File preparation, cleanup, and submitted commands
|
and job details: **Queued**, **Waiting**, **Running**, **Validating**, **Completed**,
|
||||||
awaiting host confirmation count as executing work. Within each group, older
|
**Completed with warnings**, **Failed**, **Interrupted**, or **Cancelled**.
|
||||||
requests appear first, with recording order breaking timestamp ties. **History**
|
**Validating** is the display name for `awaiting-health`. The list's Status column
|
||||||
shows the newest requests first. This display order does not change execution
|
shows only the state badge. Open **Details** or the individual job to inspect
|
||||||
priority or capacity admission.
|
execution progress and waiting reasons. File preparation can occur
|
||||||
|
while a job is Queued or Waiting; Running begins when the host command is submitted
|
||||||
|
and includes waiting for its acknowledgment. These progress details do not change
|
||||||
|
the badge, status counts, or filters.
|
||||||
|
|
||||||
|
The **Active** tab shows Validating jobs first, then Running, Waiting, and Queued.
|
||||||
|
Within each state, older requests appear first, with recording order breaking
|
||||||
|
timestamp ties. **History** shows the newest requests first. **Needs attention**
|
||||||
|
filters unresolved Failed, Interrupted, and Completed with warnings outcomes;
|
||||||
|
their individual state badges remain visible. This display order does not change
|
||||||
|
execution priority or capacity admission.
|
||||||
|
|
||||||
**Create server** opens one dialog with game and map choices followed by automatic
|
**Create server** opens one dialog with game and map choices followed by automatic
|
||||||
readiness checks. Blockers explain what needs attention before launch. Runtime
|
readiness checks. Blockers explain what needs attention before launch. Runtime
|
||||||
@@ -34,14 +43,14 @@ of its explicit removal list, which resets whenever the preview is refreshed.
|
|||||||
See [Collections](collections.md) for the review details and safe submission
|
See [Collections](collections.md) for the review details and safe submission
|
||||||
retries when a response is lost.
|
retries when a response is lost.
|
||||||
|
|
||||||
| Action | Behavior |
|
| Action | Behavior |
|
||||||
| --- | --- |
|
| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Create | Create a new instance using the latest promoted files and verify container health, game status, the selected map, and the Plutonium revision. |
|
| Create | Create a new instance using the latest promoted files and verify container health, game status, the selected map, and the Plutonium revision. |
|
||||||
| Start | Start the existing container and verify container health and RCON. |
|
| Start | Start the existing container and verify container health and RCON. |
|
||||||
| Restart | Wait until empty, restart the existing container, and verify container health and RCON. |
|
| Restart | Wait until empty, restart the existing container, and verify container health and RCON. |
|
||||||
| Rebuild | Wait until empty, replace the instance through the host agent, preserving its server identity and port while advancing its generation. Verify container health, game status, map, and Plutonium revision. |
|
| Rebuild | Wait until empty, replace the instance through the host agent, preserving its server identity and port while advancing its generation. Verify container health, game status, map, and Plutonium revision. |
|
||||||
| Stop | Wait until empty, stop the container, and verify that the host agent reports it stopped. Keep the instance for a later Start. |
|
| Stop | Wait until empty, stop the container, and verify that the host agent reports it stopped. Keep the instance for a later Start. |
|
||||||
| Remove | Wait until empty, remove the container and its files through the host agent, then retire the instance and registration. |
|
| Remove | Wait until empty, remove the container and its files through the host agent, then retire the instance and registration. |
|
||||||
|
|
||||||
## Safety and verification
|
## Safety and verification
|
||||||
|
|
||||||
@@ -140,14 +149,19 @@ and post-action health checks still apply.
|
|||||||
|
|
||||||
## Campaigns, betas and maintenance
|
## Campaigns, betas and maintenance
|
||||||
|
|
||||||
Campaigns, beta runs, and maintenance submit durable restart requests to
|
Campaign and beta file changes submit durable rebuild requests to
|
||||||
Provisioning and observe the resulting operation by ID. Their pages link to that
|
Provisioning and observe the resulting operation by ID. Their pages link to that
|
||||||
operation, and its history links back to the requesting work. Provisioning owns
|
operation, and its history links back to the requesting work. Provisioning owns
|
||||||
the empty-server check, conflict handling, restart capacity, remote command, and
|
the empty-server check, conflict handling, capacity limits, remote command, and
|
||||||
health verification. The callers retain file deployment, beta evidence, and
|
health verification. The callers retain file deployment, beta evidence, and
|
||||||
maintenance outcome verification. Jobs remain visible while waiting for players,
|
maintenance outcome verification. Jobs remain visible while waiting for players,
|
||||||
other work, or integration configuration.
|
other work, or integration configuration.
|
||||||
|
|
||||||
|
File rebuilds require host agent 1.9.2 or later. They capture rollback snapshots
|
||||||
|
before replacing the instance, and install the requested files over the promoted
|
||||||
|
setup. Queued file activations wait for the agent update when necessary.
|
||||||
|
Maintenance jobs and beta recovery cycles without file changes still use restarts.
|
||||||
|
|
||||||
[Maintenance](maintenance.md) also submits recovery operations and links each attempt
|
[Maintenance](maintenance.md) also submits recovery operations and links each attempt
|
||||||
to its incident. A failed Maintenance restart can hand its reserved slot to a rebuild
|
to its incident. A failed Maintenance restart can hand its reserved slot to a rebuild
|
||||||
after five minutes; unrelated recovery slots remain held. Drift campaigns follow
|
after five minutes; unrelated recovery slots remain held. Drift campaigns follow
|
||||||
@@ -170,6 +184,13 @@ Related work links back to the requesting campaign, beta, maintenance item, or
|
|||||||
collection. A completed provisioning job does not imply that its requester has
|
collection. A completed provisioning job does not imply that its requester has
|
||||||
finished its own verification.
|
finished its own verification.
|
||||||
|
|
||||||
|
Optional timestamps appear only when recorded. **Launch files** appears for Create
|
||||||
|
and Rebuild, or when an older operation has a recorded recipe. Start, Restart,
|
||||||
|
and Stop use the existing instance without selecting new launch files.
|
||||||
|
|
||||||
|
While a job is active, **Controller activity** lists current work and elapsed
|
||||||
|
time. Background scans run independently of provisioning.
|
||||||
|
|
||||||
### Verification evidence
|
### Verification evidence
|
||||||
|
|
||||||
New operations record structured verification evidence alongside their activity:
|
New operations record structured verification evidence alongside their activity:
|
||||||
@@ -200,13 +221,29 @@ guards still apply. **Resume rebuild cleanup** completes cleanup before creating
|
|||||||
the replacement in its reserved slot. Resuming the same operation preserves its
|
the replacement in its reserved slot. Resuming the same operation preserves its
|
||||||
earlier failure and resume events. Uncertain commands have no generic Retry action.
|
earlier failure and resume events. Uncertain commands have no generic Retry action.
|
||||||
|
|
||||||
|
Cancelling an operation does not stop a command already delivered to the host.
|
||||||
|
The confirmation explains that remaining work and health checks stop, while
|
||||||
|
changes already made remain. **Cancel creation and remove server** also deletes
|
||||||
|
the launch's container and instance files, even if players have joined or their
|
||||||
|
status is unknown. Shared game files are kept, and the key returns to its game
|
||||||
|
pool after confirmed removal.
|
||||||
|
|
||||||
|
A later rebuild waits for that command to settle. Its waiting message includes
|
||||||
|
the host command's state and last reported error; **View cancelled operation**
|
||||||
|
opens the original command details. If an interrupted rebuild removed its old
|
||||||
|
container before creating the replacement, the host can confirm failure after
|
||||||
|
verifying that both containers are absent and the instance identity is unchanged.
|
||||||
|
The original diagnostic remains available, and the queued rebuild can continue.
|
||||||
|
Uncertain inspections keep it waiting; the old command is never replayed merely
|
||||||
|
because its receipt was lost.
|
||||||
|
|
||||||
Create and rebuild capture a setup when queued and select the latest promoted
|
Create and rebuild capture a setup when queued and select the latest promoted
|
||||||
files again when creation begins. The page distinguishes **Setup captured when
|
files again when creation begins. The **Launch files** card shows one selection
|
||||||
queued** from **Files selected for this attempt**. A queued capture does not pin
|
status beside the capture time: **Captured when queued**, **Selected when creation
|
||||||
versions; an attempted selection is not proof that every file was installed.
|
began**, or **Recorded setup; selection time unknown** for older records. A queued
|
||||||
**Launch another server** opens the creation dialog with the recorded game and
|
capture does not pin versions; an attempted selection is not proof that every
|
||||||
map as editable starting values. It checks the latest promoted setup and creates
|
file was installed. Expand the managed file list to inspect versions, paths,
|
||||||
a separate server with a fresh identity and port.
|
checksums, and the recipe fingerprint.
|
||||||
|
|
||||||
### History and refresh behavior
|
### History and refresh behavior
|
||||||
|
|
||||||
@@ -267,6 +304,21 @@ query parameters. Collection launch links at `/provisioning/collections/:id` or
|
|||||||
query parameters. `/api/deployments` remains compatible, including old campaign
|
query parameters. `/api/deployments` remains compatible, including old campaign
|
||||||
detail endpoints. No history or IDs are renamed or discarded.
|
detail endpoints. No history or IDs are renamed or discarded.
|
||||||
|
|
||||||
|
The standalone Activity log has been retired. Use a server's **History** tab for
|
||||||
|
its combined history, **Activity → Maintenance → Activity** for maintenance and
|
||||||
|
recovery, **Releases → Campaigns** or **Releases → Betas → History** for release
|
||||||
|
history, and **Fleet → Hosts → Operation history** for retained host operations.
|
||||||
|
Host operation history remains available after a host is deleted.
|
||||||
|
|
||||||
|
Legacy `/activity` and `/operations` bookmarks redirect to Provisioning history
|
||||||
|
by default. Server-scoped bookmarks open the server's History tab; host-scoped
|
||||||
|
bookmarks open host operation history. Source-only bookmarks open the matching
|
||||||
|
Campaigns, Betas, Maintenance, host operations, or autoscaling provisioning
|
||||||
|
history. Shared history filters are retained when opening server or Maintenance
|
||||||
|
history; pagination is reset when switching to a different kind of history.
|
||||||
|
`GET /api/activity` and the shared history views remain available. Historical
|
||||||
|
records, event collection, and retention are unchanged.
|
||||||
|
|
||||||
Start and Stop use durable `instance.start` and `instance.stop` host-agent
|
Start and Stop use durable `instance.start` and `instance.stop` host-agent
|
||||||
operations. Rebuild uses `instance.rebuild` and replaces the instance files.
|
operations. Rebuild uses `instance.rebuild` and replaces the instance files.
|
||||||
See [native host management](native-hosts.md) for command delivery and recovery.
|
See [native host management](native-hosts.md) for command delivery and recovery.
|
||||||
|
|||||||
+22
-22
@@ -44,28 +44,28 @@ expire unused artifact versions when their age policy is disabled. Game-file gra
|
|||||||
is a delay: 0 adds no delay. A positive age and version count together preserve the
|
is a delay: 0 adds no delay. A positive age and version count together preserve the
|
||||||
minimum count as well as versions younger than the age threshold.
|
minimum count as well as versions younger than the age threshold.
|
||||||
|
|
||||||
| History or storage | Default |
|
| History or storage | Default |
|
||||||
| --- | --- |
|
| ---------------------------------------------- | ----------------------------------------------- |
|
||||||
| Campaigns | 7 days |
|
| Campaigns | 7 days |
|
||||||
| Successful routine operations | 1 day |
|
| Successful routine operations | 1 day |
|
||||||
| Provisioning workflows | Indefinite |
|
| Provisioning workflows | Indefinite |
|
||||||
| Agent actions | Indefinite |
|
| Agent actions | Indefinite |
|
||||||
| Explicitly resolved failures | Indefinite |
|
| Explicitly resolved failures | Indefinite |
|
||||||
| Non-update maintenance jobs | 30 days |
|
| Non-update maintenance jobs | 30 days |
|
||||||
| Resolved recovery incidents | Indefinite |
|
| Resolved recovery incidents | Indefinite |
|
||||||
| Host resource history | 7 days |
|
| Host resource history | 7 days |
|
||||||
| Instance resource samples | 1 day |
|
| Instance resource samples | 1 day |
|
||||||
| Fleet occupancy metrics | 31 days |
|
| Fleet occupancy metrics | 31 days |
|
||||||
| Telemetry diagnostics | 7 days; 200 per server; 10,000 overall |
|
| Telemetry diagnostics | 7 days; 200 per server; 10,000 overall |
|
||||||
| Completed/interrupted matches | Indefinite |
|
| Completed/interrupted matches | Indefinite |
|
||||||
| Closed betas and test evidence | Indefinite |
|
| Closed betas and test evidence | Indefinite |
|
||||||
| Campaign file bodies and closed beta snapshots | 30 days |
|
| Campaign file bodies and closed beta snapshots | 30 days |
|
||||||
| Completed launch and command file bodies | 30 days |
|
| Completed launch and command file bodies | 30 days |
|
||||||
| Unused artifact versions | No automatic expiry; no minimum count |
|
| Unused artifact versions | No automatic expiry; no minimum count |
|
||||||
| Unused game files | No grace period; keep latest 1 version per game |
|
| Unused game files | No grace period; keep latest 1 version per game |
|
||||||
| Incomplete browser uploads | Indefinite |
|
| Incomplete browser uploads | Indefinite |
|
||||||
| Authentication audit events | Indefinite |
|
| Authentication audit events | Indefinite |
|
||||||
| Minimum history count | 0 additional records |
|
| Minimum history count | 0 additional records |
|
||||||
|
|
||||||
Telemetry age and count policies are independent; 0 disables each individual cap.
|
Telemetry age and count policies are independent; 0 disables each individual cap.
|
||||||
Diagnostic responses remain bounded to 10,000 rows when the per-server retention
|
Diagnostic responses remain bounded to 10,000 rows when the per-server retention
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ Update October 7, 2026: Dry run and Create only have been removed. Current polic
|
|||||||
|
|
||||||
## Existing foundations and required extensions
|
## Existing foundations and required extensions
|
||||||
|
|
||||||
| Foundation | Reuse and required work |
|
| Foundation | Reuse and required work |
|
||||||
| --- | --- |
|
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Background worker | Add a requester to [pipeline/worker.ts](../../src/lib/server/pipeline/worker.ts), which already reconciles background work every 15 seconds independently of Provisioning. Keep host execution inside Provisioning. |
|
| Background worker | Add a requester to [pipeline/worker.ts](../../src/lib/server/pipeline/worker.ts), which already reconciles background work every 15 seconds independently of Provisioning. Keep host execution inside Provisioning. |
|
||||||
| Occupancy evidence | Reuse [status evidence](../../src/lib/server/fleet/status-evidence.ts) and shared freshness rules. Monitoring runs on a 30-second cadence; accepted evidence currently expires after 90 seconds. Positive occupancy vetoes an empty claim. Unknown is never zero. |
|
| Occupancy evidence | Reuse [status evidence](../../src/lib/server/fleet/status-evidence.ts) and shared freshness rules. Monitoring runs on a 30-second cadence; accepted evidence currently expires after 90 seconds. Positive occupancy vetoes an empty claim. Unknown is never zero. |
|
||||||
| Safe creation | Use the durable creation intent and placement path in [provisioning/native.ts](../../src/lib/server/provisioning/native.ts). Preserve CPU, memory, port, host eligibility, library, and startup concurrency checks. A blocked create remains queued. |
|
| Safe creation | Use the durable creation intent and placement path in [provisioning/native.ts](../../src/lib/server/provisioning/native.ts). Preserve CPU, memory, port, host eligibility, library, and startup concurrency checks. A blocked create remains queued. |
|
||||||
| Safe removal | Reuse the controller occupancy checks, instance generation checks, host-local check, and cleanup checkpoints. Add autoscaling-specific admission and cancellation behavior before enabling automatic destruction. |
|
| Safe removal | Reuse the controller occupancy checks, instance generation checks, host-local check, and cleanup checkpoints. Add autoscaling-specific admission and cancellation behavior before enabling automatic destruction. |
|
||||||
| Requester eligibility | [provisioning/worker.ts](../../src/lib/server/provisioning/worker.ts) currently gives Create and Destroy separate paths that bypass the runtime requester eligibility check. Autoscaling ownership and policy checks must cover both paths, including delayed execution. A source label alone is insufficient. |
|
| Requester eligibility | [provisioning/worker.ts](../../src/lib/server/provisioning/worker.ts) currently gives Create and Destroy separate paths that bypass the runtime requester eligibility check. Autoscaling ownership and policy checks must cover both paths, including delayed execution. A source label alone is insufficient. |
|
||||||
| Saved keys | [server-keys.ts](../../src/lib/server/fleet/server-keys.ts) stores keys in game pools. Each live or pending launch reserves a key until cleanup confirms it is safe to reuse. Allocate lobby identities numbered 1–20 independently of the key label. |
|
| Saved keys | [server-keys.ts](../../src/lib/server/fleet/server-keys.ts) stores keys in game pools. Each live or pending launch reserves a key until cleanup confirms it is safe to reuse. Allocate lobby identities numbered 1–20 independently of the key label. |
|
||||||
| Collections | [collections.ts](../../src/lib/server/fleet/collections.ts) already counts queued creations and launches promoted recipes. Exact matching can remove lobbies across the fleet, including maps omitted from the collection. Add ownership checks to preview and submission. |
|
| Collections | [collections.ts](../../src/lib/server/fleet/collections.ts) already counts queued creations and launches promoted recipes. Exact matching can remove lobbies across the fleet, including maps omitted from the collection. Add ownership checks to preview and submission. |
|
||||||
| Hosting insights | [metrics-insights.ts](../../src/lib/metrics-insights.ts) provides historical suggestions for hosting trials. Use these to help choose policies; the live evaluator needs current empty-lobby evidence and must not execute historical suggestions directly. |
|
| Hosting insights | [metrics-insights.ts](../../src/lib/metrics-insights.ts) provides historical suggestions for hosting trials. Use these to help choose policies; the live evaluator needs current empty-lobby evidence and must not execute historical suggestions directly. |
|
||||||
|
|
||||||
Create already uses the promoted launch recipe and capacity-aware placement. Start retains existing files and has recovery concurrency controls, but would need equivalent CPU and memory admission before becoming an autoscaling strategy. The proposed scope follows option 4 in [the idle CPU research](../research/t5-t6-idle-cpu-research.md).
|
Create already uses the promoted launch recipe and capacity-aware placement. Start retains existing files and has recovery concurrency controls, but would need equivalent CPU and memory admission before becoming an autoscaling strategy. The proposed scope follows option 4 in [the idle CPU research](../research/t5-t6-idle-cpu-research.md).
|
||||||
|
|
||||||
@@ -31,15 +31,15 @@ Create already uses the promoted launch recipe and capacity-aware placement. Sta
|
|||||||
|
|
||||||
Use one policy per `(game, mapSlug)`, preserving configured variants such as T6 start locations. Do not group ambiguous legacy records by engine map alone. Resolve or exclude ambiguous identities before activation.
|
Use one policy per `(game, mapSlug)`, preserving configured variants such as T6 start locations. Do not group ambiguous legacy records by engine map alone. Resolve or exclude ambiguous identities before activation.
|
||||||
|
|
||||||
| Setting | Proposed initial behavior |
|
| Setting | Proposed initial behavior |
|
||||||
| --- | --- |
|
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Mode | Off, Dry run, Create only, or Create and destroy. Default Off. |
|
| Mode | Off, Dry run, Create only, or Create and destroy. Default Off. |
|
||||||
| Minimum running lobbies | Default 1; a floor for desired ready capacity, subject to available keys and host capacity. |
|
| Minimum running lobbies | Default 1; a floor for desired ready capacity, subject to available keys and host capacity. |
|
||||||
| Maximum lobbies | Required explicit limit, at most 20. Count occupied lobbies, registered identities, reservations, and unresolved creations toward admission. |
|
| Maximum lobbies | Required explicit limit, at most 20. Count occupied lobbies, registered identities, reservations, and unresolved creations toward admission. |
|
||||||
| Empty spare target | Default 1. Count empty ready lobbies, not unfilled player slots. |
|
| Empty spare target | Default 1. Count empty ready lobbies, not unfilled player slots. |
|
||||||
| Empty duration before removal | Default 15 minutes; adjustable after the pilot. |
|
| Empty duration before removal | Default 15 minutes; adjustable after the pilot. |
|
||||||
| Action cooldown | Default 5 minutes between new policy actions, measured from durable admission. Cancellation and safety checks do not wait for cooldown. |
|
| Action cooldown | Default 5 minutes between new policy actions, measured from durable admission. Cancellation and safety checks do not wait for cooldown. |
|
||||||
| Launch settings | Reuse normal map recipes and automatic placement on eligible active hosts. Use current promoted files when provisioning begins. |
|
| Launch settings | Reuse normal map recipes and automatic placement on eligible active hosts. Use current promoted files when provisioning begins. |
|
||||||
|
|
||||||
Validate integer counts with `1 <= minimum <= maximum <= 20` and `1 <= spare target <= maximum`. Show the game pool’s saved and available counts independently of each map’s maximum. Policies can be saved with fewer keys than the maximum, or with no keys yet. Exhaustion holds growth across every map sharing that game, while eligible surplus removal can still return capacity. Game-wide targets are per map, but game keys are shared across all maps and launch paths.
|
Validate integer counts with `1 <= minimum <= maximum <= 20` and `1 <= spare target <= maximum`. Show the game pool’s saved and available counts independently of each map’s maximum. Policies can be saved with fewer keys than the maximum, or with no keys yet. Exhaustion holds growth across every map sharing that game, while eligible surplus removal can still return capacity. Game-wide targets are per map, but game keys are shared across all maps and launch paths.
|
||||||
|
|
||||||
@@ -121,20 +121,20 @@ Complete and validate each phase in a separate commit before starting the next.
|
|||||||
|
|
||||||
Required behavioral scenarios across these phases:
|
Required behavioral scenarios across these phases:
|
||||||
|
|
||||||
| Scenario | Expected result |
|
| Scenario | Expected result |
|
||||||
| --- | --- |
|
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Last spare fills, including a high-round partially filled session | One create when eligible; free player slots do not count as spares. |
|
| Last spare fills, including a high-round partially filled session | One create when eligible; free player slots do not count as spares. |
|
||||||
| Create is queued, starting, or awaiting verification | Reservation counts once; it is not advertised as ready and no duplicate is queued. |
|
| Create is queued, starting, or awaiting verification | Reservation counts once; it is not advertised as ready and no duplicate is queued. |
|
||||||
| Every eligible host is full or unavailable | Existing job waits; other policies and worker tasks continue. |
|
| Every eligible host is full or unavailable | Existing job waits; other policies and worker tasks continue. |
|
||||||
| Game key pool is empty or exhausted | Hold growth with a clear reason; other games remain independent and eligible surplus removal can proceed. |
|
| Game key pool is empty or exhausted | Hold growth with a clear reason; other games remain independent and eligible surplus removal can proceed. |
|
||||||
| A launch is queued, stopped, failed, or uncertain | Its key stays reserved until confirmed cleanup; no other launch can use that key concurrently. |
|
| A launch is queued, stopped, failed, or uncertain | Its key stays reserved until confirmed cleanup; no other launch can use that key concurrently. |
|
||||||
| Occupancy evidence conflicts, expires, or arrives from an old lifecycle | Positive current occupancy wins; invalid evidence cannot establish emptiness. |
|
| Occupancy evidence conflicts, expires, or arrives from an old lifecycle | Positive current occupancy wins; invalid evidence cannot establish emptiness. |
|
||||||
| Players join before claim, after dispatch, or while the agent holds removal | Queued work is withdrawn where possible; agent safety prevents occupied destruction; held autoscaling intent cannot execute later without a fresh decision. |
|
| Players join before claim, after dispatch, or while the agent holds removal | Queued work is withdrawn where possible; agent safety prevents occupied destruction; held autoscaling intent cannot execute later without a fresh decision. |
|
||||||
| Process restarts before or after intent submission | Existing operation is recovered by durable identity without replay or duplicate admission. |
|
| Process restarts before or after intent submission | Existing operation is recovered by durable identity without replay or duplicate admission. |
|
||||||
| Collection exact match omits a managed map | Preview and submission report the ownership conflict, including stale-preview races. |
|
| Collection exact match omits a managed map | Preview and submission report the ownership conflict, including stale-preview races. |
|
||||||
| Beta, campaign, maintenance, host drain, or recovery begins after evaluation | Admission rechecks block scaling and existing work retains its lifecycle ownership. |
|
| Beta, campaign, maintenance, host drain, or recovery begins after evaluation | Admission rechecks block scaling and existing work retains its lifecycle ownership. |
|
||||||
| Operator pauses, changes limits, or cancels a policy job | Unstarted intent is invalidated; unsettled commands stay visible and keep reservations until reconciled. |
|
| Operator pauses, changes limits, or cancels a policy job | Unstarted intent is invalidated; unsettled commands stay visible and keep reservations until reconciled. |
|
||||||
| A name is reused after confirmed deletion | Old observations, cooldown decisions, and commands cannot apply to the replacement generation. |
|
| A name is reused after confirmed deletion | Old observations, cooldown decisions, and commands cannot apply to the replacement generation. |
|
||||||
|
|
||||||
Run targeted Vitest coverage for planner, collections, provisioning, cancellation, telemetry, and routes, plus Python agent tests for the removal contract. Before each implementation phase is committed, run the relevant checks; before live release, run `npm run check`, `npm test`, `npm run test:agent`, and `npm run build`. Verify the UI with keyboard navigation and narrow layouts, including stale evidence, blocked capacity, and partial cancellation states.
|
Run targeted Vitest coverage for planner, collections, provisioning, cancellation, telemetry, and routes, plus Python agent tests for the removal contract. Before each implementation phase is committed, run the relevant checks; before live release, run `npm run check`, `npm test`, `npm run test:agent`, and `npm run build`. Verify the UI with keyboard navigation and narrow layouts, including stale evidence, blocked capacity, and partial cancellation states.
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ its own.
|
|||||||
|
|
||||||
## Scope and defaults
|
## Scope and defaults
|
||||||
|
|
||||||
| Area | First release |
|
| Area | First release |
|
||||||
| --- | --- |
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Leaderboard periods | All time, This week, and This month, with previous/next period navigation. Keep All time as the default. |
|
| Leaderboard periods | All time, This week, and This month, with previous/next period navigation. Keep All time as the default. |
|
||||||
| Calendar | Weeks start Monday at 00:00 UTC; months start on the first at 00:00 UTC. Show the boundaries explicitly. Personal timezone preferences format timestamps without changing competition membership. |
|
| Calendar | Weeks start Monday at 00:00 UTC; months start on the first at 00:00 UTC. Show the boundaries explicitly. Personal timezone preferences format timestamps without changing competition membership. |
|
||||||
| Personal bests | Highest round, best game kills, and highest reported score, compared within the same game and map variant. |
|
| Personal bests | Highest round, best game kills, and highest reported score, compared within the same game and map variant. |
|
||||||
| Sharing | A preview and PNG download for a match summary or one player's result, plus an authenticated match link. Native device sharing is an optional enhancement with download/copy fallbacks. |
|
| Sharing | A preview and PNG download for a match summary or one player's result, plus an authenticated match link. Native device sharing is an optional enhancement with download/copy fallbacks. |
|
||||||
| Visual direction | Existing dark surfaces, electric-blue accent, typography, and brand mark, extended with original SVG map emblems and consistent game/status treatments. |
|
| Visual direction | Existing dark surfaces, electric-blue accent, typography, and brand mark, extended with original SVG map emblems and consistent game/status treatments. |
|
||||||
| Initial visual coverage | Fleet, server detail, Autoscaling, provisioning and host operation history, player highlights, and match/share cards. |
|
| Initial visual coverage | Fleet, server detail, Autoscaling, provisioning and host operation history, player highlights, and match/share cards. |
|
||||||
|
|
||||||
Season rewards, player-account linking, public profile/match pages, notification
|
Season rewards, player-account linking, public profile/match pages, notification
|
||||||
subscriptions, rank-change tracking, custom themes, and photographic map artwork
|
subscriptions, rank-change tracking, custom themes, and photographic map artwork
|
||||||
@@ -212,13 +212,13 @@ styling, and document their use.
|
|||||||
|
|
||||||
### Information order and status consistency
|
### Information order and status consistency
|
||||||
|
|
||||||
| Surface | Presentation changes |
|
| Surface | Presentation changes |
|
||||||
| --- | --- |
|
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Fleet cards | Map/lobby/game first; health, player count, and round next; roster and match duration below; lower-priority timing and technical identity in supporting details. Preserve compact empty cards, search, pins, table mode, and the match spotlight. |
|
| Fleet cards | Map/lobby/game first; health, player count, and round next; roster and match duration below; lower-priority timing and technical identity in supporting details. Preserve compact empty cards, search, pins, table mode, and the match spotlight. |
|
||||||
| Server detail | Add map/game identity to the shared header with health. Keep player counts, round, and timing in the Live content so Configuration and Diagnostics remain focused on their task. |
|
| Server detail | Add map/game identity to the shared header with health. Keep player counts, round, and timing in the Live content so Configuration and Diagnostics remain focused on their task. |
|
||||||
| Autoscaling | Use shared identity and mode badges, a compact count strip, and a prominent current decision/blocker. Keep per-map targets, Off/On setting, ownership, global pause, and unsettled operations visible. Reuse existing evidence/history disclosures. |
|
| Autoscaling | Use shared identity and mode badges, a compact count strip, and a prominent current decision/blocker. Keep per-map targets, Off/On setting, ownership, global pause, and unsettled operations visible. Reuse existing evidence/history disclosures. |
|
||||||
| Provisioning and host operations | Use consistent labels and status components while preserving action, target, result, elapsed time, and specific wait reasons. Put long IDs, source paths, and low-level metadata behind explicit details/copy controls. |
|
| Provisioning and host operations | Use consistent labels and status components while preserving action, target, result, elapsed time, and specific wait reasons. Put long IDs, source paths, and low-level metadata behind explicit details/copy controls. |
|
||||||
| Player and match results | Reuse map identity and achievement treatment across profile highlights, match results, and downloaded cards. Distinguish a current record from a record earned at that time. |
|
| Player and match results | Reuse map identity and achievement treatment across profile highlights, match results, and downloaded cards. Distinguish a current record from a record earned at that time. |
|
||||||
|
|
||||||
Align status presentation by meaning, while preserving domain-specific labels.
|
Align status presentation by meaning, while preserving domain-specific labels.
|
||||||
For example, `StatusPip` currently colors starting/provisioning amber while shared
|
For example, `StatusPip` currently colors starting/provisioning amber while shared
|
||||||
@@ -238,15 +238,15 @@ Complete, validate, and commit each phase before starting the next. Finish the
|
|||||||
player-achievement workstream before starting the visual refresh. Commit any
|
player-achievement workstream before starting the visual refresh. Commit any
|
||||||
unrelated fix separately.
|
unrelated fix separately.
|
||||||
|
|
||||||
| Phase | Reviewable result | Acceptance gate |
|
| Phase | Reviewable result | Acceptance gate |
|
||||||
| --- | --- | --- |
|
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| A1 Completion time and period queries | Stable completion field/migration, period parsing, scoped ranking/evidence/profile queries, and cache keys. | Boundary, correction, qualification, and all-time compatibility tests pass; query plans and representative timing are recorded. |
|
| A1 Completion time and period queries | Stable completion field/migration, period parsing, scoped ranking/evidence/profile queries, and cache keys. | Boundary, correction, qualification, and all-time compatibility tests pass; query plans and representative timing are recorded. |
|
||||||
| A2 Period navigation | Week/month controls, history navigation, period metadata, profile context, and stable share URLs. | Direct links, back/forward, rollover, empty periods, and inspection-preserving refresh work on desktop and mobile. |
|
| A2 Period navigation | Week/month controls, history navigation, period metadata, profile context, and stable share URLs. | Direct links, back/forward, rollover, empty periods, and inspection-preserving refresh work on desktop and mobile. |
|
||||||
| A3 Personal best highlights | Derived milestones, profile section, and match annotations. | Strict improvements, ties, first results, variants, corrections, and undated history behave as specified; long player histories remain practical. |
|
| A3 Personal best highlights | Derived milestones, profile section, and match annotations. | Strict improvements, ties, first results, variants, corrections, and undated history behave as specified; long player histories remain practical. |
|
||||||
| A4 Result sharing | Accessible preview, PNG download, authenticated copy-link action, and supported native sharing. | Export matches the preview, contains only selected result data, handles long/incomplete data, and works after live-server deletion. Update leaderboard documentation and commit the completed workstream. |
|
| A4 Result sharing | Accessible preview, PNG download, authenticated copy-link action, and supported native sharing. | Export matches the preview, contains only selected result data, handles long/incomplete data, and works after live-server deletion. Update leaderboard documentation and commit the completed workstream. |
|
||||||
| B1 Shared visual identity | Original emblems, resolver, game labels, achievement tokens, and usage guidance. | Every map selection resolves correctly or falls back; no misleading variant, color-only meaning, or missing-asset layout shift. |
|
| B1 Shared visual identity | Original emblems, resolver, game labels, achievement tokens, and usage guidance. | Every map selection resolves correctly or falls back; no misleading variant, color-only meaning, or missing-asset layout shift. |
|
||||||
| B2 Fleet and operational presentation | Apply identity/hierarchy/status rules to Fleet, server detail, Autoscaling, provisioning, and host operation history. | Existing actions and critical states remain visible and usable; no new polling or behavior changes. |
|
| B2 Fleet and operational presentation | Apply identity/hierarchy/status rules to Fleet, server detail, Autoscaling, provisioning, and host operation history. | Existing actions and critical states remain visible and usable; no new polling or behavior changes. |
|
||||||
| B3 Player presentation and review | Apply the shared visual identity to player highlights, match pages, and export layouts; refresh documentation images. | Complete the visual/accessibility review and update UI guidance, then commit the completed workstream. |
|
| B3 Player presentation and review | Apply the shared visual identity to player highlights, match pages, and export layouts; refresh documentation images. | Complete the visual/accessibility review and update UI guidance, then commit the completed workstream. |
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ active, interrupted and nonqualifying records.
|
|||||||
On the local Intel i5-8279U / Node 25.9.0 / SQLite 3.49.2 test environment, three
|
On the local Intel i5-8279U / Node 25.9.0 / SQLite 3.49.2 test environment, three
|
||||||
measured runs after warmup gave these median query times on the large fixture:
|
measured runs after warmup gave these median query times on the large fixture:
|
||||||
|
|
||||||
| Request | Before | After |
|
| Request | Before | After |
|
||||||
| --- | ---: | ---: |
|
| ------------------------ | -------: | -----: |
|
||||||
| Highest round, all games | 1,717 ms | 829 ms |
|
| Highest round, all games | 1,717 ms | 829 ms |
|
||||||
| Total kills, all games | 1,555 ms | 858 ms |
|
| Total kills, all games | 1,555 ms | 858 ms |
|
||||||
| One game | 899 ms | 491 ms |
|
| One game | 899 ms | 491 ms |
|
||||||
| One map | 345 ms | 224 ms |
|
| One map | 345 ms | 224 ms |
|
||||||
| Player search | 1,515 ms | 757 ms |
|
| Player search | 1,515 ms | 757 ms |
|
||||||
| Deep page | 1,421 ms | 752 ms |
|
| Deep page | 1,421 ms | 752 ms |
|
||||||
| Headshot percentage | 1,444 ms | 760 ms |
|
| Headshot percentage | 1,444 ms | 760 ms |
|
||||||
| Exact player rank | 1,524 ms | 554 ms |
|
| Exact player rank | 1,524 ms | 554 ms |
|
||||||
|
|
||||||
The small fixture's unfiltered request fell from 41 to 30 ms. All 150 additional
|
The small fixture's unfiltered request fell from 41 to 30 ms. All 150 additional
|
||||||
small-fixture parity cases and 22 timed small/large request cases matched the
|
small-fixture parity cases and 22 timed small/large request cases matched the
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ The live host kernel, Wine version, image digest, installed scripts/plugins, CPU
|
|||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Option | Expected effect on idle CPU | Availability and gameplay tradeoff | Evidence and priority |
|
| Option | Expected effect on idle CPU | Availability and gameplay tradeoff | Evidence and priority |
|
||||||
| --- | --- | --- | --- |
|
| --------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
| 1. Enable and verify NTSYNC | Potentially substantial if Wine synchronization dominates | Intended to retain normal joins and gameplay; compatibility testing required | Official Wine/kernel support; Plutonium operator anecdote. First trial |
|
| 1. Enable and verify NTSYNC | Potentially substantial if Wine synchronization dominates | Intended to retain normal joins and gameplay; compatibility testing required | Official Wine/kernel support; Plutonium operator anecdote. First trial |
|
||||||
| 2. Audit busy-wait settings, scripts, and helpers | Small to substantial, depending on the actual cause | Usually compatible with keeping lobbies online | Documented settings plus diagnostic experiments. First trial |
|
| 2. Audit busy-wait settings, scripts, and helpers | Small to substantial, depending on the actual cause | Usually compatible with keeping lobbies online | Documented settings plus diagnostic experiments. First trial |
|
||||||
| 3. Apply CPU quotas only when empty | Enforceable ceiling on each empty container's CPU time | Excessive throttling can delay joins, RCON, and heartbeats | Docker-supported mechanism; automatic policy needs development |
|
| 3. Apply CPU quotas only when empty | Enforceable ceiling on each empty container's CPU time | Excessive throttling can delay joins, RCON, and heartbeats | Docker-supported mechanism; automatic policy needs development |
|
||||||
| 4. Run fewer spare lobbies | Removes the stopped instances' process CPU use | Fewer immediately available lobbies | Predictable mechanism; best larger saving if acceptable |
|
| 4. Run fewer spare lobbies | Removes the stopped instances' process CPU use | Fewer immediately available lobbies | Predictable mechanism; best larger saving if acceptable |
|
||||||
| 5. Start on request or on a schedule | Removes process CPU use during stopped periods | Players wait for startup; stopped servers cannot provide live listings | Practical alternative for infrequent maps or private sessions |
|
| 5. Start on request or on a schedule | Removes process CPU use during stopped periods | Players wait for startup; stopped servers cannot provide live listings | Practical alternative for infrequent maps or private sessions |
|
||||||
| 6. Pause and resume containers | Suspends their process execution while retaining memory | Server cannot answer while paused; resume behavior needs validation | Docker supports pause; Plutonium behavior unproven |
|
| 6. Pause and resume containers | Suspends their process execution while retaining memory | Server cannot answer while paused; resume behavior needs validation | Docker supports pause; Plutonium behavior unproven |
|
||||||
| 7. Wake through a UDP gateway | Could combine stopped backends with automated wakeup | Protocol, listing, session, and timeout problems | Experimental; substantially harder for T5 |
|
| 7. Wake through a UDP gateway | Could combine stopped backends with automated wakeup | Protocol, listing, session, and timeout problems | Experimental; substantially harder for T5 |
|
||||||
| 8. Change the host/runtime or develop engine idling | Potentially substantial, cause dependent | Migration cost or ongoing engine compatibility work | Conditional alternatives after profiling |
|
| 8. Change the host/runtime or develop engine idling | Potentially substantial, cause dependent | Migration cost or ongoing engine compatibility work | Conditional alternatives after profiling |
|
||||||
|
|
||||||
### 1. Test NTSYNC before treating Wine overhead as unavoidable
|
### 1. Test NTSYNC before treating Wine overhead as unavoidable
|
||||||
|
|
||||||
@@ -155,15 +155,15 @@ First record the actual host/VM details, kernel, image digest, Wine version, Plu
|
|||||||
|
|
||||||
Use one representative instance from each game, then expand successful changes to other maps. Suggested measurement windows are 10–15 minutes after startup settles, repeated after players join and all leave. These are proposed test windows, not product requirements.
|
Use one representative instance from each game, then expand successful changes to other maps. Suggested measurement windows are 10–15 minutes after startup settles, repeated after players join and all leave. These are proposed test windows, not product requirements.
|
||||||
|
|
||||||
| Test | What it resolves |
|
| Test | What it resolves |
|
||||||
| --- | --- |
|
| ---------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
| Normal deployment, empty immediately after startup | Initial baseline |
|
| Normal deployment, empty immediately after startup | Initial baseline |
|
||||||
| Same instance after the last player disconnects | Whether work accumulates or persists after play |
|
| Same instance after the last player disconnects | Whether work accumulates or persists after play |
|
||||||
| Minimal scripts/plugins on the same map | Contribution from custom behavior |
|
| Minimal scripts/plugins on the same map | Contribution from custom behavior |
|
||||||
| Verified NTSYNC versus current synchronization path | Wine synchronization contribution |
|
| Verified NTSYNC versus current synchronization path | Wine synchronization contribution |
|
||||||
| One idle quota at a time, with repeated fresh joins | Lowest useful ceiling without degraded access |
|
| One idle quota at a time, with repeated fresh joins | Lowest useful ceiling without degraded access |
|
||||||
| Stop/start or pause/resume after brief and long inactivity | Real wake latency, listing recovery, and join reliability |
|
| Stop/start or pause/resume after brief and long inactivity | Real wake latency, listing recovery, and join reliability |
|
||||||
| Occupied session with ordinary and high-round gameplay | Whether the change preserves active performance |
|
| Occupied session with ordinary and high-round gameplay | Whether the change preserves active performance |
|
||||||
|
|
||||||
Collect container CPU averages and peaks, process/thread CPU, memory, quota throttling, RCON response times/failures, external browser visibility, first-join success, and time until players can actually play. Measure the whole host as well to catch work shifted into a proxy or management process. Use unprofiled runs for final CPU comparisons because tracing itself adds overhead.
|
Collect container CPU averages and peaks, process/thread CPU, memory, quota throttling, RCON response times/failures, external browser visibility, first-join success, and time until players can actually play. Measure the whole host as well to catch work shifted into a proxy or management process. Use unprofiled runs for final CPU comparisons because tracing itself adds overhead.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import globals from 'globals';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
globalIgnores([
|
||||||
|
'**/node_modules/',
|
||||||
|
'.svelte-kit/',
|
||||||
|
'build/',
|
||||||
|
'data/',
|
||||||
|
'test-fixtures/',
|
||||||
|
'static/agent/',
|
||||||
|
'.claude/',
|
||||||
|
'.gstack/'
|
||||||
|
]),
|
||||||
|
js.configs.recommended,
|
||||||
|
ts.configs.recommended,
|
||||||
|
svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
// The controller is deployed at the origin root, including imperative navigation in TS.
|
||||||
|
rules: { 'svelte/no-navigation-without-resolve': 'off' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node }
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
ignoreRestSiblings: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Existing lists include intentionally positional rows and repeated primitive values.
|
||||||
|
'svelte/require-each-key': 'off',
|
||||||
|
// Local collection helpers do not need reactive replacements.
|
||||||
|
'svelte/prefer-svelte-reactivity': 'off',
|
||||||
|
// Preserve the existing state-reset behavior during tooling adoption.
|
||||||
|
'svelte/prefer-writable-derived': 'off',
|
||||||
|
// Explicit string interpolation preserves whitespace around inline controls and logs.
|
||||||
|
'svelte/no-useless-mustaches': 'off'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
Generated
+4492
-3270
File diff suppressed because it is too large
Load Diff
+64
-44
@@ -1,46 +1,66 @@
|
|||||||
{
|
{
|
||||||
"name": "electric-cherry",
|
"name": "electric-cherry",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "T5/T6 Plutonium Zombies fleet manager \u2014 replaces push_scripts.sh with a live web UI",
|
"description": "T5/T6 Plutonium Zombies fleet manager — replaces push_scripts.sh with a live web UI",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"engines": {
|
||||||
"dev": "vite dev",
|
"node": "^22.13.0 || >=24.0.0"
|
||||||
"build": "vite build && npm run build:reports -- --outDir build/reports && npm run build:launch -- --outDir build/launch",
|
},
|
||||||
"preview": "vite preview",
|
"scripts": {
|
||||||
"check": "npm run check:styles && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"dev": "vite dev",
|
||||||
"test": "vitest run",
|
"build": "vite build && npm run build:reports -- --outDir build/reports && npm run build:launch -- --outDir build/launch",
|
||||||
"test:watch": "vitest",
|
"preview": "vite preview",
|
||||||
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
|
"check": "npm run check:styles && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"diagnostics:database": "node scripts/database-diagnostics.mjs",
|
"test": "vitest run",
|
||||||
"seed:fixture": "node --import tsx scripts/seed-fixture.ts",
|
"test:watch": "vitest",
|
||||||
"agent:package": "python3 agent/package_release.py",
|
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
|
||||||
"prebuild": "npm run agent:package",
|
"diagnostics:database": "node scripts/database-diagnostics.mjs",
|
||||||
"test:agent": "python3 -m unittest discover -s agent/tests -v",
|
"seed:fixture": "node --import tsx scripts/seed-fixture.ts",
|
||||||
"build:reports": "vite build --config vite.reports.config.ts",
|
"agent:package": "python3 agent/package_release.py",
|
||||||
"build:launch": "vite build --config vite.launch.config.ts",
|
"prebuild": "npm run agent:package",
|
||||||
"predev": "npm run build:reports && npm run build:launch",
|
"test:agent": "python3 -m unittest discover -s agent/tests -v",
|
||||||
"pretest": "npm run build:reports && npm run build:launch",
|
"build:reports": "vite build --config vite.reports.config.ts",
|
||||||
"pretest:watch": "npm run build:reports && npm run build:launch",
|
"build:launch": "vite build --config vite.launch.config.ts",
|
||||||
"check:styles": "node scripts/check-ui-styles.mjs"
|
"predev": "npm run agent:package && npm run build:reports && npm run build:launch",
|
||||||
},
|
"pretest": "npm run build:reports && npm run build:launch",
|
||||||
"dependencies": {
|
"pretest:watch": "npm run build:reports && npm run build:launch",
|
||||||
"@fontsource/alfa-slab-one": "^5.3.0",
|
"check:styles": "node scripts/check-ui-styles.mjs",
|
||||||
"@fontsource/inter": "^5.3.0",
|
"lint": "prettier --check . && eslint .",
|
||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
"format": "prettier --write ."
|
||||||
"better-sqlite3": "^11.3.0"
|
},
|
||||||
},
|
"dependencies": {
|
||||||
"devDependencies": {
|
"@fontsource/alfa-slab-one": "^5.3.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.9",
|
"@fontsource/inter": "^5.3.0",
|
||||||
"@sveltejs/kit": "^2.8.0",
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"better-sqlite3": "^11.3.0"
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
},
|
||||||
"@types/node": "^22.7.0",
|
"devDependencies": {
|
||||||
"svelte": "^5.1.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"svelte-check": "^4.0.0",
|
"@sveltejs/adapter-node": "^5.2.9",
|
||||||
"tsx": "^4.19.0",
|
"@sveltejs/kit": "^2.70.3",
|
||||||
"typescript": "^5.6.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"vite": "^5.4.0",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"vitest": "^2.1.0"
|
"@types/node": "^22.7.0",
|
||||||
}
|
"eslint": "^10.12.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.23.1",
|
||||||
|
"globals": "^17.13.0",
|
||||||
|
"happy-dom": "^21.0.1",
|
||||||
|
"prettier": "^3.9.9",
|
||||||
|
"prettier-plugin-svelte": "^3.5.2",
|
||||||
|
"svelte": "^5.1.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"typescript-eslint": "^8.71.1",
|
||||||
|
"vite": "^7.3.7",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@sveltejs/kit": {
|
||||||
|
"cookie": "^0.7.2"
|
||||||
|
},
|
||||||
|
"devalue": "^5.9.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,33 +24,51 @@ const allowed = new Set(['--scenario', '--runs', '--baseline-ref', '--json']);
|
|||||||
for (let index = 0; index < arguments_.length; index += 2) {
|
for (let index = 0; index < arguments_.length; index += 2) {
|
||||||
const key = arguments_[index];
|
const key = arguments_[index];
|
||||||
const value = arguments_[index + 1];
|
const value = arguments_[index + 1];
|
||||||
if (!allowed.has(key) || !value) throw new Error('Usage: --scenario small|large|all --runs N --baseline-ref COMMIT --json PATH');
|
if (!allowed.has(key) || !value)
|
||||||
|
throw new Error(
|
||||||
|
'Usage: --scenario small|large|all --runs N --baseline-ref COMMIT --json PATH'
|
||||||
|
);
|
||||||
options[key.slice(2)] = value;
|
options[key.slice(2)] = value;
|
||||||
}
|
}
|
||||||
options.runs = Number(options.runs);
|
options.runs = Number(options.runs);
|
||||||
if (!Number.isInteger(options.runs) || options.runs < 1 || options.runs > 20) throw new Error('--runs must be an integer from 1 to 20');
|
if (!Number.isInteger(options.runs) || options.runs < 1 || options.runs > 20)
|
||||||
if (!['small', 'large', 'all'].includes(options.scenario)) throw new Error('--scenario must be small, large, or all');
|
throw new Error('--runs must be an integer from 1 to 20');
|
||||||
|
if (!['small', 'large', 'all'].includes(options.scenario))
|
||||||
|
throw new Error('--scenario must be small, large, or all');
|
||||||
const baseline = options['baseline-ref']
|
const baseline = options['baseline-ref']
|
||||||
? execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${options['baseline-ref']}^{commit}`], { cwd: repo, encoding: 'utf8' }).trim()
|
? execFileSync(
|
||||||
|
'git',
|
||||||
|
['rev-parse', '--verify', '--end-of-options', `${options['baseline-ref']}^{commit}`],
|
||||||
|
{ cwd: repo, encoding: 'utf8' }
|
||||||
|
).trim()
|
||||||
: null;
|
: null;
|
||||||
const definitions = [
|
const definitions = [
|
||||||
{ name: 'small', matches: 2000, players: 500 },
|
{ name: 'small', matches: 2000, players: 500 },
|
||||||
{ name: 'large', matches: 50000, players: 10000 }
|
{ name: 'large', matches: 50000, players: 10000 }
|
||||||
].filter(item => options.scenario === 'all' || item.name === options.scenario);
|
].filter((item) => options.scenario === 'all' || item.name === options.scenario);
|
||||||
const temporary = mkdtempSync(join(tmpdir(), 'cherry-leaderboard-benchmark-'));
|
const temporary = mkdtempSync(join(tmpdir(), 'cherry-leaderboard-benchmark-'));
|
||||||
const source = (file, ref) => ref
|
const source = (file, ref) =>
|
||||||
? execFileSync('git', ['show', `${ref}:${file}`], { cwd: repo, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })
|
ref
|
||||||
: readFileSync(join(repo, file), 'utf8');
|
? execFileSync('git', ['show', `${ref}:${file}`], {
|
||||||
const percentile = (values, fraction) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1];
|
cwd: repo,
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 10 * 1024 * 1024
|
||||||
|
})
|
||||||
|
: readFileSync(join(repo, file), 'utf8');
|
||||||
|
const percentile = (values, fraction) =>
|
||||||
|
[...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1];
|
||||||
|
|
||||||
function seed(handle, definition) {
|
function seed(handle, definition) {
|
||||||
handle.exec(source('src/lib/server/db/schema.sql'));
|
handle.exec(source('src/lib/server/db/schema.sql'));
|
||||||
handle.exec("CREATE TABLE report_revisions(topic TEXT PRIMARY KEY, revision INTEGER NOT NULL); INSERT INTO report_revisions VALUES ('leaderboard',0)");
|
handle.exec(
|
||||||
|
"CREATE TABLE report_revisions(topic TEXT PRIMARY KEY, revision INTEGER NOT NULL); INSERT INTO report_revisions VALUES ('leaderboard',0)"
|
||||||
|
);
|
||||||
const match = handle.prepare(`INSERT INTO leaderboard_matches
|
const match = handle.prepare(`INSERT INTO leaderboard_matches
|
||||||
(id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at,
|
(id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at,
|
||||||
status, start_observed, start_players, highest_round, last_event, last_fingerprint)
|
status, start_observed, start_players, highest_round, last_event, last_fingerprint)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 4, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 4, ?, ?, ?)`);
|
||||||
const identity = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
const identity =
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO UPDATE SET
|
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO UPDATE SET
|
||||||
name = CASE WHEN excluded.last_seen_at >= last_seen_at THEN excluded.name ELSE name END,
|
name = CASE WHEN excluded.last_seen_at >= last_seen_at THEN excluded.name ELSE name END,
|
||||||
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
||||||
@@ -60,19 +78,38 @@ function seed(handle, definition) {
|
|||||||
first_observed_round, present_at_start)
|
first_observed_round, present_at_start)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
let state = 20260918;
|
let state = 20260918;
|
||||||
const random = () => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state / 4294967296; };
|
const random = () => {
|
||||||
|
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||||
|
return state / 4294967296;
|
||||||
|
};
|
||||||
handle.transaction(() => {
|
handle.transaction(() => {
|
||||||
for (let id = 1; id <= definition.matches; id++) {
|
for (let id = 1; id <= definition.matches; id++) {
|
||||||
const mapIndex = Math.floor(random() * 12);
|
const mapIndex = Math.floor(random() * 12);
|
||||||
const game = mapIndex < 6 ? 't5' : 't6';
|
const game = mapIndex < 6 ? 't5' : 't6';
|
||||||
const map = `map_${mapIndex}`;
|
const map = `map_${mapIndex}`;
|
||||||
const status = id % 20 === 0 ? 'active' : id % 20 === 1 ? 'interrupted' : 'completed';
|
const status = id % 20 === 0 ? 'active' : id % 20 === 1 ? 'interrupted' : 'completed';
|
||||||
const started = new Date(Date.UTC(2023, 0, 1) + Math.floor(id / definition.matches * 3 * 365 * 86400000)).toISOString();
|
const started = new Date(
|
||||||
const ended = new Date(Date.parse(started) + (20 + Math.floor(random() * 130)) * 60000).toISOString();
|
Date.UTC(2023, 0, 1) + Math.floor((id / definition.matches) * 3 * 365 * 86400000)
|
||||||
|
).toISOString();
|
||||||
|
const ended = new Date(
|
||||||
|
Date.parse(started) + (20 + Math.floor(random() * 130)) * 60000
|
||||||
|
).toISOString();
|
||||||
const round = id % 101 === 0 ? 1 : 2 + Math.floor(random() * 60);
|
const round = id % 101 === 0 ? 1 : 2 + Math.floor(random() * 60);
|
||||||
match.run(id, `server_${id % 24}`, game, map, `${game}:${map}`, `Map ${mapIndex}`,
|
match.run(
|
||||||
started, status === 'completed' ? ended : null, ended, status, round,
|
id,
|
||||||
status === 'completed' ? 'game_end' : 'heartbeat', `event_${id}`);
|
`server_${id % 24}`,
|
||||||
|
game,
|
||||||
|
map,
|
||||||
|
`${game}:${map}`,
|
||||||
|
`Map ${mapIndex}`,
|
||||||
|
started,
|
||||||
|
status === 'completed' ? ended : null,
|
||||||
|
ended,
|
||||||
|
status,
|
||||||
|
round,
|
||||||
|
status === 'completed' ? 'game_end' : 'heartbeat',
|
||||||
|
`event_${id}`
|
||||||
|
);
|
||||||
const roster = new Set();
|
const roster = new Set();
|
||||||
while (roster.size < 4) roster.add(Math.floor(random() ** 2 * definition.players));
|
while (roster.size < 4) roster.add(Math.floor(random() ** 2 * definition.players));
|
||||||
// Reserve one completed participation for an explicitly ineligible player.
|
// Reserve one completed participation for an explicitly ineligible player.
|
||||||
@@ -84,16 +121,47 @@ function seed(handle, definition) {
|
|||||||
const kills = Math.floor(round * 18 + random() * 800);
|
const kills = Math.floor(round * 18 + random() * 800);
|
||||||
const presentAtStart = random() > 0.1;
|
const presentAtStart = random() > 0.1;
|
||||||
const firstRound = presentAtStart ? 0 : Math.max(1, Math.floor(random() * round));
|
const firstRound = presentAtStart ? 0 : Math.max(1, Math.floor(random() * round));
|
||||||
participation.run(id, guid, round, Math.max(0, round - Math.max(1, firstRound)), kills,
|
participation.run(
|
||||||
Math.floor(kills * random()), Math.floor(random() * 8), Math.floor(random() * 12),
|
id,
|
||||||
Math.floor(random() * 100000), Math.max(0, round - 1), Number(status === 'active'),
|
guid,
|
||||||
Number(random() > 0.01), Number(status === 'completed' && random() > 0.15),
|
round,
|
||||||
started, ended, firstRound, Number(presentAtStart));
|
Math.max(0, round - Math.max(1, firstRound)),
|
||||||
|
kills,
|
||||||
|
Math.floor(kills * random()),
|
||||||
|
Math.floor(random() * 8),
|
||||||
|
Math.floor(random() * 12),
|
||||||
|
Math.floor(random() * 100000),
|
||||||
|
Math.max(0, round - 1),
|
||||||
|
Number(status === 'active'),
|
||||||
|
Number(random() > 0.01),
|
||||||
|
Number(status === 'completed' && random() > 0.15),
|
||||||
|
started,
|
||||||
|
ended,
|
||||||
|
firstRound,
|
||||||
|
Number(presentAtStart)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (id === definition.matches - 2) {
|
if (id === definition.matches - 2) {
|
||||||
identity.run(game, 'benchmark-low-sample', 'Low sample', started, ended);
|
identity.run(game, 'benchmark-low-sample', 'Low sample', started, ended);
|
||||||
participation.run(id, 'benchmark-low-sample', 2, 1, 20, 5, 0, 0, 1000,
|
participation.run(
|
||||||
1, 0, 1, 1, started, ended, 0, 1);
|
id,
|
||||||
|
'benchmark-low-sample',
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1000,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
started,
|
||||||
|
ended,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -104,15 +172,27 @@ function loadImplementation(handle, ref) {
|
|||||||
const modules = new Map();
|
const modules = new Map();
|
||||||
const hashes = {};
|
const hashes = {};
|
||||||
let recorded = null;
|
let recorded = null;
|
||||||
const database = { prepare(sql) {
|
const database = {
|
||||||
const statement = handle.prepare(sql);
|
prepare(sql) {
|
||||||
return Object.fromEntries(['all', 'get'].map(method => [method, (...parameters) => {
|
const statement = handle.prepare(sql);
|
||||||
const start = performance.now();
|
return Object.fromEntries(
|
||||||
const result = statement[method](...parameters);
|
['all', 'get'].map((method) => [
|
||||||
if (recorded) recorded.push({ sql, parameters, milliseconds: performance.now() - start });
|
method,
|
||||||
return result;
|
(...parameters) => {
|
||||||
}]));
|
const start = performance.now();
|
||||||
} };
|
const result = statement[method](...parameters);
|
||||||
|
if (recorded)
|
||||||
|
recorded.push({
|
||||||
|
sql,
|
||||||
|
parameters,
|
||||||
|
milliseconds: performance.now() - start
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
function load(file) {
|
function load(file) {
|
||||||
if (file === 'src/lib/server/db/index.ts') return { db: () => database };
|
if (file === 'src/lib/server/db/index.ts') return { db: () => database };
|
||||||
if (modules.has(file)) return modules.get(file);
|
if (modules.has(file)) return modules.get(file);
|
||||||
@@ -123,10 +203,14 @@ function loadImplementation(handle, ref) {
|
|||||||
}).outputText;
|
}).outputText;
|
||||||
const exports = {};
|
const exports = {};
|
||||||
modules.set(file, exports);
|
modules.set(file, exports);
|
||||||
const localRequire = specifier => {
|
const localRequire = (specifier) => {
|
||||||
if (!specifier.startsWith('.')) return require(specifier);
|
if (!specifier.startsWith('.')) return require(specifier);
|
||||||
const target = relative(repo, resolve(repo, dirname(file), specifier)).replace(/\.js$/, '.ts');
|
const target = relative(repo, resolve(repo, dirname(file), specifier)).replace(
|
||||||
if (!target.startsWith('src/lib/')) throw new Error(`Unexpected benchmark dependency: ${target}`);
|
/\.js$/,
|
||||||
|
'.ts'
|
||||||
|
);
|
||||||
|
if (!target.startsWith('src/lib/'))
|
||||||
|
throw new Error(`Unexpected benchmark dependency: ${target}`);
|
||||||
return load(target);
|
return load(target);
|
||||||
};
|
};
|
||||||
new Function('require', 'exports', compiled)(localRequire, exports);
|
new Function('require', 'exports', compiled)(localRequire, exports);
|
||||||
@@ -134,10 +218,14 @@ function loadImplementation(handle, ref) {
|
|||||||
}
|
}
|
||||||
const api = load('src/lib/server/fleet/leaderboard.ts');
|
const api = load('src/lib/server/fleet/leaderboard.ts');
|
||||||
return {
|
return {
|
||||||
name: ref ? 'baseline' : 'current', ref, hashes,
|
name: ref ? 'baseline' : 'current',
|
||||||
metrics: load('src/lib/leaderboard.ts').leaderboardMetrics.map(metric => metric.key),
|
ref,
|
||||||
|
hashes,
|
||||||
|
metrics: load('src/lib/leaderboard.ts').leaderboardMetrics.map((metric) => metric.key),
|
||||||
run(test) {
|
run(test) {
|
||||||
return test.guid ? api.getPlayerLeaderboardEntry(test.guid, test.options) : api.getLeaderboard(test.options);
|
return test.guid
|
||||||
|
? api.getPlayerLeaderboardEntry(test.guid, test.options)
|
||||||
|
: api.getLeaderboard(test.options);
|
||||||
},
|
},
|
||||||
measure(test) {
|
measure(test) {
|
||||||
recorded = [];
|
recorded = [];
|
||||||
@@ -152,8 +240,16 @@ function loadImplementation(handle, ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
environment: { node: process.version, sqlite: null, platform: platform(), arch: arch(), cpu: cpus()[0]?.model },
|
environment: {
|
||||||
baselineRef: baseline, runs: options.runs, seed: 20260918,
|
node: process.version,
|
||||||
|
sqlite: null,
|
||||||
|
platform: platform(),
|
||||||
|
arch: arch(),
|
||||||
|
cpu: cpus()[0]?.model
|
||||||
|
},
|
||||||
|
baselineRef: baseline,
|
||||||
|
runs: options.runs,
|
||||||
|
seed: 20260918,
|
||||||
notes: 'Synthetic file-backed SQLite, one warmup per case, rotating execution order, warm OS cache. Includes SQL preparation and source-record lookup; excludes HTTP and browser rendering. Shared local CPU; timings are not production latency. Schema and indexes are current for both implementations.',
|
notes: 'Synthetic file-backed SQLite, one warmup per case, rotating execution order, warm OS cache. Includes SQL preparation and source-record lookup; excludes HTTP and browser rendering. Shared local CPU; timings are not production latency. Schema and indexes are current for both implementations.',
|
||||||
scenarios: []
|
scenarios: []
|
||||||
};
|
};
|
||||||
@@ -161,77 +257,148 @@ try {
|
|||||||
for (const definition of definitions) {
|
for (const definition of definitions) {
|
||||||
const handle = new Database(join(temporary, `${definition.name}.sqlite`));
|
const handle = new Database(join(temporary, `${definition.name}.sqlite`));
|
||||||
try {
|
try {
|
||||||
report.environment.sqlite = handle.prepare('SELECT sqlite_version() AS version').get().version;
|
report.environment.sqlite = handle
|
||||||
|
.prepare('SELECT sqlite_version() AS version')
|
||||||
|
.get().version;
|
||||||
seed(handle, definition);
|
seed(handle, definition);
|
||||||
const implementations = [...(baseline ? [loadImplementation(handle, baseline)] : []), loadImplementation(handle)];
|
const implementations = [
|
||||||
|
...(baseline ? [loadImplementation(handle, baseline)] : []),
|
||||||
|
loadImplementation(handle)
|
||||||
|
];
|
||||||
const cases = [
|
const cases = [
|
||||||
{ name: 'unfiltered', options: {} },
|
{ name: 'unfiltered', options: {} },
|
||||||
{ name: 'kills', options: { metric: 'kills' } },
|
{ name: 'kills', options: { metric: 'kills' } },
|
||||||
{ name: 'game', options: { game: 't5', metric: 'kills' } },
|
{ name: 'game', options: { game: 't5', metric: 'kills' } },
|
||||||
{ name: 'map', options: { game: 't6', map: 't6:map_8', metric: 'kills' } },
|
{ name: 'map', options: { game: 't6', map: 't6:map_8', metric: 'kills' } },
|
||||||
{ name: 'search', options: { q: '00042', metric: 'kills' } },
|
{ name: 'search', options: { q: '00042', metric: 'kills' } },
|
||||||
{ name: 'deep-page', options: { metric: 'kills', offset: Math.floor(definition.players / 2) } },
|
{
|
||||||
|
name: 'deep-page',
|
||||||
|
options: { metric: 'kills', offset: Math.floor(definition.players / 2) }
|
||||||
|
},
|
||||||
{ name: 'efficiency', options: { metric: 'headshotRate' } },
|
{ name: 'efficiency', options: { metric: 'headshotRate' } },
|
||||||
{ name: 'exact-player', guid: '10000000000000042', options: { metric: 'kills' } },
|
{ name: 'exact-player', guid: '10000000000000042', options: { metric: 'kills' } },
|
||||||
{ name: 'empty-search', options: { q: 'No such player exists' } },
|
{ name: 'empty-search', options: { q: 'No such player exists' } },
|
||||||
{ name: 'past-end', options: { offset: 999999 } },
|
{ name: 'past-end', options: { offset: 999999 } },
|
||||||
{ name: 'empty-map', options: { map: 't5:missing' } }
|
{ name: 'empty-map', options: { map: 't5:missing' } }
|
||||||
];
|
];
|
||||||
const counts = handle.prepare(`SELECT COUNT(*) AS participationRows,
|
const counts = handle
|
||||||
COUNT(DISTINCT guid) AS uniquePlayers FROM leaderboard_match_players`).get();
|
.prepare(
|
||||||
const scenario = { ...definition, ...counts, maps: 12, servers: 24, completedShare: 0.9, results: [] };
|
`SELECT COUNT(*) AS participationRows,
|
||||||
|
COUNT(DISTINCT guid) AS uniquePlayers FROM leaderboard_match_players`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
const scenario = {
|
||||||
|
...definition,
|
||||||
|
...counts,
|
||||||
|
maps: 12,
|
||||||
|
servers: 24,
|
||||||
|
completedShare: 0.9,
|
||||||
|
results: []
|
||||||
|
};
|
||||||
report.scenarios.push(scenario);
|
report.scenarios.push(scenario);
|
||||||
// Broad parity is separate from timings so every ratio and edge case is
|
// Broad parity is separate from timings so every ratio and edge case is
|
||||||
// checked without multiplying the larger fixture's timed request matrix.
|
// checked without multiplying the larger fixture's timed request matrix.
|
||||||
if (baseline && definition.name === 'small') {
|
if (baseline && definition.name === 'small') {
|
||||||
const scopes = [{}, { game: 't5' }, { game: 't6', map: 't6:map_8' }, { q: '00042' },
|
const scopes = [
|
||||||
{ q: 'No such player exists' }, { offset: 999999 }, { map: 't5:missing' }];
|
{},
|
||||||
|
{ game: 't5' },
|
||||||
|
{ game: 't6', map: 't6:map_8' },
|
||||||
|
{ q: '00042' },
|
||||||
|
{ q: 'No such player exists' },
|
||||||
|
{ offset: 999999 },
|
||||||
|
{ map: 't5:missing' }
|
||||||
|
];
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
for (const metric of implementations[1].metrics) {
|
for (const metric of implementations[1].metrics) {
|
||||||
for (const scope of scopes) {
|
for (const scope of scopes) {
|
||||||
const test = { options: { ...scope, metric } };
|
const test = { options: { ...scope, metric } };
|
||||||
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
|
assert.deepStrictEqual(
|
||||||
`${definition.name}/${metric}/${JSON.stringify(scope)}: full-response parity`);
|
implementations[1].run(test),
|
||||||
|
implementations[0].run(test),
|
||||||
|
`${definition.name}/${metric}/${JSON.stringify(scope)}: full-response parity`
|
||||||
|
);
|
||||||
checked++;
|
checked++;
|
||||||
}
|
}
|
||||||
for (const guid of ['10000000000000042', 'benchmark-low-sample', 'benchmark-missing']) {
|
for (const guid of [
|
||||||
|
'10000000000000042',
|
||||||
|
'benchmark-low-sample',
|
||||||
|
'benchmark-missing'
|
||||||
|
]) {
|
||||||
const test = { guid, options: { metric } };
|
const test = { guid, options: { metric } };
|
||||||
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
|
assert.deepStrictEqual(
|
||||||
`${definition.name}/${metric}/${guid}: exact-player parity`);
|
implementations[1].run(test),
|
||||||
|
implementations[0].run(test),
|
||||||
|
`${definition.name}/${metric}/${guid}: exact-player parity`
|
||||||
|
);
|
||||||
checked++;
|
checked++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scenario.parityCases = checked;
|
scenario.parityCases = checked;
|
||||||
console.log(JSON.stringify({ scenario: definition.name, parityCases: checked, parity: true }));
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
scenario: definition.name,
|
||||||
|
parityCases: checked,
|
||||||
|
parity: true
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const test of cases) {
|
for (const test of cases) {
|
||||||
const expected = implementations[0].run(test);
|
const expected = implementations[0].run(test);
|
||||||
for (const implementation of implementations.slice(1)) {
|
for (const implementation of implementations.slice(1)) {
|
||||||
assert.deepStrictEqual(implementation.run(test), expected, `${definition.name}/${test.name}: baseline parity`);
|
assert.deepStrictEqual(
|
||||||
|
implementation.run(test),
|
||||||
|
expected,
|
||||||
|
`${definition.name}/${test.name}: baseline parity`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const samples = Object.fromEntries(implementations.map(item => [item.name, []]));
|
const samples = Object.fromEntries(implementations.map((item) => [item.name, []]));
|
||||||
const latestStatements = {};
|
const latestStatements = {};
|
||||||
for (let index = 0; index < options.runs; index++) {
|
for (let index = 0; index < options.runs; index++) {
|
||||||
const rotation = index % implementations.length;
|
const rotation = index % implementations.length;
|
||||||
for (const implementation of [...implementations.slice(rotation), ...implementations.slice(0, rotation)]) {
|
for (const implementation of [
|
||||||
|
...implementations.slice(rotation),
|
||||||
|
...implementations.slice(0, rotation)
|
||||||
|
]) {
|
||||||
const measured = implementation.measure(test);
|
const measured = implementation.measure(test);
|
||||||
samples[implementation.name].push(measured.milliseconds);
|
samples[implementation.name].push(measured.milliseconds);
|
||||||
latestStatements[implementation.name] = measured.statements;
|
latestStatements[implementation.name] = measured.statements;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = { ...test, parity: baseline ? true : null, implementations: implementations.map(implementation => ({
|
const result = {
|
||||||
name: implementation.name, sourceHashes: implementation.hashes,
|
...test,
|
||||||
medianMs: percentile(samples[implementation.name], 0.5),
|
parity: baseline ? true : null,
|
||||||
p95Ms: percentile(samples[implementation.name], 0.95), samplesMs: samples[implementation.name],
|
implementations: implementations.map((implementation) => ({
|
||||||
statements: latestStatements[implementation.name].map(statement => ({ ...statement,
|
name: implementation.name,
|
||||||
plan: handle.prepare(`EXPLAIN QUERY PLAN ${statement.sql}`).all(...statement.parameters)
|
sourceHashes: implementation.hashes,
|
||||||
|
medianMs: percentile(samples[implementation.name], 0.5),
|
||||||
|
p95Ms: percentile(samples[implementation.name], 0.95),
|
||||||
|
samplesMs: samples[implementation.name],
|
||||||
|
statements: latestStatements[implementation.name].map((statement) => ({
|
||||||
|
...statement,
|
||||||
|
plan: handle
|
||||||
|
.prepare(`EXPLAIN QUERY PLAN ${statement.sql}`)
|
||||||
|
.all(...statement.parameters)
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
})) };
|
};
|
||||||
scenario.results.push(result);
|
scenario.results.push(result);
|
||||||
console.log(JSON.stringify({ scenario: definition.name, case: test.name, parity: result.parity,
|
console.log(
|
||||||
medianMs: Object.fromEntries(result.implementations.map(item => [item.name, Math.round(item.medianMs)])) }));
|
JSON.stringify({
|
||||||
|
scenario: definition.name,
|
||||||
|
case: test.name,
|
||||||
|
parity: result.parity,
|
||||||
|
medianMs: Object.fromEntries(
|
||||||
|
result.implementations.map((item) => [
|
||||||
|
item.name,
|
||||||
|
Math.round(item.medianMs)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally { handle.close(); }
|
} finally {
|
||||||
|
handle.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (options.json) {
|
if (options.json) {
|
||||||
const target = resolve(options.json);
|
const target = resolve(options.json);
|
||||||
@@ -239,4 +406,6 @@ try {
|
|||||||
writeFileSync(target, JSON.stringify(report, null, 2));
|
writeFileSync(target, JSON.stringify(report, null, 2));
|
||||||
console.log(`Detailed results: ${target}`);
|
console.log(`Detailed results: ${target}`);
|
||||||
}
|
}
|
||||||
} finally { rmSync(temporary, { recursive: true, force: true }); }
|
} finally {
|
||||||
|
rmSync(temporary, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|||||||
+196
-18
@@ -1,26 +1,204 @@
|
|||||||
import { readdir, readFile } from 'node:fs/promises';
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { parse } from 'svelte/compiler';
|
||||||
|
|
||||||
const root = fileURLToPath(new URL('../src/', import.meta.url));
|
const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url));
|
||||||
const failures = [];
|
const tokens = await readFile(path.join(sourceRoot, 'lib/ui/styles/tokens.css'), 'utf8');
|
||||||
async function visit(directory) {
|
const sharedBreakpointWidths = new Set(
|
||||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
[...tokens.matchAll(/--breakpoint-[\w-]+:\s*([\d.]+px)\s*;/g)].map((match) => match[1])
|
||||||
const file = path.join(directory, entry.name);
|
);
|
||||||
if (entry.isDirectory()) await visit(file);
|
|
||||||
else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') {
|
// Existing layouts awaiting migration (Refs #130). Remove widths as each file
|
||||||
const source = await readFile(file, 'utf8');
|
// moves to the shared scale; a listed file cannot introduce other legacy widths.
|
||||||
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
|
/** @type {Record<string, number[]>} */
|
||||||
const line = source.slice(0, match.index).split('\n').length;
|
const legacyBreakpointWidths = {
|
||||||
failures.push(`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}`);
|
'src/lib/ui/components/ArtifactDetail.svelte': [720, 1000],
|
||||||
|
'src/lib/ui/components/BetaPlanBuilder.svelte': [480, 850],
|
||||||
|
'src/lib/ui/components/BetaTestProgress.svelte': [720],
|
||||||
|
'src/lib/ui/components/BlueprintBuilder.svelte': [620],
|
||||||
|
'src/lib/ui/components/ConfigEditor.svelte': [600],
|
||||||
|
'src/lib/ui/components/ConfigInspector.svelte': [600],
|
||||||
|
'src/lib/ui/components/ConfigLibrary.svelte': [600],
|
||||||
|
'src/lib/ui/components/ConfigTextEditor.svelte': [600],
|
||||||
|
'src/lib/ui/components/DeployCollectionDialog.svelte': [440, 600],
|
||||||
|
'src/lib/ui/components/DeployServerDialog.svelte': [440],
|
||||||
|
'src/lib/ui/components/ProvisioningActivity.svelte': [600],
|
||||||
|
'src/lib/ui/components/ProvisioningProgress.svelte': [600],
|
||||||
|
'src/lib/ui/components/SettingsEditor.svelte': [720],
|
||||||
|
'src/lib/ui/components/capacity/RebalancePanel.svelte': [600],
|
||||||
|
'src/lib/ui/components/capacity/RebalanceRunView.svelte': [600],
|
||||||
|
'src/lib/ui/components/metrics/MetricsExplore.svelte': [540],
|
||||||
|
'src/lib/ui/components/metrics/MetricsSummary.svelte': [760],
|
||||||
|
'src/lib/ui/components/primitives/Button.svelte': [720],
|
||||||
|
'src/lib/ui/components/primitives/Card.svelte': [480],
|
||||||
|
'src/lib/ui/components/primitives/Dialog.svelte': [600],
|
||||||
|
'src/lib/ui/styles/components.css': [600],
|
||||||
|
'src/routes/account/+page.svelte': [540],
|
||||||
|
'src/routes/autoscaling/+page.svelte': [500],
|
||||||
|
'src/routes/betas/+page.svelte': [720],
|
||||||
|
'src/routes/betas/[id]/+page.svelte': [720],
|
||||||
|
'src/routes/betas/new/+page.svelte': [700],
|
||||||
|
'src/routes/campaigns/+page.svelte': [720],
|
||||||
|
'src/routes/campaigns/[id]/+page.svelte': [720],
|
||||||
|
'src/routes/capacity/+page.svelte': [600],
|
||||||
|
'src/routes/collections/launches/[id]/+page.svelte': [600],
|
||||||
|
'src/routes/hosts/operations/+page.svelte': [600],
|
||||||
|
'src/routes/leaderboard/+page.svelte': [800, 1000],
|
||||||
|
'src/routes/login/+page.svelte': [540],
|
||||||
|
'src/routes/matches/[id]/+page.svelte': [480, 800],
|
||||||
|
'src/routes/metrics/+page.svelte': [540],
|
||||||
|
'src/routes/plugins/+page.svelte': [720],
|
||||||
|
'src/routes/provisioning/+page.svelte': [720],
|
||||||
|
'src/routes/provisioning/[id]/+page.svelte': [600, 1000],
|
||||||
|
'src/routes/setup/+page.svelte': [540]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} source
|
||||||
|
* @param {string} filename
|
||||||
|
*/
|
||||||
|
export function checkMediaWidths(source, filename) {
|
||||||
|
// Parse styles so comments, script strings and CSS declaration values cannot
|
||||||
|
// be mistaken for media queries. Wrap standalone CSS without adding a line.
|
||||||
|
const cssSource = filename.endsWith('.css') ? `<style>${source}</style>` : source;
|
||||||
|
const stylesheet = parse(cssSource, { modern: true }).css;
|
||||||
|
const legacyWidths = legacyBreakpointWidths[filename.replaceAll('\\', '/')] ?? [];
|
||||||
|
const allowed = new Set([...sharedBreakpointWidths, ...legacyWidths.map((n) => `${n}px`)]);
|
||||||
|
/** @type {string[]} */
|
||||||
|
const failures = [];
|
||||||
|
/** @param {import('svelte/compiler').AST.CSS.StyleSheet | import('svelte/compiler').AST.CSS.Block | null} block */
|
||||||
|
function visit(block) {
|
||||||
|
for (const node of block?.children ?? []) {
|
||||||
|
if (node.type === 'Atrule' && node.name.toLowerCase() === 'media') {
|
||||||
|
const query = node.prelude.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
||||||
|
// Check both sides independently to cover chained range syntax.
|
||||||
|
const widths = [
|
||||||
|
...query.matchAll(
|
||||||
|
/(?<![\w-])(?:min-|max-)?width\s*(?::|[<>]=?|=)\s*([^\s)]+)/gi
|
||||||
|
),
|
||||||
|
...query.matchAll(/([\d.]+(?:[a-z%]+)?)\s*(?:[<>]=?|=)\s*width\b/gi)
|
||||||
|
];
|
||||||
|
for (const match of widths) {
|
||||||
|
if (allowed.has(match[1].toLowerCase())) continue;
|
||||||
|
const line = cssSource.slice(0, node.start).split('\n').length;
|
||||||
|
failures.push(
|
||||||
|
`${filename}:${line}: use a shared media width (${[...sharedBreakpointWidths].join(', ')}): ${match[1]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('block' in node) visit(node.block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(stylesheet);
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeInputTypes = new Set([
|
||||||
|
'checkbox',
|
||||||
|
'radio',
|
||||||
|
'file',
|
||||||
|
'hidden',
|
||||||
|
'range',
|
||||||
|
'color',
|
||||||
|
'button',
|
||||||
|
'submit',
|
||||||
|
'reset',
|
||||||
|
'image'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} source
|
||||||
|
* @param {string} filename
|
||||||
|
*/
|
||||||
|
export function checkFormControls(source, filename) {
|
||||||
|
if (
|
||||||
|
!filename.endsWith('.svelte') ||
|
||||||
|
/(?:^|\/)primitives\/(?:Input|Select)\.svelte$/.test(filename)
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
/** @type {string[]} */
|
||||||
|
const failures = [];
|
||||||
|
/** @param {unknown} node */
|
||||||
|
function visit(node) {
|
||||||
|
if (!node || typeof node !== 'object') return;
|
||||||
|
if ('type' in node && node.type === 'RegularElement') {
|
||||||
|
const element = /** @type {import('svelte/compiler').AST.RegularElement} */ (node);
|
||||||
|
const type = element.attributes.find(
|
||||||
|
(attribute) => attribute.type === 'Attribute' && attribute.name === 'type'
|
||||||
|
);
|
||||||
|
const value =
|
||||||
|
type?.type === 'Attribute' && type.value !== true
|
||||||
|
? Array.isArray(type.value)
|
||||||
|
? type.value[0]
|
||||||
|
: type.value
|
||||||
|
: undefined;
|
||||||
|
const inputType =
|
||||||
|
value?.type === 'Text'
|
||||||
|
? value.data
|
||||||
|
: value?.expression.type === 'Literal'
|
||||||
|
? value.expression.value
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
element.name === 'select' ||
|
||||||
|
(element.name === 'input' &&
|
||||||
|
(typeof inputType !== 'string' || !nativeInputTypes.has(inputType)))
|
||||||
|
) {
|
||||||
|
const line = source.slice(0, element.start).split('\n').length;
|
||||||
|
failures.push(
|
||||||
|
`${filename}:${line}: use Field with ${element.name === 'select' ? 'Select' : 'Input'} instead of raw <${element.name}>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const value of Object.values(node)) {
|
||||||
|
if (Array.isArray(value)) value.forEach(visit);
|
||||||
|
else if (value && typeof value === 'object') visit(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(parse(source, { modern: true }).fragment);
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const failures = [];
|
||||||
|
/** @param {string} directory */
|
||||||
|
async function visit(directory) {
|
||||||
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||||
|
const file = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) await visit(file);
|
||||||
|
else if (/\.(svelte|css)$/.test(entry.name)) {
|
||||||
|
const source = await readFile(file, 'utf8');
|
||||||
|
const filename = path
|
||||||
|
.relative(path.dirname(sourceRoot), file)
|
||||||
|
.split(path.sep)
|
||||||
|
.join('/');
|
||||||
|
failures.push(...checkMediaWidths(source, filename));
|
||||||
|
if (file === path.join(sourceRoot, 'lib/ui/styles/tokens.css')) continue;
|
||||||
|
failures.push(...checkFormControls(source, filename));
|
||||||
|
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
|
||||||
|
const line = source.slice(0, match.index).split('\n').length;
|
||||||
|
failures.push(
|
||||||
|
`${filename}:${line}: use a shared type token (12 px minimum): ${match[0]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await visit(sourceRoot);
|
||||||
|
for (const match of tokens.matchAll(
|
||||||
|
/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g
|
||||||
|
)) {
|
||||||
|
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`);
|
||||||
|
}
|
||||||
|
if (failures.length) {
|
||||||
|
console.error(failures.join('\n'));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else
|
||||||
|
console.log(
|
||||||
|
'UI styles: shared form controls, media widths, font sizes and 12 px minimum verified.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await visit(root);
|
|
||||||
const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8');
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
||||||
for (const match of tokens.matchAll(/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g)) {
|
await main();
|
||||||
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`);
|
|
||||||
}
|
|
||||||
if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; }
|
|
||||||
else console.log('UI styles: shared font sizes and 12 px minimum verified.');
|
|
||||||
|
|||||||
@@ -4,13 +4,30 @@ import { statSync } from 'node:fs';
|
|||||||
const path = process.argv[2] ?? process.env.DB_PATH ?? './data/cherry.db';
|
const path = process.argv[2] ?? process.env.DB_PATH ?? './data/cherry.db';
|
||||||
const db = new Database(path, { readonly: true, fileMustExist: true });
|
const db = new Database(path, { readonly: true, fileMustExist: true });
|
||||||
try {
|
try {
|
||||||
console.table(['', '-wal', '-shm'].map(suffix => {
|
console.table(
|
||||||
try { return { file: path + suffix, bytes: statSync(path + suffix).size }; }
|
['', '-wal', '-shm'].map((suffix) => {
|
||||||
catch (error) { if (error.code !== 'ENOENT') throw error; return { file: path + suffix, bytes: 0 }; }
|
try {
|
||||||
}));
|
return { file: path + suffix, bytes: statSync(path + suffix).size };
|
||||||
console.table(db.prepare(`SELECT name, ROUND(SUM(pgsize)/1048576.0, 2) AS mib
|
} catch (error) {
|
||||||
FROM dbstat GROUP BY name ORDER BY SUM(pgsize) DESC LIMIT 20`).all());
|
if (error.code !== 'ENOENT') throw error;
|
||||||
console.table(['page_size', 'page_count', 'freelist_count', 'auto_vacuum'].map(pragma => ({
|
return { file: path + suffix, bytes: 0 };
|
||||||
pragma, value: db.pragma(pragma, { simple: true })
|
}
|
||||||
})));
|
})
|
||||||
} finally { db.close(); }
|
);
|
||||||
|
console.table(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT name, ROUND(SUM(pgsize)/1048576.0, 2) AS mib
|
||||||
|
FROM dbstat GROUP BY name ORDER BY SUM(pgsize) DESC LIMIT 20`
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
);
|
||||||
|
console.table(
|
||||||
|
['page_size', 'page_count', 'freelist_count', 'auto_vacuum'].map((pragma) => ({
|
||||||
|
pragma,
|
||||||
|
value: db.pragma(pragma, { simple: true })
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|||||||
+514
-124
@@ -9,7 +9,14 @@ const DAY = 24 * 60 * MINUTE;
|
|||||||
const GIB = 1024 ** 3;
|
const GIB = 1024 ** 3;
|
||||||
const image = 'fixture.invalid/electric-cherry/preview:local';
|
const image = 'fixture.invalid/electric-cherry/preview:local';
|
||||||
|
|
||||||
type Scenario = { game: 't5' | 't6'; slug: string; players: number; round: number; minutes: number; state: 'active' | 'idle' | 'stopped' | 'stale' | 'starting' | 'host-offline' };
|
type Scenario = {
|
||||||
|
game: 't5' | 't6';
|
||||||
|
slug: string;
|
||||||
|
players: number;
|
||||||
|
round: number;
|
||||||
|
minutes: number;
|
||||||
|
state: 'active' | 'idle' | 'stopped' | 'stale' | 'starting' | 'host-offline';
|
||||||
|
};
|
||||||
const scenarios: Scenario[] = [
|
const scenarios: Scenario[] = [
|
||||||
{ game: 't5', slug: 'theater', players: 4, round: 27, minutes: 76, state: 'active' },
|
{ game: 't5', slug: 'theater', players: 4, round: 27, minutes: 76, state: 'active' },
|
||||||
{ game: 't5', slug: 'cosmodrome', players: 2, round: 16, minutes: 39, state: 'active' },
|
{ game: 't5', slug: 'cosmodrome', players: 2, round: 16, minutes: 39, state: 'active' },
|
||||||
@@ -25,211 +32,594 @@ const scenarios: Scenario[] = [
|
|||||||
{ game: 't6', slug: 'highrise', players: 0, round: 0, minutes: 0, state: 'host-offline' }
|
{ game: 't6', slug: 'highrise', players: 0, round: 0, minutes: 0, state: 'host-offline' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const fixtureServers = scenarios.map((scenario, index) => ({ ...scenario,
|
export const fixtureServers = scenarios.map((scenario, index) => ({
|
||||||
name: `${scenario.game}_zombie_${scenario.slug}_1`, host: `fixture-host-${index === 11 ? 3 : index % 2 + 1}`,
|
...scenario,
|
||||||
instance: `fixture-instance-${index + 1}`, port: 28960 + index,
|
name: `${scenario.game}_zombie_${scenario.slug}_1`,
|
||||||
|
host: `fixture-host-${index === 11 ? 3 : (index % 2) + 1}`,
|
||||||
|
instance: `fixture-instance-${index + 1}`,
|
||||||
|
port: 28960 + index,
|
||||||
map: zombieMapBySlug(scenario.game, scenario.slug)!
|
map: zombieMapBySlug(scenario.game, scenario.slug)!
|
||||||
}));
|
}));
|
||||||
const names = ['Jamie Cross', 'Alex', 'Sam', 'Morgan', 'Riley', 'Taylor', 'Jordan', 'Casey', 'Quinn', 'Avery', 'Drew', 'Kai'];
|
const names = [
|
||||||
|
'Jamie Cross',
|
||||||
|
'Alex',
|
||||||
|
'Sam',
|
||||||
|
'Morgan',
|
||||||
|
'Riley',
|
||||||
|
'Taylor',
|
||||||
|
'Jordan',
|
||||||
|
'Casey',
|
||||||
|
'Quinn',
|
||||||
|
'Avery',
|
||||||
|
'Drew',
|
||||||
|
'Kai'
|
||||||
|
];
|
||||||
const iso = (at: number) => new Date(at).toISOString();
|
const iso = (at: number) => new Date(at).toISOString();
|
||||||
const player = (index: number) => ({ guid: index === 0 ? 'fixture-jamie' : `fixture-player-${index}`,
|
const player = (index: number) => ({
|
||||||
name: index === 0 ? names[0] : `${names[index % names.length]} ${Math.floor(index / names.length) + 1}` });
|
guid: index === 0 ? 'fixture-jamie' : `fixture-player-${index}`,
|
||||||
const rosterFor = (server: typeof fixtureServers[number], index: number) => Array.from({ length: server.players }, (_, slot) => ({ ...player(slot === 0 ? 0 : index * 4 + slot),
|
name:
|
||||||
ping: 29 + slot * 7, kills: (slot + 1) * 137, headshots: (slot + 1) * 41, downs: slot % 2, revives: slot, score: (slot + 1) * 4100 }));
|
index === 0
|
||||||
const seenAt = (server: Scenario, now: number) => iso(now - (['stopped', 'host-offline'].includes(server.state) ? 15 * MINUTE : server.state === 'stale' ? 105_000 : 0));
|
? names[0]
|
||||||
const containerState = (server: Scenario) => server.state === 'stopped' ? 'exited' : server.state === 'starting' ? 'starting' : 'running';
|
: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`
|
||||||
const instanceStates = (host: string, now: number) => fixtureServers.filter(server => server.host === host).map(server => ({
|
});
|
||||||
instanceId: server.instance, generation: 1, state: containerState(server), healthy: !['stopped', 'starting'].includes(server.state),
|
const rosterFor = (server: (typeof fixtureServers)[number], index: number) =>
|
||||||
observedAt: iso(now - (host === 'fixture-host-3' ? 10 * MINUTE : 0))
|
Array.from({ length: server.players }, (_, slot) => ({
|
||||||
}));
|
...player(slot === 0 ? 0 : index * 4 + slot),
|
||||||
|
ping: 29 + slot * 7,
|
||||||
|
kills: (slot + 1) * 137,
|
||||||
|
headshots: (slot + 1) * 41,
|
||||||
|
downs: slot % 2,
|
||||||
|
revives: slot,
|
||||||
|
score: (slot + 1) * 4100
|
||||||
|
}));
|
||||||
|
const seenAt = (server: Scenario, now: number) =>
|
||||||
|
iso(
|
||||||
|
now -
|
||||||
|
(['stopped', 'host-offline'].includes(server.state)
|
||||||
|
? 15 * MINUTE
|
||||||
|
: server.state === 'stale'
|
||||||
|
? 105_000
|
||||||
|
: 0)
|
||||||
|
);
|
||||||
|
const containerState = (server: Scenario) =>
|
||||||
|
server.state === 'stopped' ? 'exited' : server.state === 'starting' ? 'starting' : 'running';
|
||||||
|
const instanceStates = (host: string, now: number) =>
|
||||||
|
fixtureServers
|
||||||
|
.filter((server) => server.host === host)
|
||||||
|
.map((server) => ({
|
||||||
|
instanceId: server.instance,
|
||||||
|
generation: 1,
|
||||||
|
state: containerState(server),
|
||||||
|
healthy: !['stopped', 'starting'].includes(server.state),
|
||||||
|
observedAt: iso(now - (host === 'fixture-host-3' ? 10 * MINUTE : 0))
|
||||||
|
}));
|
||||||
|
|
||||||
/** Uses only a caller-owned, fully migrated database. No host or worker code is imported. */
|
/** Uses only a caller-owned, fully migrated database. No host or worker code is imported. */
|
||||||
export function seedFixtureData(handle: Database.Database, now: number): void {
|
export function seedFixtureData(handle: Database.Database, now: number): void {
|
||||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
|
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
|
||||||
const occupied = ['servers', 'leaderboard_matches', 'ec_hosts', 'auth_users', 'scripts', 'server_deployments', 'deployments', 'betas', 'game_file_libraries']
|
const occupied = [
|
||||||
.some(table => handle.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get());
|
'servers',
|
||||||
if (occupied || handle.prepare('SELECT 1 FROM settings WHERE key=?').get(FIXTURE_KEY)) throw new Error('Fixture seeding requires a new database.');
|
'leaderboard_matches',
|
||||||
|
'ec_hosts',
|
||||||
|
'auth_users',
|
||||||
|
'scripts',
|
||||||
|
'server_deployments',
|
||||||
|
'deployments',
|
||||||
|
'betas',
|
||||||
|
'game_file_libraries'
|
||||||
|
].some((table) => handle.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get());
|
||||||
|
if (occupied || handle.prepare('SELECT 1 FROM settings WHERE key=?').get(FIXTURE_KEY))
|
||||||
|
throw new Error('Fixture seeding requires a new database.');
|
||||||
const insert = (table: string, values: Record<string, unknown>) => {
|
const insert = (table: string, values: Record<string, unknown>) => {
|
||||||
const keys = Object.keys(values);
|
const keys = Object.keys(values);
|
||||||
handle.prepare(`INSERT INTO ${table} (${keys.join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...Object.values(values));
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO ${table} (${keys.join(',')}) VALUES (${keys.map(() => '?').join(',')})`
|
||||||
|
)
|
||||||
|
.run(...Object.values(values));
|
||||||
};
|
};
|
||||||
const started = iso(now - 30 * DAY);
|
const started = iso(now - 30 * DAY);
|
||||||
const rememberPlayer = handle.prepare(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)
|
const rememberPlayer =
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)
|
||||||
ON CONFLICT(game,guid) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
ON CONFLICT(game,guid) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||||
handle.transaction(() => {
|
handle.transaction(() => {
|
||||||
insert('settings', { key: FIXTURE_KEY, value: FIXTURE_VERSION });
|
insert('settings', { key: FIXTURE_KEY, value: FIXTURE_VERSION });
|
||||||
// Preview observations must never opt into automatic lifecycle changes.
|
// Preview observations must never opt into automatic lifecycle changes.
|
||||||
for (const [key, value] of Object.entries({ maintenance_paused: '1', maintenance_health: '0', maintenance_drift: '0', maintenance_updates: '0', watchdog_enabled: '0', autoscaling_paused: '1' })) {
|
for (const [key, value] of Object.entries({
|
||||||
handle.prepare('INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value);
|
maintenance_paused: '1',
|
||||||
|
maintenance_health: '0',
|
||||||
|
maintenance_drift: '0',
|
||||||
|
maintenance_updates: '0',
|
||||||
|
watchdog_enabled: '0',
|
||||||
|
autoscaling_paused: '1'
|
||||||
|
})) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value'
|
||||||
|
)
|
||||||
|
.run(key, value);
|
||||||
}
|
}
|
||||||
for (const [index, name] of ['Chicago · Primary', 'Dallas · Overflow', 'Frankfurt · Europe'].entries()) {
|
for (const [index, name] of [
|
||||||
|
'Chicago · Primary',
|
||||||
|
'Dallas · Overflow',
|
||||||
|
'Frankfurt · Europe'
|
||||||
|
].entries()) {
|
||||||
const id = `fixture-host-${index + 1}`;
|
const id = `fixture-host-${index + 1}`;
|
||||||
insert('ec_hosts', { id, name, hostname: `host-${index + 1}.fixture.invalid`, created_at: started, enrolled_at: started,
|
insert('ec_hosts', {
|
||||||
last_heartbeat: iso(now - (index === 2 ? 10 * MINUTE : 0)), inventory: JSON.stringify({ protocolVersion: 1, agentVersion: '1.7.0', cpuCount: 16,
|
id,
|
||||||
cpuUsagePercent: 24 + index * 9, memoryTotal: 32 * GIB, memoryAvailable: (20 - index * 3) * GIB,
|
name,
|
||||||
storageTotal: 500 * GIB, storageAvailable: 240 * GIB, dockerRunning: true, directoriesWritable: true,
|
hostname: `host-${index + 1}.fixture.invalid`,
|
||||||
runtimeImages: [image], libraries: [{ id: 'fixture-t5', state: 'ready' }, { id: 'fixture-t6', state: 'ready' }],
|
created_at: started,
|
||||||
capabilities: ['instance.heartbeat-state'], instanceStates: instanceStates(id, now) }) });
|
enrolled_at: started,
|
||||||
|
last_heartbeat: iso(now - (index === 2 ? 10 * MINUTE : 0)),
|
||||||
|
inventory: JSON.stringify({
|
||||||
|
protocolVersion: 1,
|
||||||
|
agentVersion: '1.7.0',
|
||||||
|
cpuCount: 16,
|
||||||
|
cpuUsagePercent: 24 + index * 9,
|
||||||
|
memoryTotal: 32 * GIB,
|
||||||
|
memoryAvailable: (20 - index * 3) * GIB,
|
||||||
|
storageTotal: 500 * GIB,
|
||||||
|
storageAvailable: 240 * GIB,
|
||||||
|
dockerRunning: true,
|
||||||
|
directoriesWritable: true,
|
||||||
|
preparedRuntimeImages: [image],
|
||||||
|
libraries: [
|
||||||
|
{ id: 'fixture-t5', state: 'ready' },
|
||||||
|
{ id: 'fixture-t6', state: 'ready' }
|
||||||
|
],
|
||||||
|
capabilities: ['instance.heartbeat-state'],
|
||||||
|
instanceStates: instanceStates(id, now)
|
||||||
|
})
|
||||||
|
});
|
||||||
// Deliberately not a token hash; no bearer token can authenticate as these agents.
|
// Deliberately not a token hash; no bearer token can authenticate as these agents.
|
||||||
insert('ec_host_credentials', { id: `fixture-credential-${index + 1}`, host_id: id, token_hash: `invalid-fixture-${index + 1}`, created_at: started });
|
insert('ec_host_credentials', {
|
||||||
|
id: `fixture-credential-${index + 1}`,
|
||||||
|
host_id: id,
|
||||||
|
token_hash: `invalid-fixture-${index + 1}`,
|
||||||
|
created_at: started
|
||||||
|
});
|
||||||
for (let minute = 0; minute < 7 * 1440; minute += 15) {
|
for (let minute = 0; minute < 7 * 1440; minute += 15) {
|
||||||
insert('ec_host_resource_samples', { host_id: id, sampled_at: iso(now - minute * MINUTE), cpu_count: 16,
|
insert('ec_host_resource_samples', {
|
||||||
cpu_usage_percent: 28 + index * 6 + 10 * Math.sin(minute / 95), memory_total: 32 * GIB,
|
host_id: id,
|
||||||
memory_available: Math.round((18 - index * 2 + Math.sin(minute / 110)) * GIB) });
|
sampled_at: iso(now - minute * MINUTE),
|
||||||
|
cpu_count: 16,
|
||||||
|
cpu_usage_percent: 28 + index * 6 + 10 * Math.sin(minute / 95),
|
||||||
|
memory_total: 32 * GIB,
|
||||||
|
memory_available: Math.round((18 - index * 2 + Math.sin(minute / 110)) * GIB)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let liveId = 495;
|
let liveId = 495;
|
||||||
for (const [index, server] of fixtureServers.entries()) {
|
for (const [index, server] of fixtureServers.entries()) {
|
||||||
const beginning = iso(now - server.minutes * MINUTE - 12_000);
|
const beginning = iso(now - server.minutes * MINUTE - 12_000);
|
||||||
const roster = rosterFor(server, index);
|
const roster = rosterFor(server, index);
|
||||||
insert('servers', { name: server.name, game: server.game, map: server.map.canonical, map_slug: server.slug, game_mode: 'zombie',
|
insert('servers', {
|
||||||
game_port: server.port, game_id: server.game === 't5' ? 't5sp' : 't6zm', image,
|
name: server.name,
|
||||||
created_by: 'development fixture', created_at: started, last_players: server.players, last_map: server.map.canonical, last_gametype: 'zclassic',
|
game: server.game,
|
||||||
last_roster: JSON.stringify(roster), last_seen_at: seenAt(server, now),
|
map: server.map.canonical,
|
||||||
last_checked_at: iso(now), last_error: server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
map_slug: server.slug,
|
||||||
|
game_mode: 'zombie',
|
||||||
|
game_port: server.port,
|
||||||
|
game_id: server.game === 't5' ? 't5sp' : 't6zm',
|
||||||
|
image,
|
||||||
|
created_by: 'development fixture',
|
||||||
|
created_at: started,
|
||||||
|
last_players: server.players,
|
||||||
|
last_map: server.map.canonical,
|
||||||
|
last_gametype: 'zclassic',
|
||||||
|
last_roster: JSON.stringify(roster),
|
||||||
|
last_seen_at: seenAt(server, now),
|
||||||
|
last_checked_at: iso(now),
|
||||||
|
last_error: server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
||||||
rcon_failures: server.state === 'stale' ? 2 : 0,
|
rcon_failures: server.state === 'stale' ? 2 : 0,
|
||||||
container_state: containerState(server),
|
container_state: containerState(server),
|
||||||
container_checked_at: iso(now), desired_state: server.state === 'stopped' ? 'stopped' : 'running',
|
container_checked_at: iso(now),
|
||||||
active_since: server.players ? beginning : null, inactive_since: server.players ? null : iso(now - 5 * MINUTE),
|
desired_state: server.state === 'stopped' ? 'stopped' : 'running',
|
||||||
game_started_at: server.players ? beginning : null });
|
active_since: server.players ? beginning : null,
|
||||||
insert('native_instances', { id: server.instance, server: server.name, host_id: server.host,
|
inactive_since: server.players ? null : iso(now - 5 * MINUTE),
|
||||||
library_id: `fixture-${server.game}`, image, port: server.port, created_at: started });
|
game_started_at: server.players ? beginning : null
|
||||||
insert('plutonium_versions', { server: server.name, revision: 4878, checked_at: iso(now), error: null });
|
});
|
||||||
|
insert('native_instances', {
|
||||||
|
id: server.instance,
|
||||||
|
server: server.name,
|
||||||
|
host_id: server.host,
|
||||||
|
library_id: `fixture-${server.game}`,
|
||||||
|
image,
|
||||||
|
port: server.port,
|
||||||
|
created_at: started
|
||||||
|
});
|
||||||
|
insert('plutonium_versions', {
|
||||||
|
server: server.name,
|
||||||
|
revision: 4878,
|
||||||
|
checked_at: iso(now),
|
||||||
|
error: null
|
||||||
|
});
|
||||||
if (server.state === 'active' || server.state === 'idle') {
|
if (server.state === 'active' || server.state === 'idle') {
|
||||||
const matchId = server.players ? liveId++ : null;
|
const matchId = server.players ? liveId++ : null;
|
||||||
const payload = { server: server.name, map: server.map.canonical, event: 'heartbeat', round: server.round, players: roster, receivedAt: iso(now),
|
const payload = {
|
||||||
timing: { matchId, players: Object.fromEntries(roster.map(p => [p.guid, { serverJoinedAt: beginning, matchJoinedAt: beginning, roundStarted: 1 }])) } };
|
server: server.name,
|
||||||
insert('server_telemetry', { server: server.name, round: server.round, players: server.players, received_at: iso(now), payload: JSON.stringify(payload) });
|
map: server.map.canonical,
|
||||||
|
event: 'heartbeat',
|
||||||
|
round: server.round,
|
||||||
|
players: roster,
|
||||||
|
receivedAt: iso(now),
|
||||||
|
timing: {
|
||||||
|
matchId,
|
||||||
|
players: Object.fromEntries(
|
||||||
|
roster.map((p) => [
|
||||||
|
p.guid,
|
||||||
|
{
|
||||||
|
serverJoinedAt: beginning,
|
||||||
|
matchJoinedAt: beginning,
|
||||||
|
roundStarted: 1
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
insert('server_telemetry', {
|
||||||
|
server: server.name,
|
||||||
|
round: server.round,
|
||||||
|
players: server.players,
|
||||||
|
received_at: iso(now),
|
||||||
|
payload: JSON.stringify(payload)
|
||||||
|
});
|
||||||
if (matchId !== null) {
|
if (matchId !== null) {
|
||||||
insert('leaderboard_matches', { id: matchId, server: server.name, game: server.game, map: server.map.canonical,
|
insert('leaderboard_matches', {
|
||||||
map_key: server.slug, map_name: server.map.friendly, started_at: beginning, last_received_at: iso(now), status: 'active',
|
id: matchId,
|
||||||
start_observed: 1, start_players: server.players, highest_round: server.round, last_event: 'heartbeat', last_fingerprint: `fixture-live-${index}` });
|
server: server.name,
|
||||||
|
game: server.game,
|
||||||
|
map: server.map.canonical,
|
||||||
|
map_key: server.slug,
|
||||||
|
map_name: server.map.friendly,
|
||||||
|
started_at: beginning,
|
||||||
|
last_received_at: iso(now),
|
||||||
|
status: 'active',
|
||||||
|
start_observed: 1,
|
||||||
|
start_players: server.players,
|
||||||
|
highest_round: server.round,
|
||||||
|
last_event: 'heartbeat',
|
||||||
|
last_fingerprint: `fixture-live-${index}`
|
||||||
|
});
|
||||||
for (const p of roster) {
|
for (const p of roster) {
|
||||||
rememberPlayer.run(server.game, p.guid, p.name, beginning, iso(now));
|
rememberPlayer.run(server.game, p.guid, p.name, beginning, iso(now));
|
||||||
insert('leaderboard_match_players', { match_id: matchId, guid: p.guid, highest_round: server.round, rounds_survived: server.round,
|
insert('leaderboard_match_players', {
|
||||||
kills: p.kills, headshots: p.headshots, downs: p.downs, revives: p.revives, best_score: p.score,
|
match_id: matchId,
|
||||||
last_round: server.round, present: 1, has_stats: 1, completed: 0, first_seen_at: beginning, last_seen_at: iso(now),
|
guid: p.guid,
|
||||||
first_observed_round: 1, present_at_start: 1 });
|
highest_round: server.round,
|
||||||
|
rounds_survived: server.round,
|
||||||
|
kills: p.kills,
|
||||||
|
headshots: p.headshots,
|
||||||
|
downs: p.downs,
|
||||||
|
revives: p.revives,
|
||||||
|
best_score: p.score,
|
||||||
|
last_round: server.round,
|
||||||
|
present: 1,
|
||||||
|
has_stats: 1,
|
||||||
|
completed: 0,
|
||||||
|
first_seen_at: beginning,
|
||||||
|
last_seen_at: iso(now),
|
||||||
|
first_observed_round: 1,
|
||||||
|
present_at_start: 1
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const phase of ['startup', 'running']) {
|
for (const phase of ['startup', 'running']) {
|
||||||
for (let minute = 0; minute < 60; minute++) insert('ec_instance_resource_samples', {
|
for (let minute = 0; minute < 60; minute++)
|
||||||
host_id: server.host, instance_id: server.instance, generation: 1, phase, bucket: iso(now - minute * MINUTE),
|
insert('ec_instance_resource_samples', {
|
||||||
cpu_cores: phase === 'startup' ? 0.7 + index / 40 : 0.25 + index / 50,
|
host_id: server.host,
|
||||||
memory_bytes: Math.round((phase === 'startup' ? 1.1 : 0.7) * GIB) });
|
instance_id: server.instance,
|
||||||
|
generation: 1,
|
||||||
|
phase,
|
||||||
|
bucket: iso(now - minute * MINUTE),
|
||||||
|
cpu_cores: phase === 'startup' ? 0.7 + index / 40 : 0.25 + index / 50,
|
||||||
|
memory_bytes: Math.round((phase === 'startup' ? 1.1 : 0.7) * GIB)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let index = 0; index < 494; index++) {
|
for (let index = 0; index < 494; index++) {
|
||||||
// A large Kino duo cohort exercises ranking and profile pagination.
|
// A large Kino duo cohort exercises ranking and profile pagination.
|
||||||
const server = fixtureServers[index < 64 ? 0 : index % fixtureServers.length];
|
const server = fixtureServers[index < 64 ? 0 : index % fixtureServers.length];
|
||||||
const squad = index < 64 ? 2 : Math.floor(index / 12) % 4 + 1;
|
const squad = index < 64 ? 2 : (Math.floor(index / 12) % 4) + 1;
|
||||||
const unknownStart = index >= 64 && index % 37 === 0;
|
const unknownStart = index >= 64 && index % 37 === 0;
|
||||||
const interrupted = index >= 64 && index % 31 === 0;
|
const interrupted = index >= 64 && index % 31 === 0;
|
||||||
const end = now - (180 + index * 37) * MINUTE;
|
const end = now - (180 + index * 37) * MINUTE;
|
||||||
const beginning = end - (30 + index % 100) * MINUTE;
|
const beginning = end - (30 + (index % 100)) * MINUTE;
|
||||||
const round = 10 + index * 7 % 56;
|
const round = 10 + ((index * 7) % 56);
|
||||||
insert('leaderboard_matches', { id: index + 1, server: server.name, game: server.game, map: server.map.canonical,
|
insert('leaderboard_matches', {
|
||||||
map_key: server.slug, map_name: server.map.friendly, started_at: iso(beginning), ended_at: iso(end), last_received_at: iso(end),
|
id: index + 1,
|
||||||
status: interrupted ? 'interrupted' : 'completed', start_observed: unknownStart ? 0 : 1, start_players: unknownStart ? null : squad,
|
server: server.name,
|
||||||
highest_round: round, last_event: interrupted ? 'interrupted' : 'game_end', last_fingerprint: `fixture-match-${index + 1}` });
|
game: server.game,
|
||||||
|
map: server.map.canonical,
|
||||||
|
map_key: server.slug,
|
||||||
|
map_name: server.map.friendly,
|
||||||
|
started_at: iso(beginning),
|
||||||
|
ended_at: iso(end),
|
||||||
|
last_received_at: iso(end),
|
||||||
|
status: interrupted ? 'interrupted' : 'completed',
|
||||||
|
start_observed: unknownStart ? 0 : 1,
|
||||||
|
start_players: unknownStart ? null : squad,
|
||||||
|
highest_round: round,
|
||||||
|
last_event: interrupted ? 'interrupted' : 'game_end',
|
||||||
|
last_fingerprint: `fixture-match-${index + 1}`
|
||||||
|
});
|
||||||
for (let slot = 0; slot < squad; slot++) {
|
for (let slot = 0; slot < squad; slot++) {
|
||||||
const p = player(slot === 0 ? 0 : (index + slot * 19) % 80 + 1);
|
const p = player(slot === 0 ? 0 : ((index + slot * 19) % 80) + 1);
|
||||||
rememberPlayer.run(server.game, p.guid, p.name, iso(beginning), iso(end));
|
rememberPlayer.run(server.game, p.guid, p.name, iso(beginning), iso(end));
|
||||||
insert('leaderboard_match_players', { match_id: index + 1, guid: p.guid, highest_round: round, rounds_survived: round - (unknownStart ? 4 : 0),
|
insert('leaderboard_match_players', {
|
||||||
kills: round * 35 + slot * 71, headshots: round * 9, downs: index % 4, revives: slot + index % 3,
|
match_id: index + 1,
|
||||||
best_score: round * 2100 + slot * 1000, last_round: round, present: slot > 0 && index % 9 === 0 ? 0 : 1, has_stats: 1,
|
guid: p.guid,
|
||||||
completed: !interrupted && !(slot > 0 && index % 9 === 0) ? 1 : 0, first_seen_at: iso(beginning), last_seen_at: iso(end),
|
highest_round: round,
|
||||||
first_observed_round: unknownStart ? 5 : 1, present_at_start: unknownStart ? null : 1 });
|
rounds_survived: round - (unknownStart ? 4 : 0),
|
||||||
|
kills: round * 35 + slot * 71,
|
||||||
|
headshots: round * 9,
|
||||||
|
downs: index % 4,
|
||||||
|
revives: slot + (index % 3),
|
||||||
|
best_score: round * 2100 + slot * 1000,
|
||||||
|
last_round: round,
|
||||||
|
present: slot > 0 && index % 9 === 0 ? 0 : 1,
|
||||||
|
has_stats: 1,
|
||||||
|
completed: !interrupted && !(slot > 0 && index % 9 === 0) ? 1 : 0,
|
||||||
|
first_seen_at: iso(beginning),
|
||||||
|
last_seen_at: iso(end),
|
||||||
|
first_observed_round: unknownStart ? 5 : 1,
|
||||||
|
present_at_start: unknownStart ? null : 1
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const metric = handle.prepare('INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,4)');
|
const metric = handle.prepare(
|
||||||
|
'INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,NULL)'
|
||||||
|
);
|
||||||
for (let minute = 0; minute < 7 * 1440; minute++) {
|
for (let minute = 0; minute < 7 * 1440; minute++) {
|
||||||
for (const [index, server] of fixtureServers.entries()) {
|
for (const [index, server] of fixtureServers.entries()) {
|
||||||
const count = Math.floor((Math.sin(minute / 90 + index) + 1) * 2.49);
|
const count = Math.floor((Math.sin(minute / 90 + index) + 1) * 2.49);
|
||||||
metric.run(now - minute * MINUTE, server.name, server.game, server.map.friendly,
|
metric.run(
|
||||||
minute % 720 < 8 && index === 5 ? null : count);
|
now - minute * MINUTE,
|
||||||
|
server.name,
|
||||||
|
server.game,
|
||||||
|
server.map.friendly,
|
||||||
|
minute % 720 < 8 && index === 5 ? null : count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const scope of ['*', ...[1, 2, 3].map(index => `fixture-host-${index}`)]) for (const phase of ['startup', 'running']) {
|
for (const scope of ['*', ...[1, 2, 3].map((index) => `fixture-host-${index}`)])
|
||||||
insert('ec_resource_allowances', { scope, phase, cpu_cores: phase === 'startup' ? 1 : 0.5,
|
for (const phase of ['startup', 'running']) {
|
||||||
memory_bytes: Math.round((phase === 'startup' ? 1.2 : 0.8) * GIB), samples: 240,
|
insert('ec_resource_allowances', {
|
||||||
window_start: iso(now - DAY), observed_at: iso(now), computed_at: iso(now), usage_margin: 1 });
|
scope,
|
||||||
}
|
phase,
|
||||||
|
cpu_cores: phase === 'startup' ? 1 : 0.5,
|
||||||
|
memory_bytes: Math.round((phase === 'startup' ? 1.2 : 0.8) * GIB),
|
||||||
|
samples: 240,
|
||||||
|
window_start: iso(now - DAY),
|
||||||
|
observed_at: iso(now),
|
||||||
|
computed_at: iso(now),
|
||||||
|
usage_margin: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
// Real artifact blobs, so details and downloads work without game files.
|
// Real artifact blobs, so details and downloads work without game files.
|
||||||
for (const [index, kind] of ['script', 'baseline', 'config'].entries()) {
|
for (const [index, kind] of ['script', 'baseline', 'config'].entries()) {
|
||||||
const content = Buffer.from(kind === 'script' ? '// Synthetic UI fixture; no gameplay behavior.\n' : '// Synthetic UI fixture configuration.\nset sv_maxclients 4\n');
|
const content = Buffer.from(
|
||||||
|
kind === 'script'
|
||||||
|
? '// Synthetic UI fixture; no gameplay behavior.\n'
|
||||||
|
: '// Synthetic UI fixture configuration.\nset sv_maxclients 4\n'
|
||||||
|
);
|
||||||
const hash = createHash('sha256').update(content).digest('hex');
|
const hash = createHash('sha256').update(content).digest('hex');
|
||||||
handle.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)').run(hash, content.length, content);
|
handle
|
||||||
insert('scripts', { id: index + 1, name: ['Fixture telemetry', 'Fixture Black Ops I baseline', 'Fixture lobby settings'][index],
|
.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)')
|
||||||
target_rel_path: index === 0 ? 'fixture.gsc' : index === 1 ? 'dedicated_sp.cfg' : 'fixture.cfg',
|
.run(hash, content.length, content);
|
||||||
scope_type: 'game', scope_value: 't5', current_version_id: index + 1, kind, created_at: started });
|
insert('scripts', {
|
||||||
insert('script_versions', { id: index + 1, script_id: index + 1, version: 1, sha256: hash, content_hash: hash,
|
id: index + 1,
|
||||||
size: content.length, notes: 'Synthetic preview content.', state: 'tested', kind, uploaded_at: started });
|
name: [
|
||||||
|
'Fixture telemetry',
|
||||||
|
'Fixture Black Ops I baseline',
|
||||||
|
'Fixture lobby settings'
|
||||||
|
][index],
|
||||||
|
target_rel_path:
|
||||||
|
index === 0 ? 'fixture.gsc' : index === 1 ? 'dedicated_sp.cfg' : 'fixture.cfg',
|
||||||
|
scope_type: 'game',
|
||||||
|
scope_value: 't5',
|
||||||
|
current_version_id: index + 1,
|
||||||
|
kind,
|
||||||
|
created_at: started
|
||||||
|
});
|
||||||
|
insert('script_versions', {
|
||||||
|
id: index + 1,
|
||||||
|
script_id: index + 1,
|
||||||
|
version: 1,
|
||||||
|
sha256: hash,
|
||||||
|
content_hash: hash,
|
||||||
|
size: content.length,
|
||||||
|
notes: 'Synthetic preview content.',
|
||||||
|
state: 'tested',
|
||||||
|
kind,
|
||||||
|
uploaded_at: started
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (let index = 0; index < 36; index++) {
|
for (let index = 0; index < 36; index++) {
|
||||||
const failed = index % 7 === 0;
|
const failed = index % 7 === 0;
|
||||||
const server = fixtureServers[failed ? 5 : index % fixtureServers.length];
|
const server = fixtureServers[failed ? 5 : index % fixtureServers.length];
|
||||||
const id = `fixture-operation-${index + 1}`;
|
const id = `fixture-operation-${index + 1}`;
|
||||||
const state = failed ? 'failed' : index % 11 === 0 ? 'cancelled' : 'done';
|
const state = failed ? 'failed' : index % 11 === 0 ? 'cancelled' : 'done';
|
||||||
insert('server_deployments', { id, server: server.name, action: ['restart', 'start', 'rebuild', 'stop'][index % 4], state,
|
insert('server_deployments', {
|
||||||
stage: failed ? 'Verification failed' : state === 'done' ? 'Completed' : 'Cancelled before dispatch',
|
id,
|
||||||
started_at: iso(now - (60 + index * 2) * MINUTE), finished_at: iso(now - (59 + index * 2) * MINUTE), triggered_by: 'Fixture operator',
|
server: server.name,
|
||||||
error: failed ? 'Synthetic verification timeout. No host command was sent.' : null, warnings: '[]' });
|
action: ['restart', 'start', 'rebuild', 'stop'][index % 4],
|
||||||
insert('native_provisioning', { deployment_id: id, instance_id: server.instance, generation: 1 });
|
state,
|
||||||
insert('server_deployment_events', { deployment_id: id, created_at: iso(now - (59 + index * 2) * MINUTE), message: 'Synthetic retained history; no command was sent.' });
|
stage: failed
|
||||||
|
? 'Verification failed'
|
||||||
|
: state === 'done'
|
||||||
|
? 'Completed'
|
||||||
|
: 'Cancelled before dispatch',
|
||||||
|
started_at: iso(now - (60 + index * 2) * MINUTE),
|
||||||
|
finished_at: iso(now - (59 + index * 2) * MINUTE),
|
||||||
|
triggered_by: 'Fixture operator',
|
||||||
|
error: failed ? 'Synthetic verification timeout. No host command was sent.' : null,
|
||||||
|
warnings: '[]'
|
||||||
|
});
|
||||||
|
insert('native_provisioning', {
|
||||||
|
deployment_id: id,
|
||||||
|
instance_id: server.instance,
|
||||||
|
generation: 1
|
||||||
|
});
|
||||||
|
insert('server_deployment_events', {
|
||||||
|
deployment_id: id,
|
||||||
|
created_at: iso(now - (59 + index * 2) * MINUTE),
|
||||||
|
message: 'Synthetic retained history; no command was sent.'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (let index = 0; index < 8; index++) {
|
for (let index = 0; index < 8; index++) {
|
||||||
const server = fixtureServers[index % fixtureServers.length];
|
const server = fixtureServers[index % fixtureServers.length];
|
||||||
insert('ec_host_operations', { id: `fixture-host-command-${index + 1}`, host_id: server.host, instance_id: server.instance,
|
insert('ec_host_operations', {
|
||||||
server_name: server.name, kind: index % 2 ? 'instance.inspect' : 'instance.restart', payload: '{}',
|
id: `fixture-host-command-${index + 1}`,
|
||||||
state: index % 3 ? 'completed' : 'failed', result: '{}', error: index % 3 ? null : 'Synthetic command failure.',
|
host_id: server.host,
|
||||||
created_at: iso(now - (90 + index * 3) * MINUTE), updated_at: iso(now - (89 + index * 3) * MINUTE), source: 'Fixture operator' });
|
instance_id: server.instance,
|
||||||
|
server_name: server.name,
|
||||||
|
kind: index % 2 ? 'instance.inspect' : 'instance.restart',
|
||||||
|
payload: '{}',
|
||||||
|
state: index % 3 ? 'completed' : 'failed',
|
||||||
|
result: '{}',
|
||||||
|
error: index % 3 ? null : 'Synthetic command failure.',
|
||||||
|
created_at: iso(now - (90 + index * 3) * MINUTE),
|
||||||
|
updated_at: iso(now - (89 + index * 3) * MINUTE),
|
||||||
|
source: 'Fixture operator'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (let index = 0; index < 3; index++) {
|
for (let index = 0; index < 3; index++) {
|
||||||
const server = fixtureServers[index === 0 ? 1 : index === 1 ? 5 : index];
|
const server = fixtureServers[index === 0 ? 1 : index === 1 ? 5 : index];
|
||||||
insert('maintenance_jobs', { id: index + 1, server: server.name, state: index === 1 ? 'failed' : 'done', target_revision: 4878,
|
insert('maintenance_jobs', {
|
||||||
requested_at: iso(now - (120 + index * 4) * MINUTE), finished_at: iso(now - (119 + index * 4) * MINUTE),
|
id: index + 1,
|
||||||
detail: index === 1 ? 'Synthetic update could not be verified.' : 'Synthetic scheduled restart completed.', kind: 'schedule', origin: 'manual',
|
server: server.name,
|
||||||
provisioning_id: index === 0 ? 'fixture-operation-2' : null });
|
state: index === 1 ? 'failed' : 'done',
|
||||||
insert('watchdog_incidents', { id: `fixture-recovery-${index + 1}`, server: server.name, kind: 'health', state: 'resolved',
|
target_revision: 4878,
|
||||||
summary: 'Synthetic recovery finding resolved.', details: '{}', first_seen_at: iso(now - (130 + index * 4) * MINUTE),
|
requested_at: iso(now - (120 + index * 4) * MINUTE),
|
||||||
last_seen_at: iso(now - (129 + index * 4) * MINUTE), finished_at: iso(now - (129 + index * 4) * MINUTE) });
|
finished_at: iso(now - (119 + index * 4) * MINUTE),
|
||||||
insert('deployments', { id: `fixture-campaign-${index + 1}`, action: 'drift', started_at: iso(now - (140 + index * 4) * MINUTE),
|
detail:
|
||||||
finished_at: iso(now - (139 + index * 4) * MINUTE), triggered_by: 'Fixture operator' });
|
index === 1
|
||||||
insert('deployment_servers', { deployment_id: `fixture-campaign-${index + 1}`, server: server.name, files_changed: 1, restart_state: 'done', players: 0 });
|
? 'Synthetic update could not be verified.'
|
||||||
|
: 'Synthetic scheduled restart completed.',
|
||||||
|
kind: 'schedule',
|
||||||
|
origin: 'manual',
|
||||||
|
provisioning_id: index === 0 ? 'fixture-operation-2' : null
|
||||||
|
});
|
||||||
|
insert('watchdog_incidents', {
|
||||||
|
id: `fixture-recovery-${index + 1}`,
|
||||||
|
server: server.name,
|
||||||
|
kind: 'health',
|
||||||
|
state: 'resolved',
|
||||||
|
summary: 'Synthetic recovery finding resolved.',
|
||||||
|
details: '{}',
|
||||||
|
first_seen_at: iso(now - (130 + index * 4) * MINUTE),
|
||||||
|
last_seen_at: iso(now - (129 + index * 4) * MINUTE),
|
||||||
|
finished_at: iso(now - (129 + index * 4) * MINUTE)
|
||||||
|
});
|
||||||
|
insert('deployments', {
|
||||||
|
id: `fixture-campaign-${index + 1}`,
|
||||||
|
action: 'drift',
|
||||||
|
started_at: iso(now - (140 + index * 4) * MINUTE),
|
||||||
|
finished_at: iso(now - (139 + index * 4) * MINUTE),
|
||||||
|
triggered_by: 'Fixture operator'
|
||||||
|
});
|
||||||
|
insert('deployment_servers', {
|
||||||
|
deployment_id: `fixture-campaign-${index + 1}`,
|
||||||
|
server: server.name,
|
||||||
|
files_changed: 1,
|
||||||
|
restart_state: 'done',
|
||||||
|
players: 0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (let index = 0; index < 2; index++) {
|
for (let index = 0; index < 2; index++) {
|
||||||
insert('betas', { id: index + 1, script_id: 1, script_version_id: 1, trigger_kind: 'ui', state: index ? 'rolled-back' : 'promoted',
|
insert('betas', {
|
||||||
opened_at: iso(now - (150 + index * 3) * MINUTE), closed_at: iso(now - (149 + index * 3) * MINUTE), notes: 'Synthetic completed beta.' });
|
id: index + 1,
|
||||||
insert('beta_scripts', { beta_id: index + 1, script_id: 1, script_version_id: 1, kind: 'script' });
|
script_id: 1,
|
||||||
insert('beta_servers', { beta_id: index + 1, server: fixtureServers[index].name, had_original: 0, stage: 'ready', updated_at: iso(now - (149 + index * 3) * MINUTE) });
|
script_version_id: 1,
|
||||||
|
trigger_kind: 'ui',
|
||||||
|
state: index ? 'rolled-back' : 'promoted',
|
||||||
|
opened_at: iso(now - (150 + index * 3) * MINUTE),
|
||||||
|
closed_at: iso(now - (149 + index * 3) * MINUTE),
|
||||||
|
notes: 'Synthetic completed beta.'
|
||||||
|
});
|
||||||
|
insert('beta_scripts', {
|
||||||
|
beta_id: index + 1,
|
||||||
|
script_id: 1,
|
||||||
|
script_version_id: 1,
|
||||||
|
kind: 'script'
|
||||||
|
});
|
||||||
|
insert('beta_servers', {
|
||||||
|
beta_id: index + 1,
|
||||||
|
server: fixtureServers[index].name,
|
||||||
|
had_original: 0,
|
||||||
|
stage: 'ready',
|
||||||
|
updated_at: iso(now - (149 + index * 3) * MINUTE)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
insert('provisioning_sources', { operation_id: 'fixture-operation-2', kind: 'maintenance', source_id: '1', server: fixtureServers[1].name });
|
insert('provisioning_sources', {
|
||||||
insert('provisioning_sources', { operation_id: 'fixture-operation-3', kind: 'autoscaling', source_id: 'fixture-paused-plan', server: fixtureServers[2].name });
|
operation_id: 'fixture-operation-2',
|
||||||
|
kind: 'maintenance',
|
||||||
|
source_id: '1',
|
||||||
|
server: fixtureServers[1].name
|
||||||
|
});
|
||||||
|
insert('provisioning_sources', {
|
||||||
|
operation_id: 'fixture-operation-3',
|
||||||
|
kind: 'autoscaling',
|
||||||
|
source_id: 'fixture-paused-plan',
|
||||||
|
server: fixtureServers[2].name
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refreshes existing synthetic observations only; never queues work or writes real files. */
|
/** Refreshes existing synthetic observations only; never queues work or writes real files. */
|
||||||
export function refreshFixtureData(handle: Database.Database, now: number): void {
|
export function refreshFixtureData(handle: Database.Database, now: number): void {
|
||||||
const marker = handle.prepare('SELECT value FROM settings WHERE key=?').get(FIXTURE_KEY) as { value: string } | undefined;
|
const marker = handle.prepare('SELECT value FROM settings WHERE key=?').get(FIXTURE_KEY) as
|
||||||
if (marker?.value !== FIXTURE_VERSION) throw new Error('Refusing to refresh an unmarked database.');
|
{ value: string } | undefined;
|
||||||
|
if (marker?.value !== FIXTURE_VERSION)
|
||||||
|
throw new Error('Refusing to refresh an unmarked database.');
|
||||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
|
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
|
||||||
handle.transaction(() => {
|
handle.transaction(() => {
|
||||||
for (const [index, server] of fixtureServers.entries()) {
|
for (const [index, server] of fixtureServers.entries()) {
|
||||||
handle.prepare(`UPDATE servers SET last_seen_at=?,last_checked_at=?,container_checked_at=?,container_state=?,
|
handle
|
||||||
last_players=?,last_map=?,last_roster=?,last_error=?,rcon_failures=?,last_reply_snippet=NULL WHERE name=?`)
|
.prepare(
|
||||||
.run(seenAt(server, now), iso(now), iso(now), containerState(server), server.players, server.map.canonical,
|
`UPDATE servers SET last_seen_at=?,last_checked_at=?,container_checked_at=?,container_state=?,
|
||||||
JSON.stringify(rosterFor(server, index)), server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
last_players=?,last_map=?,last_roster=?,last_error=?,rcon_failures=?,last_reply_snippet=NULL WHERE name=?`
|
||||||
server.state === 'stale' ? 2 : 0, server.name);
|
)
|
||||||
handle.prepare(`UPDATE server_telemetry SET received_at=?,payload=json_set(payload,'$.receivedAt',?) WHERE server=?`).run(iso(now), iso(now), server.name);
|
.run(
|
||||||
handle.prepare("UPDATE leaderboard_matches SET last_received_at=? WHERE server=? AND status='active' AND id BETWEEN 495 AND 500").run(iso(now), server.name);
|
seenAt(server, now),
|
||||||
handle.prepare('UPDATE plutonium_versions SET checked_at=? WHERE server=?').run(iso(now), server.name);
|
iso(now),
|
||||||
|
iso(now),
|
||||||
|
containerState(server),
|
||||||
|
server.players,
|
||||||
|
server.map.canonical,
|
||||||
|
JSON.stringify(rosterFor(server, index)),
|
||||||
|
server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
||||||
|
server.state === 'stale' ? 2 : 0,
|
||||||
|
server.name
|
||||||
|
);
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`UPDATE server_telemetry SET received_at=?,payload=json_set(payload,'$.receivedAt',?) WHERE server=?`
|
||||||
|
)
|
||||||
|
.run(iso(now), iso(now), server.name);
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE leaderboard_matches SET last_received_at=? WHERE server=? AND status='active' AND id BETWEEN 495 AND 500"
|
||||||
|
)
|
||||||
|
.run(iso(now), server.name);
|
||||||
|
handle
|
||||||
|
.prepare('UPDATE plutonium_versions SET checked_at=? WHERE server=?')
|
||||||
|
.run(iso(now), server.name);
|
||||||
}
|
}
|
||||||
for (let index = 1; index <= 3; index++) {
|
for (let index = 1; index <= 3; index++) {
|
||||||
const id = `fixture-host-${index}`;
|
const id = `fixture-host-${index}`;
|
||||||
handle.prepare("UPDATE ec_hosts SET last_heartbeat=?,inventory=json_set(inventory,'$.instanceStates',json(?)) WHERE id=?")
|
handle
|
||||||
.run(iso(now - (index === 3 ? 10 * MINUTE : 0)), JSON.stringify(instanceStates(id, now)), id);
|
.prepare(
|
||||||
|
"UPDATE ec_hosts SET last_heartbeat=?,inventory=json_set(inventory,'$.instanceStates',json(?)) WHERE id=?"
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
iso(now - (index === 3 ? 10 * MINUTE : 0)),
|
||||||
|
JSON.stringify(instanceStates(id, now)),
|
||||||
|
id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-11
@@ -1,29 +1,49 @@
|
|||||||
import { mkdir, open, realpath, rm } from 'node:fs/promises';
|
import { mkdir, open, realpath, rm } from 'node:fs/promises';
|
||||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
||||||
|
|
||||||
export interface FixtureOptions { dbPath: string; now: number; watch: boolean }
|
export interface FixtureOptions {
|
||||||
|
dbPath: string;
|
||||||
|
now: number;
|
||||||
|
watch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseFixtureOptions(args: string[], clock = Date.now()): FixtureOptions {
|
export function parseFixtureOptions(args: string[], clock = Date.now()): FixtureOptions {
|
||||||
let dbPath = '', now = clock, watch = false, explicitTime = false;
|
let dbPath = '',
|
||||||
|
now = clock,
|
||||||
|
watch = false,
|
||||||
|
explicitTime = false;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (let index = 0; index < args.length; index++) {
|
for (let index = 0; index < args.length; index++) {
|
||||||
const option = args[index];
|
const option = args[index];
|
||||||
if (seen.has(option)) throw new Error(`Duplicate option: ${option}`);
|
if (seen.has(option)) throw new Error(`Duplicate option: ${option}`);
|
||||||
seen.add(option);
|
seen.add(option);
|
||||||
if (option === '--watch') { watch = true; continue; }
|
if (option === '--watch') {
|
||||||
|
watch = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (option !== '--db' && option !== '--now') throw new Error(`Unknown option: ${option}`);
|
if (option !== '--db' && option !== '--now') throw new Error(`Unknown option: ${option}`);
|
||||||
const value = args[++index];
|
const value = args[++index];
|
||||||
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${option}.`);
|
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${option}.`);
|
||||||
if (option === '--db') dbPath = value;
|
if (option === '--db') dbPath = value;
|
||||||
else {
|
else {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|
if (
|
||||||
|| !Number.isFinite(Date.parse(value))) throw new Error('--now must be an ISO timestamp with a timezone.');
|
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
||||||
now = Date.parse(value); explicitTime = true;
|
value
|
||||||
|
) ||
|
||||||
|
!Number.isFinite(Date.parse(value))
|
||||||
|
)
|
||||||
|
throw new Error('--now must be an ISO timestamp with a timezone.');
|
||||||
|
now = Date.parse(value);
|
||||||
|
explicitTime = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!dbPath || !isAbsolute(dbPath)) throw new Error('Provide --db with an absolute path inside a new fixture directory. DB_PATH is never used.');
|
if (!dbPath || !isAbsolute(dbPath))
|
||||||
|
throw new Error(
|
||||||
|
'Provide --db with an absolute path inside a new fixture directory. DB_PATH is never used.'
|
||||||
|
);
|
||||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture clock.');
|
if (!Number.isFinite(now)) throw new Error('Invalid fixture clock.');
|
||||||
if (watch && explicitTime) throw new Error('Use --watch for live time or --now for a fixed snapshot, separately.');
|
if (watch && explicitTime)
|
||||||
|
throw new Error('Use --watch for live time or --now for a fixed snapshot, separately.');
|
||||||
return { dbPath: resolve(dbPath), now, watch };
|
return { dbPath: resolve(dbPath), now, watch };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +56,14 @@ export async function reserveFixture(dbPath: string): Promise<{ root: string; db
|
|||||||
// but never follow a pre-existing fixture directory or database symlink.
|
// but never follow a pre-existing fixture directory or database symlink.
|
||||||
const parent = await realpath(dirname(requestedRoot));
|
const parent = await realpath(dirname(requestedRoot));
|
||||||
const root = join(parent, basename(requestedRoot));
|
const root = join(parent, basename(requestedRoot));
|
||||||
try { await mkdir(root, { mode: 0o700 }); }
|
try {
|
||||||
catch (cause) {
|
await mkdir(root, { mode: 0o700 });
|
||||||
if ((cause as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Fixture directory already exists. Choose a new directory; existing data is never overwritten.');
|
} catch (cause) {
|
||||||
|
if ((cause as NodeJS.ErrnoException).code === 'EEXIST')
|
||||||
|
throw new Error(
|
||||||
|
'Fixture directory already exists. Choose a new directory; existing data is never overwritten.',
|
||||||
|
{ cause }
|
||||||
|
);
|
||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
const target = join(root, basename(requested));
|
const target = join(root, basename(requested));
|
||||||
|
|||||||
+114
-29
@@ -1,9 +1,13 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Local recovery only: requires filesystem/container access to the controller.
|
// Local recovery only: requires filesystem/container access to the controller.
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { emitKeypressEvents } from 'node:readline';
|
import { emitKeypressEvents } from 'node:readline';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
||||||
|
|
||||||
|
/** @param {string} prompt @returns {Promise<string>} */
|
||||||
function secret(prompt) {
|
function secret(prompt) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let value = '';
|
let value = '';
|
||||||
@@ -17,39 +21,120 @@ function secret(prompt) {
|
|||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
};
|
};
|
||||||
|
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
|
||||||
const listener = (text, key) => {
|
const listener = (text, key) => {
|
||||||
if (key?.ctrl && key.name === 'c') { cleanup(); reject(new Error('Cancelled.')); }
|
if (key?.ctrl && key.name === 'c') {
|
||||||
else if (key?.name === 'return' || key?.name === 'enter') { cleanup(); resolve(value); }
|
cleanup();
|
||||||
else if (key?.name === 'backspace') value = [...value].slice(0,-1).join('');
|
reject(new Error('Cancelled.'));
|
||||||
else if (text && !key?.ctrl && !key?.meta && !/[\x00-\x1f\x7f]/.test(text) && [...value].length < 128) value += text;
|
} else if (key?.name === 'return' || key?.name === 'enter') {
|
||||||
|
cleanup();
|
||||||
|
resolve(value);
|
||||||
|
} else if (key?.name === 'backspace') value = [...value].slice(0, -1).join('');
|
||||||
|
else if (
|
||||||
|
text &&
|
||||||
|
!key?.ctrl &&
|
||||||
|
!key?.meta &&
|
||||||
|
// eslint-disable-next-line no-control-regex -- Reject terminal control characters while collecting password input.
|
||||||
|
!/[\x00-\x1f\x7f]/.test(text) &&
|
||||||
|
[...value].length < 128
|
||||||
|
)
|
||||||
|
value += text;
|
||||||
};
|
};
|
||||||
process.stdin.on('keypress',listener);
|
process.stdin.on('keypress', listener);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
/** @param {import('better-sqlite3').Database} database @param {string} username */
|
||||||
const username = process.argv[2]?.toLowerCase();
|
function administrator(database, username) {
|
||||||
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username)) throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
const user = /** @type {{ id: string, username: string } | undefined} */ (
|
||||||
if (!process.stdin.isTTY) throw new Error('Run this command in an interactive terminal. Passwords are never accepted as command arguments.');
|
database
|
||||||
const database = new Database(process.env.DB_PATH || './data/cherry.db',{fileMustExist:true});
|
.prepare(
|
||||||
database.pragma('busy_timeout=5000');
|
"SELECT id,username FROM auth_users WHERE username=? AND role='admin' AND disabled=0"
|
||||||
const user = database.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0").get(username);
|
)
|
||||||
|
.get(username)
|
||||||
|
);
|
||||||
if (!user) throw new Error('An enabled administrator with that username was not found.');
|
if (!user) throw new Error('An enabled administrator with that username was not found.');
|
||||||
const password = await secret('New administrator password (8–128 characters): ');
|
return user;
|
||||||
const confirmation = await secret('Repeat password: ');
|
}
|
||||||
if (password !== confirmation) throw new Error('Passwords did not match.');
|
|
||||||
const passwordHash = await hashPassword(password);
|
/**
|
||||||
database.transaction(() => {
|
* Local recovery shared with regression tests; the CLI only accepts passwords on its TTY.
|
||||||
const changed = database.prepare("UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0")
|
* @param {import('better-sqlite3').Database} database
|
||||||
.run(passwordHash,Date.now(),user.id);
|
* @param {string} username
|
||||||
if (changed.changes !== 1) throw new Error('Administrator changed during recovery. Try again.');
|
* @param {string} password
|
||||||
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
|
*/
|
||||||
database.prepare('INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)')
|
export async function recoverAdministrator(database, username, password) {
|
||||||
.run('password.recovered',user.id,'{"source":"local-cli"}',Date.now());
|
const user = administrator(database, username);
|
||||||
}).immediate();
|
const passwordHash = await hashPassword(password);
|
||||||
database.close();
|
const digest = (/** @type {string} */ value) =>
|
||||||
console.log('Administrator password reset. Existing sessions have been revoked.');
|
createHash('sha256').update(value).digest('hex');
|
||||||
} catch (cause) {
|
database
|
||||||
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
.transaction(() => {
|
||||||
process.exitCode=1;
|
const changed = database
|
||||||
|
.prepare(
|
||||||
|
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
|
||||||
|
)
|
||||||
|
.run(passwordHash, Date.now(), user.id);
|
||||||
|
if (changed.changes !== 1)
|
||||||
|
throw new Error('Administrator changed during recovery. Try again.');
|
||||||
|
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
|
||||||
|
// Older databases may not have device credentials yet; recovery still works.
|
||||||
|
if (
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='auth_login_devices'"
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
) {
|
||||||
|
database.prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(user.id);
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.prepare('DELETE FROM auth_rate_limits WHERE key GLOB ? OR key IN (?,?)')
|
||||||
|
.run(
|
||||||
|
`login:account:${digest(user.username.toLowerCase())}:ip:*`,
|
||||||
|
digest(`login:account:${user.username.toLowerCase()}`),
|
||||||
|
digest(`password:account:${user.id.toLowerCase()}`)
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const username = process.argv[2]?.toLowerCase();
|
||||||
|
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
|
||||||
|
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
||||||
|
if (!process.stdin.isTTY)
|
||||||
|
throw new Error(
|
||||||
|
'Run this command in an interactive terminal. Passwords are never accepted as command arguments.'
|
||||||
|
);
|
||||||
|
const database = new Database(process.env.DB_PATH || './data/cherry.db', {
|
||||||
|
fileMustExist: true
|
||||||
|
});
|
||||||
|
database.pragma('busy_timeout=5000');
|
||||||
|
try {
|
||||||
|
administrator(database, username);
|
||||||
|
const password = await secret('New administrator password (8–128 characters): ');
|
||||||
|
const confirmation = await secret('Repeat password: ');
|
||||||
|
if (password !== confirmation) throw new Error('Passwords did not match.');
|
||||||
|
await recoverAdministrator(database, username, password);
|
||||||
|
console.log(
|
||||||
|
'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} catch (cause) {
|
||||||
|
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-37
@@ -5,7 +5,13 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { createServer, type ViteDevServer } from 'vite';
|
import { createServer, type ViteDevServer } from 'vite';
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import { FIXTURE_KEY, FIXTURE_VERSION, fixtureServers, refreshFixtureData, seedFixtureData } from './fixture-data.js';
|
import {
|
||||||
|
FIXTURE_KEY,
|
||||||
|
FIXTURE_VERSION,
|
||||||
|
fixtureServers,
|
||||||
|
refreshFixtureData,
|
||||||
|
seedFixtureData
|
||||||
|
} from './fixture-data.js';
|
||||||
import { parseFixtureOptions, reserveFixture } from './fixture-options.js';
|
import { parseFixtureOptions, reserveFixture } from './fixture-options.js';
|
||||||
|
|
||||||
const help = `Create an isolated synthetic UI preview database.
|
const help = `Create an isolated synthetic UI preview database.
|
||||||
@@ -22,7 +28,10 @@ No web server, worker, game agent or network request is started by this command.
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(help); return; }
|
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
||||||
|
console.log(help);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const options = parseFixtureOptions(args);
|
const options = parseFixtureOptions(args);
|
||||||
process.umask(0o077);
|
process.umask(0o077);
|
||||||
const destination = await reserveFixture(options.dbPath);
|
const destination = await reserveFixture(options.dbPath);
|
||||||
@@ -30,70 +39,148 @@ async function main() {
|
|||||||
let closeDb: (() => void) | undefined;
|
let closeDb: (() => void) | undefined;
|
||||||
let complete = false;
|
let complete = false;
|
||||||
try {
|
try {
|
||||||
const paths = { DB_PATH: destination.dbPath, SERVERS_ROOT: join(destination.root, 't5'), T6_SERVERS_ROOT: join(destination.root, 't6'),
|
const paths = {
|
||||||
BETA_ARCHIVE_DIR: join(destination.root, 'beta-archive'), GAME_FILES_ROOT: join(destination.root, 'game-files'), SQLITE_TMPDIR: join(destination.root, 'sqlite-tmp') };
|
DB_PATH: destination.dbPath,
|
||||||
|
SERVERS_ROOT: join(destination.root, 't5'),
|
||||||
|
T6_SERVERS_ROOT: join(destination.root, 't6'),
|
||||||
|
BETA_ARCHIVE_DIR: join(destination.root, 'beta-archive'),
|
||||||
|
GAME_FILES_ROOT: join(destination.root, 'game-files'),
|
||||||
|
SQLITE_TMPDIR: join(destination.root, 'sqlite-tmp')
|
||||||
|
};
|
||||||
for (const [key, value] of Object.entries(paths)) {
|
for (const [key, value] of Object.entries(paths)) {
|
||||||
process.env[key] = value;
|
process.env[key] = value;
|
||||||
if (key !== 'DB_PATH') await mkdir(value, { mode: 0o700 });
|
if (key !== 'DB_PATH') await mkdir(value, { mode: 0o700 });
|
||||||
}
|
}
|
||||||
Object.assign(process.env, { RCON_PASSWORD: '', RCON_HOST: '127.0.0.1', EC_AUTH_ALLOW_HTTP: 'true',
|
Object.assign(process.env, {
|
||||||
DEPLOY_KEEP_DAYS: '7', DEPLOYMENT_RESTART_CONCURRENCY: '1', DEPLOYMENT_CREATE_CONCURRENCY: '1',
|
RCON_PASSWORD: '',
|
||||||
EC_SQLITE_MMAP_MB: '0', PLUTAINER_IMAGE: 'fixture.invalid/electric-cherry/preview:local' });
|
RCON_HOST: '127.0.0.1',
|
||||||
|
EC_AUTH_ALLOW_HTTP: 'true',
|
||||||
|
DEPLOY_KEEP_DAYS: '7',
|
||||||
|
DEPLOYMENT_RESTART_CONCURRENCY: '1',
|
||||||
|
DEPLOYMENT_CREATE_CONCURRENCY: '1',
|
||||||
|
EC_SQLITE_MMAP_MB: '0',
|
||||||
|
PLUTAINER_IMAGE: 'fixture.invalid/electric-cherry/preview:local'
|
||||||
|
});
|
||||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
vite = await createServer({ root: repository, cacheDir: join(destination.root, 'vite-cache'), configFile: false, envFile: false, appType: 'custom',
|
vite = await createServer({
|
||||||
|
root: repository,
|
||||||
|
cacheDir: join(destination.root, 'vite-cache'),
|
||||||
|
configFile: false,
|
||||||
|
envFile: false,
|
||||||
|
appType: 'custom',
|
||||||
// Vite 5 otherwise opens its HMR WebSocket port even with hmr:false.
|
// Vite 5 otherwise opens its HMR WebSocket port even with hmr:false.
|
||||||
// Attach it to an HTTP server that is deliberately never listened on.
|
// Attach it to an HTTP server that is deliberately never listened on.
|
||||||
server: { middlewareMode: true, hmr: { server: createHttpServer() }, watch: null }, optimizeDeps: { noDiscovery: true },
|
server: { middlewareMode: true, hmr: { server: createHttpServer() }, watch: null },
|
||||||
resolve: { alias: { $lib: join(repository, 'src/lib'), $ui: join(repository, 'src/lib/ui') } },
|
optimizeDeps: { noDiscovery: true },
|
||||||
logLevel: 'error' });
|
resolve: {
|
||||||
|
alias: { $lib: join(repository, 'src/lib'), $ui: join(repository, 'src/lib/ui') }
|
||||||
|
},
|
||||||
|
logLevel: 'error'
|
||||||
|
});
|
||||||
// Load the real migrations through Vite's ?raw support. Importing hooks
|
// Load the real migrations through Vite's ?raw support. Importing hooks
|
||||||
// would start queues and agents; this script never loads the app router.
|
// would start queues and agents; this script never loads the app router.
|
||||||
const database = await vite.ssrLoadModule('/src/lib/server/db/index.ts') as { db: () => Database.Database; closeDb: () => void };
|
const database = (await vite.ssrLoadModule('/src/lib/server/db/index.ts')) as {
|
||||||
|
db: () => Database.Database;
|
||||||
|
closeDb: () => void;
|
||||||
|
};
|
||||||
closeDb = database.closeDb;
|
closeDb = database.closeDb;
|
||||||
const handle = database.db();
|
const handle = database.db();
|
||||||
const hosts = await vite.ssrLoadModule('/src/lib/server/hosts/schema.ts');
|
|
||||||
const auth = await vite.ssrLoadModule('/src/lib/server/auth/schema.ts');
|
|
||||||
const files = await vite.ssrLoadModule('/src/lib/server/game-files/schema.ts');
|
|
||||||
hosts.ensureHostSchema(handle); auth.ensureAuthSchema(handle); files.ensureGameFileSchema(handle);
|
|
||||||
seedFixtureData(handle, options.now);
|
seedFixtureData(handle, options.now);
|
||||||
const password = randomBytes(24).toString('base64url');
|
const password = randomBytes(24).toString('base64url');
|
||||||
const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js');
|
const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js');
|
||||||
handle.prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
handle
|
||||||
VALUES('fixture-admin','demo','Demo operator',?,'admin',?,?)`).run(await hashPassword(password), options.now, options.now);
|
.prepare(
|
||||||
if ((handle.pragma('foreign_key_check') as unknown[]).length || handle.pragma('integrity_check', { simple: true }) !== 'ok') {
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES('fixture-admin','demo','Demo operator',?,'admin',?,?)`
|
||||||
|
)
|
||||||
|
.run(await hashPassword(password), options.now, options.now);
|
||||||
|
if (
|
||||||
|
(handle.pragma('foreign_key_check') as unknown[]).length ||
|
||||||
|
handle.pragma('integrity_check', { simple: true }) !== 'ok'
|
||||||
|
) {
|
||||||
throw new Error('Fixture integrity validation failed.');
|
throw new Error('Fixture integrity validation failed.');
|
||||||
}
|
}
|
||||||
const environment = { ...paths, RCON_PASSWORD: '', RCON_HOST: '127.0.0.1', EC_AUTH_ALLOW_HTTP: 'true',
|
const environment = {
|
||||||
PLUTAINER_IMAGE: process.env.PLUTAINER_IMAGE! };
|
...paths,
|
||||||
|
RCON_PASSWORD: '',
|
||||||
|
RCON_HOST: '127.0.0.1',
|
||||||
|
EC_AUTH_ALLOW_HTTP: 'true',
|
||||||
|
PLUTAINER_IMAGE: process.env.PLUTAINER_IMAGE!
|
||||||
|
};
|
||||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
await writeFile(join(destination.root, 'preview.env'), Object.entries(environment).map(([key, value]) => `export ${key}=${shellQuote(value)}`).join('\n') + '\n', { flag: 'wx', mode: 0o600 });
|
await writeFile(
|
||||||
await writeFile(join(destination.root, 'fixture.json'), JSON.stringify({ format: FIXTURE_VERSION, marker: FIXTURE_KEY,
|
join(destination.root, 'preview.env'),
|
||||||
dbPath: destination.dbPath, createdAt: new Date(options.now).toISOString(), username: 'demo', password,
|
Object.entries(environment)
|
||||||
hosts: 3, servers: fixtureServers.map(server => server.name), matches: 500,
|
.map(([key, value]) => `export ${key}=${shellQuote(value)}`)
|
||||||
note: 'Synthetic local data only. Host credentials cannot authenticate; game images and library IDs are fictitious.' }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
.join('\n') + '\n',
|
||||||
|
{ flag: 'wx', mode: 0o600 }
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(destination.root, 'fixture.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
format: FIXTURE_VERSION,
|
||||||
|
marker: FIXTURE_KEY,
|
||||||
|
dbPath: destination.dbPath,
|
||||||
|
createdAt: new Date(options.now).toISOString(),
|
||||||
|
username: 'demo',
|
||||||
|
password,
|
||||||
|
hosts: 3,
|
||||||
|
servers: fixtureServers.map((server) => server.name),
|
||||||
|
matches: 500,
|
||||||
|
note: 'Synthetic local data only. Host credentials cannot authenticate; game images and library IDs are fictitious.'
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n',
|
||||||
|
{ flag: 'wx', mode: 0o600 }
|
||||||
|
);
|
||||||
handle.pragma('wal_checkpoint(TRUNCATE)');
|
handle.pragma('wal_checkpoint(TRUNCATE)');
|
||||||
complete = true;
|
complete = true;
|
||||||
console.log(`Created ${destination.dbPath}\n3 hosts · 12 servers · 500 matches · 7 days of metrics\nLocal sign-in details: ${join(destination.root, 'fixture.json')}\nPreview environment: ${join(destination.root, 'preview.env')}`);
|
console.log(
|
||||||
|
`Created ${destination.dbPath}\n3 hosts · 12 servers · 500 matches · 7 days of metrics\nLocal sign-in details: ${join(destination.root, 'fixture.json')}\nPreview environment: ${join(destination.root, 'preview.env')}`
|
||||||
|
);
|
||||||
if (options.watch) {
|
if (options.watch) {
|
||||||
refreshFixtureData(handle, Date.now());
|
refreshFixtureData(handle, Date.now());
|
||||||
console.log('Refreshing synthetic observations every 15 seconds. Leave this terminal open; Ctrl+C stops the feeder.');
|
console.log(
|
||||||
|
'Refreshing synthetic observations every 15 seconds. Leave this terminal open; Ctrl+C stops the feeder.'
|
||||||
|
);
|
||||||
await new Promise<void>((resolvePromise, reject) => {
|
await new Promise<void>((resolvePromise, reject) => {
|
||||||
const finish = (error?: unknown) => { clearInterval(timer); process.off('SIGINT', stop); process.off('SIGTERM', stop); if (error) reject(error); else resolvePromise(); };
|
const finish = (error?: unknown) => {
|
||||||
|
clearInterval(timer);
|
||||||
|
process.off('SIGINT', stop);
|
||||||
|
process.off('SIGTERM', stop);
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolvePromise();
|
||||||
|
};
|
||||||
const stop = () => finish();
|
const stop = () => finish();
|
||||||
const timer = setInterval(() => { try { refreshFixtureData(handle, Date.now()); } catch (error) { finish(error); } }, 15_000);
|
const timer = setInterval(() => {
|
||||||
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
try {
|
||||||
|
refreshFixtureData(handle, Date.now());
|
||||||
|
} catch (error) {
|
||||||
|
finish(error);
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
process.on('SIGINT', stop);
|
||||||
|
process.on('SIGTERM', stop);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
try { closeDb?.(); }
|
try {
|
||||||
finally {
|
closeDb?.();
|
||||||
try { await vite?.close(); }
|
} finally {
|
||||||
finally { if (!complete) await rm(destination.root, { recursive: true, force: true }); }
|
try {
|
||||||
|
await vite?.close();
|
||||||
|
} finally {
|
||||||
|
if (!complete) await rm(destination.root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(error => {
|
main().catch((error) => {
|
||||||
console.error(`Fixture creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
console.error(
|
||||||
|
`Fixture creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-9
@@ -7,30 +7,56 @@ import { invalidateFleet } from '$lib/server/pipeline/bus.js';
|
|||||||
import { startPerformanceMonitor } from '$lib/server/performance.js';
|
import { startPerformanceMonitor } from '$lib/server/performance.js';
|
||||||
import { withOperationSource } from '$lib/server/hosts/operation-context.js';
|
import { withOperationSource } from '$lib/server/hosts/operation-context.js';
|
||||||
import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
|
import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
|
||||||
|
import { startDiscordWorker } from '$lib/server/discord/worker.js';
|
||||||
|
|
||||||
let booted = false;
|
let booted = false;
|
||||||
|
let rejectionLoggerRegistered = false;
|
||||||
function boot(): void {
|
function boot(): void {
|
||||||
if (booted) return;
|
if (booted) return;
|
||||||
booted = true;
|
if (!rejectionLoggerRegistered) {
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('[process] unhandled rejection:', reason);
|
||||||
|
});
|
||||||
|
rejectionLoggerRegistered = true;
|
||||||
|
}
|
||||||
migrate();
|
migrate();
|
||||||
interruptDeployments();
|
interruptDeployments();
|
||||||
startWorker();
|
startWorker();
|
||||||
|
startDiscordWorker();
|
||||||
startPerformanceMonitor();
|
startPerformanceMonitor();
|
||||||
|
booted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SvelteKit calls handle() for every request; using the first request as the
|
// Boot synchronously before serving requests. Failed startup stays unbooted so
|
||||||
// boot trigger keeps the whole app single-process without needing an
|
// the next request retries migrations and worker startup before reaching the UI.
|
||||||
// out-of-band init hook.
|
|
||||||
export const handle: Handle = async (input) => {
|
export const handle: Handle = async (input) => {
|
||||||
boot();
|
boot();
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
const response = await withHostRequestContext(() => withOperationSource(`${input.event.request.method} ${input.event.route.id ?? 'Web request'}`,
|
const response = await withHostRequestContext(() =>
|
||||||
() => authenticationHandle(input)));
|
withOperationSource(
|
||||||
|
`${input.event.request.method} ${input.event.route.id ?? 'Web request'}`,
|
||||||
|
() => authenticationHandle(input)
|
||||||
|
)
|
||||||
|
);
|
||||||
const elapsed = performance.now() - started;
|
const elapsed = performance.now() - started;
|
||||||
response.headers.append('Server-Timing', `app;dur=${elapsed.toFixed(1)}`);
|
response.headers.append('Server-Timing', `app;dur=${elapsed.toFixed(1)}`);
|
||||||
if (process.env.EC_PERF_LOG === 'true' && elapsed > 250 && !input.event.route.id?.startsWith('/api/agent/')) {
|
if (
|
||||||
console.info('[request]', { route: input.event.route.id, method: input.event.request.method, status: response.status, elapsedMs: Math.round(elapsed) });
|
process.env.EC_PERF_LOG === 'true' &&
|
||||||
|
elapsed > 250 &&
|
||||||
|
!input.event.route.id?.startsWith('/api/agent/')
|
||||||
|
) {
|
||||||
|
console.info('[request]', {
|
||||||
|
route: input.event.route.id,
|
||||||
|
method: input.event.request.method,
|
||||||
|
status: response.status,
|
||||||
|
elapsedMs: Math.round(elapsed)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (input.event.locals.user && !['GET','HEAD','OPTIONS'].includes(input.event.request.method) && response.status < 400) invalidateFleet();
|
if (
|
||||||
|
input.event.locals.user &&
|
||||||
|
!['GET', 'HEAD', 'OPTIONS'].includes(input.event.request.method) &&
|
||||||
|
response.status < 400
|
||||||
|
)
|
||||||
|
invalidateFleet();
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const historyFilters = ['host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'];
|
||||||
|
|
||||||
|
/** Route retired activity bookmarks to the existing history that owns their scope. */
|
||||||
|
export function activityRedirectHref(url: URL): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const copy = (keys: string[]) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = url.searchParams.get(key);
|
||||||
|
if (value) params.set(key, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const server = url.searchParams.get('server')?.trim();
|
||||||
|
const host = url.searchParams.get('host')?.trim();
|
||||||
|
const source = url.searchParams.get('source');
|
||||||
|
let path: string;
|
||||||
|
let hash = '';
|
||||||
|
if (server) {
|
||||||
|
path = `/servers/${encodeURIComponent(server)}`;
|
||||||
|
params.set('tab', 'history');
|
||||||
|
copy(historyFilters);
|
||||||
|
hash = url.hash;
|
||||||
|
} else if (host || source === 'host') {
|
||||||
|
path = '/hosts/operations';
|
||||||
|
if (host) params.set('host', host);
|
||||||
|
} else if (source === 'maintenance' || source === 'recovery') {
|
||||||
|
path = '/maintenance';
|
||||||
|
params.set('view', 'activity');
|
||||||
|
copy(historyFilters);
|
||||||
|
hash = url.hash;
|
||||||
|
} else if (source === 'campaign') {
|
||||||
|
path = '/campaigns';
|
||||||
|
copy(['q']);
|
||||||
|
} else if (source === 'beta') {
|
||||||
|
path = '/betas';
|
||||||
|
params.set('view', 'history');
|
||||||
|
copy(['q']);
|
||||||
|
} else {
|
||||||
|
path = '/provisioning';
|
||||||
|
params.set('view', 'history');
|
||||||
|
if (source === 'autoscaling') params.set('source', source);
|
||||||
|
}
|
||||||
|
copy(['returnTo']);
|
||||||
|
return path + (params.size ? `?${params}` : '') + hash;
|
||||||
|
}
|
||||||
+38
-11
@@ -1,17 +1,37 @@
|
|||||||
export const activityTypes = {
|
export const activityTypes = {
|
||||||
create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server',
|
create: 'Create server',
|
||||||
stop: 'Stop server', remove: 'Destroy server', update: 'Game update', files: 'File changes',
|
start: 'Start server',
|
||||||
health: 'Health finding', drift: 'File drift', host: 'Host operation', beta: 'Beta test'
|
restart: 'Restart server',
|
||||||
|
rebuild: 'Rebuild server',
|
||||||
|
stop: 'Stop server',
|
||||||
|
remove: 'Destroy server',
|
||||||
|
update: 'Game update',
|
||||||
|
files: 'File changes',
|
||||||
|
health: 'Health finding',
|
||||||
|
drift: 'File drift',
|
||||||
|
host: 'Host operation',
|
||||||
|
beta: 'Beta test'
|
||||||
} as const;
|
} as const;
|
||||||
export type ActivityType = keyof typeof activityTypes;
|
export type ActivityType = keyof typeof activityTypes;
|
||||||
export const activitySources = {
|
export const activitySources = {
|
||||||
provisioning: 'Server operations', maintenance: 'Maintenance & recovery', recovery: 'Recovery',
|
provisioning: 'Server operations',
|
||||||
campaign: 'Campaigns', beta: 'Betas', host: 'Hosts', autoscaling: 'Autoscaling'
|
maintenance: 'Maintenance & recovery',
|
||||||
|
recovery: 'Recovery',
|
||||||
|
campaign: 'Campaigns',
|
||||||
|
beta: 'Betas',
|
||||||
|
host: 'Hosts',
|
||||||
|
autoscaling: 'Autoscaling'
|
||||||
} as const;
|
} as const;
|
||||||
export type ActivitySource = keyof typeof activitySources;
|
export type ActivitySource = keyof typeof activitySources;
|
||||||
export const activityStates = {
|
export const activityStates = {
|
||||||
queued: 'Queued', running: 'In progress', succeeded: 'Completed', warning: 'Completed with warnings',
|
queued: 'Queued',
|
||||||
failed: 'Failed', cancelled: 'Cancelled', attention: 'Needs attention', unknown: 'Unknown'
|
running: 'In progress',
|
||||||
|
succeeded: 'Completed',
|
||||||
|
warning: 'Completed with warnings',
|
||||||
|
failed: 'Failed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
attention: 'Needs attention',
|
||||||
|
unknown: 'Unknown'
|
||||||
} as const;
|
} as const;
|
||||||
export type ActivityState = keyof typeof activityStates;
|
export type ActivityState = keyof typeof activityStates;
|
||||||
|
|
||||||
@@ -54,8 +74,11 @@ export interface ActivityPage {
|
|||||||
query: ActivityQuery;
|
query: ActivityQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selection<T extends string>(value: string | null, labels: Record<T, string>): T | undefined {
|
function selection<T extends string>(
|
||||||
return value !== null && Object.hasOwn(labels, value) ? value as T : undefined;
|
value: string | null,
|
||||||
|
labels: Record<T, string>
|
||||||
|
): T | undefined {
|
||||||
|
return value !== null && Object.hasOwn(labels, value) ? (value as T) : undefined;
|
||||||
}
|
}
|
||||||
function integer(value: string | null, fallback: number, min: number, max: number): number {
|
function integer(value: string | null, fallback: number, min: number, max: number): number {
|
||||||
if (value === null || !/^\d+$/.test(value)) return fallback;
|
if (value === null || !/^\d+$/.test(value)) return fallback;
|
||||||
@@ -72,7 +95,10 @@ export function parseActivityQuery(params: URLSearchParams): ActivityQuery {
|
|||||||
if (params.get('excludeProvisioning') === 'true') query.excludeProvisioning = true;
|
if (params.get('excludeProvisioning') === 'true') query.excludeProvisioning = true;
|
||||||
if (params.get('excludeMaintenance') === 'true') query.excludeMaintenance = true;
|
if (params.get('excludeMaintenance') === 'true') query.excludeMaintenance = true;
|
||||||
for (const key of ['server', 'host', 'q'] as const) {
|
for (const key of ['server', 'host', 'q'] as const) {
|
||||||
const value = params.get(key)?.trim().slice(0, key === 'q' ? 200 : 255);
|
const value = params
|
||||||
|
.get(key)
|
||||||
|
?.trim()
|
||||||
|
.slice(0, key === 'q' ? 200 : 255);
|
||||||
if (value) query[key] = value;
|
if (value) query[key] = value;
|
||||||
}
|
}
|
||||||
query.type = selection(params.get('type'), activityTypes);
|
query.type = selection(params.get('type'), activityTypes);
|
||||||
@@ -86,6 +112,7 @@ export function parseActivityQuery(params: URLSearchParams): ActivityQuery {
|
|||||||
|
|
||||||
export function normalizeActivityQuery(query: ActivityQuery): ActivityQuery {
|
export function normalizeActivityQuery(query: ActivityQuery): ActivityQuery {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value));
|
for (const [key, value] of Object.entries(query))
|
||||||
|
if (value !== undefined) params.set(key, String(value));
|
||||||
return parseActivityQuery(params);
|
return parseActivityQuery(params);
|
||||||
}
|
}
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
export const afkPageSize = 25;
|
||||||
|
export const afkLedgerKinds = [
|
||||||
|
'free_grant',
|
||||||
|
'purchase',
|
||||||
|
'reward',
|
||||||
|
'reserve',
|
||||||
|
'start',
|
||||||
|
'release',
|
||||||
|
'unresolved',
|
||||||
|
'consume',
|
||||||
|
'correction'
|
||||||
|
] as const;
|
||||||
|
export type AfkLedgerKind = (typeof afkLedgerKinds)[number];
|
||||||
|
export type AfkSessionState = 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected';
|
||||||
|
|
||||||
|
export interface AfkQuery {
|
||||||
|
view: 'accounts' | 'sessions' | 'ledger';
|
||||||
|
q: string;
|
||||||
|
balance: '' | 'saved' | 'reserved' | 'unresolved';
|
||||||
|
sort: 'saved' | 'available' | 'recent';
|
||||||
|
game: '' | 't5' | 't6';
|
||||||
|
server: string;
|
||||||
|
state: '' | AfkSessionState;
|
||||||
|
kind: '' | AfkLedgerKind;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
offset: number;
|
||||||
|
player: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAfkQuery(params: URLSearchParams): AfkQuery {
|
||||||
|
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
|
||||||
|
const value = params.get(key) || fallback;
|
||||||
|
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
function text(key: string, max: number) {
|
||||||
|
const value = (params.get(key) ?? '').trim();
|
||||||
|
if (value.length > max) throw new Error(`${key} is too long.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function date(key: string) {
|
||||||
|
const value = text(key, 10);
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
||||||
|
!Number.isFinite(Date.parse(value)) ||
|
||||||
|
new Date(value).toISOString().slice(0, 10) !== value)
|
||||||
|
)
|
||||||
|
throw new Error('Use a valid date in YYYY-MM-DD format.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const offset = Number(params.get('offset') || 0);
|
||||||
|
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid page offset.');
|
||||||
|
const from = date('from');
|
||||||
|
const to = date('to');
|
||||||
|
if (from && to && from > to) throw new Error('The end date must follow the start date.');
|
||||||
|
return {
|
||||||
|
view: choice('view', ['accounts', 'sessions', 'ledger'], 'accounts'),
|
||||||
|
q: text('q', 100),
|
||||||
|
balance: choice('balance', ['', 'saved', 'reserved', 'unresolved'], ''),
|
||||||
|
sort: choice('sort', ['saved', 'available', 'recent'], 'saved'),
|
||||||
|
game: choice('game', ['', 't5', 't6'], ''),
|
||||||
|
server: text('server', 256),
|
||||||
|
state: choice(
|
||||||
|
'state',
|
||||||
|
['', 'reserved', 'started', 'settled', 'unresolved', 'corrected'],
|
||||||
|
''
|
||||||
|
),
|
||||||
|
kind: choice('kind', ['', ...afkLedgerKinds], ''),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
offset: Math.floor(offset / afkPageSize) * afkPageSize,
|
||||||
|
player: text('player', 128)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AfkIdentity {
|
||||||
|
guid: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
export interface AfkAccount extends AfkIdentity {
|
||||||
|
freeSeconds: number;
|
||||||
|
savedSeconds: number;
|
||||||
|
availableSeconds: number;
|
||||||
|
reservedSeconds: number;
|
||||||
|
reservedSavedSeconds: number;
|
||||||
|
unresolvedSessions: number;
|
||||||
|
lastActivity: string | null;
|
||||||
|
freeRenewsAt: string | null;
|
||||||
|
}
|
||||||
|
export interface AfkSession extends AfkIdentity {
|
||||||
|
sessionId: string;
|
||||||
|
server: string;
|
||||||
|
game: string | null;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
state: AfkSessionState;
|
||||||
|
authorizedSeconds: number;
|
||||||
|
consumedSeconds: number | null;
|
||||||
|
chargedSeconds: number | null;
|
||||||
|
confirmedSeconds: number;
|
||||||
|
endReason: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
startedAt: string | null;
|
||||||
|
endedAt: string | null;
|
||||||
|
}
|
||||||
|
export interface AfkLedgerEntry extends AfkIdentity {
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
game: string | null;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
kind: AfkLedgerKind;
|
||||||
|
freeDelta: number;
|
||||||
|
savedDelta: number;
|
||||||
|
freeSeconds: number;
|
||||||
|
savedSeconds: number;
|
||||||
|
operationId: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
eventAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface AfkPage<T> {
|
||||||
|
entries: T[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
export interface AfkOverview {
|
||||||
|
query: AfkQuery;
|
||||||
|
checkedAt: string;
|
||||||
|
capacitySeconds: number;
|
||||||
|
summary: {
|
||||||
|
accounts: number;
|
||||||
|
savedSeconds: number;
|
||||||
|
reservedSeconds: number;
|
||||||
|
unresolvedSessions: number;
|
||||||
|
};
|
||||||
|
servers: string[];
|
||||||
|
accounts: AfkPage<AfkAccount>;
|
||||||
|
sessions: AfkPage<AfkSession>;
|
||||||
|
ledger: AfkPage<AfkLedgerEntry>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Keep managed artifacts within the host agent's MAX_FILE limit.
|
||||||
|
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
||||||
@@ -1,7 +1,25 @@
|
|||||||
|
import type { LaunchFileSummary } from './deployment-plan.js';
|
||||||
|
|
||||||
/** Keep the upload number visible: two builds may share a publisher's release tag. */
|
/** Keep the upload number visible: two builds may share a publisher's release tag. */
|
||||||
export function artifactVersionLabel(version: { version: number; release_version?: string | null } | null | undefined): string {
|
export function artifactVersionLabel(
|
||||||
|
version: { version: number; release_version?: string | null } | null | undefined,
|
||||||
|
kind?: string
|
||||||
|
): string {
|
||||||
if (!version) return '—';
|
if (!version) return '—';
|
||||||
return version.release_version
|
return version.release_version
|
||||||
? `${version.release_version} (upload ${version.version})`
|
? `${version.release_version} (upload ${version.version})`
|
||||||
: `v${version.version}`;
|
: kind === 'script'
|
||||||
|
? `Unversioned (upload ${version.version})`
|
||||||
|
: `v${version.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchFileVersionLabel(
|
||||||
|
file: Pick<LaunchFileSummary, 'version' | 'releaseVersion' | 'kind'>
|
||||||
|
): string {
|
||||||
|
return file.version === null
|
||||||
|
? 'Generated'
|
||||||
|
: artifactVersionLabel(
|
||||||
|
{ version: file.version, release_version: file.releaseVersion },
|
||||||
|
file.kind
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -1,4 +1,9 @@
|
|||||||
export type UserRole = 'admin' | 'operator' | 'viewer';
|
export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'moderator' | 'viewer';
|
||||||
|
|
||||||
|
export const moderatorMaximumRestrictionMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Reserved identity for work requested by the controller. */
|
||||||
|
export const systemUsername = 'Maxis';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,5 +18,29 @@ export interface AuthUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const roleLabels: Record<UserRole, string> = {
|
export const roleLabels: Record<UserRole, string> = {
|
||||||
admin: 'Administrator', operator: 'Operator', viewer: 'Viewer'
|
admin: 'Administrator',
|
||||||
|
super_viewer: 'Super Viewer',
|
||||||
|
operator: 'Operator',
|
||||||
|
moderator: 'Moderator',
|
||||||
|
viewer: 'Viewer'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function canOperate(role: UserRole | null | undefined): boolean {
|
||||||
|
return role === 'admin' || role === 'operator';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canReadAdmin(role: UserRole | null | undefined): boolean {
|
||||||
|
return role === 'admin' || role === 'super_viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canReadSensitive(role: UserRole | null | undefined): boolean {
|
||||||
|
return canOperate(role) || role === 'super_viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canModerate(role: UserRole | null | undefined): boolean {
|
||||||
|
return role === 'admin' || role === 'moderator';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canReadModeration(role: UserRole | null | undefined): boolean {
|
||||||
|
return canModerate(role) || role === 'super_viewer';
|
||||||
|
}
|
||||||
|
|||||||
+97
-23
@@ -1,56 +1,130 @@
|
|||||||
import { zombieMapBySlug, type ZombieGame } from './map-aliases.js';
|
import { zombieMapBySlug, type ZombieGame } from './map-aliases.js';
|
||||||
|
|
||||||
export const autoscalingModes = ['off', 'create-destroy'] as const;
|
export const autoscalingModes = ['off', 'create-destroy'] as const;
|
||||||
export type AutoscalingMode = typeof autoscalingModes[number];
|
export type AutoscalingMode = (typeof autoscalingModes)[number];
|
||||||
export interface ScalingSettings {
|
export interface ScalingSettings {
|
||||||
game: ZombieGame;
|
game: ZombieGame;
|
||||||
/** '*' applies the targets independently to every map. */
|
/** '*' applies the targets independently to every map. */
|
||||||
mapSlug: string;
|
mapSlug: string;
|
||||||
mode: AutoscalingMode;
|
mode: AutoscalingMode;
|
||||||
minimum: number; maximum: number; spares: number; emptyMinutes: number;
|
minimum: number;
|
||||||
|
maximum: number;
|
||||||
|
spares: number;
|
||||||
|
emptyMinutes: number;
|
||||||
}
|
}
|
||||||
export interface ScalingPolicy extends ScalingSettings {
|
export interface ScalingPolicy extends ScalingSettings {
|
||||||
id: string; revision: number; pausedReason: string | null; updatedAt: string;
|
id: string;
|
||||||
|
revision: number;
|
||||||
|
pausedReason: string | null;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
export interface ScalingMember {
|
export interface ScalingMember {
|
||||||
name: string; mapSlug?: string; identity: string; state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected';
|
name: string;
|
||||||
players: number | null; observedAt: number | null; source: string | null;
|
mapSlug?: string;
|
||||||
emptySince: number | null; blocker: string | null; operationId: string | null;
|
identity: string;
|
||||||
|
state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected';
|
||||||
|
players: number | null;
|
||||||
|
/** Current occupied match round from fresh telemetry; null when unavailable. */
|
||||||
|
round: number | null;
|
||||||
|
observedAt: number | null;
|
||||||
|
source: string | null;
|
||||||
|
emptySince: number | null;
|
||||||
|
blocker: string | null;
|
||||||
|
operationId: string | null;
|
||||||
emptyStartedAt?: number | null;
|
emptyStartedAt?: number | null;
|
||||||
}
|
}
|
||||||
export interface ScalingSnapshot {
|
export interface ScalingSnapshot {
|
||||||
members: ScalingMember[]; reservedNames: string[]; keyPool: { total: number; available: number }; blockers: string[];
|
members: ScalingMember[];
|
||||||
|
reservedNames: string[];
|
||||||
|
keyPool: { total: number; available: number };
|
||||||
|
blockers: string[];
|
||||||
}
|
}
|
||||||
export interface ScalingDecision {
|
export interface ScalingDecision {
|
||||||
action: 'create' | 'destroy' | 'wait'; reason: string; server: string | null;
|
action: 'create' | 'destroy' | 'wait';
|
||||||
desired: number; deficit: number; occupied: number; empty: number; pending: number; unknown: number;
|
reason: string;
|
||||||
|
server: string | null;
|
||||||
|
desired: number;
|
||||||
|
deficit: number;
|
||||||
|
occupied: number;
|
||||||
|
empty: number;
|
||||||
|
pending: number;
|
||||||
|
unknown: number;
|
||||||
|
}
|
||||||
|
export interface ScalingEvaluation {
|
||||||
|
policy: ScalingPolicy;
|
||||||
|
snapshot: ScalingSnapshot;
|
||||||
|
decision: ScalingDecision;
|
||||||
|
blockers: string[];
|
||||||
|
}
|
||||||
|
export interface ScalingHistory {
|
||||||
|
id: string;
|
||||||
|
at: number;
|
||||||
|
lastSeenAt: number;
|
||||||
|
decision: ScalingDecision;
|
||||||
|
operationId: string | null;
|
||||||
|
operationState: string | null;
|
||||||
}
|
}
|
||||||
export interface ScalingEvaluation { policy: ScalingPolicy; snapshot: ScalingSnapshot; decision: ScalingDecision; blockers: string[] }
|
|
||||||
export interface ScalingHistory { id: string; at: number; lastSeenAt: number; decision: ScalingDecision; operationId: string | null; operationState: string | null }
|
|
||||||
export interface ScalingView extends ScalingEvaluation {
|
export interface ScalingView extends ScalingEvaluation {
|
||||||
history: ScalingHistory[]; lastEvaluatedAt: number | null;
|
history: ScalingHistory[];
|
||||||
totals: { observedMinutes: number; emptyLobbyHours: number; deficitMinutes: number; unknownMinutes: number };
|
lastEvaluatedAt: number | null;
|
||||||
operations: { creates: number; removals: number; cancelled: number; failed: number; readinessSamples: number; readinessMeanSeconds: number | null; readinessMaxSeconds: number | null };
|
totals: {
|
||||||
|
observedMinutes: number;
|
||||||
|
emptyLobbyHours: number;
|
||||||
|
deficitMinutes: number;
|
||||||
|
unknownMinutes: number;
|
||||||
|
};
|
||||||
|
operations: {
|
||||||
|
creates: number;
|
||||||
|
removals: number;
|
||||||
|
cancelled: number;
|
||||||
|
failed: number;
|
||||||
|
readinessSamples: number;
|
||||||
|
readinessMeanSeconds: number | null;
|
||||||
|
readinessMaxSeconds: number | null;
|
||||||
|
};
|
||||||
ownsMap: boolean;
|
ownsMap: boolean;
|
||||||
unsettled: { id: string; action: string; state: string; stage: string }[];
|
unsettled: { id: string; action: string; state: string; stage: string }[];
|
||||||
}
|
}
|
||||||
export interface ScalingReport { policies: ScalingView[]; globalPaused: boolean; liveAvailable: boolean; removalAvailable: boolean }
|
export interface ScalingReport {
|
||||||
|
policies: ScalingView[];
|
||||||
|
globalPaused: boolean;
|
||||||
|
liveAvailable: boolean;
|
||||||
|
removalAvailable: boolean;
|
||||||
|
}
|
||||||
export const liveScaling = (mode: AutoscalingMode): boolean => mode === 'create-destroy';
|
export const liveScaling = (mode: AutoscalingMode): boolean => mode === 'create-destroy';
|
||||||
|
|
||||||
export function scalingSettings(input: unknown): ScalingSettings {
|
export function scalingSettings(input: unknown): ScalingSettings {
|
||||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Provide an autoscaling policy.');
|
if (!input || typeof input !== 'object' || Array.isArray(input))
|
||||||
|
throw new Error('Provide an autoscaling policy.');
|
||||||
const value = input as Record<string, unknown>;
|
const value = input as Record<string, unknown>;
|
||||||
const { game, mapSlug } = value;
|
const { game, mapSlug } = value;
|
||||||
if ((game !== 't5' && game !== 't6') || typeof mapSlug !== 'string' || (mapSlug !== '*' && zombieMapBySlug(game, mapSlug)?.slug !== mapSlug)) throw new Error('Choose a supported game and map variant, or all maps.');
|
if (
|
||||||
|
(game !== 't5' && game !== 't6') ||
|
||||||
|
typeof mapSlug !== 'string' ||
|
||||||
|
(mapSlug !== '*' && zombieMapBySlug(game, mapSlug)?.slug !== mapSlug)
|
||||||
|
)
|
||||||
|
throw new Error('Choose a supported game and map variant, or all maps.');
|
||||||
const mode = value.mode ?? 'off';
|
const mode = value.mode ?? 'off';
|
||||||
if (!autoscalingModes.includes(mode as AutoscalingMode)) throw new Error('Choose a valid autoscaling mode.');
|
if (!autoscalingModes.includes(mode as AutoscalingMode))
|
||||||
|
throw new Error('Choose a valid autoscaling mode.');
|
||||||
const integer = (key: string, fallback: number | undefined, max: number) => {
|
const integer = (key: string, fallback: number | undefined, max: number) => {
|
||||||
const n = value[key] ?? fallback;
|
const n = value[key] ?? fallback;
|
||||||
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > max) throw new Error(`${key} must be an integer from 1 to ${max}.`);
|
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > max)
|
||||||
|
throw new Error(`${key} must be an integer from 1 to ${max}.`);
|
||||||
return n;
|
return n;
|
||||||
};
|
};
|
||||||
const minimum = integer('minimum', 1, 20), maximum = integer('maximum', undefined, 20), spares = integer('spares', 1, 20);
|
const minimum = integer('minimum', 1, 20),
|
||||||
if (minimum > maximum || spares > maximum) throw new Error('Minimum and spare target cannot exceed the maximum.');
|
maximum = integer('maximum', undefined, 20),
|
||||||
return { game, mapSlug, mode: mode as AutoscalingMode, minimum, maximum, spares,
|
spares = integer('spares', 1, 20);
|
||||||
emptyMinutes: integer('emptyMinutes', 15, 1440) };
|
if (minimum > maximum || spares > maximum)
|
||||||
|
throw new Error('Minimum and spare target cannot exceed the maximum.');
|
||||||
|
return {
|
||||||
|
game,
|
||||||
|
mapSlug,
|
||||||
|
mode: mode as AutoscalingMode,
|
||||||
|
minimum,
|
||||||
|
maximum,
|
||||||
|
spares,
|
||||||
|
emptyMinutes: integer('emptyMinutes', 15, 1440)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
export const bankPageSize = 25;
|
||||||
|
export const bankAttentionAgeMs = 5 * 60_000;
|
||||||
|
|
||||||
|
export interface BankQuery {
|
||||||
|
view: 'accounts' | 'transactions' | 'pending';
|
||||||
|
q: string;
|
||||||
|
balance: '' | 'funded' | 'limit' | 'pending';
|
||||||
|
sort: 'balance' | 'recent';
|
||||||
|
game: '' | 't5' | 't6';
|
||||||
|
server: string;
|
||||||
|
operation: '' | 'deposit' | 'withdraw';
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
offset: number;
|
||||||
|
player: string;
|
||||||
|
historyOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBankQuery(params: URLSearchParams): BankQuery {
|
||||||
|
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
|
||||||
|
const value = params.get(key) || fallback;
|
||||||
|
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
function text(key: string, max: number) {
|
||||||
|
const value = (params.get(key) ?? '').trim();
|
||||||
|
if (value.length > max) throw new Error(`${key} is too long.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function offset(key: string) {
|
||||||
|
const value = Number(params.get(key) || 0);
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid page offset.');
|
||||||
|
return Math.floor(value / bankPageSize) * bankPageSize;
|
||||||
|
}
|
||||||
|
function date(key: string) {
|
||||||
|
const value = text(key, 10);
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
||||||
|
!Number.isFinite(Date.parse(value)) ||
|
||||||
|
new Date(value).toISOString().slice(0, 10) !== value)
|
||||||
|
)
|
||||||
|
throw new Error('Use a valid date in YYYY-MM-DD format.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const from = date('from');
|
||||||
|
const to = date('to');
|
||||||
|
if (from && to && from > to) throw new Error('The end date must follow the start date.');
|
||||||
|
return {
|
||||||
|
view: choice('view', ['accounts', 'transactions', 'pending'], 'accounts'),
|
||||||
|
q: text('q', 100),
|
||||||
|
balance: choice('balance', ['', 'funded', 'limit', 'pending'], ''),
|
||||||
|
sort: choice('sort', ['balance', 'recent'], 'balance'),
|
||||||
|
game: choice('game', ['', 't5', 't6'], ''),
|
||||||
|
server: text('server', 256),
|
||||||
|
operation: choice('operation', ['', 'deposit', 'withdraw'], ''),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
offset: offset('offset'),
|
||||||
|
player: text('player', 128),
|
||||||
|
historyOffset: offset('historyOffset')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankIdentity {
|
||||||
|
guid: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
export interface BankAccount extends BankIdentity {
|
||||||
|
balance: number;
|
||||||
|
available: number;
|
||||||
|
pendingOperation: 'deposit' | 'withdraw' | null;
|
||||||
|
pendingAmount: number | null;
|
||||||
|
lastActivity: string | null;
|
||||||
|
lastAmount: number | null;
|
||||||
|
}
|
||||||
|
export interface BankTransfer extends BankIdentity {
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
game: string | null;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
amount: number;
|
||||||
|
balance: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface BankPending extends BankIdentity {
|
||||||
|
requestId: string;
|
||||||
|
server: string;
|
||||||
|
game: string | null;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
operation: 'deposit' | 'withdraw';
|
||||||
|
amount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface BankPage<T> {
|
||||||
|
entries: T[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
export interface BankOverview {
|
||||||
|
query: BankQuery;
|
||||||
|
maximum: number | null;
|
||||||
|
checkedAt: string;
|
||||||
|
summary: { totalBanked: number; fundedAccounts: number; transfers24h: number; pending: number };
|
||||||
|
servers: string[];
|
||||||
|
accounts: BankPage<BankAccount>;
|
||||||
|
transactions: BankPage<BankTransfer>;
|
||||||
|
pending: BankPage<BankPending>;
|
||||||
|
detail: {
|
||||||
|
account: BankAccount;
|
||||||
|
pending: BankPending | null;
|
||||||
|
history: BankPage<BankTransfer>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
+120
-34
@@ -2,9 +2,12 @@ import { requirementKinds, type BetaPlan } from './beta-plan.js';
|
|||||||
|
|
||||||
export const betaDraftKey = 'electric-cherry:beta-draft:v1';
|
export const betaDraftKey = 'electric-cherry:beta-draft:v1';
|
||||||
export interface BetaDraft {
|
export interface BetaDraft {
|
||||||
version: 1; savedAt: string; step: number;
|
version: 1;
|
||||||
|
savedAt: string;
|
||||||
|
step: number;
|
||||||
picks: Array<{ scriptId: number | null; versionId: number | null }>;
|
picks: Array<{ scriptId: number | null; versionId: number | null }>;
|
||||||
servers: string[]; testPlan: BetaPlan;
|
servers: string[];
|
||||||
|
testPlan: BetaPlan;
|
||||||
selectionMode: 'automatic' | 'manual';
|
selectionMode: 'automatic' | 'manual';
|
||||||
instanceCount: number | null;
|
instanceCount: number | null;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -14,55 +17,138 @@ export function readBetaDraft(raw: string | null): BetaDraft | null {
|
|||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(raw);
|
const d = JSON.parse(raw);
|
||||||
if (d?.version !== 1 || !Array.isArray(d.picks) || !d.picks.length || d.picks.length > 100
|
if (
|
||||||
|| !d.picks.every((p: BetaDraft['picks'][number]) => p && [p.scriptId, p.versionId].every(v => v === null || Number.isSafeInteger(v) && v! > 0))
|
d?.version !== 1 ||
|
||||||
|| !Array.isArray(d.servers) || !d.servers.every((s: unknown) => typeof s === 'string')
|
!Array.isArray(d.picks) ||
|
||||||
|| d.testPlan?.version !== 1 || !Array.isArray(d.testPlan.requirements) || d.testPlan.requirements.length > 30
|
!d.picks.length ||
|
||||||
|| !d.testPlan.requirements.every((r: BetaPlan['requirements'][number]) => r && requirementKinds.includes(r.kind) && typeof r.id === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(r.id) && typeof r.label === 'string')) return null;
|
d.picks.length > 100 ||
|
||||||
if (d.selectionMode !== undefined && !['automatic', 'manual'].includes(d.selectionMode)) return null;
|
!d.picks.every(
|
||||||
if (d.instancesPerGame !== undefined && (!d.instancesPerGame || typeof d.instancesPerGame !== 'object'
|
(p: BetaDraft['picks'][number]) =>
|
||||||
|| Array.isArray(d.instancesPerGame) || !Object.entries(d.instancesPerGame).every(([game, count]) =>
|
p &&
|
||||||
['t5', 't6'].includes(game) && (count === null || typeof count === 'number' && Number.isFinite(count))))) return null;
|
[p.scriptId, p.versionId].every(
|
||||||
if (d.instanceCount !== undefined && d.instanceCount !== null
|
(v) => v === null || (Number.isSafeInteger(v) && v! > 0)
|
||||||
&& (typeof d.instanceCount !== 'number' || !Number.isFinite(d.instanceCount))) return null;
|
)
|
||||||
|
) ||
|
||||||
|
!Array.isArray(d.servers) ||
|
||||||
|
!d.servers.every((s: unknown) => typeof s === 'string') ||
|
||||||
|
d.testPlan?.version !== 1 ||
|
||||||
|
!Array.isArray(d.testPlan.requirements) ||
|
||||||
|
d.testPlan.requirements.length > 30 ||
|
||||||
|
!d.testPlan.requirements.every(
|
||||||
|
(r: BetaPlan['requirements'][number]) =>
|
||||||
|
r &&
|
||||||
|
requirementKinds.includes(r.kind) &&
|
||||||
|
typeof r.id === 'string' &&
|
||||||
|
/^[a-zA-Z0-9_-]{1,64}$/.test(r.id) &&
|
||||||
|
typeof r.label === 'string'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (d.selectionMode !== undefined && !['automatic', 'manual'].includes(d.selectionMode))
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
d.instancesPerGame !== undefined &&
|
||||||
|
(!d.instancesPerGame ||
|
||||||
|
typeof d.instancesPerGame !== 'object' ||
|
||||||
|
Array.isArray(d.instancesPerGame) ||
|
||||||
|
!Object.entries(d.instancesPerGame).every(
|
||||||
|
([game, count]) =>
|
||||||
|
['t5', 't6'].includes(game) &&
|
||||||
|
(count === null || (typeof count === 'number' && Number.isFinite(count)))
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
d.instanceCount !== undefined &&
|
||||||
|
d.instanceCount !== null &&
|
||||||
|
(typeof d.instanceCount !== 'number' || !Number.isFinite(d.instanceCount))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
const previousCounts = Object.values(d.instancesPerGame ?? {}) as Array<number | null>;
|
const previousCounts = Object.values(d.instancesPerGame ?? {}) as Array<number | null>;
|
||||||
const instanceCount = d.instanceCount !== undefined ? d.instanceCount
|
const instanceCount =
|
||||||
: previousCounts.includes(null) ? null : previousCounts.length ? Math.max(...previousCounts as number[]) : 1;
|
d.instanceCount !== undefined
|
||||||
|
? d.instanceCount
|
||||||
|
: previousCounts.includes(null)
|
||||||
|
? null
|
||||||
|
: previousCounts.length
|
||||||
|
? Math.max(...(previousCounts as number[]))
|
||||||
|
: 1;
|
||||||
// Existing drafts represent explicit selections and must not silently switch modes.
|
// Existing drafts represent explicit selections and must not silently switch modes.
|
||||||
const selectionMode = d.selectionMode ?? (d.servers.length ? 'manual' : 'automatic');
|
const selectionMode = d.selectionMode ?? (d.servers.length ? 'manual' : 'automatic');
|
||||||
// Revisit server setup when old per-game counts cannot be retained as one value.
|
// Revisit server setup when old per-game counts cannot be retained as one value.
|
||||||
const changedCounts = selectionMode === 'automatic' && d.instanceCount === undefined && new Set(previousCounts).size > 1;
|
const changedCounts =
|
||||||
return { ...d, selectionMode,
|
selectionMode === 'automatic' &&
|
||||||
instanceCount, step: Math.max(0, Math.min(changedCounts ? 1 : 3, Number(d.step) || 0)) };
|
d.instanceCount === undefined &&
|
||||||
} catch { return null; }
|
new Set(previousCounts).size > 1;
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
selectionMode,
|
||||||
|
instanceCount,
|
||||||
|
step: Math.max(0, Math.min(changedCounts ? 1 : 3, Number(d.step) || 0))
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BetaFileScope { scope_type: 'game' | 'map' | null; scope_value: string | null }
|
interface BetaFileScope {
|
||||||
interface BetaServerScope { name: string; game: string | null; map: string | null }
|
scope_type: 'game' | 'map' | null;
|
||||||
|
scope_value: string | null;
|
||||||
|
}
|
||||||
|
interface BetaServerScope {
|
||||||
|
name: string;
|
||||||
|
game: string | null;
|
||||||
|
map: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function betaScopeMatches(file: BetaFileScope, server: BetaServerScope): boolean {
|
export function betaScopeMatches(file: BetaFileScope, server: BetaServerScope): boolean {
|
||||||
return !file.scope_type || (file.scope_type === 'game' ? server.game : server.map) === file.scope_value;
|
return (
|
||||||
|
!file.scope_type ||
|
||||||
|
(file.scope_type === 'game' ? server.game : server.map) === file.scope_value
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A bundle installs each file only on its own matching participants. */
|
/** A bundle installs each file only on its own matching participants. */
|
||||||
export function betaCandidateServers<T extends BetaServerScope>(files: readonly BetaFileScope[], servers: readonly T[]): T[] {
|
export function betaCandidateServers<T extends BetaServerScope>(
|
||||||
return servers.filter(server => files.some(file => betaScopeMatches(file, server)));
|
files: readonly BetaFileScope[],
|
||||||
|
servers: readonly T[]
|
||||||
|
): T[] {
|
||||||
|
return servers.filter((server) => files.some((file) => betaScopeMatches(file, server)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function betaApplicableGames(files: readonly BetaFileScope[], servers: readonly BetaServerScope[]): string[] {
|
export function betaApplicableGames(
|
||||||
return [...new Set([
|
files: readonly BetaFileScope[],
|
||||||
...files.filter(file => file.scope_type === 'game').map(file => file.scope_value),
|
servers: readonly BetaServerScope[]
|
||||||
...betaCandidateServers(files, servers).map(server => server.game)
|
): string[] {
|
||||||
].filter((game): game is string => game === 't5' || game === 't6'))].sort();
|
return [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...files
|
||||||
|
.filter((file) => file.scope_type === 'game')
|
||||||
|
.map((file) => file.scope_value),
|
||||||
|
...betaCandidateServers(files, servers).map((server) => server.game)
|
||||||
|
].filter((game): game is string => game === 't5' || game === 't6')
|
||||||
|
)
|
||||||
|
].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expand the shared count only to games represented by the selected files. */
|
/** Expand the shared count only to games represented by the selected files. */
|
||||||
export function betaInstanceCounts(games: readonly string[], count: number | null): Record<string, number> {
|
export function betaInstanceCounts(
|
||||||
|
games: readonly string[],
|
||||||
|
count: number | null
|
||||||
|
): Record<string, number> {
|
||||||
if (count === null || !Number.isSafeInteger(count) || count < 1 || count > 1000) return {};
|
if (count === null || !Number.isSafeInteger(count) || count < 1 || count > 1000) return {};
|
||||||
return Object.fromEntries(games.map(game => [game, count]));
|
return Object.fromEntries(games.map((game) => [game, count]));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compatibleBetaServers(selected: Iterable<string>, candidates: readonly string[]): { kept: Set<string>; removed: string[] } {
|
export function compatibleBetaServers(
|
||||||
const allowed = new Set(candidates), names = [...selected];
|
selected: Iterable<string>,
|
||||||
return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) };
|
candidates: readonly string[]
|
||||||
|
): { kept: Set<string>; removed: string[] } {
|
||||||
|
const allowed = new Set(candidates),
|
||||||
|
names = [...selected];
|
||||||
|
return {
|
||||||
|
kept: new Set(names.filter((name) => allowed.has(name))),
|
||||||
|
removed: names.filter((name) => !allowed.has(name))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-18
@@ -1,23 +1,35 @@
|
|||||||
import type { BetaRequirement, BetaTestView } from './beta-plan.js';
|
import type { BetaRequirement, BetaTestView } from './beta-plan.js';
|
||||||
import { needsGameTelemetry } from './beta-plan-summary.js';
|
import { needsGameTelemetry } from './beta-plan-summary.js';
|
||||||
|
|
||||||
export function betaWaitingCondition(requirement: BetaRequirement, observation?: BetaTestView['servers'][number]['observation']): string {
|
export function betaWaitingCondition(
|
||||||
if (requirement.kind === 'manual') return 'Waiting for a tester to record their name and observation.';
|
requirement: BetaRequirement,
|
||||||
if (requirement.kind === 'recovery') return 'The other checks must pass before the next restart when empty; each cycle needs fresh evidence.';
|
observation?: BetaTestView['servers'][number]['observation']
|
||||||
if (requirement.kind === 'healthy-minutes') return observation?.healthy === true
|
): string {
|
||||||
? 'Collecting consecutive fresh healthy observations; an outage or monitoring gap resets this duration.'
|
if (requirement.kind === 'manual')
|
||||||
: 'Waiting for fresh container and RCON health observations. Unhealthy or stale observations reset this duration.';
|
return 'Waiting for a tester to record their name and observation.';
|
||||||
if (requirement.kind === 'failure-budget') return 'An observed health outage exceeded the budget. Investigate before starting a fresh attempt.';
|
if (requirement.kind === 'recovery')
|
||||||
|
return 'The other checks must pass before the next restart when empty; each cycle needs fresh evidence.';
|
||||||
|
if (requirement.kind === 'healthy-minutes')
|
||||||
|
return observation?.healthy === true
|
||||||
|
? 'Collecting consecutive fresh healthy observations; an outage or monitoring gap resets this duration.'
|
||||||
|
: 'Waiting for fresh container and RCON health observations. Unhealthy or stale observations reset this duration.';
|
||||||
|
if (requirement.kind === 'failure-budget')
|
||||||
|
return 'An observed health outage exceeded the budget. Investigate before starting a fresh attempt.';
|
||||||
if (needsGameTelemetry(requirement)) {
|
if (needsGameTelemetry(requirement)) {
|
||||||
if (!observation?.lastGameplayAt) return 'No gameplay report received for this attempt. Check the game reporter and Telemetry API requests.';
|
if (!observation?.lastGameplayAt)
|
||||||
|
return 'No gameplay report received for this attempt. Check the game reporter and Telemetry API requests.';
|
||||||
if (observation.gameEnded) return 'Waiting for the next game and its qualifying reports.';
|
if (observation.gameEnded) return 'Waiting for the next game and its qualifying reports.';
|
||||||
if (requirement.kind === 'population') return `Waiting for a game report containing at least ${requirement.target} players.`;
|
if (requirement.kind === 'population')
|
||||||
|
return `Waiting for a game report containing at least ${requirement.target} players.`;
|
||||||
const players = `at least ${requirement.minPlayers} player${requirement.minPlayers === 1 ? '' : 's'}`;
|
const players = `at least ${requirement.minPlayers} player${requirement.minPlayers === 1 ? '' : 's'}`;
|
||||||
if ((observation.players ?? 0) < requirement.minPlayers) return `The latest report has ${observation.players ?? 0} players; this check needs ${players}.`;
|
if ((observation.players ?? 0) < requirement.minPlayers)
|
||||||
if (requirement.kind === 'matches') return requirement.minMinutes && !observation.matchStarted
|
return `The latest report has ${observation.players ?? 0} players; this check needs ${players}.`;
|
||||||
? 'This match has no game-start report, so its duration cannot qualify. Waiting for a new game start and game end.'
|
if (requirement.kind === 'matches')
|
||||||
: `Waiting for game end${requirement.minRound ? ` after reaching round ${requirement.minRound}` : ''} with ${players}${requirement.minMinutes ? ` and at least ${requirement.minMinutes} minutes since game start` : ''}.`;
|
return requirement.minMinutes && !observation.matchStarted
|
||||||
if (requirement.kind === 'round') return `Waiting for a round change, heartbeat, or game end reporting round ${requirement.target} or higher with ${players}.`;
|
? 'This match has no game-start report, so its duration cannot qualify. Waiting for a new game start and game end.'
|
||||||
|
: `Waiting for game end${requirement.minRound ? ` after reaching round ${requirement.minRound}` : ''} with ${players}${requirement.minMinutes ? ` and at least ${requirement.minMinutes} minutes since game start` : ''}.`;
|
||||||
|
if (requirement.kind === 'round')
|
||||||
|
return `Waiting for a round change, heartbeat, or game end reporting round ${requirement.target} or higher with ${players}.`;
|
||||||
return `Time is credited when the next report arrives, with ${players} at both ends of the interval. It does not advance continuously between reports.`;
|
return `Time is credited when the next report arrives, with ${players} at both ends of the interval. It does not advance continuously between reports.`;
|
||||||
}
|
}
|
||||||
return 'Waiting for qualifying evidence.';
|
return 'Waiting for qualifying evidence.';
|
||||||
@@ -25,13 +37,26 @@ export function betaWaitingCondition(requirement: BetaRequirement, observation?:
|
|||||||
|
|
||||||
export function betaEvidenceSummary(event: BetaTestView['evidence'][number]): string {
|
export function betaEvidenceSummary(event: BetaTestView['evidence'][number]): string {
|
||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try { payload = JSON.parse(event.payload); } catch { return event.kind.replaceAll('-', ' ').replaceAll('_', ' '); }
|
try {
|
||||||
|
payload = JSON.parse(event.payload);
|
||||||
|
} catch {
|
||||||
|
return event.kind.replaceAll('-', ' ').replaceAll('_', ' ');
|
||||||
|
}
|
||||||
if (!payload || typeof payload !== 'object') return event.kind;
|
if (!payload || typeof payload !== 'object') return event.kind;
|
||||||
if (['game_start', 'round_change', 'heartbeat', 'game_end'].includes(event.kind)) {
|
if (['game_start', 'round_change', 'heartbeat', 'game_end'].includes(event.kind)) {
|
||||||
const label = event.kind === 'game_start' ? 'Game started' : event.kind === 'game_end' ? 'Game ended' : event.kind === 'heartbeat' ? 'Heartbeat' : 'Round changed';
|
const label =
|
||||||
|
event.kind === 'game_start'
|
||||||
|
? 'Game started'
|
||||||
|
: event.kind === 'game_end'
|
||||||
|
? 'Game ended'
|
||||||
|
: event.kind === 'heartbeat'
|
||||||
|
? 'Heartbeat'
|
||||||
|
: 'Round changed';
|
||||||
return `${label}${typeof payload.round === 'number' ? ` at round ${payload.round}` : ''}${Array.isArray(payload.players) ? ` with ${payload.players.length} players` : ''}${typeof payload.map === 'string' ? ` on ${payload.map}` : ''}.`;
|
return `${label}${typeof payload.round === 'number' ? ` at round ${payload.round}` : ''}${Array.isArray(payload.players) ? ` with ${payload.players.length} players` : ''}${typeof payload.map === 'string' ? ` on ${payload.map}` : ''}.`;
|
||||||
}
|
}
|
||||||
if (event.kind === 'manual-sign-off') return `${payload.requirement ?? 'Checklist'}: ${payload.note ?? 'Signed off'}`;
|
if (event.kind === 'manual-sign-off')
|
||||||
if (event.kind === 'health' || event.kind === 'status-sample') return `${payload.healthy ? 'Healthy observation' : 'Unhealthy observation'}${payload.reason ? `: ${payload.reason}` : '.'}`;
|
return `${payload.requirement ?? 'Checklist'}: ${payload.note ?? 'Signed off'}`;
|
||||||
|
if (event.kind === 'health' || event.kind === 'status-sample')
|
||||||
|
return `${payload.healthy ? 'Healthy observation' : 'Unhealthy observation'}${payload.reason ? `: ${payload.reason}` : '.'}`;
|
||||||
return event.kind.replaceAll('-', ' ').replaceAll('_', ' ');
|
return event.kind.replaceAll('-', ' ').replaceAll('_', ' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,66 @@
|
|||||||
import type { BetaPlan, BetaRequirement } from './beta-plan.js';
|
import type { BetaPlan, BetaRequirement } from './beta-plan.js';
|
||||||
|
|
||||||
const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||||
const duration = (minutes: number) => minutes >= 60 && minutes % 60 === 0 ? count(minutes / 60, 'hour') : count(minutes, 'minute');
|
const duration = (minutes: number) =>
|
||||||
|
minutes >= 60 && minutes % 60 === 0 ? count(minutes / 60, 'hour') : count(minutes, 'minute');
|
||||||
export const presetDescriptions: Record<string, string> = {
|
export const presetDescriptions: Record<string, string> = {
|
||||||
'Quick smoke test': 'A quick check that the server starts and stays healthy.',
|
'Quick smoke test': 'A quick check that the server starts and stays healthy.',
|
||||||
'Gameplay validation': 'Prove the build through real matches and populated playtime.',
|
'Gameplay validation': 'Prove the build through real matches and populated playtime.',
|
||||||
'Long soak': 'Exercise stability and participation over a longer test.'
|
'Long soak': 'Exercise stability and participation over a longer test.'
|
||||||
};
|
};
|
||||||
export function requirementSummary(r: BetaRequirement): string {
|
export function requirementSummary(r: BetaRequirement): string {
|
||||||
const players = r.minPlayers > 1 ? ` with at least ${r.minPlayers} players` : ' with players present';
|
const players =
|
||||||
|
r.minPlayers > 1 ? ` with at least ${r.minPlayers} players` : ' with players present';
|
||||||
switch (r.kind) {
|
switch (r.kind) {
|
||||||
case 'round': return `Reach round ${r.target}${players}`;
|
case 'round':
|
||||||
case 'matches': return `Complete ${count(r.target, 'match').replace('matchs', 'matches')}${r.minRound ? ` reaching round ${r.minRound}` : ''}${r.minPlayers > 1 ? players : ''}${r.minMinutes ? ` lasting at least ${duration(r.minMinutes)} each` : ''}`;
|
return `Reach round ${r.target}${players}`;
|
||||||
case 'populated-minutes': return `Accumulate ${duration(r.target)}${players}`;
|
case 'matches':
|
||||||
case 'player-hours': return `Accumulate ${r.target} player-hours${r.minPlayers > 1 ? players : ''}`;
|
return `Complete ${count(r.target, 'match').replace('matchs', 'matches')}${r.minRound ? ` reaching round ${r.minRound}` : ''}${r.minPlayers > 1 ? players : ''}${r.minMinutes ? ` lasting at least ${duration(r.minMinutes)} each` : ''}`;
|
||||||
case 'population': return `Reach ${count(r.target, 'player')} online`;
|
case 'populated-minutes':
|
||||||
case 'sustained-population': return `Keep at least ${count(r.minPlayers, 'player')} online for ${duration(r.target)}`;
|
return `Accumulate ${duration(r.target)}${players}`;
|
||||||
case 'healthy-minutes': return `Stay continuously healthy for ${duration(r.target)}`;
|
case 'player-hours':
|
||||||
case 'failure-budget': return r.target === 0 ? 'Allow no health outages' : `Allow at most ${count(r.target, 'health outage')}`;
|
return `Accumulate ${r.target} player-hours${r.minPlayers > 1 ? players : ''}`;
|
||||||
case 'recovery': return `Repeat the checks after ${count(r.target, 'recovery restart')}`;
|
case 'population':
|
||||||
case 'manual': return r.label;
|
return `Reach ${count(r.target, 'player')} online`;
|
||||||
|
case 'sustained-population':
|
||||||
|
return `Keep at least ${count(r.minPlayers, 'player')} online for ${duration(r.target)}`;
|
||||||
|
case 'healthy-minutes':
|
||||||
|
return `Stay continuously healthy for ${duration(r.target)}`;
|
||||||
|
case 'failure-budget':
|
||||||
|
return r.target === 0
|
||||||
|
? 'Allow no health outages'
|
||||||
|
: `Allow at most ${count(r.target, 'health outage')}`;
|
||||||
|
case 'recovery':
|
||||||
|
return `Repeat the checks after ${count(r.target, 'recovery restart')}`;
|
||||||
|
case 'manual':
|
||||||
|
return r.label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export function coverageSummary(plan: BetaPlan): string {
|
export function coverageSummary(plan: BetaPlan): string {
|
||||||
return plan.scope === 'all' ? 'Every selected server' : plan.scope === 'quorum' ? `At least ${count(plan.quorum, 'selected server')}`
|
return plan.scope === 'all'
|
||||||
: plan.scope === 'map' ? 'One server per selected map' : plan.scope === 'game' ? 'One server per selected game' : 'One server per game / map';
|
? 'Every selected server'
|
||||||
|
: plan.scope === 'quorum'
|
||||||
|
? `At least ${count(plan.quorum, 'selected server')}`
|
||||||
|
: plan.scope === 'map'
|
||||||
|
? 'One server per selected map'
|
||||||
|
: plan.scope === 'game'
|
||||||
|
? 'One server per selected game'
|
||||||
|
: 'One server per game / map';
|
||||||
}
|
}
|
||||||
export function needsGameTelemetry(r: BetaRequirement): boolean {
|
export function needsGameTelemetry(r: BetaRequirement): boolean {
|
||||||
return ['round', 'matches', 'population', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind);
|
return [
|
||||||
|
'round',
|
||||||
|
'matches',
|
||||||
|
'population',
|
||||||
|
'populated-minutes',
|
||||||
|
'player-hours',
|
||||||
|
'sustained-population'
|
||||||
|
].includes(r.kind);
|
||||||
}
|
}
|
||||||
export function requirementCategory(r: BetaRequirement): string {
|
export function requirementCategory(r: BetaRequirement): string {
|
||||||
return r.kind === 'manual' ? 'Tester sign-off' : ['healthy-minutes', 'failure-budget'].includes(r.kind) ? 'Continuous check' : 'Milestone';
|
return r.kind === 'manual'
|
||||||
|
? 'Tester sign-off'
|
||||||
|
: ['healthy-minutes', 'failure-budget'].includes(r.kind)
|
||||||
|
? 'Continuous check'
|
||||||
|
: 'Milestone';
|
||||||
}
|
}
|
||||||
|
|||||||
+282
-56
@@ -1,6 +1,16 @@
|
|||||||
export const requirementKinds = ['round', 'matches', 'populated-minutes', 'player-hours', 'population',
|
export const requirementKinds = [
|
||||||
'sustained-population', 'healthy-minutes', 'failure-budget', 'recovery', 'manual'] as const;
|
'round',
|
||||||
export type RequirementKind = typeof requirementKinds[number];
|
'matches',
|
||||||
|
'populated-minutes',
|
||||||
|
'player-hours',
|
||||||
|
'population',
|
||||||
|
'sustained-population',
|
||||||
|
'healthy-minutes',
|
||||||
|
'failure-budget',
|
||||||
|
'recovery',
|
||||||
|
'manual'
|
||||||
|
] as const;
|
||||||
|
export type RequirementKind = (typeof requirementKinds)[number];
|
||||||
export interface BetaRequirement {
|
export interface BetaRequirement {
|
||||||
id: string;
|
id: string;
|
||||||
kind: RequirementKind;
|
kind: RequirementKind;
|
||||||
@@ -21,90 +31,306 @@ export interface BetaPlan {
|
|||||||
waveSize: number; // 0 deploys all selected servers; otherwise pass each wave before expanding.
|
waveSize: number; // 0 deploys all selected servers; otherwise pass each wave before expanding.
|
||||||
}
|
}
|
||||||
export const requirementLabels: Record<RequirementKind, string> = {
|
export const requirementLabels: Record<RequirementKind, string> = {
|
||||||
round: 'Reach round', matches: 'Complete qualifying matches', 'populated-minutes': 'Populated minutes',
|
round: 'Reach round',
|
||||||
'player-hours': 'Player-hours', population: 'Reach player count', 'sustained-population': 'Sustained population (minutes)',
|
matches: 'Complete qualifying matches',
|
||||||
'healthy-minutes': 'Continuous healthy minutes', 'failure-budget': 'Maximum health outages',
|
'populated-minutes': 'Populated minutes',
|
||||||
recovery: 'Successful recovery cycles', manual: 'Tester checklist'
|
'player-hours': 'Player-hours',
|
||||||
|
population: 'Reach player count',
|
||||||
|
'sustained-population': 'Sustained population (minutes)',
|
||||||
|
'healthy-minutes': 'Continuous healthy minutes',
|
||||||
|
'failure-budget': 'Maximum health outages',
|
||||||
|
recovery: 'Successful recovery cycles',
|
||||||
|
manual: 'Tester checklist'
|
||||||
};
|
};
|
||||||
export function requirement(kind: RequirementKind, id: string): BetaRequirement {
|
export function requirement(kind: RequirementKind, id: string): BetaRequirement {
|
||||||
return { id, kind, label: requirementLabels[kind], target: kind === 'round' ? 25 : kind === 'failure-budget' ? 0 : 1,
|
return {
|
||||||
minRound: 0, minPlayers: 1, minMinutes: 0 };
|
id,
|
||||||
|
kind,
|
||||||
|
label: requirementLabels[kind],
|
||||||
|
target: kind === 'round' ? 25 : kind === 'failure-budget' ? 0 : 1,
|
||||||
|
minRound: 0,
|
||||||
|
minPlayers: 1,
|
||||||
|
minMinutes: 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export function defaultPlan(): BetaPlan {
|
export function defaultPlan(): BetaPlan {
|
||||||
return { version: 1, requirements: [requirement('healthy-minutes', 'health')], scope: 'all', quorum: 1,
|
return {
|
||||||
timeoutHours: 24, failureAction: 'fail', rollbackRestart: 'when-empty', waveSize: 0 };
|
version: 1,
|
||||||
|
requirements: [requirement('healthy-minutes', 'health')],
|
||||||
|
scope: 'all',
|
||||||
|
quorum: 1,
|
||||||
|
timeoutHours: 24,
|
||||||
|
failureAction: 'fail',
|
||||||
|
rollbackRestart: 'when-empty',
|
||||||
|
waveSize: 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export const builtInPlans: Array<{ name: string; plan: BetaPlan }> = [
|
export const builtInPlans: Array<{ name: string; plan: BetaPlan }> = [
|
||||||
{ name: 'Quick smoke test', plan: defaultPlan() },
|
{ name: 'Quick smoke test', plan: defaultPlan() },
|
||||||
{ name: 'Gameplay validation', plan: { ...defaultPlan(), requirements: [
|
{
|
||||||
{ ...requirement('matches', 'matches'), target: 2, minRound: 25 },
|
name: 'Gameplay validation',
|
||||||
{ ...requirement('populated-minutes', 'playtime'), target: 90 }, requirement('failure-budget', 'outages')
|
plan: {
|
||||||
] } },
|
...defaultPlan(),
|
||||||
{ name: 'Long soak', plan: { ...defaultPlan(), timeoutHours: 72, requirements: [
|
requirements: [
|
||||||
{ ...requirement('healthy-minutes', 'health'), target: 360 },
|
{ ...requirement('matches', 'matches'), target: 2, minRound: 25 },
|
||||||
{ ...requirement('player-hours', 'participation'), target: 10 }, requirement('failure-budget', 'outages')
|
{ ...requirement('populated-minutes', 'playtime'), target: 90 },
|
||||||
] } }
|
requirement('failure-budget', 'outages')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Long soak',
|
||||||
|
plan: {
|
||||||
|
...defaultPlan(),
|
||||||
|
timeoutHours: 72,
|
||||||
|
requirements: [
|
||||||
|
{ ...requirement('healthy-minutes', 'health'), target: 360 },
|
||||||
|
{ ...requirement('player-hours', 'participation'), target: 10 },
|
||||||
|
requirement('failure-budget', 'outages')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Strict allow-list validation at every boundary (including stored presets). */
|
/** Strict allow-list validation at every boundary (including stored presets). */
|
||||||
export function parseBetaPlan(value: unknown): BetaPlan {
|
export function parseBetaPlan(value: unknown): BetaPlan {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Test plan must be an object');
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
throw new Error('Test plan must be an object');
|
||||||
const p = value as BetaPlan;
|
const p = value as BetaPlan;
|
||||||
if (p.version !== 1 || !['all', 'quorum', 'map', 'game', 'map-game'].includes(p.scope)) throw new Error('Invalid test plan version or scope');
|
if (p.version !== 1 || !['all', 'quorum', 'map', 'game', 'map-game'].includes(p.scope))
|
||||||
if (!['fail', 'pause', 'rollback'].includes(p.failureAction) || !['manual', 'when-empty'].includes(p.rollbackRestart)) throw new Error('Invalid failure policy');
|
throw new Error('Invalid test plan version or scope');
|
||||||
for (const [key, min, max] of [['quorum', 1, 1000], ['timeoutHours', 0.01, 8760], ['waveSize', 0, 1000]] as const) {
|
if (
|
||||||
if (typeof p[key] !== 'number' || !Number.isFinite(p[key]) || p[key] < min || p[key] > max) throw new Error(`Invalid ${key}`);
|
!['fail', 'pause', 'rollback'].includes(p.failureAction) ||
|
||||||
|
!['manual', 'when-empty'].includes(p.rollbackRestart)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid failure policy');
|
||||||
|
for (const [key, min, max] of [
|
||||||
|
['quorum', 1, 1000],
|
||||||
|
['timeoutHours', 0.01, 8760],
|
||||||
|
['waveSize', 0, 1000]
|
||||||
|
] as const) {
|
||||||
|
if (typeof p[key] !== 'number' || !Number.isFinite(p[key]) || p[key] < min || p[key] > max)
|
||||||
|
throw new Error(`Invalid ${key}`);
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(p.quorum) || !Number.isInteger(p.waveSize)) throw new Error('Quorum and wave size must be integers');
|
if (!Number.isInteger(p.quorum) || !Number.isInteger(p.waveSize))
|
||||||
if (!Array.isArray(p.requirements) || !p.requirements.length || p.requirements.length > 30) throw new Error('Choose 1–30 requirements');
|
throw new Error('Quorum and wave size must be integers');
|
||||||
|
if (!Array.isArray(p.requirements) || !p.requirements.length || p.requirements.length > 30)
|
||||||
|
throw new Error('Choose 1–30 requirements');
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
const requirements = p.requirements.map(r => {
|
const requirements = p.requirements.map((r) => {
|
||||||
if (!r || typeof r !== 'object' || !requirementKinds.includes(r.kind)) throw new Error('Unknown requirement');
|
if (!r || typeof r !== 'object' || !requirementKinds.includes(r.kind))
|
||||||
if (typeof r.id !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(r.id) || r.id in Object.prototype || ids.has(r.id)) throw new Error('Requirement IDs must be unique, non-reserved identifiers');
|
throw new Error('Unknown requirement');
|
||||||
|
if (
|
||||||
|
typeof r.id !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9_-]{1,64}$/.test(r.id) ||
|
||||||
|
r.id in Object.prototype ||
|
||||||
|
ids.has(r.id)
|
||||||
|
)
|
||||||
|
throw new Error('Requirement IDs must be unique, non-reserved identifiers');
|
||||||
ids.add(r.id);
|
ids.add(r.id);
|
||||||
if (typeof r.label !== 'string' || !r.label.trim() || r.label.length > 200) throw new Error('Requirement needs a label (up to 200 characters)');
|
if (typeof r.label !== 'string' || !r.label.trim() || r.label.length > 200)
|
||||||
|
throw new Error('Requirement needs a label (up to 200 characters)');
|
||||||
for (const key of ['target', 'minRound', 'minPlayers', 'minMinutes'] as const) {
|
for (const key of ['target', 'minRound', 'minPlayers', 'minMinutes'] as const) {
|
||||||
if (typeof r[key] !== 'number' || !Number.isFinite(r[key]) || r[key] < 0 || r[key] > 1_000_000) throw new Error(`Invalid requirement ${key}`);
|
if (
|
||||||
|
typeof r[key] !== 'number' ||
|
||||||
|
!Number.isFinite(r[key]) ||
|
||||||
|
r[key] < 0 ||
|
||||||
|
r[key] > 1_000_000
|
||||||
|
)
|
||||||
|
throw new Error(`Invalid requirement ${key}`);
|
||||||
}
|
}
|
||||||
if (r.kind !== 'failure-budget' && r.target <= 0) throw new Error('Requirement target must be positive');
|
if (r.kind !== 'failure-budget' && r.target <= 0)
|
||||||
if (['round', 'matches', 'population', 'failure-budget', 'recovery', 'manual'].includes(r.kind) && !Number.isInteger(r.target)) throw new Error('Count targets must be integers');
|
throw new Error('Requirement target must be positive');
|
||||||
if (!Number.isInteger(r.minRound) || !Number.isInteger(r.minPlayers)) throw new Error('Round and player qualifiers must be integers');
|
if (
|
||||||
if (r.kind === 'manual' && r.target !== 1) throw new Error('Add a separate requirement for each checklist item');
|
['round', 'matches', 'population', 'failure-budget', 'recovery', 'manual'].includes(
|
||||||
return { id: r.id, kind: r.kind, label: r.label.trim(), target: r.target, minRound: r.minRound,
|
r.kind
|
||||||
minPlayers: r.minPlayers, minMinutes: r.minMinutes };
|
) &&
|
||||||
|
!Number.isInteger(r.target)
|
||||||
|
)
|
||||||
|
throw new Error('Count targets must be integers');
|
||||||
|
if (!Number.isInteger(r.minRound) || !Number.isInteger(r.minPlayers))
|
||||||
|
throw new Error('Round and player qualifiers must be integers');
|
||||||
|
if (r.kind === 'manual' && r.target !== 1)
|
||||||
|
throw new Error('Add a separate requirement for each checklist item');
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
kind: r.kind,
|
||||||
|
label: r.label.trim(),
|
||||||
|
target: r.target,
|
||||||
|
minRound: r.minRound,
|
||||||
|
minPlayers: r.minPlayers,
|
||||||
|
minMinutes: r.minMinutes
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return { version: 1, requirements, scope: p.scope, quorum: p.quorum, timeoutHours: p.timeoutHours,
|
return {
|
||||||
failureAction: p.failureAction, rollbackRestart: p.rollbackRestart, waveSize: p.waveSize };
|
version: 1,
|
||||||
|
requirements,
|
||||||
|
scope: p.scope,
|
||||||
|
quorum: p.quorum,
|
||||||
|
timeoutHours: p.timeoutHours,
|
||||||
|
failureAction: p.failureAction,
|
||||||
|
rollbackRestart: p.rollbackRestart,
|
||||||
|
waveSize: p.waveSize
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequirementProgress { id: string; label: string; current: number; target: number; passed: boolean; failed: boolean; detail: string }
|
export interface RequirementProgress {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
passed: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
export interface BetaTestView {
|
export interface BetaTestView {
|
||||||
plan: BetaPlan;
|
plan: BetaPlan;
|
||||||
outcome: string;
|
outcome: string;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
servers: Array<{ server: string; server_deleted_at: string | null; phase: number; passed: boolean; progress: RequirementProgress[]; observingAt?: string | null;
|
servers: Array<{
|
||||||
observation?: { lastGameplayAt: string | null; lastEvent: string | null; players: number | null; round: number | null; matchStarted: boolean; gameEnded: boolean; healthy: boolean | null; healthCheckedAt: string | null } }>;
|
server: string;
|
||||||
evidence: Array<{ id: number; server: string; server_deleted_at: string | null; received_at: string; kind: string; payload: string }>;
|
server_deleted_at: string | null;
|
||||||
|
phase: number;
|
||||||
|
passed: boolean;
|
||||||
|
progress: RequirementProgress[];
|
||||||
|
observingAt?: string | null;
|
||||||
|
observation?: {
|
||||||
|
lastGameplayAt: string | null;
|
||||||
|
lastEvent: string | null;
|
||||||
|
players: number | null;
|
||||||
|
round: number | null;
|
||||||
|
matchStarted: boolean;
|
||||||
|
gameEnded: boolean;
|
||||||
|
healthy: boolean | null;
|
||||||
|
healthCheckedAt: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
evidence: Array<{
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
server_deleted_at: string | null;
|
||||||
|
received_at: string;
|
||||||
|
kind: string;
|
||||||
|
payload: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BetaPlanIssue { field: string; message: string; requirementId?: string }
|
export interface BetaPlanIssue {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
requirementId?: string;
|
||||||
|
}
|
||||||
export function betaPlanIssues(plan: BetaPlan, serverCount?: number): BetaPlanIssue[] {
|
export function betaPlanIssues(plan: BetaPlan, serverCount?: number): BetaPlanIssue[] {
|
||||||
const issues: BetaPlanIssue[] = [];
|
const issues: BetaPlanIssue[] = [];
|
||||||
const check = (field: string, value: number, min: number, max: number, integer: boolean, message: string, requirementId?: string) => {
|
const check = (
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) issues.push({ field, message, requirementId });
|
field: string,
|
||||||
|
value: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
integer: boolean,
|
||||||
|
message: string,
|
||||||
|
requirementId?: string
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
typeof value !== 'number' ||
|
||||||
|
!Number.isFinite(value) ||
|
||||||
|
value < min ||
|
||||||
|
value > max ||
|
||||||
|
(integer && !Number.isInteger(value))
|
||||||
|
)
|
||||||
|
issues.push({ field, message, requirementId });
|
||||||
};
|
};
|
||||||
check('timeoutHours', plan.timeoutHours, .01, 8760, false, 'Enter a testing window between 0.01 and 8,760 hours.');
|
check(
|
||||||
check('waveSize', plan.waveSize, 0, 1000, true, 'Enter a whole number from 0 to 1,000; 0 deploys to all selected servers.');
|
'timeoutHours',
|
||||||
if (plan.scope === 'quorum') check('quorum', plan.quorum, 1, serverCount ?? 1000, true, `Choose between 1 and ${serverCount ?? 1000} qualifying servers.`);
|
plan.timeoutHours,
|
||||||
if (!plan.requirements.length) issues.push({ field: 'requirements', message: 'Add at least one requirement to continue.' });
|
0.01,
|
||||||
|
8760,
|
||||||
|
false,
|
||||||
|
'Enter a testing window between 0.01 and 8,760 hours.'
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'waveSize',
|
||||||
|
plan.waveSize,
|
||||||
|
0,
|
||||||
|
1000,
|
||||||
|
true,
|
||||||
|
'Enter a whole number from 0 to 1,000; 0 deploys to all selected servers.'
|
||||||
|
);
|
||||||
|
if (plan.scope === 'quorum')
|
||||||
|
check(
|
||||||
|
'quorum',
|
||||||
|
plan.quorum,
|
||||||
|
1,
|
||||||
|
serverCount ?? 1000,
|
||||||
|
true,
|
||||||
|
`Choose between 1 and ${serverCount ?? 1000} qualifying servers.`
|
||||||
|
);
|
||||||
|
if (!plan.requirements.length)
|
||||||
|
issues.push({
|
||||||
|
field: 'requirements',
|
||||||
|
message: 'Add at least one requirement to continue.'
|
||||||
|
});
|
||||||
for (const r of plan.requirements) {
|
for (const r of plan.requirements) {
|
||||||
if (!r.label?.trim()) issues.push({ field: `${r.id}-label`, message: 'Describe what this requirement checks.', requirementId: r.id });
|
if (!r.label?.trim())
|
||||||
const integral = ['round', 'matches', 'population', 'failure-budget', 'recovery', 'manual'].includes(r.kind);
|
issues.push({
|
||||||
check(`${r.id}-target`, r.target, r.kind === 'failure-budget' ? 0 : .000001, 1_000_000, integral, `Enter ${integral ? 'a whole number' : 'a number'} ${r.kind === 'failure-budget' ? 'of zero or more' : 'greater than zero'} for ${requirementLabels[r.kind].toLowerCase()}.`, r.id);
|
field: `${r.id}-label`,
|
||||||
if (['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)) check(`${r.id}-minPlayers`, r.minPlayers, 1, 1_000_000, true, 'Enter at least one player, using a whole number.', r.id);
|
message: 'Describe what this requirement checks.',
|
||||||
|
requirementId: r.id
|
||||||
|
});
|
||||||
|
const integral = [
|
||||||
|
'round',
|
||||||
|
'matches',
|
||||||
|
'population',
|
||||||
|
'failure-budget',
|
||||||
|
'recovery',
|
||||||
|
'manual'
|
||||||
|
].includes(r.kind);
|
||||||
|
check(
|
||||||
|
`${r.id}-target`,
|
||||||
|
r.target,
|
||||||
|
r.kind === 'failure-budget' ? 0 : 0.000001,
|
||||||
|
1_000_000,
|
||||||
|
integral,
|
||||||
|
`Enter ${integral ? 'a whole number' : 'a number'} ${r.kind === 'failure-budget' ? 'of zero or more' : 'greater than zero'} for ${requirementLabels[r.kind].toLowerCase()}.`,
|
||||||
|
r.id
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'round',
|
||||||
|
'matches',
|
||||||
|
'populated-minutes',
|
||||||
|
'player-hours',
|
||||||
|
'sustained-population'
|
||||||
|
].includes(r.kind)
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
`${r.id}-minPlayers`,
|
||||||
|
r.minPlayers,
|
||||||
|
1,
|
||||||
|
1_000_000,
|
||||||
|
true,
|
||||||
|
'Enter at least one player, using a whole number.',
|
||||||
|
r.id
|
||||||
|
);
|
||||||
if (r.kind === 'matches') {
|
if (r.kind === 'matches') {
|
||||||
check(`${r.id}-minRound`, r.minRound, 0, 1_000_000, true, 'Enter a whole round number; 0 allows any round.', r.id);
|
check(
|
||||||
check(`${r.id}-minMinutes`, r.minMinutes, 0, 1_000_000, false, 'Enter a duration of zero or more minutes.', r.id);
|
`${r.id}-minRound`,
|
||||||
|
r.minRound,
|
||||||
|
0,
|
||||||
|
1_000_000,
|
||||||
|
true,
|
||||||
|
'Enter a whole round number; 0 allows any round.',
|
||||||
|
r.id
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${r.id}-minMinutes`,
|
||||||
|
r.minMinutes,
|
||||||
|
0,
|
||||||
|
1_000_000,
|
||||||
|
false,
|
||||||
|
'Enter a duration of zero or more minutes.',
|
||||||
|
r.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues;
|
return issues;
|
||||||
|
|||||||
@@ -3,12 +3,24 @@ export interface BetaPromotionPreview {
|
|||||||
token: string;
|
token: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
files: Array<{
|
files: Array<{
|
||||||
id: number; name: string; kind: string; path: string; versionId: number;
|
id: number;
|
||||||
version: number; release_version: string | null; sha256: string;
|
name: string;
|
||||||
scope: string; servers: string[];
|
kind: string;
|
||||||
|
path: string;
|
||||||
|
versionId: number;
|
||||||
|
version: number;
|
||||||
|
release_version: string | null;
|
||||||
|
sha256: string;
|
||||||
|
scope: string;
|
||||||
|
servers: string[];
|
||||||
}>;
|
}>;
|
||||||
servers: Array<{ name: string; tested: boolean; game: string | null; map: string | null }>;
|
servers: Array<{ name: string; tested: boolean; game: string | null; map: string | null }>;
|
||||||
configWrites: Array<{ server: string; path: string; sha256: string | null; error: string | null }>;
|
configWrites: Array<{
|
||||||
|
server: string;
|
||||||
|
path: string;
|
||||||
|
sha256: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
restartPolicy: 'additional-when-empty';
|
restartPolicy: 'additional-when-empty';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import type { BetaTestView } from './beta-plan.js';
|
import type { BetaTestView } from './beta-plan.js';
|
||||||
|
|
||||||
export interface BetaRevisionFile {
|
export interface BetaRevisionFile {
|
||||||
script_id: number; name: string; kind: string; path: string;
|
script_id: number;
|
||||||
version_id: number; version: number; release_version: string | null; sha256: string;
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
path: string;
|
||||||
|
version_id: number;
|
||||||
|
version: number;
|
||||||
|
release_version: string | null;
|
||||||
|
sha256: string;
|
||||||
}
|
}
|
||||||
export interface BetaRevision {
|
export interface BetaRevision {
|
||||||
revision: number; started_at: string; notes: string | null;
|
revision: number;
|
||||||
|
started_at: string;
|
||||||
|
notes: string | null;
|
||||||
files: BetaRevisionFile[];
|
files: BetaRevisionFile[];
|
||||||
results: { state: string; test: BetaTestView | null; servers: Array<{ server: string; stage: string; error: string | null }> } | null;
|
results: {
|
||||||
|
state: string;
|
||||||
|
test: BetaTestView | null;
|
||||||
|
servers: Array<{ server: string; stage: string; error: string | null }>;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-4
@@ -2,12 +2,19 @@ import type { CapacityCalibration, HostReadiness } from './hosts.js';
|
|||||||
|
|
||||||
export type CapacityRange = '1h' | '24h' | '7d';
|
export type CapacityRange = '1h' | '24h' | '7d';
|
||||||
export type CapacityGame = 'all' | 't5' | 't6';
|
export type CapacityGame = 'all' | 't5' | 't6';
|
||||||
export interface CapacityQuery { range: CapacityRange; hostId: string | null; game: CapacityGame }
|
export interface CapacityQuery {
|
||||||
|
range: CapacityRange;
|
||||||
|
hostId: string | null;
|
||||||
|
game: CapacityGame;
|
||||||
|
}
|
||||||
export function parseCapacityQuery(params: URLSearchParams): CapacityQuery {
|
export function parseCapacityQuery(params: URLSearchParams): CapacityQuery {
|
||||||
const range = params.get('range');
|
const range = params.get('range');
|
||||||
const game = params.get('game');
|
const game = params.get('game');
|
||||||
return { range: range === '1h' || range === '7d' ? range : '24h',
|
return {
|
||||||
game: game === 't5' || game === 't6' ? game : 'all', hostId: params.get('host')?.trim().slice(0, 100) || null };
|
range: range === '1h' || range === '7d' ? range : '24h',
|
||||||
|
game: game === 't5' || game === 't6' ? game : 'all',
|
||||||
|
hostId: params.get('host')?.trim().slice(0, 100) || null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CapacityHistoryPoint {
|
export interface CapacityHistoryPoint {
|
||||||
@@ -23,6 +30,14 @@ export interface CapacityHistory {
|
|||||||
endAt: number;
|
endAt: number;
|
||||||
bucketMs: number;
|
bucketMs: number;
|
||||||
points: CapacityHistoryPoint[];
|
points: CapacityHistoryPoint[];
|
||||||
|
temperatures: TemperatureHistorySeries[];
|
||||||
|
}
|
||||||
|
export interface TemperatureHistorySeries {
|
||||||
|
id: string;
|
||||||
|
hostId: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'cpu' | 'nvme' | 'chipset';
|
||||||
|
points: { at: number; celsius: number | null }[];
|
||||||
}
|
}
|
||||||
export interface CapacityHost {
|
export interface CapacityHost {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,4 +75,8 @@ export interface CapacityCurrent {
|
|||||||
hostOptions: { id: string; name: string }[];
|
hostOptions: { id: string; name: string }[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
export interface CapacityData { query: CapacityQuery; current: CapacityCurrent; history: CapacityHistory }
|
export interface CapacityData {
|
||||||
|
query: CapacityQuery;
|
||||||
|
current: CapacityCurrent;
|
||||||
|
history: CapacityHistory;
|
||||||
|
}
|
||||||
|
|||||||
+32
-15
@@ -27,33 +27,45 @@ export interface ConfigInspection {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigServer { name: string; game: string | null; map: string | null; }
|
export interface ConfigServer {
|
||||||
|
name: string;
|
||||||
|
game: string | null;
|
||||||
|
map: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The same lowercase `set` grammar that the overlay composer accepts. */
|
/** The same lowercase `set` grammar that the overlay composer accepts. */
|
||||||
export function configLines(text: string) {
|
export function configLines(text: string) {
|
||||||
return text.split(/\r?\n/).flatMap((line, index) => {
|
return text.split(/\r?\n/).flatMap((line, index) => {
|
||||||
const match = /^(\s*set\s+)(\S+)(\s+)(.*)$/.exec(line);
|
const match = /^(\s*set\s+)(\S+)(\s+)(.*)$/.exec(line);
|
||||||
return match ? [{ index, key: match[2], value: match[4], prefix: match[1] + match[2] + match[3] }] : [];
|
return match
|
||||||
|
? [{ index, key: match[2], value: match[4], prefix: match[1] + match[2] + match[3] }]
|
||||||
|
: [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeConfigValue(text: string, index: number, value: string): string {
|
export function changeConfigValue(text: string, index: number, value: string): string {
|
||||||
// Keep every separator, comment and unrelated line byte-for-byte intact.
|
// Keep every separator, comment and unrelated line byte-for-byte intact.
|
||||||
const parts = text.split(/(\r?\n)/);
|
const parts = text.split(/(\r?\n)/);
|
||||||
const entry = configLines(text).find(line => line.index === index);
|
const entry = configLines(text).find((line) => line.index === index);
|
||||||
if (entry) parts[index * 2] = entry.prefix + value.replace(/[\r\n]/g, '');
|
if (entry) parts[index * 2] = entry.prefix + value.replace(/[\r\n]/g, '');
|
||||||
return parts.join('');
|
return parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add one assignment without changing existing commands or line endings. */
|
/** Add one assignment without changing existing commands or line endings. */
|
||||||
export function addConfigSetting(text: string, key: string, value: string): string {
|
export function addConfigSetting(text: string, key: string, value: string): string {
|
||||||
key = key.trim(); value = value.trim();
|
key = key.trim();
|
||||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) throw new Error('Use a setting name containing letters, numbers, and underscores.');
|
value = value.trim();
|
||||||
if (!value || /[\r\n]/.test(value) || value.startsWith('//')) throw new Error('Enter a value on one line.');
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key))
|
||||||
if (configLines(text).some(entry => entry.key.toLowerCase() === key.toLowerCase())) throw new Error('This setting already exists. Find it above to edit its value.');
|
throw new Error('Use a setting name containing letters, numbers, and underscores.');
|
||||||
|
if (!value || /[\r\n]/.test(value) || value.startsWith('//'))
|
||||||
|
throw new Error('Enter a value on one line.');
|
||||||
|
if (configLines(text).some((entry) => entry.key.toLowerCase() === key.toLowerCase()))
|
||||||
|
throw new Error('This setting already exists. Find it above to edit its value.');
|
||||||
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
||||||
const formatted = /\s/.test(value) && !value.startsWith('"') ? JSON.stringify(value) : value;
|
const formatted = /\s/.test(value) && !value.startsWith('"') ? JSON.stringify(value) : value;
|
||||||
return text + (text && !text.endsWith('\n') ? newline : '') + `set ${key} ${formatted}` + newline;
|
return (
|
||||||
|
text + (text && !text.endsWith('\n') ? newline : '') + `set ${key} ${formatted}` + newline
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configWarnings(text: string, overlay: boolean): string[] {
|
export function configWarnings(text: string, overlay: boolean): string[] {
|
||||||
@@ -61,15 +73,20 @@ export function configWarnings(text: string, overlay: boolean): string[] {
|
|||||||
const seen = new Map<string, number>();
|
const seen = new Map<string, number>();
|
||||||
for (const entry of configLines(text)) {
|
for (const entry of configLines(text)) {
|
||||||
const key = entry.key.toLowerCase();
|
const key = entry.key.toLowerCase();
|
||||||
if (seen.has(key)) warnings.push(`Line ${entry.index + 1}: ${entry.key} is also assigned on line ${seen.get(key)}.`);
|
if (seen.has(key))
|
||||||
|
warnings.push(
|
||||||
|
`Line ${entry.index + 1}: ${entry.key} is also assigned on line ${seen.get(key)}.`
|
||||||
|
);
|
||||||
seen.set(key, entry.index + 1);
|
seen.set(key, entry.index + 1);
|
||||||
if (!entry.value.trim() || entry.value.trim().startsWith('//')) warnings.push(`Line ${entry.index + 1}: ${entry.key} has no value.`);
|
if (!entry.value.trim() || entry.value.trim().startsWith('//'))
|
||||||
|
warnings.push(`Line ${entry.index + 1}: ${entry.key} has no value.`);
|
||||||
}
|
}
|
||||||
if (overlay) text.split(/\r?\n/).forEach((line, index) => {
|
if (overlay)
|
||||||
if (line.trim() && !/^\s*\/\//.test(line) && !/^\s*set\s+\S+\s+/.test(line)) {
|
text.split(/\r?\n/).forEach((line, index) => {
|
||||||
warnings.push(`Line ${index + 1} is ignored by overlays. Use set KEY VALUE.`);
|
if (line.trim() && !/^\s*\/\//.test(line) && !/^\s*set\s+\S+\s+/.test(line)) {
|
||||||
}
|
warnings.push(`Line ${index + 1} is ignored by overlays. Use set KEY VALUE.`);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
if (/^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/im.test(text)) {
|
if (/^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/im.test(text)) {
|
||||||
warnings.push('For recognized maps, the server’s selected map overrides sv_maprotation.');
|
warnings.push('For recognized maps, the server’s selected map overrides sv_maprotation.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface LaunchFileSummary {
|
|||||||
path: string;
|
path: string;
|
||||||
versionId: number | null;
|
versionId: number | null;
|
||||||
version: number | null;
|
version: number | null;
|
||||||
|
/** Missing on snapshots recorded before publisher release versions were retained. */
|
||||||
|
releaseVersion?: string | null;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
@@ -55,9 +57,21 @@ export interface CollectionPreview {
|
|||||||
mode: CollectionMode;
|
mode: CollectionMode;
|
||||||
retainedServers: string[];
|
retainedServers: string[];
|
||||||
removals: { name: string; reason: string }[];
|
removals: { name: string; reason: string }[];
|
||||||
entries: { count: number; existing: number; missing: number; existingServers: string[];
|
entries: {
|
||||||
coverage: { name: string; health: string; href: string }[]; recipe: LaunchRecipeSummary }[];
|
count: number;
|
||||||
servers: { native?: import('./hosts.js').NativePlacement; entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[];
|
existing: number;
|
||||||
|
missing: number;
|
||||||
|
existingServers: string[];
|
||||||
|
coverage: { name: string; health: string; href: string }[];
|
||||||
|
recipe: LaunchRecipeSummary;
|
||||||
|
}[];
|
||||||
|
servers: {
|
||||||
|
native?: import('./hosts.js').NativePlacement;
|
||||||
|
entryIndex: number;
|
||||||
|
name: string | null;
|
||||||
|
port: number | null;
|
||||||
|
hasSavedKey: boolean;
|
||||||
|
}[];
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
availability?: LaunchAvailability;
|
availability?: LaunchAvailability;
|
||||||
@@ -87,10 +101,32 @@ export interface CollectionDetail {
|
|||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
retainedServers: string[];
|
retainedServers: string[];
|
||||||
retainedServerDeletedAt?: Record<string, string>;
|
retainedServerDeletedAt?: Record<string, string>;
|
||||||
queue: { concurrency: number; active: { id: string; server: string; state: string; stage: string }[]; queued: number };
|
queue: {
|
||||||
servers: { id: string; server: string; action: 'create' | 'destroy'; port: number | null; map_slug: string | null; state: string; stage: string;
|
concurrency: number;
|
||||||
started_at: string; finished_at: string | null; error: string | null; server_exists: number; server_deleted_at?: string | null; queuePosition: number | null;
|
active: { id: string; server: string; state: string; stage: string }[];
|
||||||
cancellation: { cleanupId: string; state: string; stage: string; error: string | null } | null }[];
|
queued: number;
|
||||||
|
};
|
||||||
|
servers: {
|
||||||
|
id: string;
|
||||||
|
server: string;
|
||||||
|
action: 'create' | 'destroy';
|
||||||
|
port: number | null;
|
||||||
|
map_slug: string | null;
|
||||||
|
state: string;
|
||||||
|
stage: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
error: string | null;
|
||||||
|
server_exists: number;
|
||||||
|
server_deleted_at?: string | null;
|
||||||
|
queuePosition: number | null;
|
||||||
|
cancellation: {
|
||||||
|
cleanupId: string;
|
||||||
|
state: string;
|
||||||
|
stage: string;
|
||||||
|
error: string | null;
|
||||||
|
} | null;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaunchPreview {
|
export interface LaunchPreview {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
export type DiscordGame = 't5' | 't6';
|
||||||
|
|
||||||
|
export interface DiscordSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
guildId: string;
|
||||||
|
channels: Record<DiscordGame, string>;
|
||||||
|
excludedServers: string[];
|
||||||
|
showPlayers: boolean;
|
||||||
|
showAddresses: boolean;
|
||||||
|
leaderboardEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultDiscordSettings: DiscordSettings = {
|
||||||
|
enabled: false,
|
||||||
|
guildId: '',
|
||||||
|
channels: { t5: '', t6: '' },
|
||||||
|
excludedServers: [],
|
||||||
|
showPlayers: false,
|
||||||
|
showAddresses: false,
|
||||||
|
leaderboardEnabled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export const discordSnowflake = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && /^[1-9][0-9]{16,19}$/.test(value);
|
||||||
|
|
||||||
|
export function validateDiscordSettings(value: unknown): DiscordSettings {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
throw new Error('Provide Discord settings.');
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
for (const key of ['enabled', 'showPlayers', 'showAddresses', 'leaderboardEnabled']) {
|
||||||
|
if (typeof input[key] !== 'boolean') throw new Error(`Invalid ${key} setting.`);
|
||||||
|
}
|
||||||
|
if (typeof input.guildId !== 'string' || (input.guildId && !discordSnowflake(input.guildId)))
|
||||||
|
throw new Error('Provide a valid Discord server ID.');
|
||||||
|
if (!input.channels || typeof input.channels !== 'object' || Array.isArray(input.channels))
|
||||||
|
throw new Error('Provide Discord channels for each game.');
|
||||||
|
const channels = input.channels as Record<string, unknown>;
|
||||||
|
for (const game of ['t5', 't6'] as const) {
|
||||||
|
if (
|
||||||
|
typeof channels[game] !== 'string' ||
|
||||||
|
(channels[game] && !discordSnowflake(channels[game]))
|
||||||
|
)
|
||||||
|
throw new Error('Channel IDs must be valid Discord IDs or empty.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(input.excludedServers) ||
|
||||||
|
input.excludedServers.length > 100 ||
|
||||||
|
input.excludedServers.some(
|
||||||
|
(name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Choose up to 100 servers to exclude from Discord.');
|
||||||
|
if (input.enabled && (!input.guildId || (!channels.t5 && !channels.t6)))
|
||||||
|
throw new Error('Choose a Discord server and at least one board channel before enabling.');
|
||||||
|
return {
|
||||||
|
enabled: input.enabled as boolean,
|
||||||
|
guildId: input.guildId,
|
||||||
|
channels: { t5: channels.t5 as string, t6: channels.t6 as string },
|
||||||
|
excludedServers: [...new Set(input.excludedServers as string[])].sort(),
|
||||||
|
showPlayers: input.showPlayers as boolean,
|
||||||
|
showAddresses: input.showAddresses as boolean,
|
||||||
|
leaderboardEnabled: input.leaderboardEnabled as boolean
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordPublicServer {
|
||||||
|
name: string;
|
||||||
|
game: DiscordGame;
|
||||||
|
label: string;
|
||||||
|
map: string;
|
||||||
|
mapKey: string;
|
||||||
|
state: 'empty' | 'in-progress' | 'full' | 'starting' | 'offline' | 'stale' | 'unknown';
|
||||||
|
players: number | null;
|
||||||
|
capacity: number | null;
|
||||||
|
round: number | null;
|
||||||
|
observedAt: string | null;
|
||||||
|
playerNames: string[];
|
||||||
|
connection: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordMessage {
|
||||||
|
content?: string;
|
||||||
|
flags?: number;
|
||||||
|
components?: Record<string, unknown>[];
|
||||||
|
allowed_mentions: { parse: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordDeliveryStatus {
|
||||||
|
lastAttemptAt: string | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
boards: { game: DiscordGame; channelId: string; messageCount: number }[];
|
||||||
|
activeSubscriptions: number;
|
||||||
|
pendingNotifications: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordAdminState {
|
||||||
|
settings: DiscordSettings;
|
||||||
|
credentials: {
|
||||||
|
applicationId: string;
|
||||||
|
publicKeyConfigured: boolean;
|
||||||
|
botTokenConfigured: boolean;
|
||||||
|
};
|
||||||
|
status: DiscordDeliveryStatus;
|
||||||
|
servers: { name: string; game: DiscordGame; label: string; map: string }[];
|
||||||
|
preview: Record<DiscordGame, DiscordPublicServer[]>;
|
||||||
|
}
|
||||||
+94
-22
@@ -9,7 +9,10 @@ import { mapTitle, serverTitle } from './ui/server-labels.js';
|
|||||||
export interface FleetServer {
|
export interface FleetServer {
|
||||||
plutonium?: import('./plutonium.js').PlutoniumVersion;
|
plutonium?: import('./plutonium.js').PlutoniumVersion;
|
||||||
host_id?: string | null;
|
host_id?: string | null;
|
||||||
host?: Pick<import('./hosts.js').Host, 'id' | 'name' | 'connectionState' | 'connected' | 'lastHeartbeat'> | null;
|
host?: Pick<
|
||||||
|
import('./hosts.js').Host,
|
||||||
|
'id' | 'name' | 'connectionState' | 'connected' | 'lastHeartbeat'
|
||||||
|
> | null;
|
||||||
container_state?: string | null;
|
container_state?: string | null;
|
||||||
container_checked_at?: string | null;
|
container_checked_at?: string | null;
|
||||||
last_checked_at?: string | null;
|
last_checked_at?: string | null;
|
||||||
@@ -55,50 +58,119 @@ export interface FleetOperation {
|
|||||||
export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned';
|
export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned';
|
||||||
export type FleetSort = 'name' | 'players' | 'round' | 'duration';
|
export type FleetSort = 'name' | 'players' | 'round' | 'duration';
|
||||||
|
|
||||||
export function freshServer(server: FleetServer, now: number): boolean {
|
export function freshServer(
|
||||||
return server.health === 'online' && !server.last_error && freshStatusTimestamp(server.last_seen_at, now);
|
server: Pick<FleetServer, 'health' | 'last_error' | 'last_seen_at'>,
|
||||||
|
now: number
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
server.health === 'online' &&
|
||||||
|
!server.last_error &&
|
||||||
|
freshStatusTimestamp(server.last_seen_at, now)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fleetIdentity(server: FleetServer) {
|
export function fleetIdentity(
|
||||||
|
server: Pick<FleetServer, 'name' | 'game' | 'map' | 'map_slug' | 'game_mode' | 'last_map'>
|
||||||
|
) {
|
||||||
const parsed = parseServerName(server.name);
|
const parsed = parseServerName(server.name);
|
||||||
const game = server.game ?? parsed?.game ?? '';
|
const game = server.game ?? parsed?.game ?? '';
|
||||||
const mode = server.game_mode ?? parsed?.gameMode;
|
const mode = server.game_mode ?? parsed?.gameMode;
|
||||||
const selection = !mode || mode === 'zombie'
|
const selection =
|
||||||
? zombieMapBySlug(game, server.map_slug ?? parsed?.mapSlug ?? '') : undefined;
|
!mode || mode === 'zombie'
|
||||||
|
? zombieMapBySlug(game, server.map_slug ?? parsed?.mapSlug ?? '')
|
||||||
|
: undefined;
|
||||||
const map = server.last_map ?? server.map;
|
const map = server.last_map ?? server.map;
|
||||||
// A live map change must not keep the registered map's launch shortcut.
|
// A live map change must not keep the registered map's launch shortcut.
|
||||||
const matchingSelection = selection && (!map || selection.canonical === map) ? selection : undefined;
|
const matchingSelection =
|
||||||
|
selection && (!map || selection.canonical === map) ? selection : undefined;
|
||||||
return {
|
return {
|
||||||
game,
|
game,
|
||||||
title: serverTitle(server.name),
|
title: serverTitle(server.name),
|
||||||
map: matchingSelection?.friendly ?? mapTitle(map),
|
map: matchingSelection?.friendly ?? mapTitle(map),
|
||||||
group: `${game}:${matchingSelection?.slug ?? map ?? 'unknown'}`,
|
group: `${game}:${matchingSelection?.slug ?? map ?? 'unknown'}`,
|
||||||
launch: matchingSelection && (game === 't5' || game === 't6')
|
launch:
|
||||||
? { game: game as ZombieGame, slug: matchingSelection.slug } : null
|
matchingSelection && (game === 't5' || game === 't6')
|
||||||
|
? { game: game as ZombieGame, slug: matchingSelection.slug }
|
||||||
|
: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function currentRound(server: FleetServer, report: GameTelemetry | undefined, now: number): number | null {
|
export function currentRound(
|
||||||
if (server.telemetry && (!report || Date.parse(server.telemetry.receivedAt) > Date.parse(report.receivedAt))) report = server.telemetry;
|
server: Pick<
|
||||||
if (!report || (report.event !== 'round_change' && report.event !== 'heartbeat') || !freshServer(server, now) || !server.active_since || !server.game_started_at
|
FleetServer,
|
||||||
|| server.game_ended_at || (server.last_players ?? 0) === 0) return null;
|
| 'telemetry'
|
||||||
|
| 'telemetry_after'
|
||||||
|
| 'health'
|
||||||
|
| 'last_error'
|
||||||
|
| 'last_seen_at'
|
||||||
|
| 'last_players'
|
||||||
|
| 'active_since'
|
||||||
|
| 'game_started_at'
|
||||||
|
| 'game_ended_at'
|
||||||
|
>,
|
||||||
|
report: GameTelemetry | undefined,
|
||||||
|
now: number
|
||||||
|
): number | null {
|
||||||
|
if (
|
||||||
|
server.telemetry &&
|
||||||
|
(!report || Date.parse(server.telemetry.receivedAt) > Date.parse(report.receivedAt))
|
||||||
|
)
|
||||||
|
report = server.telemetry;
|
||||||
|
if (
|
||||||
|
!report ||
|
||||||
|
(report.event !== 'round_change' && report.event !== 'heartbeat') ||
|
||||||
|
!freshServer(server, now) ||
|
||||||
|
!server.active_since ||
|
||||||
|
server.game_ended_at ||
|
||||||
|
(server.last_players ?? 0) === 0
|
||||||
|
)
|
||||||
|
return null;
|
||||||
const received = Date.parse(report.receivedAt);
|
const received = Date.parse(report.receivedAt);
|
||||||
return freshTelemetry(report, now, server.telemetry_after) && received >= Date.parse(server.game_started_at)
|
return freshTelemetry(report, now, server.telemetry_after) &&
|
||||||
&& received >= Date.parse(server.active_since) ? report.round : null;
|
(!server.game_started_at || received >= Date.parse(server.game_started_at)) &&
|
||||||
|
received >= Date.parse(server.active_since)
|
||||||
|
? report.round
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesFleet(server: FleetServer, query: string, filter: FleetFilter, game: string,
|
export function matchesFleet(
|
||||||
pinned: readonly string[], operations: readonly FleetOperation[], now: number): boolean {
|
server: FleetServer,
|
||||||
|
query: string,
|
||||||
|
filter: FleetFilter,
|
||||||
|
game: string,
|
||||||
|
pinned: readonly string[],
|
||||||
|
operations: readonly FleetOperation[],
|
||||||
|
now: number
|
||||||
|
): boolean {
|
||||||
const identity = fleetIdentity(server);
|
const identity = fleetIdentity(server);
|
||||||
if (game && identity.game !== game) return false;
|
if (game && identity.game !== game) return false;
|
||||||
const fresh = freshServer(server, now);
|
const fresh = freshServer(server, now);
|
||||||
if (filter === 'occupied' && !(fresh && (server.last_players ?? 0) > 0)) return false;
|
if (filter === 'occupied' && !(fresh && (server.last_players ?? 0) > 0)) return false;
|
||||||
if (filter === 'empty' && !(fresh && server.last_players === 0)) return false;
|
if (filter === 'empty' && !(fresh && server.last_players === 0)) return false;
|
||||||
if (filter === 'attention' && !(server.health === 'offline' || server.health === 'stale'
|
if (
|
||||||
|| (server.health === 'online' && !fresh) || operations.some(op => op.server === server.name && op.state === 'attention'))) return false;
|
filter === 'attention' &&
|
||||||
|
!(
|
||||||
|
server.health === 'offline' ||
|
||||||
|
server.health === 'stale' ||
|
||||||
|
(server.health === 'online' && !fresh) ||
|
||||||
|
operations.some((op) => op.server === server.name && op.state === 'attention')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
if (filter === 'testing' && !server.held.length) return false;
|
if (filter === 'testing' && !server.held.length) return false;
|
||||||
if (filter === 'pinned' && !pinned.includes(server.name)) return false;
|
if (filter === 'pinned' && !pinned.includes(server.name)) return false;
|
||||||
const text = [server.name, identity.title, identity.map, identity.game,
|
const text = [
|
||||||
...(server.roster ?? []).map(player => player.name)].join(' ').toLocaleLowerCase();
|
server.name,
|
||||||
return query.trim().toLocaleLowerCase().split(/\s+/).every(term => text.includes(term));
|
identity.title,
|
||||||
|
identity.map,
|
||||||
|
identity.game,
|
||||||
|
...(server.roster ?? []).map((player) => player.name)
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLocaleLowerCase();
|
||||||
|
return query
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.every((term) => text.includes(term));
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-54
@@ -33,7 +33,6 @@ export interface FleetSettings {
|
|||||||
lifecycleConcurrency: number;
|
lifecycleConcurrency: number;
|
||||||
rebuildConcurrency: number;
|
rebuildConcurrency: number;
|
||||||
preparationConcurrency: number;
|
preparationConcurrency: number;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultFleetSettings: FleetSettings = {
|
export const defaultFleetSettings: FleetSettings = {
|
||||||
@@ -70,7 +69,7 @@ export const defaultFleetSettings: FleetSettings = {
|
|||||||
destroyConcurrencyPerHost: 0,
|
destroyConcurrencyPerHost: 0,
|
||||||
lifecycleConcurrency: 0,
|
lifecycleConcurrency: 0,
|
||||||
rebuildConcurrency: 0,
|
rebuildConcurrency: 0,
|
||||||
preparationConcurrency: 0,
|
preparationConcurrency: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
|
export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
|
||||||
@@ -107,12 +106,12 @@ export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
|
|||||||
destroyConcurrencyPerHost: 'Concurrent server removals per host',
|
destroyConcurrencyPerHost: 'Concurrent server removals per host',
|
||||||
lifecycleConcurrency: 'Combined lifecycle limit',
|
lifecycleConcurrency: 'Combined lifecycle limit',
|
||||||
rebuildConcurrency: 'Concurrent rebuilds',
|
rebuildConcurrency: 'Concurrent rebuilds',
|
||||||
preparationConcurrency: 'Hosts preparing game files simultaneously',
|
preparationConcurrency: 'Hosts preparing game files simultaneously'
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function validateFleetSettings(input: unknown): FleetSettings {
|
export function validateFleetSettings(input: unknown): FleetSettings {
|
||||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Provide fleet settings.');
|
if (!input || typeof input !== 'object' || Array.isArray(input))
|
||||||
|
throw new Error('Provide fleet settings.');
|
||||||
const values = input as Record<string, unknown>;
|
const values = input as Record<string, unknown>;
|
||||||
for (const key of Object.keys(values)) {
|
for (const key of Object.keys(values)) {
|
||||||
if (!Object.hasOwn(defaultFleetSettings, key)) throw new Error(`Unknown setting: ${key}`);
|
if (!Object.hasOwn(defaultFleetSettings, key)) throw new Error(`Unknown setting: ${key}`);
|
||||||
@@ -121,20 +120,68 @@ export function validateFleetSettings(input: unknown): FleetSettings {
|
|||||||
const value = values[key];
|
const value = values[key];
|
||||||
const minimum = settingMinimum(key);
|
const minimum = settingMinimum(key);
|
||||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) {
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) {
|
||||||
throw new Error(`${fleetSettingLabels[key]} must be ${minimum === 0 ? 'zero or a positive whole number' : 'a positive whole number'}.`);
|
throw new Error(
|
||||||
|
`${fleetSettingLabels[key]} must be ${minimum === 0 ? 'zero or a positive whole number' : 'a positive whole number'}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (key.endsWith('Days') && value > 36500) {
|
if (key.endsWith('Days') && value > 36500) {
|
||||||
throw new Error(`${fleetSettingLabels[key]} must be at most 36500 days.`);
|
throw new Error(`${fleetSettingLabels[key]} must be at most 36500 days.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Object.fromEntries(Object.keys(defaultFleetSettings).map(key => [key, values[key]])) as unknown as FleetSettings;
|
return Object.fromEntries(
|
||||||
|
Object.keys(defaultFleetSettings).map((key) => [key, values[key]])
|
||||||
|
) as unknown as FleetSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const additionalConcurrencyKeys: (keyof FleetSettings)[] = ["restartConcurrencyPerHost", "createConcurrencyPerHost", "destroyConcurrencyPerHost", "lifecycleConcurrency", "rebuildConcurrency", "preparationConcurrency"];
|
export const additionalConcurrencyKeys: (keyof FleetSettings)[] = [
|
||||||
export const concurrencyKeys: (keyof FleetSettings)[] = ['restartConcurrency', 'createConcurrency', 'destroyConcurrency', ...additionalConcurrencyKeys];
|
'restartConcurrencyPerHost',
|
||||||
export const retentionKeys: (keyof FleetSettings)[] = ['deployKeepDays', 'observationKeepDays', 'provisioningKeepDays', 'actionKeepDays', 'failureKeepDays', 'maintenanceKeepDays', 'recoveryKeepDays', 'hostResourceKeepDays', 'instanceResourceKeepDays', 'metricsKeepDays', 'telemetryKeepDays', 'telemetryMaxPerServer', 'telemetryMaxTotal', 'matchKeepDays', 'betaKeepDays', 'fileContentKeepDays', 'payloadKeepDays', 'versionKeepDays', 'versionKeepCount', 'gameFileGraceDays', 'gameFileKeepCount', 'uploadKeepDays', 'auditKeepDays', 'historyKeepCount'];
|
'createConcurrencyPerHost',
|
||||||
|
'destroyConcurrencyPerHost',
|
||||||
|
'lifecycleConcurrency',
|
||||||
|
'rebuildConcurrency',
|
||||||
|
'preparationConcurrency'
|
||||||
|
];
|
||||||
|
export const concurrencyKeys: (keyof FleetSettings)[] = [
|
||||||
|
'restartConcurrency',
|
||||||
|
'createConcurrency',
|
||||||
|
'destroyConcurrency',
|
||||||
|
...additionalConcurrencyKeys
|
||||||
|
];
|
||||||
|
export const retentionKeys: (keyof FleetSettings)[] = [
|
||||||
|
'deployKeepDays',
|
||||||
|
'observationKeepDays',
|
||||||
|
'provisioningKeepDays',
|
||||||
|
'actionKeepDays',
|
||||||
|
'failureKeepDays',
|
||||||
|
'maintenanceKeepDays',
|
||||||
|
'recoveryKeepDays',
|
||||||
|
'hostResourceKeepDays',
|
||||||
|
'instanceResourceKeepDays',
|
||||||
|
'metricsKeepDays',
|
||||||
|
'telemetryKeepDays',
|
||||||
|
'telemetryMaxPerServer',
|
||||||
|
'telemetryMaxTotal',
|
||||||
|
'matchKeepDays',
|
||||||
|
'betaKeepDays',
|
||||||
|
'fileContentKeepDays',
|
||||||
|
'payloadKeepDays',
|
||||||
|
'versionKeepDays',
|
||||||
|
'versionKeepCount',
|
||||||
|
'gameFileGraceDays',
|
||||||
|
'gameFileKeepCount',
|
||||||
|
'uploadKeepDays',
|
||||||
|
'auditKeepDays',
|
||||||
|
'historyKeepCount'
|
||||||
|
];
|
||||||
export function settingMinimum(key: keyof FleetSettings): number {
|
export function settingMinimum(key: keyof FleetSettings): number {
|
||||||
return ['restartConcurrency','createConcurrency','destroyConcurrency','gameFileKeepCount'].includes(key) ? 1 : 0;
|
return [
|
||||||
|
'restartConcurrency',
|
||||||
|
'createConcurrency',
|
||||||
|
'destroyConcurrency',
|
||||||
|
'gameFileKeepCount'
|
||||||
|
].includes(key)
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsGroup {
|
export interface SettingsGroup {
|
||||||
@@ -146,34 +193,94 @@ export interface SettingsGroup {
|
|||||||
|
|
||||||
/** Presentation groups do not change the saved schema or the section PATCH API. */
|
/** Presentation groups do not change the saved schema or the section PATCH API. */
|
||||||
export const settingsGroups: SettingsGroup[] = [
|
export const settingsGroups: SettingsGroup[] = [
|
||||||
{ id: 'concurrency', title: 'Concurrency', description: 'Limit simultaneous server operations and game-file preparation.', keys: concurrencyKeys },
|
{
|
||||||
{ id: 'operation-history', title: 'Operation history', description: 'Campaigns, server operations, maintenance, and routine checks.', keys: [
|
id: 'concurrency',
|
||||||
'deployKeepDays', 'provisioningKeepDays', 'actionKeepDays', 'observationKeepDays', 'failureKeepDays',
|
title: 'Concurrency',
|
||||||
'maintenanceKeepDays', 'recoveryKeepDays', 'betaKeepDays', 'historyKeepCount'
|
description: 'Limit simultaneous server operations and game-file preparation.',
|
||||||
] },
|
keys: concurrencyKeys
|
||||||
{ id: 'matches-players', title: 'Matches & players', description: 'Saved matches, player records, and leaderboard history.', keys: ['matchKeepDays'] },
|
},
|
||||||
{ id: 'telemetry-metrics', title: 'Telemetry & metrics', description: 'Game diagnostics, fleet activity, and host resource samples.', keys: [
|
{
|
||||||
'telemetryKeepDays', 'telemetryMaxPerServer', 'telemetryMaxTotal', 'metricsKeepDays', 'hostResourceKeepDays', 'instanceResourceKeepDays'
|
id: 'operation-history',
|
||||||
] },
|
title: 'Operation history',
|
||||||
{ id: 'files-versions', title: 'Files & versions', description: 'File snapshots, saved versions, game files, and abandoned uploads.', keys: [
|
description: 'Campaigns, server operations, maintenance, and routine checks.',
|
||||||
'fileContentKeepDays', 'payloadKeepDays', 'versionKeepDays', 'versionKeepCount', 'gameFileGraceDays', 'gameFileKeepCount', 'uploadKeepDays'
|
keys: [
|
||||||
] },
|
'deployKeepDays',
|
||||||
{ id: 'accounts-audit', title: 'Accounts & audit', description: 'Sign-in and account administration records.', keys: ['auditKeepDays'] }
|
'provisioningKeepDays',
|
||||||
|
'actionKeepDays',
|
||||||
|
'observationKeepDays',
|
||||||
|
'failureKeepDays',
|
||||||
|
'maintenanceKeepDays',
|
||||||
|
'recoveryKeepDays',
|
||||||
|
'betaKeepDays',
|
||||||
|
'historyKeepCount'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'matches-players',
|
||||||
|
title: 'Matches & players',
|
||||||
|
description: 'Saved matches, player records, and leaderboard history.',
|
||||||
|
keys: ['matchKeepDays']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'telemetry-metrics',
|
||||||
|
title: 'Telemetry & metrics',
|
||||||
|
description: 'Game diagnostics, fleet activity, and host resource samples.',
|
||||||
|
keys: [
|
||||||
|
'telemetryKeepDays',
|
||||||
|
'telemetryMaxPerServer',
|
||||||
|
'telemetryMaxTotal',
|
||||||
|
'metricsKeepDays',
|
||||||
|
'hostResourceKeepDays',
|
||||||
|
'instanceResourceKeepDays'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'files-versions',
|
||||||
|
title: 'Files & versions',
|
||||||
|
description: 'File snapshots, saved versions, game files, and abandoned uploads.',
|
||||||
|
keys: [
|
||||||
|
'fileContentKeepDays',
|
||||||
|
'payloadKeepDays',
|
||||||
|
'versionKeepDays',
|
||||||
|
'versionKeepCount',
|
||||||
|
'gameFileGraceDays',
|
||||||
|
'gameFileKeepCount',
|
||||||
|
'uploadKeepDays'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accounts-audit',
|
||||||
|
title: 'Accounts & audit',
|
||||||
|
description: 'Sign-in and account administration records.',
|
||||||
|
keys: ['auditKeepDays']
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Zero has a different meaning for age limits, count caps, and minimums. */
|
/** Zero has a different meaning for age limits, count caps, and minimums. */
|
||||||
export function settingZeroMode(key: keyof FleetSettings): string | null {
|
export function settingZeroMode(key: keyof FleetSettings): string | null {
|
||||||
if (settingMinimum(key) > 0) return null;
|
if (settingMinimum(key) > 0) return null;
|
||||||
if (additionalConcurrencyKeys.includes(key) || key === 'telemetryMaxPerServer' || key === 'telemetryMaxTotal') return 'No limit';
|
if (
|
||||||
|
additionalConcurrencyKeys.includes(key) ||
|
||||||
|
key === 'telemetryMaxPerServer' ||
|
||||||
|
key === 'telemetryMaxTotal'
|
||||||
|
)
|
||||||
|
return 'No limit';
|
||||||
if (key === 'versionKeepDays') return 'No age expiry';
|
if (key === 'versionKeepDays') return 'No age expiry';
|
||||||
if (key === 'gameFileGraceDays') return 'No delay';
|
if (key === 'gameFileGraceDays') return 'No delay';
|
||||||
if (key === 'versionKeepCount' || key === 'historyKeepCount') return 'No minimum';
|
if (key === 'versionKeepCount' || key === 'historyKeepCount') return 'No minimum';
|
||||||
return key.endsWith('Days') ? 'Forever' : null;
|
return key.endsWith('Days') ? 'Forever' : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function settingUnit(key: keyof FleetSettings): 'days' | 'records' | 'versions' | 'operations' | 'hosts' {
|
export function settingUnit(
|
||||||
|
key: keyof FleetSettings
|
||||||
|
): 'days' | 'records' | 'versions' | 'operations' | 'hosts' {
|
||||||
if (key.endsWith('Days')) return 'days';
|
if (key.endsWith('Days')) return 'days';
|
||||||
if (key === 'telemetryMaxPerServer' || key === 'telemetryMaxTotal' || key === 'historyKeepCount') return 'records';
|
if (
|
||||||
|
key === 'telemetryMaxPerServer' ||
|
||||||
|
key === 'telemetryMaxTotal' ||
|
||||||
|
key === 'historyKeepCount'
|
||||||
|
)
|
||||||
|
return 'records';
|
||||||
if (key === 'versionKeepCount' || key === 'gameFileKeepCount') return 'versions';
|
if (key === 'versionKeepCount' || key === 'gameFileKeepCount') return 'versions';
|
||||||
return key === 'preparationConcurrency' ? 'hosts' : 'operations';
|
return key === 'preparationConcurrency' ? 'hosts' : 'operations';
|
||||||
}
|
}
|
||||||
@@ -188,38 +295,64 @@ export function settingDisplayValue(key: keyof FleetSettings, value: number): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
|
export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
|
||||||
provisioningKeepDays: 'Completed lifecycle history. Recovery, source workflows and cancellation dependencies remain protected.',
|
provisioningKeepDays:
|
||||||
actionKeepDays: 'Completed agent actions. Referenced commands and retry evidence remain protected. Expired actions keep compact receipts to prevent duplicate execution.',
|
'Completed lifecycle history. Recovery, source workflows and cancellation dependencies remain protected.',
|
||||||
failureKeepDays: 'Explicitly resolved failed operations and campaigns, measured from resolution. Unresolved failures remain protected.',
|
actionKeepDays:
|
||||||
maintenanceKeepDays: 'Finished non-update maintenance. Update attempts remain durable to prevent duplicate automatic retries.',
|
'Completed agent actions. Referenced commands and retry evidence remain protected. Expired actions keep compact receipts to prevent duplicate execution.',
|
||||||
recoveryKeepDays: 'Resolved watchdog incidents with no unfinished recovery. Dismissed incidents remain as suppression evidence.',
|
failureKeepDays:
|
||||||
hostResourceKeepDays: 'Host CPU and memory history.',
|
'Explicitly resolved failed operations and campaigns, measured from resolution. Unresolved failures remain protected.',
|
||||||
instanceResourceKeepDays: 'Minute resource samples. Capacity learning continues to use its independent 24-hour window.',
|
maintenanceKeepDays:
|
||||||
|
'Finished non-update maintenance. Update attempts remain durable to prevent duplicate automatic retries.',
|
||||||
|
recoveryKeepDays:
|
||||||
|
'Resolved watchdog incidents with no unfinished recovery. Dismissed incidents remain as suppression evidence.',
|
||||||
|
hostResourceKeepDays: 'Host CPU, memory and temperature history.',
|
||||||
|
instanceResourceKeepDays:
|
||||||
|
'Minute resource samples. Capacity learning continues to use its independent 24-hour window.',
|
||||||
metricsKeepDays: 'Historical fleet occupancy and utilization samples.',
|
metricsKeepDays: 'Historical fleet occupancy and utilization samples.',
|
||||||
telemetryKeepDays: 'Raw received telemetry diagnostics. Independent of saved matches. Count limits also apply.',
|
telemetryKeepDays:
|
||||||
|
'Raw received telemetry diagnostics. Independent of saved matches. Count limits also apply.',
|
||||||
telemetryMaxPerServer: 'Count cap per server, in addition to the age limit.',
|
telemetryMaxPerServer: 'Count cap per server, in addition to the age limit.',
|
||||||
telemetryMaxTotal: 'Fleet-wide count cap for telemetry diagnostics, in addition to the age limit.',
|
telemetryMaxTotal:
|
||||||
matchKeepDays: 'Completed and interrupted matches and player details. Expiry changes leaderboards and player totals; no lifetime aggregates are preserved.',
|
'Fleet-wide count cap for telemetry diagnostics, in addition to the age limit.',
|
||||||
betaKeepDays: 'Closed betas, revisions and test evidence, after linked campaigns and recovery work no longer need them.',
|
matchKeepDays:
|
||||||
fileContentKeepDays: 'Expired campaign file bodies are removed while summaries remain. Rollback becomes unavailable for those campaigns. Active beta snapshots remain protected.',
|
'Completed and interrupted matches and player details. Expiry changes leaderboards and player totals; no lifetime aggregates are preserved.',
|
||||||
payloadKeepDays: 'Expire file bodies in completed launch recipes, commands and receipts while keeping their history. Unfinished work and protected records keep their files.',
|
betaKeepDays:
|
||||||
versionKeepDays: 'Unused script, plugin, configuration and baseline versions. With no age expiry, the version count policy may still remove older unused versions. Current and referenced versions are always protected.',
|
'Closed betas, revisions and test evidence, after linked campaigns and recovery work no longer need them.',
|
||||||
versionKeepCount: 'Keep at least this many recent versions per artifact. With no age expiry, a positive count removes older unused versions.',
|
fileContentKeepDays:
|
||||||
gameFileGraceDays: 'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected.',
|
'Expired campaign file bodies are removed while summaries remain. Rollback becomes unavailable for those campaigns. Active beta snapshots remain protected.',
|
||||||
gameFileKeepCount: 'Keep this many recent published versions per game, in addition to every referenced or protected version.',
|
payloadKeepDays:
|
||||||
uploadKeepDays: 'Remove incomplete browser uploads after this many days without activity. Active transfers and publication are protected.',
|
'Expire file bodies in completed launch recipes, commands and receipts while keeping their history. Unfinished work and protected records keep their files.',
|
||||||
|
versionKeepDays:
|
||||||
|
'Unused script, plugin, configuration and baseline versions. With no age expiry, the version count policy may still remove older unused versions. Current and referenced versions are always protected.',
|
||||||
|
versionKeepCount:
|
||||||
|
'Keep at least this many recent versions per artifact. With no age expiry, a positive count removes older unused versions.',
|
||||||
|
gameFileGraceDays:
|
||||||
|
'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected.',
|
||||||
|
gameFileKeepCount:
|
||||||
|
'Keep this many recent published versions per game, in addition to every referenced or protected version.',
|
||||||
|
uploadKeepDays:
|
||||||
|
'Remove incomplete browser uploads after this many days without activity. Active transfers and publication are protected.',
|
||||||
auditKeepDays: 'Recorded authentication and account-administration events.',
|
auditKeepDays: 'Recorded authentication and account-administration events.',
|
||||||
historyKeepCount: 'Keep at least this many recent records per history category. Does not apply to metrics, resource samples, telemetry count caps or game-file storage.',
|
historyKeepCount:
|
||||||
|
'Keep at least this many recent records per history category. Does not apply to metrics, resource samples, telemetry count caps or game-file storage.',
|
||||||
|
|
||||||
restartConcurrency: 'Fleet recovery slots stay occupied until container and game health are verified.',
|
restartConcurrency:
|
||||||
createConcurrency: 'Fleet startup slots cover creation and rebuilds through health and selected-map verification.',
|
'Fleet recovery slots stay occupied until container and game health are verified.',
|
||||||
|
createConcurrency:
|
||||||
|
'Fleet startup slots cover creation and rebuilds through health and selected-map verification.',
|
||||||
destroyConcurrency: 'Fleet removals hold capacity until teardown and cleanup finish.',
|
destroyConcurrency: 'Fleet removals hold capacity until teardown and cleanup finish.',
|
||||||
restartConcurrencyPerHost: 'Additional recovery limit on each host, including starts and rebuilds.',
|
restartConcurrencyPerHost:
|
||||||
|
'Additional recovery limit on each host, including starts and rebuilds.',
|
||||||
createConcurrencyPerHost: 'Additional startup limit on each host, including rebuilds.',
|
createConcurrencyPerHost: 'Additional startup limit on each host, including rebuilds.',
|
||||||
destroyConcurrencyPerHost: 'Additional removal limit on each host, including migration source removal.',
|
destroyConcurrencyPerHost:
|
||||||
lifecycleConcurrency: 'Combined fleet limit for creates, starts, stops, restarts, rebuilds and removals. Each workflow counts once.',
|
'Additional removal limit on each host, including migration source removal.',
|
||||||
|
lifecycleConcurrency:
|
||||||
|
'Combined fleet limit for creates, starts, stops, restarts, rebuilds and removals. Each workflow counts once.',
|
||||||
rebuildConcurrency: 'Additional fleet limit for rebuilds, including host drain migrations.',
|
rebuildConcurrency: 'Additional fleet limit for rebuilds, including host drain migrations.',
|
||||||
preparationConcurrency: 'Maximum hosts downloading or verifying game files at once. Interrupted transfers keep their slot until reconciled.',
|
preparationConcurrency:
|
||||||
deployKeepDays: 'Eligible campaign records and file diffs. Pending, unresolved failed, held and recovery-linked campaigns are protected.',
|
'Maximum hosts downloading or verifying game files at once. Interrupted transfers keep their slot until reconciled.',
|
||||||
observationKeepDays: 'Successful monitoring and file-read records. Failures and actions are retained.'
|
deployKeepDays:
|
||||||
|
'Eligible campaign records and file diffs. Pending, unresolved failed, held and recovery-linked campaigns are protected.',
|
||||||
|
observationKeepDays:
|
||||||
|
'Successful monitoring and file-read records. Failures and actions are retained.'
|
||||||
};
|
};
|
||||||
|
|||||||
+98
-32
@@ -4,6 +4,8 @@ export interface ChatMessage {
|
|||||||
slot: number;
|
slot: number;
|
||||||
player: string;
|
player: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
/** Omitted for public chat to preserve existing archived message identities. */
|
||||||
|
channel?: 'team';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatEntry =
|
export type ChatEntry =
|
||||||
@@ -17,14 +19,19 @@ export interface ChatContext {
|
|||||||
round: number | null;
|
round: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContextualChatEntry = ChatEntry & { context: ChatContext; matchId?: number };
|
export type ContextualChatEntry = ChatEntry & {
|
||||||
|
context: ChatContext;
|
||||||
|
matchId?: number;
|
||||||
|
/** Stable archive identity, independent of estimated wall-clock timestamps. */
|
||||||
|
messageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ChatMatch {
|
export interface ChatMatch {
|
||||||
id?: number;
|
id?: number;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
lastReceivedAt: string;
|
lastReceivedAt: string;
|
||||||
status: 'active' | 'completed' | 'interrupted';
|
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||||
startObserved: boolean;
|
startObserved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +52,20 @@ function logSeconds(time: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function plainText(value: string): string {
|
function plainText(value: string): string {
|
||||||
return value.replace(/\^[0-9]/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim();
|
return (
|
||||||
|
value
|
||||||
|
.replace(/\^[0-9]/g, '')
|
||||||
|
// eslint-disable-next-line no-control-regex -- Strip control characters from displayed chat text.
|
||||||
|
.replace(/[\x00-\x1f\x7f]/g, '')
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preserve log order: elapsed timestamps can reset between matches. */
|
/** Preserve log order: elapsed timestamps can reset between matches. */
|
||||||
export function parseGameChat(lines: readonly string[]): ChatMessage[] {
|
export function parseGameChat(lines: readonly string[]): ChatMessage[] {
|
||||||
return parseGameChatTimeline(lines).flatMap(entry => entry.type === 'message' ? [entry.message] : []);
|
return parseGameChatTimeline(lines).flatMap((entry) =>
|
||||||
|
entry.type === 'message' ? [entry.message] : []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep match boundaries alongside messages, including when the log clock resets. */
|
/** Keep match boundaries alongside messages, including when the log clock resets. */
|
||||||
@@ -85,12 +100,21 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
|
|||||||
previousSeconds = seconds;
|
previousSeconds = seconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
|
const match = /^\s*(\d+:[0-5]\d)\s+(say|sayteam);([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
const message = plainText(match[5]);
|
const message = plainText(match[6]);
|
||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
push({ type: 'message', message: { time: match[1], guid: match[2], slot: Number(match[3]),
|
push({
|
||||||
player: plainText(match[4]) || 'Unknown player', message } });
|
type: 'message',
|
||||||
|
message: {
|
||||||
|
time: match[1],
|
||||||
|
guid: match[3],
|
||||||
|
slot: Number(match[4]),
|
||||||
|
player: plainText(match[5]) || 'Unknown player',
|
||||||
|
message,
|
||||||
|
...(match[2] === 'sayteam' ? { channel: 'team' as const } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
@@ -100,11 +124,14 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
|
|||||||
* segment to the file's last write, then join observed gameplay at that time.
|
* segment to the file's last write, then join observed gameplay at that time.
|
||||||
* Resets have unknown downtime, so earlier segments cannot share this anchor.
|
* Resets have unknown downtime, so earlier segments cannot share this anchor.
|
||||||
*/
|
*/
|
||||||
export function enrichGameChatTimeline(lines: readonly string[], options: {
|
export function enrichGameChatTimeline(
|
||||||
mtime: string | null;
|
lines: readonly string[],
|
||||||
matches: readonly ChatMatch[];
|
options: {
|
||||||
reports: readonly ChatRoundReport[];
|
mtime: string | null;
|
||||||
}): ContextualChatEntry[] {
|
matches: readonly ChatMatch[];
|
||||||
|
reports: readonly ChatRoundReport[];
|
||||||
|
}
|
||||||
|
): ContextualChatEntry[] {
|
||||||
let segmentStart = 0;
|
let segmentStart = 0;
|
||||||
let previousSeconds: number | null = null;
|
let previousSeconds: number | null = null;
|
||||||
let lastLine = -1;
|
let lastLine = -1;
|
||||||
@@ -115,30 +142,43 @@ export function enrichGameChatTimeline(lines: readonly string[], options: {
|
|||||||
const event = line.slice(timestamp?.[0].length ?? 0).trimStart();
|
const event = line.slice(timestamp?.[0].length ?? 0).trimStart();
|
||||||
const lifecycle = /^(?:InitGame|ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event);
|
const lifecycle = /^(?:InitGame|ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event);
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
if (lifecycle) { segmentStart = index + 1; previousSeconds = null; }
|
if (lifecycle) {
|
||||||
|
segmentStart = index + 1;
|
||||||
|
previousSeconds = null;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const seconds = logSeconds(timestamp[1]);
|
const seconds = logSeconds(timestamp[1]);
|
||||||
if (previousSeconds !== null && (seconds < previousSeconds
|
if (
|
||||||
|| (seconds === 0 && /^InitGame(?:\s|:|$)/.test(event)))) segmentStart = index;
|
previousSeconds !== null &&
|
||||||
|
(seconds < previousSeconds || (seconds === 0 && /^InitGame(?:\s|:|$)/.test(event)))
|
||||||
|
)
|
||||||
|
segmentStart = index;
|
||||||
previousSeconds = seconds;
|
previousSeconds = seconds;
|
||||||
}
|
}
|
||||||
// An untimed final write cannot locate the preceding timed event in real time.
|
// An untimed final write cannot locate the preceding timed event in real time.
|
||||||
const finalTimestamp = lastLine < 0 ? null : logTimestamp(lines[lastLine]);
|
const finalTimestamp = lastLine < 0 ? null : logTimestamp(lines[lastLine]);
|
||||||
const writtenAt = options.mtime ? Date.parse(options.mtime) : NaN;
|
const writtenAt = options.mtime ? Date.parse(options.mtime) : NaN;
|
||||||
const offset = finalTimestamp ? writtenAt - logSeconds(finalTimestamp[1]) * 1000 : NaN;
|
const offset = finalTimestamp ? writtenAt - logSeconds(finalTimestamp[1]) * 1000 : NaN;
|
||||||
const reports = options.reports.map(report => ({ ...report, at: Date.parse(report.receivedAt) }))
|
const reports = options.reports
|
||||||
|
.map((report) => ({ ...report, at: Date.parse(report.receivedAt) }))
|
||||||
.sort((a, b) => b.at - a.at);
|
.sort((a, b) => b.at - a.at);
|
||||||
const matches = options.matches.map(match => ({ ...match, start: Date.parse(match.startedAt) }))
|
const matches = options.matches
|
||||||
|
.map((match) => ({ ...match, start: Date.parse(match.startedAt) }))
|
||||||
.sort((a, b) => b.start - a.start)
|
.sort((a, b) => b.start - a.start)
|
||||||
.map((match, index, sorted) => {
|
.map((match, index, sorted) => {
|
||||||
const nextStart = sorted[index - 1]?.start ?? Infinity;
|
const nextStart = sorted[index - 1]?.start ?? Infinity;
|
||||||
// Identical heartbeats are deduplicated by the leaderboard, but still
|
// Identical heartbeats are deduplicated by the leaderboard, but still
|
||||||
// establish that this match was running when the message was sent.
|
// establish that this match was running when the message was sent.
|
||||||
const latest = reports.find(report => report.at >= match.start && report.at < nextStart);
|
const latest = reports.find(
|
||||||
|
(report) => report.at >= match.start && report.at < nextStart
|
||||||
|
);
|
||||||
const lastSeen = Math.max(Date.parse(match.lastReceivedAt), latest?.at ?? -Infinity);
|
const lastSeen = Math.max(Date.parse(match.lastReceivedAt), latest?.at ?? -Infinity);
|
||||||
const end = match.endedAt ? Date.parse(match.endedAt)
|
const end = match.endedAt
|
||||||
: match.status === 'active' ? lastSeen + 120_000 : Date.parse(match.lastReceivedAt);
|
? Date.parse(match.endedAt)
|
||||||
|
: match.status === 'active'
|
||||||
|
? lastSeen + 120_000
|
||||||
|
: Date.parse(match.lastReceivedAt);
|
||||||
return { ...match, end: Math.min(end, nextStart) };
|
return { ...match, end: Math.min(end, nextStart) };
|
||||||
});
|
});
|
||||||
let logStartedAt: number | null = null;
|
let logStartedAt: number | null = null;
|
||||||
@@ -147,8 +187,10 @@ export function enrichGameChatTimeline(lines: readonly string[], options: {
|
|||||||
return indexedTimeline(lines).map(({ entry, lineIndex }) => {
|
return indexedTimeline(lines).map(({ entry, lineIndex }) => {
|
||||||
const context: ChatContext = { realTime: null, matchElapsedMs: null, round: null };
|
const context: ChatContext = { realTime: null, matchElapsedMs: null, round: null };
|
||||||
const time = entry.type === 'message' ? entry.message.time : entry.time;
|
const time = entry.type === 'message' ? entry.message.time : entry.time;
|
||||||
const at = time !== null && lineIndex >= segmentStart ? offset + logSeconds(time) * 1000 : NaN;
|
const at =
|
||||||
if (Number.isFinite(at) && Number.isFinite(new Date(at).getTime())) context.realTime = new Date(at).toISOString();
|
time !== null && lineIndex >= segmentStart ? offset + logSeconds(time) * 1000 : NaN;
|
||||||
|
if (Number.isFinite(at) && Number.isFinite(new Date(at).getTime()))
|
||||||
|
context.realTime = new Date(at).toISOString();
|
||||||
if (entry.type === 'boundary' && entry.event !== 'end') {
|
if (entry.type === 'boundary' && entry.event !== 'end') {
|
||||||
// The first event after an implicit reset can be several seconds into
|
// The first event after an implicit reset can be several seconds into
|
||||||
// the new process. Its clock origin is the lower bound, not that event.
|
// the new process. Its clock origin is the lower bound, not that event.
|
||||||
@@ -156,20 +198,44 @@ export function enrichGameChatTimeline(lines: readonly string[], options: {
|
|||||||
logStartedAt = context.realTime ? (entry.event === 'reset' ? offset : at) : Infinity;
|
logStartedAt = context.realTime ? (entry.event === 'reset' ? offset : at) : Infinity;
|
||||||
logEnded = false;
|
logEnded = false;
|
||||||
}
|
}
|
||||||
const match = context.realTime && !logEnded ? matches.find(match => at >= match.start && at <= match.end
|
const match =
|
||||||
// Allow the log's one-second precision at a start, but never cross a later InitGame.
|
context.realTime && !logEnded
|
||||||
&& (logStartedAt === null || match.start + 1000 >= logStartedAt
|
? matches.find(
|
||||||
|| reports.some(report => report.event === 'game_start' && report.playerCount > 0
|
(match) =>
|
||||||
&& report.at >= match.start && report.at <= at && report.at + 1000 >= logStartedAt!))) : undefined;
|
at >= match.start &&
|
||||||
|
at <= match.end &&
|
||||||
|
// Allow the log's one-second precision at a start, but never cross a later InitGame.
|
||||||
|
(logStartedAt === null ||
|
||||||
|
match.start + 1000 >= logStartedAt ||
|
||||||
|
reports.some(
|
||||||
|
(report) =>
|
||||||
|
report.event === 'game_start' &&
|
||||||
|
report.playerCount > 0 &&
|
||||||
|
report.at >= match.start &&
|
||||||
|
report.at <= at &&
|
||||||
|
report.at + 1000 >= logStartedAt!
|
||||||
|
))
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
if (match) {
|
if (match) {
|
||||||
const report = reports.find(report => report.at <= at && report.at >= match.start);
|
const report = reports.find((report) => report.at <= at && report.at >= match.start);
|
||||||
if (!report || (report.event !== 'game_end' && report.playerCount > 0)) {
|
if (!report || (report.event !== 'game_end' && report.playerCount > 0)) {
|
||||||
// A repeated start updates the fleet timer even if the leaderboard
|
// A repeated start updates the fleet timer even if the leaderboard
|
||||||
// deduplicates it. Use that same observed start for later messages.
|
// deduplicates it. Use that same observed start for later messages.
|
||||||
const start = reports.find(report => report.event === 'game_start' && report.playerCount > 0
|
const start =
|
||||||
&& report.at >= match.start && report.at <= at)?.at ?? (match.startObserved ? match.start : null);
|
reports.find(
|
||||||
|
(report) =>
|
||||||
|
report.event === 'game_start' &&
|
||||||
|
report.playerCount > 0 &&
|
||||||
|
report.at >= match.start &&
|
||||||
|
report.at <= at
|
||||||
|
)?.at ?? (match.startObserved ? match.start : null);
|
||||||
if (start !== null) context.matchElapsedMs = at - start;
|
if (start !== null) context.matchElapsedMs = at - start;
|
||||||
if (report && at - report.at <= 120_000 && (report.event === 'round_change' || report.event === 'heartbeat')) {
|
if (
|
||||||
|
report &&
|
||||||
|
at - report.at <= 120_000 &&
|
||||||
|
(report.event === 'round_change' || report.event === 'heartbeat')
|
||||||
|
) {
|
||||||
context.round = report.round;
|
context.round = report.round;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-16
@@ -4,33 +4,65 @@ export const GAME_FILE_GAMES = [
|
|||||||
{ id: 't6', name: 'Black Ops II' }
|
{ id: 't6', name: 'Black Ops II' }
|
||||||
] as const;
|
] as const;
|
||||||
export type GameLibraryState = 'importing' | 'ready' | 'failed';
|
export type GameLibraryState = 'importing' | 'ready' | 'failed';
|
||||||
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
|
export type LibraryCacheState =
|
||||||
|
'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
|
||||||
|
|
||||||
export interface GameFileEntry { path: string; size: number; sha256: string }
|
export interface GameFileEntry {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
export interface LibraryTransferProgress {
|
export interface LibraryTransferProgress {
|
||||||
phase: 'downloading' | 'verifying'; startedAt: string;
|
phase: 'downloading' | 'verifying';
|
||||||
elapsedSeconds: number; phaseElapsedSeconds: number;
|
startedAt: string;
|
||||||
bytesPerSecond: number | null; etaSeconds: number | null;
|
elapsedSeconds: number;
|
||||||
|
phaseElapsedSeconds: number;
|
||||||
|
bytesPerSecond: number | null;
|
||||||
|
etaSeconds: number | null;
|
||||||
}
|
}
|
||||||
export interface HostLibraryCache {
|
export interface HostLibraryCache {
|
||||||
hostId: string; libraryVersionId: string; status: LibraryCacheState;
|
hostId: string;
|
||||||
bytesDownloaded: number; bytesTotal: number; manifestSha256: string | null;
|
libraryVersionId: string;
|
||||||
error: string | null; updatedAt: string; operationId: string | null;
|
status: LibraryCacheState;
|
||||||
bytesVerified?: number; transfer?: LibraryTransferProgress;
|
bytesDownloaded: number;
|
||||||
|
bytesTotal: number;
|
||||||
|
manifestSha256: string | null;
|
||||||
|
error: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
operationId: string | null;
|
||||||
|
bytesVerified?: number;
|
||||||
|
transfer?: LibraryTransferProgress;
|
||||||
}
|
}
|
||||||
export interface GameLibraryVersion {
|
export interface GameLibraryVersion {
|
||||||
id: string; libraryId: string; game: GameLibraryGame; version: string;
|
id: string;
|
||||||
description: string; state: GameLibraryState; sizeBytes: number;
|
libraryId: string;
|
||||||
fileCount: number; manifestSha256: string | null; files: GameFileEntry[];
|
game: GameLibraryGame;
|
||||||
createdAt: string; publishedAt: string | null; storageLocation: string;
|
version: string;
|
||||||
sourceHostId: string | null; sourceVolume: string | null; error: string | null; operationId: string | null;
|
description: string;
|
||||||
|
state: GameLibraryState;
|
||||||
|
sizeBytes: number;
|
||||||
|
fileCount: number;
|
||||||
|
manifestSha256: string | null;
|
||||||
|
files: GameFileEntry[];
|
||||||
|
createdAt: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
storageLocation: string;
|
||||||
|
sourceHostId: string | null;
|
||||||
|
sourceVolume: string | null;
|
||||||
|
error: string | null;
|
||||||
|
operationId: string | null;
|
||||||
caches: HostLibraryCache[];
|
caches: HostLibraryCache[];
|
||||||
filesRemoved?: boolean;
|
filesRemoved?: boolean;
|
||||||
filesRemoving?: boolean;
|
filesRemoving?: boolean;
|
||||||
}
|
}
|
||||||
export interface GameLibrary {
|
export interface GameLibrary {
|
||||||
id: string; name: string; game: GameLibraryGame; description: string;
|
id: string;
|
||||||
createdAt: string; latestVersionId: string | null; versions: GameLibraryVersion[];
|
name: string;
|
||||||
|
game: GameLibraryGame;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
latestVersionId: string | null;
|
||||||
|
versions: GameLibraryVersion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatGameBytes(bytes: number): string {
|
export function formatGameBytes(bytes: number): string {
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
export const hostOperationTitles: Record<string, string> = {
|
export const hostOperationTitles: Record<string, string> = {
|
||||||
'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container',
|
'instance.create': 'Create server container',
|
||||||
'instance.start': 'Start server container', 'instance.restart': 'Restart server container',
|
'instance.rebuild': 'Rebuild server container',
|
||||||
'instance.stop': 'Stop server container', 'instance.remove': 'Destroy server container',
|
'instance.start': 'Start server container',
|
||||||
'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container',
|
'instance.restart': 'Restart server container',
|
||||||
'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime',
|
'instance.stop': 'Stop server container',
|
||||||
'library.download': 'Download game files', 'library.import': 'Import game files',
|
'instance.remove': 'Destroy server container',
|
||||||
'plutonium.prepare': 'Prepare game update', 'files.stage': 'Stage server files',
|
'instance.cancel-create': 'Cancel server creation',
|
||||||
'files.apply': 'Apply server files', 'files.read': 'Read server files', 'files.list': 'List server files',
|
'instance.inspect': 'Check server container',
|
||||||
'files.stat': 'Check server files', 'files.manifest': 'Check file versions', 'files.remove': 'Remove server files',
|
'host.inspect': 'Check host',
|
||||||
'rcon.status-batch': 'Check game status', 'rcon.execute': 'Game command', 'logs.read': 'Read server logs'
|
'host.decommission': 'Remove host',
|
||||||
|
'runtime.pull': 'Prepare container runtime',
|
||||||
|
'library.download': 'Download game files',
|
||||||
|
'library.import': 'Import game files',
|
||||||
|
'plutonium.prepare': 'Prepare game update',
|
||||||
|
'files.stage': 'Stage server files',
|
||||||
|
'files.apply': 'Apply server files',
|
||||||
|
'files.read': 'Read server files',
|
||||||
|
'files.list': 'List server files',
|
||||||
|
'files.stat': 'Check server files',
|
||||||
|
'files.manifest': 'Check file versions',
|
||||||
|
'files.remove': 'Remove server files',
|
||||||
|
'rcon.status-batch': 'Check game status',
|
||||||
|
'rcon.execute': 'Game command',
|
||||||
|
'logs.read': 'Read server logs'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function hostOperationTitle(kind: string): string {
|
export function hostOperationTitle(kind: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
export const temperatureKinds = ['cpu-package', 'cpu-die', 'cpu-core', 'nvme', 'chipset'] as const;
|
||||||
|
export type TemperatureKind = (typeof temperatureKinds)[number];
|
||||||
|
export interface HostTemperatureSensor {
|
||||||
|
id: string;
|
||||||
|
device: string;
|
||||||
|
label: string;
|
||||||
|
kind: TemperatureKind;
|
||||||
|
celsius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional telemetry must never make an otherwise valid heartbeat fail. */
|
||||||
|
export function validateTemperatures(value: unknown): HostTemperatureSensor[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return value.slice(0, 128).flatMap((sensor) => {
|
||||||
|
if (
|
||||||
|
!sensor ||
|
||||||
|
typeof sensor !== 'object' ||
|
||||||
|
Array.isArray(sensor) ||
|
||||||
|
typeof sensor.id !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9_-]{1,100}$/.test(sensor.id) ||
|
||||||
|
seen.has(sensor.id) ||
|
||||||
|
!['device', 'label'].every(
|
||||||
|
(key) =>
|
||||||
|
typeof sensor[key] === 'string' &&
|
||||||
|
sensor[key].length > 0 &&
|
||||||
|
sensor[key].length <= 120
|
||||||
|
) ||
|
||||||
|
!temperatureKinds.includes(sensor.kind) ||
|
||||||
|
typeof sensor.celsius !== 'number' ||
|
||||||
|
!Number.isFinite(sensor.celsius) ||
|
||||||
|
sensor.celsius < -100 ||
|
||||||
|
sensor.celsius > 200
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
seen.add(sensor.id);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: sensor.id,
|
||||||
|
device: sensor.device,
|
||||||
|
label: sensor.label,
|
||||||
|
kind: sensor.kind,
|
||||||
|
celsius: sensor.celsius
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cpuTemperature(
|
||||||
|
sensors: HostTemperatureSensor[] = []
|
||||||
|
): HostTemperatureSensor | null {
|
||||||
|
// Prefer physical package/die readings. Cores are a fallback, never mixed
|
||||||
|
// into an average that might hide a hot package on a multi-socket host.
|
||||||
|
for (const kind of ['cpu-package', 'cpu-die', 'cpu-core'] as const) {
|
||||||
|
const candidates = sensors.filter((sensor) => sensor.kind === kind);
|
||||||
|
if (candidates.length)
|
||||||
|
return candidates.reduce((hot, sensor) =>
|
||||||
|
sensor.celsius > hot.celsius ? sensor : hot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function temperatureLabel(sensor: HostTemperatureSensor): string {
|
||||||
|
if (sensor.kind === 'cpu-package')
|
||||||
|
return `CPU package ${sensor.label.replace(/^Package id /, '')}`;
|
||||||
|
if (sensor.kind === 'cpu-core') return `CPU ${sensor.label.toLowerCase()}`;
|
||||||
|
if (sensor.kind === 'cpu-die') return `CPU die · ${sensor.label}`;
|
||||||
|
if (sensor.kind === 'chipset') return 'Chipset';
|
||||||
|
return `NVMe · ${sensor.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTemperature(celsius: number | null | undefined): string {
|
||||||
|
return typeof celsius === 'number' && Number.isFinite(celsius)
|
||||||
|
? `${Number(celsius.toFixed(1))} °C`
|
||||||
|
: 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One CPU series per host, plus bounded device series for the host drill-down. */
|
||||||
|
export function historyTemperatures(
|
||||||
|
sensors: HostTemperatureSensor[] = []
|
||||||
|
): HostTemperatureSensor[] {
|
||||||
|
const cpu = cpuTemperature(sensors);
|
||||||
|
const devices = sensors
|
||||||
|
.filter(
|
||||||
|
(sensor) =>
|
||||||
|
sensor.kind === 'chipset' ||
|
||||||
|
(sensor.kind === 'nvme' && sensor.label.toLowerCase() === 'composite')
|
||||||
|
)
|
||||||
|
.slice(0, 16);
|
||||||
|
return [...(cpu ? [{ ...cpu, id: 'cpu', label: 'CPU temperature' }] : []), ...devices];
|
||||||
|
}
|
||||||
+209
-58
@@ -1,31 +1,68 @@
|
|||||||
import type { HostLibraryCache } from './game-files.js';
|
import type { HostLibraryCache } from './game-files.js';
|
||||||
|
import type { HostTemperatureSensor } from './host-temperatures.js';
|
||||||
|
|
||||||
export type HostMode = 'active' | 'draining' | 'disabled';
|
export type HostMode = 'active' | 'draining' | 'disabled';
|
||||||
export type HostReadiness = 'preparing' | 'needs-runtime' | 'needs-game-files' | 'ready' | 'degraded' | 'draining' | 'disabled' | 'deleting';
|
export type HostReadiness =
|
||||||
export type HostOperationState = 'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
|
| 'preparing'
|
||||||
|
| 'needs-runtime'
|
||||||
|
| 'needs-game-files'
|
||||||
|
| 'ready'
|
||||||
|
| 'degraded'
|
||||||
|
| 'draining'
|
||||||
|
| 'disabled'
|
||||||
|
| 'deleting';
|
||||||
|
export type HostOperationState =
|
||||||
|
'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
|
||||||
|
|
||||||
export function hostReadinessLabel(readiness: HostReadiness): string {
|
export function hostReadinessLabel(readiness: HostReadiness): string {
|
||||||
return { preparing: 'Preparing', 'needs-runtime': 'Needs runtime', 'needs-game-files': 'Needs game files',
|
return {
|
||||||
ready: 'Ready', degraded: 'Degraded', draining: 'Draining', disabled: 'Disabled', deleting: 'Deleting' }[readiness];
|
preparing: 'Preparing',
|
||||||
|
'needs-runtime': 'Needs runtime',
|
||||||
|
'needs-game-files': 'Needs game files',
|
||||||
|
ready: 'Ready',
|
||||||
|
degraded: 'Degraded',
|
||||||
|
draining: 'Draining',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
deleting: 'Deleting'
|
||||||
|
}[readiness];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostDeletion { allowed: boolean; blockers: string[] }
|
export interface HostDeletion {
|
||||||
|
allowed: boolean;
|
||||||
|
blockers: string[];
|
||||||
|
}
|
||||||
export interface HostDecommission {
|
export interface HostDecommission {
|
||||||
operationId: string; stage: 'queued' | 'running' | 'cleaned' | 'failed' | 'completed';
|
operationId: string;
|
||||||
requestedAt: string; updatedAt: string; error: string | null; retryable: boolean;
|
stage: 'queued' | 'running' | 'cleaned' | 'failed' | 'completed';
|
||||||
|
requestedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
error: string | null;
|
||||||
|
retryable: boolean;
|
||||||
}
|
}
|
||||||
export function hostDeletionMessage(host: Pick<Host, 'connected' | 'decommission'>): string {
|
export function hostDeletionMessage(host: Pick<Host, 'connected' | 'decommission'>): string {
|
||||||
if (!host.decommission) return '';
|
if (!host.decommission) return '';
|
||||||
switch (host.decommission.stage) {
|
switch (host.decommission.stage) {
|
||||||
case 'queued': return host.connected ? 'Host cleanup is queued.' : 'Waiting for the host to reconnect before removing its files and agent.';
|
case 'queued':
|
||||||
case 'running': return 'Removing host files, cached game libraries, and the agent installation.';
|
return host.connected
|
||||||
case 'cleaned': return 'Host data is removed. Waiting for confirmation that the agent has uninstalled itself.';
|
? 'Host cleanup is queued.'
|
||||||
case 'failed': return 'Cleanup needs attention. Complete removal has not been confirmed.';
|
: 'Waiting for the host to reconnect before removing its files and agent.';
|
||||||
case 'completed': return 'Host files and agent removal confirmed.';
|
case 'running':
|
||||||
|
return 'Removing host files, cached game libraries, and the agent installation.';
|
||||||
|
case 'cleaned':
|
||||||
|
return 'Host data is removed. Waiting for confirmation that the agent has uninstalled itself.';
|
||||||
|
case 'failed':
|
||||||
|
return 'Cleanup needs attention. Complete removal has not been confirmed.';
|
||||||
|
case 'completed':
|
||||||
|
return 'Host files and agent removal confirmed.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostLibrary { id: string; state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; progress?: number; error?: string }
|
export interface HostLibrary {
|
||||||
|
id: string;
|
||||||
|
state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
|
||||||
|
progress?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
export interface HostAgentUpdate {
|
export interface HostAgentUpdate {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
state: 'current' | 'checking' | 'deferred' | 'updating' | 'updated' | 'failed';
|
state: 'current' | 'checking' | 'deferred' | 'updating' | 'updated' | 'failed';
|
||||||
@@ -37,50 +74,97 @@ export interface HostAgentUpdate {
|
|||||||
|
|
||||||
export interface HostAgentLane {
|
export interface HostAgentLane {
|
||||||
name: 'health-1' | 'health-2' | 'read' | 'write';
|
name: 'health-1' | 'health-2' | 'read' | 'write';
|
||||||
alive: boolean; busy: boolean; lastPollAt: string | null; pollAgeSeconds: number;
|
alive: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
lastPollAt: string | null;
|
||||||
|
pollAgeSeconds: number;
|
||||||
}
|
}
|
||||||
export function hostAgentLaneLabel(name: HostAgentLane['name']): string {
|
export function hostAgentLaneLabel(name: HostAgentLane['name']): string {
|
||||||
return { 'health-1': 'Health check 1', 'health-2': 'Health check 2', read: 'File reads', write: 'Actions' }[name];
|
return {
|
||||||
|
'health-1': 'Health check 1',
|
||||||
|
'health-2': 'Health check 2',
|
||||||
|
read: 'File reads',
|
||||||
|
write: 'Actions'
|
||||||
|
}[name];
|
||||||
}
|
}
|
||||||
export function hostAgentLaneState(lane: HostAgentLane): 'stopped' | 'stalled' | 'busy' | 'starting' | 'polling' {
|
export function hostAgentLaneState(
|
||||||
|
lane: HostAgentLane
|
||||||
|
): 'stopped' | 'stalled' | 'busy' | 'starting' | 'polling' {
|
||||||
if (!lane.alive) return 'stopped';
|
if (!lane.alive) return 'stopped';
|
||||||
// Reads and mutations can legitimately take minutes. Health work has a
|
// Reads and mutations can legitimately take minutes. Health work has a
|
||||||
// bounded budget; an old poll there indicates a blocked worker even if busy.
|
// bounded budget; an old poll there indicates a blocked worker even if busy.
|
||||||
if (lane.pollAgeSeconds > 90 && (!lane.busy || lane.name.startsWith('health-'))) return 'stalled';
|
if (lane.pollAgeSeconds > 90 && (!lane.busy || lane.name.startsWith('health-')))
|
||||||
|
return 'stalled';
|
||||||
return lane.busy ? 'busy' : lane.lastPollAt === null ? 'starting' : 'polling';
|
return lane.busy ? 'busy' : lane.lastPollAt === null ? 'starting' : 'polling';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hostAgentUpdatesEnabled(inventory: HostInventory): boolean {
|
export function hostAgentUpdatesEnabled(inventory: HostInventory): boolean {
|
||||||
return inventory.agentUpdate?.enabled === true && inventory.capabilities?.includes('agent.update') === true;
|
return (
|
||||||
|
inventory.agentUpdate?.enabled === true &&
|
||||||
|
inventory.capabilities?.includes('agent.update') === true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hostAgentUpdateLabel(inventory: HostInventory): string {
|
export function hostAgentUpdateLabel(inventory: HostInventory): string {
|
||||||
if (!hostAgentUpdatesEnabled(inventory)) return 'Setup required';
|
if (!hostAgentUpdatesEnabled(inventory)) return 'Setup required';
|
||||||
const update = inventory.agentUpdate!;
|
const update = inventory.agentUpdate!;
|
||||||
if (update.state === 'current' && !update.checkedAt) return 'Awaiting first check';
|
if (update.state === 'current' && !update.checkedAt) return 'Awaiting first check';
|
||||||
return { current: 'Current', checking: 'Checking', deferred: 'Deferred',
|
return {
|
||||||
updating: 'Updating', updated: 'Updated', failed: 'Needs attention' }[update.state];
|
current: 'Current',
|
||||||
|
checking: 'Checking',
|
||||||
|
deferred: 'Deferred',
|
||||||
|
updating: 'Updating',
|
||||||
|
updated: 'Updated',
|
||||||
|
failed: 'Needs attention'
|
||||||
|
}[update.state];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hostAgentUpdateMessage(inventory: HostInventory): string {
|
export function hostAgentUpdateMessage(inventory: HostInventory): string {
|
||||||
if (!hostAgentUpdatesEnabled(inventory)) return 'Run the current installer once on this host to enable automatic agent updates.';
|
if (!hostAgentUpdatesEnabled(inventory))
|
||||||
|
return 'Run the current installer once on this host to enable automatic agent updates.';
|
||||||
const update = inventory.agentUpdate!;
|
const update = inventory.agentUpdate!;
|
||||||
switch (update.state) {
|
switch (update.state) {
|
||||||
case 'current': return update.checkedAt ? 'The last check found no newer compatible agent release.' : 'The host timer will check for a compatible agent release automatically.';
|
case 'current':
|
||||||
case 'checking': return 'The host is checking the controller release and verifying its download.';
|
return update.checkedAt
|
||||||
case 'deferred': return 'The updater is waiting for the reported condition to clear. The host checks again automatically.';
|
? 'The last check found no newer compatible agent release.'
|
||||||
case 'updating': return 'The host is replacing agent code and checking that the new process connects successfully.';
|
: 'The host timer will check for a compatible agent release automatically.';
|
||||||
case 'updated': return 'The new agent connected successfully. Automatic checks continue.';
|
case 'checking':
|
||||||
case 'failed': return 'The update did not complete successfully. Review the reported error before retrying setup.';
|
return 'The host is checking the controller release and verifying its download.';
|
||||||
|
case 'deferred':
|
||||||
|
return 'The updater is waiting for the reported condition to clear. The host checks again automatically.';
|
||||||
|
case 'updating':
|
||||||
|
return 'The host is replacing agent code and checking that the new process connects successfully.';
|
||||||
|
case 'updated':
|
||||||
|
return 'The new agent connected successfully. Automatic checks continue.';
|
||||||
|
case 'failed':
|
||||||
|
return 'The update did not complete successfully. Review the reported error before retrying setup.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostInventory {
|
export interface HostInventory {
|
||||||
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string;
|
hostname?: string;
|
||||||
cpu?: string; cpuCount?: number; cpuUsagePercent?: number; memoryTotal?: number; memoryAvailable?: number;
|
agentVersion?: string;
|
||||||
storageTotal?: number; storageAvailable?: number; dockerVersion?: string; dockerRunning?: boolean;
|
protocolVersion?: number;
|
||||||
directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[];
|
os?: string;
|
||||||
currentOperation?: string | null; [key: string]: unknown;
|
architecture?: string;
|
||||||
|
cpu?: string;
|
||||||
|
cpuCount?: number;
|
||||||
|
cpuUsagePercent?: number;
|
||||||
|
temperatures?: HostTemperatureSensor[];
|
||||||
|
memoryTotal?: number;
|
||||||
|
memoryAvailable?: number;
|
||||||
|
storageTotal?: number;
|
||||||
|
storageAvailable?: number;
|
||||||
|
dockerVersion?: string;
|
||||||
|
dockerRunning?: boolean;
|
||||||
|
directoriesWritable?: boolean;
|
||||||
|
/** Agent-reported Docker cache; removed from presented host inventory. */
|
||||||
|
runtimeImages?: string[];
|
||||||
|
/** Prepared, locally available image references eligible for launches. */
|
||||||
|
preparedRuntimeImages?: string[];
|
||||||
|
libraries?: HostLibrary[];
|
||||||
|
currentOperation?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
plutoniumCache?: { revision: number; bytes: number };
|
plutoniumCache?: { revision: number; bytes: number };
|
||||||
agentUpdate?: HostAgentUpdate;
|
agentUpdate?: HostAgentUpdate;
|
||||||
@@ -90,37 +174,82 @@ export interface HostInventory {
|
|||||||
instanceStates?: InstanceState[];
|
instanceStates?: InstanceState[];
|
||||||
}
|
}
|
||||||
export interface InstanceState {
|
export interface InstanceState {
|
||||||
instanceId: string; generation: number; state: string; healthy: boolean; observedAt: string;
|
instanceId: string;
|
||||||
|
generation: number;
|
||||||
|
state: string;
|
||||||
|
healthy: boolean;
|
||||||
|
observedAt: string;
|
||||||
}
|
}
|
||||||
export interface InstanceResources {
|
export interface InstanceResources {
|
||||||
instanceId: string; generation: number; uptimeSeconds: number; cpuCores: number; memoryBytes: number;
|
instanceId: string;
|
||||||
|
generation: number;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
cpuCores: number;
|
||||||
|
memoryBytes: number;
|
||||||
}
|
}
|
||||||
export interface ResourceAllowance {
|
export interface ResourceAllowance {
|
||||||
cpuCores: number; memoryBytes: number; source: 'host' | 'fleet' | 'bootstrap';
|
cpuCores: number;
|
||||||
samples: number; observedAt: string | null;
|
memoryBytes: number;
|
||||||
|
source: 'host' | 'fleet' | 'bootstrap';
|
||||||
|
samples: number;
|
||||||
|
observedAt: string | null;
|
||||||
|
}
|
||||||
|
export interface CapacityCalibration {
|
||||||
|
running: ResourceAllowance;
|
||||||
|
startup: ResourceAllowance;
|
||||||
}
|
}
|
||||||
export interface CapacityCalibration { running: ResourceAllowance; startup: ResourceAllowance }
|
|
||||||
export interface CapacitySample {
|
export interface CapacitySample {
|
||||||
hostId: string; hostName: string | null; instanceId: string; server: string | null;
|
hostId: string;
|
||||||
generation: number; phase: 'startup' | 'running'; timestamp: string; cpuCores: number; memoryBytes: number;
|
hostName: string | null;
|
||||||
|
instanceId: string;
|
||||||
|
server: string | null;
|
||||||
|
generation: number;
|
||||||
|
phase: 'startup' | 'running';
|
||||||
|
timestamp: string;
|
||||||
|
cpuCores: number;
|
||||||
|
memoryBytes: number;
|
||||||
}
|
}
|
||||||
export interface CapacitySamplePage {
|
export interface CapacitySamplePage {
|
||||||
samples: CapacitySample[]; total: number; page: number; pageSize: number;
|
samples: CapacitySample[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
}
|
}
|
||||||
export interface Host {
|
export interface Host {
|
||||||
id: string; name: string; hostname: string; mode: HostMode; portMin: number; portMax: number;
|
id: string;
|
||||||
bindAddress: string; publicAddress: string; minimumFreeBytes: number; createdAt: string;
|
name: string;
|
||||||
lastHeartbeat: string | null; connected: boolean; connectionState: 'online' | 'offline';
|
hostname: string;
|
||||||
readiness: HostReadiness; readinessReasons: string[]; inventory: HostInventory;
|
mode: HostMode;
|
||||||
enrolled: boolean; credentialActive: boolean;
|
portMin: number;
|
||||||
|
portMax: number;
|
||||||
|
bindAddress: string;
|
||||||
|
publicAddress: string;
|
||||||
|
minimumFreeBytes: number;
|
||||||
|
createdAt: string;
|
||||||
|
lastHeartbeat: string | null;
|
||||||
|
connected: boolean;
|
||||||
|
connectionState: 'online' | 'offline';
|
||||||
|
readiness: HostReadiness;
|
||||||
|
readinessReasons: string[];
|
||||||
|
inventory: HostInventory;
|
||||||
|
enrolled: boolean;
|
||||||
|
credentialActive: boolean;
|
||||||
decommission: HostDecommission | null;
|
decommission: HostDecommission | null;
|
||||||
preparation?: HostPreparation;
|
preparation?: HostPreparation;
|
||||||
enablingSince?: string | null;
|
enablingSince?: string | null;
|
||||||
}
|
}
|
||||||
export interface HostOperation {
|
export interface HostOperation {
|
||||||
id: string; hostId: string; kind: string; instanceId: string | null; generation: number | null;
|
id: string;
|
||||||
payload: Record<string, unknown>; state: HostOperationState; result: unknown; error: string | null;
|
hostId: string;
|
||||||
createdAt: string; updatedAt: string;
|
kind: string;
|
||||||
|
instanceId: string | null;
|
||||||
|
generation: number | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
state: HostOperationState;
|
||||||
|
result: unknown;
|
||||||
|
error: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'result'> {
|
export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'result'> {
|
||||||
hostName: string | null;
|
hostName: string | null;
|
||||||
@@ -130,11 +259,16 @@ export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'r
|
|||||||
routine: boolean;
|
routine: boolean;
|
||||||
}
|
}
|
||||||
export type HostOperationFilter = 'important' | 'routine' | 'all';
|
export type HostOperationFilter = 'important' | 'routine' | 'all';
|
||||||
export interface HostOperationScope { host?: string; server?: string; operation?: string }
|
export interface HostOperationScope {
|
||||||
|
host?: string;
|
||||||
|
server?: string;
|
||||||
|
operation?: string;
|
||||||
|
}
|
||||||
export function hostOperationScope(params: URLSearchParams): HostOperationScope {
|
export function hostOperationScope(params: URLSearchParams): HostOperationScope {
|
||||||
const scope: HostOperationScope = {};
|
const scope: HostOperationScope = {};
|
||||||
for (const key of ['host', 'server', 'operation'] as const) {
|
for (const key of ['host', 'server', 'operation'] as const) {
|
||||||
const value = params.get(key);
|
const value = params.get(key);
|
||||||
|
// eslint-disable-next-line no-control-regex -- Reject control characters in operation scope filters.
|
||||||
if (value && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value)) scope[key] = value;
|
if (value && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value)) scope[key] = value;
|
||||||
}
|
}
|
||||||
return scope;
|
return scope;
|
||||||
@@ -143,28 +277,45 @@ export function isHostOperationFilter(value: unknown): value is HostOperationFil
|
|||||||
return typeof value === 'string' && ['important', 'routine', 'all'].includes(value);
|
return typeof value === 'string' && ['important', 'routine', 'all'].includes(value);
|
||||||
}
|
}
|
||||||
export interface HostOperationHistoryPage {
|
export interface HostOperationHistoryPage {
|
||||||
operations: HostOperationSummary[]; page: number; pageSize: 25; total: number; totalPages: number; filter: HostOperationFilter;
|
operations: HostOperationSummary[];
|
||||||
|
page: number;
|
||||||
|
pageSize: 25;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
filter: HostOperationFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostPreparationItem {
|
export interface HostPreparationItem {
|
||||||
kind: 'runtime' | 'library' | 'plutonium'; label: string;
|
kind: 'runtime' | 'library' | 'plutonium';
|
||||||
state: 'waiting' | 'preparing' | 'ready' | 'blocked'; detail: string;
|
label: string;
|
||||||
operationId?: string; bytesDownloaded?: number; bytesTotal?: number;
|
state: 'waiting' | 'preparing' | 'ready' | 'blocked';
|
||||||
|
detail: string;
|
||||||
|
operationId?: string;
|
||||||
|
bytesDownloaded?: number;
|
||||||
|
bytesTotal?: number;
|
||||||
cache?: HostLibraryCache;
|
cache?: HostLibraryCache;
|
||||||
}
|
}
|
||||||
export interface HostPreparation {
|
export interface HostPreparation {
|
||||||
state: HostPreparationItem['state']; items: HostPreparationItem[];
|
state: HostPreparationItem['state'];
|
||||||
|
items: HostPreparationItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NativeDeploymentSelection {
|
export interface NativeDeploymentSelection {
|
||||||
libraryIds: Partial<Record<'t5' | 't6', string>>; image: string;
|
libraryIds: Partial<Record<'t5' | 't6', string>>;
|
||||||
bindAddress?: string; publicAddress?: string; publicPort?: number;
|
image: string;
|
||||||
|
bindAddress?: string;
|
||||||
|
publicAddress?: string;
|
||||||
|
publicPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queued automatic launches have no assigned host or port until provisioning. */
|
/** Queued automatic launches have no assigned host or port until provisioning. */
|
||||||
export interface NativePlacement {
|
export interface NativePlacement {
|
||||||
hostId?: string; libraryId: string; image?: string;
|
hostId?: string;
|
||||||
bindAddress?: string; publicAddress?: string; publicPort?: number;
|
libraryId: string;
|
||||||
|
image?: string;
|
||||||
|
bindAddress?: string;
|
||||||
|
publicAddress?: string;
|
||||||
|
publicPort?: number;
|
||||||
automatic?: boolean;
|
automatic?: boolean;
|
||||||
network?: { bindAddress?: string; publicAddress?: string; publicPort?: number };
|
network?: { bindAddress?: string; publicAddress?: string; publicPort?: number };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,40 @@ export const EFFICIENCY_MIN_KILLS = 100;
|
|||||||
export const EFFICIENCY_MIN_DOWNS = 5;
|
export const EFFICIENCY_MIN_DOWNS = 5;
|
||||||
|
|
||||||
export const efficiencyMetrics = [
|
export const efficiencyMetrics = [
|
||||||
{ key: 'headshotRate', label: 'Headshots / kills', format: 'percent',
|
{
|
||||||
description: `Total headshots divided by total kills in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_KILLS} kills.` },
|
key: 'headshotRate',
|
||||||
{ key: 'killsPerDown', label: 'Kills per down', format: 'number',
|
label: 'Headshots / kills',
|
||||||
description: `Total kills divided by total downs in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_DOWNS} downs.` },
|
format: 'percent',
|
||||||
{ key: 'averageKills', label: 'Average kills', format: 'number',
|
description: `Total headshots divided by total kills in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_KILLS} kills.`
|
||||||
description: `Total kills divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.` },
|
},
|
||||||
{ key: 'averageRounds', label: 'Average rounds', format: 'number',
|
{
|
||||||
description: `Total observed rounds divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.` },
|
key: 'killsPerDown',
|
||||||
{ key: 'completionRate', label: 'Completion rate', format: 'percent',
|
label: 'Kills per down',
|
||||||
description: `Games with the player in the final report divided by games played. Only ended games count, including games the player left early. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games.` }
|
format: 'number',
|
||||||
|
description: `Total kills divided by total downs in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_DOWNS} downs.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'averageKills',
|
||||||
|
label: 'Average kills',
|
||||||
|
format: 'number',
|
||||||
|
description: `Total kills divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'averageRounds',
|
||||||
|
label: 'Average rounds',
|
||||||
|
format: 'number',
|
||||||
|
description: `Total observed rounds divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'completionRate',
|
||||||
|
label: 'Completion rate',
|
||||||
|
format: 'percent',
|
||||||
|
description: `Games with the player in the final report divided by games played. Only ended games count, including games the player left early. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games.`
|
||||||
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EfficiencyMetric = typeof efficiencyMetrics[number]['key'];
|
export type EfficiencyMetric = (typeof efficiencyMetrics)[number]['key'];
|
||||||
const metrics = new Set<string>(efficiencyMetrics.map(metric => metric.key));
|
const metrics = new Set<string>(efficiencyMetrics.map((metric) => metric.key));
|
||||||
|
|
||||||
export function isEfficiencyMetric(metric: string): metric is EfficiencyMetric {
|
export function isEfficiencyMetric(metric: string): metric is EfficiencyMetric {
|
||||||
return metrics.has(metric);
|
return metrics.has(metric);
|
||||||
@@ -26,11 +46,16 @@ export function isEfficiencyMetric(metric: string): metric is EfficiencyMetric {
|
|||||||
|
|
||||||
function sample(stats: PlayerStats, metric: EfficiencyMetric): [number, number] {
|
function sample(stats: PlayerStats, metric: EfficiencyMetric): [number, number] {
|
||||||
switch (metric) {
|
switch (metric) {
|
||||||
case 'headshotRate': return [stats.headshots, stats.kills];
|
case 'headshotRate':
|
||||||
case 'killsPerDown': return [stats.kills, stats.downs];
|
return [stats.headshots, stats.kills];
|
||||||
case 'averageKills': return [stats.kills, stats.gamesPlayed];
|
case 'killsPerDown':
|
||||||
case 'averageRounds': return [stats.roundsSurvived, stats.gamesPlayed];
|
return [stats.kills, stats.downs];
|
||||||
case 'completionRate': return [stats.gamesCompleted, stats.gamesPlayed];
|
case 'averageKills':
|
||||||
|
return [stats.kills, stats.gamesPlayed];
|
||||||
|
case 'averageRounds':
|
||||||
|
return [stats.roundsSurvived, stats.gamesPlayed];
|
||||||
|
case 'completionRate':
|
||||||
|
return [stats.gamesCompleted, stats.gamesPlayed];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +85,16 @@ const count = (value: number, singular: string, plural = `${singular}s`) =>
|
|||||||
|
|
||||||
export function efficiencySample(stats: PlayerStats, metric: EfficiencyMetric): string {
|
export function efficiencySample(stats: PlayerStats, metric: EfficiencyMetric): string {
|
||||||
switch (metric) {
|
switch (metric) {
|
||||||
case 'headshotRate': return `${count(stats.headshots, 'headshot')} / ${count(stats.kills, 'kill')}`;
|
case 'headshotRate':
|
||||||
case 'killsPerDown': return `${count(stats.kills, 'kill')} / ${count(stats.downs, 'down')}`;
|
return `${count(stats.headshots, 'headshot')} / ${count(stats.kills, 'kill')}`;
|
||||||
case 'averageKills': return `${count(stats.kills, 'kill')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
case 'killsPerDown':
|
||||||
case 'averageRounds': return `${count(stats.roundsSurvived, 'observed round')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
return `${count(stats.kills, 'kill')} / ${count(stats.downs, 'down')}`;
|
||||||
case 'completionRate': return `${count(stats.gamesCompleted, 'finish', 'finishes')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
case 'averageKills':
|
||||||
|
return `${count(stats.kills, 'kill')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||||
|
case 'averageRounds':
|
||||||
|
return `${count(stats.roundsSurvived, 'observed round')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||||
|
case 'completionRate':
|
||||||
|
return `${count(stats.gamesCompleted, 'finish', 'finishes')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ export function leaderboardReturnHref(value: string | null | undefined): string
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const key of contextKeys) {
|
for (const key of contextKeys) {
|
||||||
const entry = url.searchParams.get(key);
|
const entry = url.searchParams.get(key);
|
||||||
if (entry || key === 'map' && entry !== null) params.set(key, entry!);
|
if (entry || (key === 'map' && entry !== null)) params.set(key, entry!);
|
||||||
}
|
}
|
||||||
return `/leaderboard${params.size ? `?${params}` : ''}`;
|
return `/leaderboard${params.size ? `?${params}` : ''}`;
|
||||||
} catch { return '/leaderboard'; }
|
} catch {
|
||||||
|
return '/leaderboard';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function playerContextHref(guid: string, source: string): string {
|
export function playerContextHref(guid: string, source: string): string {
|
||||||
@@ -31,7 +33,8 @@ export function playerContextHref(guid: string, source: string): string {
|
|||||||
if (game === 't5' || game === 't6') params.set('game', game);
|
if (game === 't5' || game === 't6') params.set('game', game);
|
||||||
if (map !== null && map.length <= 1024) params.set('map', map);
|
if (map !== null && map.length <= 1024) params.set('map', map);
|
||||||
if (players && /^[1-4]$/.test(players)) params.set('players', players);
|
if (players && /^[1-4]$/.test(players)) params.set('players', players);
|
||||||
if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric);
|
if (metric && leaderboardMetrics.some((item) => item.key === metric))
|
||||||
|
params.set('metric', metric);
|
||||||
return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`;
|
return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export const playerStatMetrics = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const leaderboardMetrics = [...playerStatMetrics, ...efficiencyMetrics] as const;
|
export const leaderboardMetrics = [...playerStatMetrics, ...efficiencyMetrics] as const;
|
||||||
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
export type LeaderboardMetric = (typeof leaderboardMetrics)[number]['key'];
|
||||||
export type PlayerStatMetric = typeof playerStatMetrics[number]['key'];
|
export type PlayerStatMetric = (typeof playerStatMetrics)[number]['key'];
|
||||||
|
|
||||||
export type PlayerStats = Record<PlayerStatMetric, number>;
|
export type PlayerStats = Record<PlayerStatMetric, number>;
|
||||||
export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore';
|
export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore';
|
||||||
@@ -65,7 +65,7 @@ export interface PlayerProfile {
|
|||||||
firstSeen: string;
|
firstSeen: string;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
stats: PlayerStats;
|
stats: PlayerStats;
|
||||||
records: PersonalBestRecords;
|
records: PersonalBestRecords | null;
|
||||||
maps: PlayerMapRecord[];
|
maps: PlayerMapRecord[];
|
||||||
matches: PlayerMatch[];
|
matches: PlayerMatch[];
|
||||||
matchesTotal: number;
|
matchesTotal: number;
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { WatchdogOverview } from './watchdog.js';
|
|||||||
import type { PlutoniumStatus } from './plutonium.js';
|
import type { PlutoniumStatus } from './plutonium.js';
|
||||||
|
|
||||||
export type MaintenancePolicy = 'health' | 'drift' | 'updates';
|
export type MaintenancePolicy = 'health' | 'drift' | 'updates';
|
||||||
export interface MaintenancePolicies { paused: boolean; health: boolean; drift: boolean; updates: boolean }
|
export interface MaintenancePolicies {
|
||||||
|
paused: boolean;
|
||||||
|
health: boolean;
|
||||||
|
drift: boolean;
|
||||||
|
updates: boolean;
|
||||||
|
}
|
||||||
export interface MaintenanceOverview {
|
export interface MaintenanceOverview {
|
||||||
policies: MaintenancePolicies;
|
policies: MaintenancePolicies;
|
||||||
recovery: WatchdogOverview;
|
recovery: WatchdogOverview;
|
||||||
|
|||||||
+71
-24
@@ -36,14 +36,54 @@ export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
|
|||||||
// Rulesets from xerxes-at/T6ServerConfigs, also seeded by Plutainer.
|
// Rulesets from xerxes-at/T6ServerConfigs, also seeded by Plutainer.
|
||||||
// Several selections share an engine map; slug preserves the selected variant.
|
// Several selections share an engine map; slug preserves the selected variant.
|
||||||
export const T6_ZOMBIE_MAPS: readonly ZombieMapEntry[] = Object.freeze([
|
export const T6_ZOMBIE_MAPS: readonly ZombieMapEntry[] = Object.freeze([
|
||||||
{ friendly: 'TranZit', slug: 'transit', canonical: 'zm_transit', gameSettings: 'zm_classic_transit.cfg' },
|
{
|
||||||
{ friendly: 'Town Survival', slug: 'town', canonical: 'zm_transit', gameSettings: 'zm_standard_town.cfg' },
|
friendly: 'TranZit',
|
||||||
{ friendly: 'Farm Survival', slug: 'farm', canonical: 'zm_transit', gameSettings: 'zm_standard_farm.cfg' },
|
slug: 'transit',
|
||||||
{ friendly: 'Bus Depot Survival', slug: 'busdepot', canonical: 'zm_transit', gameSettings: 'zm_standard_transit.cfg' },
|
canonical: 'zm_transit',
|
||||||
{ friendly: 'Nuketown', slug: 'nuked', canonical: 'zm_nuked', gameSettings: 'zm_standard_nuked.cfg' },
|
gameSettings: 'zm_classic_transit.cfg'
|
||||||
{ friendly: 'Die Rise', slug: 'highrise', canonical: 'zm_highrise', gameSettings: 'zm_classic_rooftop.cfg' },
|
},
|
||||||
{ friendly: 'Mob of the Dead', slug: 'prison', canonical: 'zm_prison', gameSettings: 'zm_classic_prison.cfg' },
|
{
|
||||||
{ friendly: 'Buried', slug: 'buried', canonical: 'zm_buried', gameSettings: 'zm_classic_processing.cfg' },
|
friendly: 'Town Survival',
|
||||||
|
slug: 'town',
|
||||||
|
canonical: 'zm_transit',
|
||||||
|
gameSettings: 'zm_standard_town.cfg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
friendly: 'Farm Survival',
|
||||||
|
slug: 'farm',
|
||||||
|
canonical: 'zm_transit',
|
||||||
|
gameSettings: 'zm_standard_farm.cfg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
friendly: 'Bus Depot Survival',
|
||||||
|
slug: 'busdepot',
|
||||||
|
canonical: 'zm_transit',
|
||||||
|
gameSettings: 'zm_standard_transit.cfg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
friendly: 'Nuketown',
|
||||||
|
slug: 'nuked',
|
||||||
|
canonical: 'zm_nuked',
|
||||||
|
gameSettings: 'zm_standard_nuked.cfg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
friendly: 'Die Rise',
|
||||||
|
slug: 'highrise',
|
||||||
|
canonical: 'zm_highrise',
|
||||||
|
gameSettings: 'zm_classic_rooftop.cfg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
friendly: 'Mob of the Dead',
|
||||||
|
slug: 'prison',
|
||||||
|
canonical: 'zm_prison',
|
||||||
|
gameSettings: 'zm_classic_prison.cfg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
friendly: 'Buried',
|
||||||
|
slug: 'buried',
|
||||||
|
canonical: 'zm_buried',
|
||||||
|
gameSettings: 'zm_classic_processing.cfg'
|
||||||
|
},
|
||||||
{ friendly: 'Origins', slug: 'tomb', canonical: 'zm_tomb', gameSettings: 'zm_classic_tomb.cfg' }
|
{ friendly: 'Origins', slug: 'tomb', canonical: 'zm_tomb', gameSettings: 'zm_classic_tomb.cfg' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -55,23 +95,29 @@ export function zombieMapBySlug(game: string, slug: string): ZombieMapEntry | un
|
|||||||
return zombieMaps(game).find((entry) => entry.slug === slug.toLowerCase());
|
return zombieMaps(game).find((entry) => entry.slug === slug.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectedZombieMap(game: string | null, map: string | null, slug?: string | null): ZombieMapEntry | undefined {
|
export function selectedZombieMap(
|
||||||
|
game: string | null,
|
||||||
|
map: string | null,
|
||||||
|
slug?: string | null
|
||||||
|
): ZombieMapEntry | undefined {
|
||||||
const maps = zombieMaps(game);
|
const maps = zombieMaps(game);
|
||||||
// T5 maps are unambiguous, including legacy friendly slugs. T6 variants
|
// T5 maps are unambiguous, including legacy friendly slugs. T6 variants
|
||||||
// require the stored selection; never fall back from an unknown variant.
|
// require the stored selection; never fall back from an unknown variant.
|
||||||
return maps.find((entry) => entry.canonical === map && (
|
return maps.find(
|
||||||
game !== 't6' || !slug || entry.slug === slug
|
(entry) =>
|
||||||
|| entry.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === slug
|
entry.canonical === map &&
|
||||||
));
|
(game !== 't6' ||
|
||||||
|
!slug ||
|
||||||
|
entry.slug === slug ||
|
||||||
|
entry.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === slug)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapRotation(entry: ZombieMapEntry): string {
|
export function mapRotation(entry: ZombieMapEntry): string {
|
||||||
return `${entry.gameSettings ? `execgts ${entry.gameSettings} ` : ''}map ${entry.canonical}`;
|
return `${entry.gameSettings ? `execgts ${entry.gameSettings} ` : ''}map ${entry.canonical}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BY_SLUG: ReadonlyMap<string, T5MapEntry> = new Map(
|
const BY_SLUG: ReadonlyMap<string, T5MapEntry> = new Map(T5_ZOMBIE_MAPS.map((m) => [m.slug, m]));
|
||||||
T5_ZOMBIE_MAPS.map((m) => [m.slug, m])
|
|
||||||
);
|
|
||||||
|
|
||||||
export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined {
|
export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined {
|
||||||
return BY_SLUG.get(slug.toLowerCase());
|
return BY_SLUG.get(slug.toLowerCase());
|
||||||
@@ -80,10 +126,10 @@ export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined {
|
|||||||
export type GameMode = 'zombie' | 'mp' | 'sp';
|
export type GameMode = 'zombie' | 'mp' | 'sp';
|
||||||
|
|
||||||
export interface ParsedServerName {
|
export interface ParsedServerName {
|
||||||
game: string; // 't5'
|
game: string; // 't5'
|
||||||
gameMode: GameMode | null; // 'zombie' — null when the second segment isn't a known mode
|
gameMode: GameMode | null; // 'zombie' — null when the second segment isn't a known mode
|
||||||
mapSlug: string; // 'cosmodrome' — joined without separators for lookup
|
mapSlug: string; // 'cosmodrome' — joined without separators for lookup
|
||||||
mapCanonical: string | null;// 'zombie_cosmodrome' — null when slug not in catalog
|
mapCanonical: string | null; // 'zombie_cosmodrome' — null when slug not in catalog
|
||||||
lobby: number | null;
|
lobby: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,10 +168,11 @@ export function parseServerName(name: string): ParsedServerName | null {
|
|||||||
|
|
||||||
const mapSlug = slugParts.join('').toLowerCase();
|
const mapSlug = slugParts.join('').toLowerCase();
|
||||||
const catalog = zombieMaps(game.toLowerCase());
|
const catalog = zombieMaps(game.toLowerCase());
|
||||||
const entry = gameMode === null || gameMode === 'zombie'
|
const entry =
|
||||||
? catalog.find((m) => m.slug === mapSlug)
|
gameMode === null || gameMode === 'zombie'
|
||||||
?? catalog.find((m) => m.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === mapSlug)
|
? (catalog.find((m) => m.slug === mapSlug) ??
|
||||||
: undefined;
|
catalog.find((m) => m.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === mapSlug))
|
||||||
|
: undefined;
|
||||||
const mapCanonical = entry?.canonical ?? null;
|
const mapCanonical = entry?.canonical ?? null;
|
||||||
return { game, gameMode, mapSlug, mapCanonical, lobby };
|
return { game, gameMode, mapSlug, mapCanonical, lobby };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
export const matchPageSize = 25;
|
export const matchPageSize = 25;
|
||||||
export const matchSorts = ['started', 'round', 'duration', 'players'] as const;
|
export const matchSorts = ['started', 'round', 'duration', 'players'] as const;
|
||||||
export type MatchSort = typeof matchSorts[number];
|
export type MatchSort = (typeof matchSorts)[number];
|
||||||
export interface MatchHistoryQuery {
|
export interface MatchHistoryQuery {
|
||||||
game: string; map: string; q: string; days: number;
|
game: string;
|
||||||
status: '' | 'active' | 'completed' | 'interrupted';
|
map: string;
|
||||||
sort: MatchSort; direction: 'asc' | 'desc'; page: number;
|
q: string;
|
||||||
|
days: number;
|
||||||
|
status: '' | 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||||
|
sort: MatchSort;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
page: number;
|
||||||
|
archive: boolean;
|
||||||
}
|
}
|
||||||
export const matchQueryKeys = ['game', 'map', 'q', 'days', 'status', 'sort', 'direction', 'page'] as const;
|
export const matchQueryKeys = [
|
||||||
|
'game',
|
||||||
|
'map',
|
||||||
|
'q',
|
||||||
|
'days',
|
||||||
|
'status',
|
||||||
|
'sort',
|
||||||
|
'direction',
|
||||||
|
'page',
|
||||||
|
'archive'
|
||||||
|
] as const;
|
||||||
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
||||||
const game = params.get('game') ?? '';
|
const game = params.get('game') ?? '';
|
||||||
const map = params.get('map') ?? '';
|
const map = params.get('map') ?? '';
|
||||||
@@ -16,11 +32,32 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
|||||||
const sort = params.get('sort') || 'started';
|
const sort = params.get('sort') || 'started';
|
||||||
const direction = params.get('direction') || 'desc';
|
const direction = params.get('direction') || 'desc';
|
||||||
const page = Number(params.get('page') || 1);
|
const page = Number(params.get('page') || 1);
|
||||||
if (!['', 't5', 't6'].includes(game) || map.length > 1024 || q.length > 100
|
const archive = params.get('archive') ?? '';
|
||||||
|| ![0, 7, 30, 90].includes(days) || !['', 'active', 'completed', 'interrupted'].includes(status)
|
if (
|
||||||
|| !matchSorts.includes(sort as MatchSort) || !['asc', 'desc'].includes(direction)
|
!['', 't5', 't6'].includes(game) ||
|
||||||
|| !Number.isSafeInteger(page) || page < 1 || page > 1000000) throw new Error('Invalid match history filters.');
|
map.length > 1024 ||
|
||||||
return { game, map, q, days, status: status as MatchHistoryQuery['status'], sort: sort as MatchSort, direction: direction as 'asc' | 'desc', page };
|
q.length > 100 ||
|
||||||
|
![0, 7, 30, 90].includes(days) ||
|
||||||
|
!['', 'active', 'completed', 'interrupted', 'end-report-missing'].includes(status) ||
|
||||||
|
!matchSorts.includes(sort as MatchSort) ||
|
||||||
|
!['asc', 'desc'].includes(direction) ||
|
||||||
|
!Number.isSafeInteger(page) ||
|
||||||
|
page < 1 ||
|
||||||
|
page > 1000000 ||
|
||||||
|
!['', '0', '1'].includes(archive)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid match history filters.');
|
||||||
|
return {
|
||||||
|
game,
|
||||||
|
map,
|
||||||
|
q,
|
||||||
|
days,
|
||||||
|
status: status as MatchHistoryQuery['status'],
|
||||||
|
sort: sort as MatchSort,
|
||||||
|
direction: direction as 'asc' | 'desc',
|
||||||
|
page,
|
||||||
|
archive: archive === '1'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/** Namespaced filters also work inside an instance's Matches tab. */
|
/** Namespaced filters also work inside an instance's Matches tab. */
|
||||||
export function matchApiParams(url: URL): URLSearchParams {
|
export function matchApiParams(url: URL): URLSearchParams {
|
||||||
@@ -31,13 +68,17 @@ export function matchApiParams(url: URL): URLSearchParams {
|
|||||||
}
|
}
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
export function matchHistoryHref(url: URL, changes: Partial<Record<keyof MatchHistoryQuery, string>>): string {
|
export function matchHistoryHref(
|
||||||
|
url: URL,
|
||||||
|
changes: Partial<Record<keyof MatchHistoryQuery, string>>
|
||||||
|
): string {
|
||||||
const next = new URL(url);
|
const next = new URL(url);
|
||||||
next.searchParams.delete('matches'); // Retire the former growing-list limit.
|
next.searchParams.delete('matches'); // Retire the former growing-list limit.
|
||||||
next.searchParams.delete('matchPage');
|
next.searchParams.delete('matchPage');
|
||||||
for (const [key, value] of Object.entries(changes)) {
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
const name = `match${key[0].toUpperCase()}${key.slice(1)}`;
|
const name = `match${key[0].toUpperCase()}${key.slice(1)}`;
|
||||||
if (value) next.searchParams.set(name, value); else next.searchParams.delete(name);
|
if (value) next.searchParams.set(name, value);
|
||||||
|
else next.searchParams.delete(name);
|
||||||
}
|
}
|
||||||
return next.pathname + next.search;
|
return next.pathname + next.search;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ export interface ServerMatch {
|
|||||||
server_deleted_at?: string | null;
|
server_deleted_at?: string | null;
|
||||||
map: string;
|
map: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
lastReportedAt: string | null;
|
||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
status: 'active' | 'completed' | 'interrupted';
|
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||||
|
reporterMatchKey: string | null;
|
||||||
|
// Unknown without sequence reports; otherwise gaps through the highest received sequence.
|
||||||
|
missingReports: number | null;
|
||||||
startPlayers: number | null;
|
startPlayers: number | null;
|
||||||
uniquePlayers: number;
|
uniquePlayers: number;
|
||||||
endPlayers: number | null;
|
endPlayers: number | null;
|
||||||
@@ -38,10 +42,11 @@ export interface MatchPlayer {
|
|||||||
downs: number | null;
|
downs: number | null;
|
||||||
revives: number | null;
|
revives: number | null;
|
||||||
bestScore: number | null;
|
bestScore: number | null;
|
||||||
|
/** Highest reported cumulative match score; null when the reporter omitted it. */
|
||||||
|
scoreTotal: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchDetail extends ServerMatch {
|
export interface MatchDetail extends ServerMatch {
|
||||||
status: 'completed';
|
|
||||||
game: string;
|
game: string;
|
||||||
startObserved: boolean;
|
startObserved: boolean;
|
||||||
players: MatchPlayer[];
|
players: MatchPlayer[];
|
||||||
|
|||||||
+117
-56
@@ -1,73 +1,134 @@
|
|||||||
import type { MapMetrics } from './metrics.js';
|
import type { MapMetrics } from './metrics.js';
|
||||||
import { metricDuration, metricPercent } from './ui/metrics.js';
|
import { metricDuration, metricPercent } from './ui/metrics.js';
|
||||||
|
|
||||||
/** Product hypotheses for advisory experiments; these are not demand forecasts. */
|
/** Advisory thresholds for reviewing a policy, not demand forecasts or scaling commands. */
|
||||||
export const HOSTING_RULES = {
|
export const POLICY_SIGNAL_RULES = {
|
||||||
pressureExpansion: 0.2,
|
noEmptyShare: 0.02,
|
||||||
occupancyExpansion: 0.8,
|
minimumEvidenceHours: 1,
|
||||||
idleUtilization: 0.1,
|
minimumKnownShare: 0.8
|
||||||
quietPressure: 0.01,
|
|
||||||
minimumEvidenceHours: 1
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface MetricRecommendation {
|
export interface MetricRecommendation {
|
||||||
game: string;
|
game: string;
|
||||||
map: string;
|
map: string;
|
||||||
kind: 'expand' | 'consolidate' | 'capacity' | 'watch';
|
kind: 'maximum' | 'spares' | 'policy' | 'reporting' | 'watch';
|
||||||
title: string;
|
title: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
evidence: string;
|
evidence: string;
|
||||||
outcome: string;
|
outcome: string;
|
||||||
tentative: boolean;
|
tentative: boolean;
|
||||||
|
policySummary: string | null;
|
||||||
|
href: string | null;
|
||||||
|
action: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply the same visible rules to all selected maps, including sparse observations. */
|
/** Compare captured availability with the current policy; historical settings are not recorded. */
|
||||||
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
|
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
|
||||||
return maps.map(map => {
|
return maps
|
||||||
const utilization = map.observed > 0 ? map.active / map.observed : null;
|
.map((map): MetricRecommendation => {
|
||||||
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null;
|
const availability = map.availabilityObserved + map.availabilityUnknown;
|
||||||
const knownShare = map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0;
|
const knownShare = availability > 0 ? map.availabilityObserved / availability : 0;
|
||||||
const base = {
|
const lobbyHours = map.observed + map.unknown;
|
||||||
game: map.game,
|
const knownLobbyShare = lobbyHours > 0 ? map.observed / lobbyHours : 0;
|
||||||
map: map.map,
|
const noEmptyShare =
|
||||||
evidence: `${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` +
|
map.availabilityObserved > 0 ? map.noEmpty / map.availabilityObserved : null;
|
||||||
(pressure === null ? 'Capacity unavailable.' : `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`),
|
const policy = map.policy;
|
||||||
tentative: map.observed < HOSTING_RULES.minimumEvidenceHours || pressure === null || map.capacityObserved < HOSTING_RULES.minimumEvidenceHours || knownShare < 0.8
|
const base = {
|
||||||
};
|
game: map.game,
|
||||||
if (utilization === null) return {
|
map: map.map,
|
||||||
...base, kind: 'capacity' as const, title: map.unknown > 0 ? 'Check occupancy reporting' : 'Let observations accumulate',
|
evidence:
|
||||||
reason: map.unknown > 0
|
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known lobby time (${metricPercent(map.active, map.observed)}). ` +
|
||||||
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.'
|
(map.unknown > 0
|
||||||
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.',
|
? `${metricDuration(map.unknown)} of lobby time has unknown occupancy. `
|
||||||
outcome: 'Watch for known server time in the next report.', tentative: true
|
: '') +
|
||||||
};
|
(noEmptyShare === null
|
||||||
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion) return {
|
? 'Lobby availability is unknown.'
|
||||||
...base, kind: 'expand' as const, title: 'Trial one additional lobby',
|
: `No empty lobby reported for ${metricDuration(map.noEmpty)} / ${metricDuration(map.availabilityObserved)} known map time (${metricPercent(map.noEmpty, map.availabilityObserved)}).`) +
|
||||||
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.',
|
(policy && noEmptyShare !== null
|
||||||
outcome: 'Compare all-full time and use of the additional lobby over the next comparable period.'
|
? ` At or above the current maximum (${policy.maximum}): ${metricDuration(map.noEmptyAtMaximum)}; below it: ${metricDuration(map.noEmptyBelowMaximum)}.`
|
||||||
};
|
: ''),
|
||||||
if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion) return {
|
tentative:
|
||||||
...base, kind: 'expand' as const, title: 'Trial an extra lobby during busy hours',
|
map.availabilityObserved < POLICY_SIGNAL_RULES.minimumEvidenceHours ||
|
||||||
reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.',
|
knownShare < POLICY_SIGNAL_RULES.minimumKnownShare ||
|
||||||
outcome: 'Check whether players use the extra lobby, and measure capacity if the server reports it.', tentative: true
|
knownLobbyShare < POLICY_SIGNAL_RULES.minimumKnownShare,
|
||||||
};
|
policySummary: policy
|
||||||
if (utilization <= HOSTING_RULES.idleUtilization && (pressure === null || pressure < HOSTING_RULES.quietPressure)) return {
|
? `Current policy: ${policy.minimum}–${policy.maximum} lobbies, ${policy.spares} spare${policy.spares === 1 ? '' : 's'}`
|
||||||
...base, kind: 'consolidate' as const, title: 'Trial less idle hosting',
|
: null,
|
||||||
reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.',
|
href: policy ? `/autoscaling#${encodeURIComponent(policy.id)}` : null,
|
||||||
outcome: 'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.'
|
action: policy ? 'Review policy' : null
|
||||||
};
|
};
|
||||||
if (pressure === null) return {
|
if (noEmptyShare === null)
|
||||||
...base, kind: 'capacity' as const, title: 'Check capacity during busy hours',
|
return {
|
||||||
reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.',
|
...base,
|
||||||
outcome: 'Look for known-capacity time and all-full periods before the next hosting trial.'
|
kind: 'reporting',
|
||||||
};
|
title:
|
||||||
return {
|
map.availabilityUnknown > 0 || map.unknown > 0
|
||||||
...base, kind: 'watch' as const, title: 'Keep the lineup and watch peaks',
|
? 'Check occupancy reporting'
|
||||||
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.',
|
: 'Let observations accumulate',
|
||||||
outcome: 'Watch whether occupied time and measured capacity pressure change.'
|
reason:
|
||||||
};
|
map.availabilityUnknown > 0 || map.unknown > 0
|
||||||
}).sort((a, b) => {
|
? 'Captured occupancy is unknown. Check a lobby’s telemetry or RCON diagnostics before choosing a policy change.'
|
||||||
const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 };
|
: 'This map has no elapsed availability observations in the selected window. Wait for another status check or try a wider range.',
|
||||||
return priority[a.kind] - priority[b.kind] || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
outcome: 'Watch for known lobby availability in the next report.',
|
||||||
});
|
tentative: true
|
||||||
|
};
|
||||||
|
if (!policy)
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: 'policy',
|
||||||
|
title: 'Consider adding an autoscaling policy',
|
||||||
|
reason: 'This map has no current autoscaling policy. Set a minimum, maximum, and spare target to maintain an empty lobby as players arrive.',
|
||||||
|
outcome:
|
||||||
|
'Choose initial limits from observed lobby use, then compare empty-lobby availability over a comparable period.',
|
||||||
|
href: '/autoscaling',
|
||||||
|
action: 'Add policy'
|
||||||
|
};
|
||||||
|
if (policy.pausedReason || policy.mode === 'off')
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: 'policy',
|
||||||
|
title: policy.pausedReason
|
||||||
|
? 'Review and resume the autoscaling policy'
|
||||||
|
: 'Review and enable the autoscaling policy',
|
||||||
|
reason: policy.pausedReason
|
||||||
|
? `The current policy is paused: ${policy.pausedReason}`
|
||||||
|
: 'The current policy is off, so it cannot maintain the spare target.',
|
||||||
|
outcome:
|
||||||
|
'Review the policy and any blockers before enabling it, then observe availability before adjusting its limits.'
|
||||||
|
};
|
||||||
|
if (noEmptyShare >= POLICY_SIGNAL_RULES.noEmptyShare) {
|
||||||
|
if (map.noEmptyAtMaximum >= map.noEmptyBelowMaximum)
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: 'maximum',
|
||||||
|
title: 'Consider raising the policy maximum',
|
||||||
|
reason: `No empty lobby was reported for at least 2% of known map time. At least half of that time had a tracked lobby count at or above the current maximum of ${policy.maximum}; review whether that limit leaves room for the spare target.`,
|
||||||
|
outcome:
|
||||||
|
'Review host resources and create time, then compare empty-lobby availability after a maximum change.'
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: 'spares',
|
||||||
|
title: 'Review the spare target and create time',
|
||||||
|
reason: `No empty lobby was reported for at least 2% of known map time. Most of that time was below the current maximum of ${policy.maximum}; consider raising the spare target from ${policy.spares} and check how long new lobbies take to become ready.`,
|
||||||
|
outcome:
|
||||||
|
'Review creation delays and blockers, then compare empty-lobby availability after a spare-target change.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: 'watch',
|
||||||
|
title: 'Keep the policy and watch availability',
|
||||||
|
reason: 'No-empty-lobby time is below the policy review threshold. These observations do not suggest increasing the maximum or spare target.',
|
||||||
|
outcome: 'Watch for recurring periods without a reported empty lobby.'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const priority = { maximum: 0, spares: 1, policy: 2, reporting: 3, watch: 4 };
|
||||||
|
return (
|
||||||
|
priority[a.kind] - priority[b.kind] ||
|
||||||
|
a.map.localeCompare(b.map) ||
|
||||||
|
a.game.localeCompare(b.game)
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-17
@@ -1,4 +1,5 @@
|
|||||||
import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js';
|
import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js';
|
||||||
|
import type { AutoscalingMode } from './autoscaling.js';
|
||||||
|
|
||||||
export type MetricsDays = 1 | 7 | 30;
|
export type MetricsDays = 1 | 7 | 30;
|
||||||
|
|
||||||
@@ -22,11 +23,22 @@ export function parseMetricsQuery(params: URLSearchParams): MetricsQuery {
|
|||||||
const requestedGame = params.get('game')?.trim() ?? '';
|
const requestedGame = params.get('game')?.trim() ?? '';
|
||||||
const game = /^[a-z0-9_-]{1,32}$/.test(requestedGame) ? requestedGame : null;
|
const game = /^[a-z0-9_-]{1,32}$/.test(requestedGame) ? requestedGame : null;
|
||||||
const requestedMap = params.get('map');
|
const requestedMap = params.get('map');
|
||||||
const map = game && requestedMap && requestedMap.trim() && requestedMap.length <= 200 && !/[\u0000-\u001f\u007f]/.test(requestedMap)
|
const map =
|
||||||
? requestedMap : null;
|
game &&
|
||||||
|
requestedMap &&
|
||||||
|
requestedMap.trim() &&
|
||||||
|
requestedMap.length <= 200 &&
|
||||||
|
// eslint-disable-next-line no-control-regex -- Reject control characters in requested map names.
|
||||||
|
!/[\u0000-\u001f\u007f]/.test(requestedMap)
|
||||||
|
? requestedMap
|
||||||
|
: null;
|
||||||
const requestedZone = params.get('timeZone');
|
const requestedZone = params.get('timeZone');
|
||||||
return { days: parseMetricsDays(params.get('days')), game, map,
|
return {
|
||||||
timeZone: validTimeZone(requestedZone) ? requestedZone : DEFAULT_TIME_ZONE };
|
days: parseMetricsDays(params.get('days')),
|
||||||
|
game,
|
||||||
|
map,
|
||||||
|
timeZone: validTimeZone(requestedZone) ? requestedZone : DEFAULT_TIME_ZONE
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All durations are hours. Observed is known server time, not collection coverage. */
|
/** All durations are hours. Observed is known server time, not collection coverage. */
|
||||||
@@ -38,32 +50,76 @@ export interface Utilization {
|
|||||||
playerHours: number;
|
playerHours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerMetrics extends Utilization {
|
export interface MatchMetrics {
|
||||||
server: string;
|
/** Retained matches whose recorded start is within the selected window. */
|
||||||
/** A current registration with this name exists; historical identity may differ. */
|
started: number;
|
||||||
currentRegistered: boolean;
|
/** Matches with at least one distinct recorded participant. */
|
||||||
|
withRecordedPlayers: number;
|
||||||
|
/** Distinct participants over each match's lifetime, not simultaneous players. */
|
||||||
|
averageGroupSize: number | null;
|
||||||
|
solo: number;
|
||||||
|
/** Percentage of matches with recorded participants, from 0 to 100. */
|
||||||
|
soloShare: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapMetrics extends Utilization {
|
export interface MapMetrics extends Utilization {
|
||||||
game: string;
|
game: string;
|
||||||
map: string;
|
map: string;
|
||||||
/** Map wall time, not summed server time. */
|
matches: MatchMetrics;
|
||||||
full: number;
|
/** Concurrent lobbies over this map's captured wall time, including unknown occupancy. */
|
||||||
capacityObserved: number;
|
running: RunningMetrics;
|
||||||
|
/** Map wall time, not summed lobby time. Capacity does not affect availability. */
|
||||||
|
noEmpty: number;
|
||||||
|
availabilityObserved: number;
|
||||||
|
availabilityUnknown: number;
|
||||||
|
/** Historical lobby counts compared with the current policy's maximum. */
|
||||||
|
noEmptyAtMaximum: number;
|
||||||
|
noEmptyBelowMaximum: number;
|
||||||
|
policy: {
|
||||||
|
id: string;
|
||||||
|
mode: AutoscalingMode;
|
||||||
|
minimum: number;
|
||||||
|
maximum: number;
|
||||||
|
spares: number;
|
||||||
|
pausedReason: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetricPoint extends Utilization {
|
export interface RunningMetrics {
|
||||||
|
/** Concurrent tracked lobbies, including unknown player status, during captured scope time. */
|
||||||
|
average: number | null;
|
||||||
|
peak: number;
|
||||||
|
capturedHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerCounts {
|
||||||
|
/** Distinct GUIDs, across games; null means this interval predates retained history. */
|
||||||
|
uniquePlayers: number | null;
|
||||||
|
newPlayers: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerMetrics extends PlayerCounts {
|
||||||
|
/** Global earliest retained match, independent of the selected game/map. */
|
||||||
|
historyStartAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricPoint extends Utilization, PlayerCounts {
|
||||||
/** UTC bucket boundary, including when the selected interval starts later. */
|
/** UTC bucket boundary, including when the selected interval starts later. */
|
||||||
at: number;
|
at: number;
|
||||||
startAt: number;
|
startAt: number;
|
||||||
endAt: number;
|
endAt: number;
|
||||||
partial: boolean;
|
partial: boolean;
|
||||||
|
/** Captured scope wall time, excluding collection gaps. */
|
||||||
|
capturedHours: number;
|
||||||
|
/** Retained player history begins inside this period. */
|
||||||
|
playerHistoryPartial: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreviousMetrics {
|
export interface PreviousMetrics {
|
||||||
since: number;
|
since: number;
|
||||||
until: number;
|
until: number;
|
||||||
summary: Utilization;
|
summary: Utilization;
|
||||||
|
running: RunningMetrics;
|
||||||
firstSampleAt: number | null;
|
firstSampleAt: number | null;
|
||||||
lastSampleAt: number | null;
|
lastSampleAt: number | null;
|
||||||
/** Retained scope evidence starts late or is absent, not a completeness guarantee. */
|
/** Retained scope evidence starts late or is absent, not a completeness guarantee. */
|
||||||
@@ -78,32 +134,54 @@ export interface HeatmapCell extends Utilization {
|
|||||||
distinctDates: number;
|
distinctDates: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PooledPressure {
|
export interface PooledAvailability {
|
||||||
/** Share of known map-hours with every tracked lobby full, from 0 to 100. */
|
/** Share of known map-hours with no tracked lobby empty, from 0 to 100. */
|
||||||
value: number | null;
|
value: number | null;
|
||||||
fullMapHours: number;
|
noEmptyMapHours: number;
|
||||||
knownMapHours: number;
|
knownMapHours: number;
|
||||||
|
unknownMapHours: number;
|
||||||
coveredMaps: number;
|
coveredMaps: number;
|
||||||
totalMaps: number;
|
totalMaps: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutoscalingMetrics {
|
||||||
|
/** Retained Provisioning outcomes whose finish time is within the selected window. */
|
||||||
|
added: number;
|
||||||
|
removed: number;
|
||||||
|
failed: number;
|
||||||
|
cancelled: number;
|
||||||
|
/** Successful creates with valid request/finish timestamps; includes queue time. */
|
||||||
|
readinessSamples: number;
|
||||||
|
readinessMeanSeconds: number | null;
|
||||||
|
readinessMaxSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MetricsData {
|
export interface MetricsData {
|
||||||
days: MetricsDays;
|
days: MetricsDays;
|
||||||
|
/** Configured occupancy history retention; zero keeps samples indefinitely. */
|
||||||
|
metricsKeepDays: number;
|
||||||
since: number;
|
since: number;
|
||||||
now: number;
|
now: number;
|
||||||
/** Retained sample timestamps describe collection, not gameplay freshness. */
|
/** Retained sample timestamps describe collection, not gameplay freshness. */
|
||||||
firstRetainedSampleAt: number | null;
|
firstRetainedSampleAt: number | null;
|
||||||
latestSampleAt: number | null;
|
latestSampleAt: number | null;
|
||||||
registeredServerCount: number;
|
registeredServerCount: number;
|
||||||
|
/** Current fleet-wide pause, separate from each policy's own state. */
|
||||||
|
autoscalingPaused: boolean;
|
||||||
scope: MetricsScope;
|
scope: MetricsScope;
|
||||||
scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
|
scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
|
||||||
scopeFirstSampleAt: number | null;
|
scopeFirstSampleAt: number | null;
|
||||||
scopeLatestSampleAt: number | null;
|
scopeLatestSampleAt: number | null;
|
||||||
summary: Utilization;
|
summary: Utilization;
|
||||||
servers: ServerMetrics[];
|
running: RunningMetrics;
|
||||||
|
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
|
||||||
|
sampledServerCount: number;
|
||||||
|
matches: MatchMetrics;
|
||||||
|
players: PlayerMetrics;
|
||||||
|
autoscaling: AutoscalingMetrics;
|
||||||
maps: MapMetrics[];
|
maps: MapMetrics[];
|
||||||
points: MetricPoint[];
|
points: MetricPoint[];
|
||||||
previous: PreviousMetrics;
|
previous: PreviousMetrics;
|
||||||
heatmap: { timeZone: string; cells: HeatmapCell[] };
|
heatmap: { timeZone: string; cells: HeatmapCell[] };
|
||||||
pooledPressure: PooledPressure;
|
pooledAvailability: PooledAvailability;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { moderatorMaximumRestrictionMs, type AuthUser } from './auth.js';
|
||||||
|
|
||||||
|
export type ModerationGame = 't5' | 't6';
|
||||||
|
export type ModerationScope = 'server' | 'game' | 'fleet';
|
||||||
|
export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
|
||||||
|
export type ModerationState =
|
||||||
|
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
|
||||||
|
|
||||||
|
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
|
||||||
|
export const moderationCaseOutcomes = [
|
||||||
|
'resolved',
|
||||||
|
'dismissed',
|
||||||
|
'warning_issued',
|
||||||
|
'restriction_applied',
|
||||||
|
'no_action',
|
||||||
|
'duplicate',
|
||||||
|
'appeal_upheld',
|
||||||
|
'appeal_denied'
|
||||||
|
] as const;
|
||||||
|
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
|
||||||
|
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
|
||||||
|
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
|
||||||
|
export type ModerationCaseRelation = (typeof moderationCaseRelations)[number];
|
||||||
|
export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string> = {
|
||||||
|
same_incident: 'Same incident',
|
||||||
|
appeal_of: 'Appeal of',
|
||||||
|
duplicate_of: 'Duplicate of'
|
||||||
|
};
|
||||||
|
export const moderationCaseStatusLabels: Record<
|
||||||
|
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
open: 'Open',
|
||||||
|
in_review: 'In progress',
|
||||||
|
closed: 'Closed',
|
||||||
|
escalated: 'Escalated',
|
||||||
|
resolved: 'Resolved',
|
||||||
|
dismissed: 'Dismissed',
|
||||||
|
warning_issued: 'Warning issued',
|
||||||
|
restriction_applied: 'Restriction applied',
|
||||||
|
no_action: 'No action needed',
|
||||||
|
duplicate: 'Duplicate',
|
||||||
|
appeal_upheld: 'Appeal upheld',
|
||||||
|
appeal_denied: 'Appeal denied'
|
||||||
|
};
|
||||||
|
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
|
||||||
|
return status === 'open' || status === 'in_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationConcernCategories = [
|
||||||
|
'hate_speech',
|
||||||
|
'harassment',
|
||||||
|
'threats',
|
||||||
|
'safeguarding',
|
||||||
|
'spam',
|
||||||
|
'cheating',
|
||||||
|
'other'
|
||||||
|
] as const;
|
||||||
|
export type ModerationConcernCategory = (typeof moderationConcernCategories)[number];
|
||||||
|
export const moderationConcernLabels: Record<ModerationConcernCategory, string> = {
|
||||||
|
hate_speech: 'Hate speech',
|
||||||
|
harassment: 'Harassment',
|
||||||
|
threats: 'Threats',
|
||||||
|
safeguarding: 'Safeguarding',
|
||||||
|
spam: 'Spam',
|
||||||
|
cheating: 'Cheating',
|
||||||
|
other: 'Other'
|
||||||
|
};
|
||||||
|
export const moderationFindingOutcomes = [
|
||||||
|
'substantiated',
|
||||||
|
'inconclusive',
|
||||||
|
'unsubstantiated'
|
||||||
|
] as const;
|
||||||
|
export type ModerationFindingOutcome = (typeof moderationFindingOutcomes)[number];
|
||||||
|
export const moderationFindingLabels: Record<ModerationFindingOutcome, string> = {
|
||||||
|
substantiated: 'Substantiated',
|
||||||
|
inconclusive: 'Inconclusive',
|
||||||
|
unsubstantiated: 'Unsubstantiated'
|
||||||
|
};
|
||||||
|
export interface ModerationCaseAssessment {
|
||||||
|
revision: number;
|
||||||
|
summary: string;
|
||||||
|
rules: string[];
|
||||||
|
findings: {
|
||||||
|
category: ModerationConcernCategory;
|
||||||
|
finding: ModerationFindingOutcome;
|
||||||
|
detail: string;
|
||||||
|
}[];
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModerationPlayer {
|
||||||
|
game: ModerationGame;
|
||||||
|
guid: string;
|
||||||
|
name: string;
|
||||||
|
aliases: string[];
|
||||||
|
firstSeen: string | null;
|
||||||
|
lastSeen: string | null;
|
||||||
|
servers: string[];
|
||||||
|
restrictions: number;
|
||||||
|
}
|
||||||
|
export interface ModerationSanction {
|
||||||
|
id: string;
|
||||||
|
caseId?: string | null;
|
||||||
|
game: ModerationGame;
|
||||||
|
guid: string;
|
||||||
|
kind: 'ban' | 'mute';
|
||||||
|
scope: ModerationScope;
|
||||||
|
server: string | null;
|
||||||
|
reason: string;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
actorId: string;
|
||||||
|
actor: string;
|
||||||
|
revision: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRevokeModerationRestriction(
|
||||||
|
actor: Pick<AuthUser, 'id' | 'role'> | null | undefined,
|
||||||
|
restriction: Pick<ModerationSanction, 'actorId' | 'scope' | 'createdAt' | 'expiresAt'>
|
||||||
|
): boolean {
|
||||||
|
if (actor?.role === 'admin') return true;
|
||||||
|
if (
|
||||||
|
actor?.role !== 'moderator' ||
|
||||||
|
actor.id !== restriction.actorId ||
|
||||||
|
(restriction.scope !== 'server' && restriction.scope !== 'game') ||
|
||||||
|
!restriction.expiresAt
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const duration = Date.parse(restriction.expiresAt) - Date.parse(restriction.createdAt);
|
||||||
|
return duration > 0 && duration <= moderatorMaximumRestrictionMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModerationAction {
|
||||||
|
id: string;
|
||||||
|
game: ModerationGame;
|
||||||
|
guid: string | null;
|
||||||
|
server: string;
|
||||||
|
kind: ModerationActionKind;
|
||||||
|
reason: string;
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
state: ModerationState;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
export interface ModerationCase {
|
||||||
|
id: string;
|
||||||
|
game: ModerationGame;
|
||||||
|
guid: string;
|
||||||
|
title: string;
|
||||||
|
status: ModerationCaseStatus;
|
||||||
|
priority: ModerationCasePriority;
|
||||||
|
assigneeId: string | null;
|
||||||
|
assignee: string | null;
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
revision: number;
|
||||||
|
assessment?: ModerationCaseAssessment | null;
|
||||||
|
reviewAt?: string | null;
|
||||||
|
reviewReason?: string;
|
||||||
|
reviewSanctionId?: string | null;
|
||||||
|
unreadUpdates?: number;
|
||||||
|
evidence: {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
source: string;
|
||||||
|
snapshot?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
actor: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
export interface ModerationCaseDetail {
|
||||||
|
case: ModerationCase;
|
||||||
|
player: ModerationPlayer;
|
||||||
|
sanctions: ModerationSanction[];
|
||||||
|
linkedActions?: ModerationCaseAction[];
|
||||||
|
availableActions?: ModerationCaseAction[];
|
||||||
|
assessment?: ModerationCaseAssessment | null;
|
||||||
|
assessmentHistory?: ModerationCaseAssessment[];
|
||||||
|
relatedCases?: ModerationCaseRelationship[];
|
||||||
|
candidateCases?: ModerationCaseCandidate[];
|
||||||
|
notifications?: ModerationCaseNotification[];
|
||||||
|
events: ModerationEvent[];
|
||||||
|
coverage: ModerationCoverage[];
|
||||||
|
assignees: { id: string; username: string }[];
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
export type ModerationCaseReference = Pick<
|
||||||
|
ModerationCase,
|
||||||
|
'id' | 'title' | 'status' | 'game' | 'guid'
|
||||||
|
>;
|
||||||
|
export interface ModerationCaseRelationship {
|
||||||
|
id: string;
|
||||||
|
relation: ModerationCaseRelation;
|
||||||
|
direction: 'outgoing' | 'incoming';
|
||||||
|
case: ModerationCaseReference;
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface ModerationCaseCandidate extends ModerationCaseReference {
|
||||||
|
sharedMatch: boolean;
|
||||||
|
}
|
||||||
|
export interface ModerationCaseNotification {
|
||||||
|
id: number;
|
||||||
|
caseId: string;
|
||||||
|
caseTitle: string;
|
||||||
|
kind: 'evidence' | 'assignment';
|
||||||
|
createdAt: string;
|
||||||
|
readAt: string | null;
|
||||||
|
}
|
||||||
|
export interface ModerationCaseAction {
|
||||||
|
id: string;
|
||||||
|
type: 'restriction' | 'action';
|
||||||
|
kind: string;
|
||||||
|
reason: string;
|
||||||
|
actor: string;
|
||||||
|
createdAt: string;
|
||||||
|
state: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
export interface ModerationEvent {
|
||||||
|
id: number;
|
||||||
|
game: ModerationGame | null;
|
||||||
|
guid: string | null;
|
||||||
|
action: string;
|
||||||
|
subjectId: string | null;
|
||||||
|
actor: string;
|
||||||
|
detail: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface ModerationCoverage {
|
||||||
|
server: string;
|
||||||
|
game: ModerationGame;
|
||||||
|
connectedAt: string | null;
|
||||||
|
revision: number | null;
|
||||||
|
desiredRevision: number;
|
||||||
|
state: 'current' | 'pending' | 'offline' | 'unavailable';
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
export interface ModerationQuery {
|
||||||
|
view: 'players' | 'restrictions' | 'cases' | 'history';
|
||||||
|
q: string;
|
||||||
|
game: '' | ModerationGame;
|
||||||
|
guid: string;
|
||||||
|
offset: number;
|
||||||
|
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
|
||||||
|
priority?: '' | ModerationCasePriority;
|
||||||
|
assignedTo?: string;
|
||||||
|
review?: '' | 'due' | 'scheduled' | 'updates';
|
||||||
|
}
|
||||||
|
export interface ModerationOverview {
|
||||||
|
query: ModerationQuery;
|
||||||
|
players: ModerationPlayer[];
|
||||||
|
sanctions: ModerationSanction[];
|
||||||
|
cases: ModerationCase[];
|
||||||
|
events: ModerationEvent[];
|
||||||
|
actions: ModerationAction[];
|
||||||
|
coverage: ModerationCoverage[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
checkedAt: string;
|
||||||
|
notifications?: ModerationCaseNotification[];
|
||||||
|
unreadCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
|
||||||
|
export function moderationGuid(
|
||||||
|
game: ModerationGame,
|
||||||
|
guid: string,
|
||||||
|
source: 'game' | 'rcon' = 'game'
|
||||||
|
): string {
|
||||||
|
const value = guid.trim();
|
||||||
|
if (game === 't6' && source === 'rcon' && /^(?:0x)?[0-9a-f]+$/i.test(value))
|
||||||
|
return BigInt(`0x${value.replace(/^0x/i, '')}`).toString();
|
||||||
|
if (/^\d+$/.test(value)) return BigInt(value).toString();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
|
import { activityRedirectHref } from './activity-redirect.js';
|
||||||
|
|
||||||
const origin = 'https://electric-cherry.invalid';
|
const origin = 'https://electric-cherry.invalid';
|
||||||
const pagePath = /^\/(?:$|activity|operations|autoscaling|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
const pagePath =
|
||||||
|
/^\/(?:$|activity|operations|autoscaling|account|bank|afk|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
||||||
|
|
||||||
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
||||||
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
||||||
if (!value || value.length > 12000 || !value.startsWith('/') || value.startsWith('//') || /[\\\x00-\x1f\x7f]/.test(value)) return fallback;
|
if (
|
||||||
|
!value ||
|
||||||
|
value.length > 12000 ||
|
||||||
|
!value.startsWith('/') ||
|
||||||
|
value.startsWith('//') ||
|
||||||
|
// eslint-disable-next-line no-control-regex -- Reject control characters in redirect paths.
|
||||||
|
/[\\\x00-\x1f\x7f]/.test(value)
|
||||||
|
)
|
||||||
|
return fallback;
|
||||||
try {
|
try {
|
||||||
const url = new URL(value, origin);
|
const url = new URL(value, origin);
|
||||||
if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback;
|
if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback;
|
||||||
|
if (url.pathname === '/activity' || url.pathname === '/operations')
|
||||||
|
return activityRedirectHref(url);
|
||||||
return url.pathname + url.search + url.hash;
|
return url.pathname + url.search + url.hash;
|
||||||
} catch { return fallback; }
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function contextHref(destination: string, source: URL | string): string {
|
export function contextHref(destination: string, source: URL | string): string {
|
||||||
@@ -25,11 +40,32 @@ export function returnLabel(href: string): string {
|
|||||||
if (path.startsWith('/servers/')) return 'Back to server';
|
if (path.startsWith('/servers/')) return 'Back to server';
|
||||||
if (path.startsWith('/leaderboard/players/')) return 'Back to player';
|
if (path.startsWith('/leaderboard/players/')) return 'Back to player';
|
||||||
if (path.startsWith('/collections/launches/')) return 'Back to collection launch';
|
if (path.startsWith('/collections/launches/')) return 'Back to collection launch';
|
||||||
if (path === '/collections' && url.searchParams.get('view') === 'active') return 'Back to active launches';
|
if (path === '/collections' && url.searchParams.get('view') === 'active')
|
||||||
if (path === '/collections' && url.searchParams.get('view') === 'history') return 'Back to launch history';
|
return 'Back to active launches';
|
||||||
|
if (path === '/collections' && url.searchParams.get('view') === 'history')
|
||||||
|
return 'Back to launch history';
|
||||||
if (path.startsWith('/matches/')) return 'Back to match';
|
if (path.startsWith('/matches/')) return 'Back to match';
|
||||||
if (path === '/hosts/operations') return 'Back to host operation history';
|
if (path === '/hosts/operations') return 'Back to host operation history';
|
||||||
if (path.startsWith('/hosts/')) return 'Back to host';
|
if (path.startsWith('/hosts/')) return 'Back to host';
|
||||||
const names: Record<string, string> = { '': 'Servers', activity: 'Activity', operations: 'Activity', autoscaling: 'Autoscaling', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Server queues', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' };
|
const names: Record<string, string> = {
|
||||||
|
'': 'Servers',
|
||||||
|
autoscaling: 'Autoscaling',
|
||||||
|
collections: 'Collections',
|
||||||
|
hosts: 'Hosts',
|
||||||
|
capacity: 'Capacity',
|
||||||
|
scripts: 'Scripts',
|
||||||
|
configs: 'Configs',
|
||||||
|
plugins: 'Plugins',
|
||||||
|
'game-files': 'Game Files',
|
||||||
|
betas: 'Betas',
|
||||||
|
campaigns: 'Campaigns',
|
||||||
|
maintenance: 'Maintenance',
|
||||||
|
provisioning: 'Server queues',
|
||||||
|
leaderboard: 'Leaderboard',
|
||||||
|
bank: 'Bank',
|
||||||
|
afk: 'AFK',
|
||||||
|
matches: 'Match History',
|
||||||
|
metrics: 'Metrics'
|
||||||
|
};
|
||||||
return `Back to ${names[path.split('/')[1]] ?? 'previous page'}`;
|
return `Back to ${names[path.split('/')[1]] ?? 'previous page'}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-36
@@ -1,52 +1,118 @@
|
|||||||
import type { UserRole } from './auth.js';
|
import { canReadAdmin, canReadModeration, type UserRole } from './auth.js';
|
||||||
|
|
||||||
export interface NavigationItem { label: string; href: string; shortcut?: string; matches?: (path: string) => boolean }
|
export interface NavigationItem {
|
||||||
export interface NavigationGroup { id: string; label: string; admin?: boolean; items: NavigationItem[] }
|
label: string;
|
||||||
|
href: string;
|
||||||
|
shortcut?: string;
|
||||||
|
moderation?: boolean;
|
||||||
|
matches?: (path: string) => boolean;
|
||||||
|
}
|
||||||
|
export interface NavigationGroup {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
admin?: boolean;
|
||||||
|
items: NavigationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export const navigationGroups: NavigationGroup[] = [
|
export const navigationGroups: NavigationGroup[] = [
|
||||||
{ id: 'fleet', label: 'Fleet', items: [
|
{
|
||||||
{ label: 'Servers', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') },
|
id: 'fleet',
|
||||||
{ label: 'Hosts', href: '/hosts', shortcut: 'o', matches: path => path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path) },
|
label: 'Fleet',
|
||||||
{ label: 'Capacity', href: '/capacity' },
|
items: [
|
||||||
{ label: 'Autoscaling', href: '/autoscaling' },
|
{
|
||||||
{ label: 'Collections', href: '/collections' }
|
label: 'Servers',
|
||||||
] },
|
href: '/',
|
||||||
{ id: 'releases', label: 'Releases', items: [
|
shortcut: 'f',
|
||||||
{ label: 'Scripts', href: '/scripts', shortcut: 's' },
|
matches: (path) => path === '/' || path.startsWith('/servers/')
|
||||||
{ label: 'Configs', href: '/configs', shortcut: 'c' },
|
},
|
||||||
{ label: 'Plugins', href: '/plugins', shortcut: 'p' },
|
{
|
||||||
{ label: 'Game files', href: '/game-files' },
|
label: 'Hosts',
|
||||||
{ label: 'Betas', href: '/betas', shortcut: 'b' },
|
href: '/hosts',
|
||||||
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' }
|
shortcut: 'o',
|
||||||
] },
|
matches: (path) => path === '/hosts' || path.startsWith('/hosts/')
|
||||||
{ id: 'activity', label: 'Activity', items: [
|
},
|
||||||
{ label: 'Activity log', href: '/activity', shortcut: 'd', matches: path => path === '/activity' || path === '/operations' || path === '/hosts/operations' },
|
{ label: 'Capacity', href: '/capacity' },
|
||||||
{ label: 'Provisioning', href: '/provisioning', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
|
{ label: 'Autoscaling', href: '/autoscaling' },
|
||||||
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) }
|
{ label: 'Collections', href: '/collections' }
|
||||||
] },
|
]
|
||||||
{ id: 'players', label: 'Players', items: [
|
},
|
||||||
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
{
|
||||||
{ label: 'Matches', href: '/matches' },
|
id: 'releases',
|
||||||
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
label: 'Releases',
|
||||||
] },
|
items: [
|
||||||
{ id: 'admin', label: 'Admin', admin: true, items: [
|
{ label: 'Scripts', href: '/scripts', shortcut: 's' },
|
||||||
{ label: 'Users', href: '/settings/users' },
|
{ label: 'Configs', href: '/configs', shortcut: 'c' },
|
||||||
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
|
{ label: 'Plugins', href: '/plugins', shortcut: 'p' },
|
||||||
{ label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' }
|
{ label: 'Game files', href: '/game-files' },
|
||||||
] }
|
{ label: 'Betas', href: '/betas', shortcut: 'b' },
|
||||||
|
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'activity',
|
||||||
|
label: 'Activity',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Provisioning',
|
||||||
|
href: '/provisioning',
|
||||||
|
matches: (path) => /^\/(provisioning|deployments)(\/|$)/.test(path)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Maintenance',
|
||||||
|
href: '/maintenance',
|
||||||
|
shortcut: 'w',
|
||||||
|
matches: (path) => /^\/(maintenance|watchdog)(\/|$)/.test(path)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'players',
|
||||||
|
label: 'Players',
|
||||||
|
items: [
|
||||||
|
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
||||||
|
{ label: 'Matches', href: '/matches' },
|
||||||
|
{ label: 'Bank', href: '/bank' },
|
||||||
|
{ label: 'AFK', href: '/afk' },
|
||||||
|
{ label: 'Moderation', href: '/moderation', moderation: true },
|
||||||
|
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
label: 'Admin',
|
||||||
|
admin: true,
|
||||||
|
items: [
|
||||||
|
{ label: 'Users', href: '/settings/users' },
|
||||||
|
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
|
||||||
|
{ label: 'Discord', href: '/settings/integrations/discord' },
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
href: '/settings',
|
||||||
|
shortcut: 'h',
|
||||||
|
matches: (path) => path === '/settings'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function visibleNavigation(role: UserRole): NavigationGroup[] {
|
export function visibleNavigation(role: UserRole): NavigationGroup[] {
|
||||||
return navigationGroups.filter(group => !group.admin || role === 'admin');
|
return navigationGroups
|
||||||
|
.filter((group) => !group.admin || canReadAdmin(role))
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) => !item.moderation || canReadModeration(role))
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigationItemActive(item: NavigationItem, path: string): boolean {
|
export function navigationItemActive(item: NavigationItem, path: string): boolean {
|
||||||
return item.matches ? item.matches(path) : path === item.href || path.startsWith(item.href + '/');
|
return item.matches
|
||||||
|
? item.matches(path)
|
||||||
|
: path === item.href || path.startsWith(item.href + '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigationLocation(path: string, role: UserRole) {
|
export function navigationLocation(path: string, role: UserRole) {
|
||||||
for (const group of visibleNavigation(role)) {
|
for (const group of visibleNavigation(role)) {
|
||||||
const item = group.items.find(item => navigationItemActive(item, path));
|
const item = group.items.find((item) => navigationItemActive(item, path));
|
||||||
if (item) return { group, item };
|
if (item) return { group, item };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+90
-29
@@ -1,19 +1,42 @@
|
|||||||
export interface ActivityEvent { id: number; created_at: string; message: string }
|
export interface ActivityEvent {
|
||||||
export interface OperationStep { label: string; state: 'pending' | 'current' | 'done' | 'failed' }
|
id: number;
|
||||||
|
created_at: string;
|
||||||
interface BetaServer {
|
message: string;
|
||||||
stage: string; deployed_at: string | null; restarted_at: string | null;
|
}
|
||||||
healthy_at: string | null; match_seen_at: string | null;
|
export interface OperationStep {
|
||||||
|
label: string;
|
||||||
|
state: 'pending' | 'current' | 'done' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function betaFlow(beta: { state: string; wait_for_health: number; wait_for_match: number; testPlan?: boolean }, servers: BetaServer[]): OperationStep[] {
|
interface BetaServer {
|
||||||
|
stage: string;
|
||||||
|
deployed_at: string | null;
|
||||||
|
restarted_at: string | null;
|
||||||
|
healthy_at: string | null;
|
||||||
|
match_seen_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function betaFlow(
|
||||||
|
beta: { state: string; wait_for_health: number; wait_for_match: number; testPlan?: boolean },
|
||||||
|
servers: BetaServer[]
|
||||||
|
): OperationStep[] {
|
||||||
const stages = ['deploying', 'awaiting-empty', 'restarting'];
|
const stages = ['deploying', 'awaiting-empty', 'restarting'];
|
||||||
const labels = ['Stage file changes', 'Wait for empty servers', 'Apply files and restart'];
|
const labels = ['Stage file changes', 'Wait for empty servers', 'Apply files and restart'];
|
||||||
if (beta.wait_for_health) { stages.push('awaiting-health'); labels.push('Verify health'); }
|
if (beta.wait_for_health) {
|
||||||
if (beta.wait_for_match) { stages.push('awaiting-match'); labels.push('Observe a completed match'); }
|
stages.push('awaiting-health');
|
||||||
if (beta.testPlan) { stages.push('observing'); labels.push('Evaluate test requirements'); }
|
labels.push('Verify health');
|
||||||
stages.push('ready'); labels.push('Review and promote');
|
}
|
||||||
const positions = servers.map(s => {
|
if (beta.wait_for_match) {
|
||||||
|
stages.push('awaiting-match');
|
||||||
|
labels.push('Observe a completed match');
|
||||||
|
}
|
||||||
|
if (beta.testPlan) {
|
||||||
|
stages.push('observing');
|
||||||
|
labels.push('Evaluate test requirements');
|
||||||
|
}
|
||||||
|
stages.push('ready');
|
||||||
|
labels.push('Review and promote');
|
||||||
|
const positions = servers.map((s) => {
|
||||||
if (s.stage !== 'failed') return Math.max(0, stages.indexOf(s.stage));
|
if (s.stage !== 'failed') return Math.max(0, stages.indexOf(s.stage));
|
||||||
if (!s.deployed_at) return 0;
|
if (!s.deployed_at) return 0;
|
||||||
if (!s.restarted_at) return 2;
|
if (!s.restarted_at) return 2;
|
||||||
@@ -21,29 +44,67 @@ export function betaFlow(beta: { state: string; wait_for_health: number; wait_fo
|
|||||||
if (beta.wait_for_match && !s.match_seen_at) return stages.indexOf('awaiting-match');
|
if (beta.wait_for_match && !s.match_seen_at) return stages.indexOf('awaiting-match');
|
||||||
return stages.length - 1;
|
return stages.length - 1;
|
||||||
});
|
});
|
||||||
const current = beta.state === 'promoted' ? stages.length
|
const current =
|
||||||
: beta.state === 'ready-promote' ? stages.length - 1
|
beta.state === 'promoted'
|
||||||
: positions.length ? Math.min(...positions) : 0;
|
? stages.length
|
||||||
return labels.map((label, i) => ({ label, state: i < current ? 'done' : i === current
|
: beta.state === 'ready-promote'
|
||||||
? beta.state === 'rolled-back' ? 'pending' : servers.some((s, j) => s.stage === 'failed' && positions[j] === i) ? 'failed' : 'current'
|
? stages.length - 1
|
||||||
: 'pending' }));
|
: positions.length
|
||||||
|
? Math.min(...positions)
|
||||||
|
: 0;
|
||||||
|
return labels.map((label, i) => ({
|
||||||
|
label,
|
||||||
|
state:
|
||||||
|
i < current
|
||||||
|
? 'done'
|
||||||
|
: i === current
|
||||||
|
? beta.state === 'rolled-back'
|
||||||
|
? 'pending'
|
||||||
|
: servers.some((s, j) => s.stage === 'failed' && positions[j] === i)
|
||||||
|
? 'failed'
|
||||||
|
: 'current'
|
||||||
|
: 'pending'
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function campaignFlow(servers: { server?: string; restart_state: string; restarted_at: string | null; files_changed: number }[], finished: boolean,
|
export function campaignFlow(
|
||||||
files: { server: string; snapshot_pending: number; apply_state: string }[] = []): OperationStep[] {
|
servers: {
|
||||||
const active = servers.filter(s => !['skipped', 'cancelled'].includes(s.restart_state));
|
server?: string;
|
||||||
const positions = active.map(s => {
|
restart_state: string;
|
||||||
|
restarted_at: string | null;
|
||||||
|
files_changed: number;
|
||||||
|
}[],
|
||||||
|
finished: boolean,
|
||||||
|
files: { server: string; snapshot_pending: number; apply_state: string }[] = []
|
||||||
|
): OperationStep[] {
|
||||||
|
const active = servers.filter((s) => !['skipped', 'cancelled'].includes(s.restart_state));
|
||||||
|
const positions = active.map((s) => {
|
||||||
if (s.restart_state === 'done') return 5;
|
if (s.restart_state === 'done') return 5;
|
||||||
if (s.restarted_at) return 4;
|
if (s.restarted_at) return 4;
|
||||||
const changes = files.filter(f => f.server === s.server && f.apply_state !== 'cancelled');
|
const changes = files.filter((f) => f.server === s.server && f.apply_state !== 'cancelled');
|
||||||
if (changes.some(f => f.apply_state === 'applying')) return changes.some(f => f.snapshot_pending) ? 1 : 2;
|
if (changes.some((f) => f.apply_state === 'applying'))
|
||||||
if (changes.length && changes.every(f => f.apply_state === 'applied')) return 3;
|
return changes.some((f) => f.snapshot_pending) ? 1 : 2;
|
||||||
|
if (changes.length && changes.every((f) => f.apply_state === 'applied')) return 3;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
const current = positions.length ? Math.min(...positions) : finished ? 5 : 0;
|
const current = positions.length ? Math.min(...positions) : finished ? 5 : 0;
|
||||||
return ['Wait for empty servers and restart capacity', 'Capture rollback snapshots', 'Apply file changes', 'Restart servers', 'Verify health'].map((label, i) => ({
|
return [
|
||||||
label, state: i < current ? 'done' : i !== current ? 'pending'
|
'Wait for empty servers and restart capacity',
|
||||||
: active.some((s, j) => s.restart_state === 'failed' && positions[j] === i) ? 'failed'
|
'Capture rollback snapshots',
|
||||||
: active.some(s => ['manual', 'held'].includes(s.restart_state)) ? 'pending' : 'current'
|
'Apply file changes',
|
||||||
|
'Restart servers',
|
||||||
|
'Verify health'
|
||||||
|
].map((label, i) => ({
|
||||||
|
label,
|
||||||
|
state:
|
||||||
|
i < current
|
||||||
|
? 'done'
|
||||||
|
: i !== current
|
||||||
|
? 'pending'
|
||||||
|
: active.some((s, j) => s.restart_state === 'failed' && positions[j] === i)
|
||||||
|
? 'failed'
|
||||||
|
: active.some((s) => ['manual', 'held'].includes(s.restart_state))
|
||||||
|
? 'pending'
|
||||||
|
: 'current'
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-10
@@ -52,9 +52,16 @@ export const PLUTONIUM_MANIFEST_URL = 'https://cdn.plutoniummod.com/updater/prod
|
|||||||
|
|
||||||
export function parsePlutoniumManifest(value: unknown): number {
|
export function parsePlutoniumManifest(value: unknown): number {
|
||||||
const manifest = value as { product?: unknown; revision?: unknown } | null;
|
const manifest = value as { product?: unknown; revision?: unknown } | null;
|
||||||
if (!manifest || manifest.product !== 'plutonium-core-prod' || typeof manifest.revision !== 'number'
|
if (
|
||||||
|| !Number.isSafeInteger(manifest.revision) || manifest.revision < 1) {
|
!manifest ||
|
||||||
throw new Error('The production updater manifest did not contain a valid Plutonium revision.');
|
manifest.product !== 'plutonium-core-prod' ||
|
||||||
|
typeof manifest.revision !== 'number' ||
|
||||||
|
!Number.isSafeInteger(manifest.revision) ||
|
||||||
|
manifest.revision < 1
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The production updater manifest did not contain a valid Plutonium revision.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return manifest.revision;
|
return manifest.revision;
|
||||||
}
|
}
|
||||||
@@ -66,12 +73,20 @@ export function nextMaintenanceAt(time: string, now = new Date(), timeZone = 'UT
|
|||||||
const [hour, minute] = time.split(':').map(Number);
|
const [hour, minute] = time.split(':').map(Number);
|
||||||
const target = hour * 60 + minute;
|
const target = hour * 60 + minute;
|
||||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
|
timeZone,
|
||||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23'
|
||||||
});
|
});
|
||||||
function local(at: number) {
|
function local(at: number) {
|
||||||
const parts = Object.fromEntries(formatter.formatToParts(at).map(p => [p.type, p.value]));
|
const parts = Object.fromEntries(formatter.formatToParts(at).map((p) => [p.type, p.value]));
|
||||||
return { day: `${parts.year}-${parts.month}-${parts.day}`, minute: Number(parts.hour) * 60 + Number(parts.minute) };
|
return {
|
||||||
|
day: `${parts.year}-${parts.month}-${parts.day}`,
|
||||||
|
minute: Number(parts.hour) * 60 + Number(parts.minute)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const today = local(now.getTime()).day;
|
const today = local(now.getTime()).day;
|
||||||
const start = Math.floor(now.getTime() / 60_000) * 60_000;
|
const start = Math.floor(now.getTime() / 60_000) * 60_000;
|
||||||
@@ -83,9 +98,13 @@ export function nextMaintenanceAt(time: string, now = new Date(), timeZone = 'UT
|
|||||||
const wall = local(at);
|
const wall = local(at);
|
||||||
if (wall.day < today || wall.minute < target) continue;
|
if (wall.day < today || wall.minute < target) continue;
|
||||||
const previous = days.get(wall.day);
|
const previous = days.get(wall.day);
|
||||||
if (!previous || wall.minute < previous.minute) days.set(wall.day, { at, minute: wall.minute });
|
if (!previous || wall.minute < previous.minute)
|
||||||
|
days.set(wall.day, { at, minute: wall.minute });
|
||||||
}
|
}
|
||||||
const next = [...days.values()].map(d => d.at).filter(at => at > now.getTime()).sort((a, b) => a - b)[0];
|
const next = [...days.values()]
|
||||||
|
.map((d) => d.at)
|
||||||
|
.filter((at) => at > now.getTime())
|
||||||
|
.sort((a, b) => a - b)[0];
|
||||||
if (next === undefined) throw new Error('Could not calculate the next maintenance time.');
|
if (next === undefined) throw new Error('Could not calculate the next maintenance time.');
|
||||||
return new Date(next).toISOString();
|
return new Date(next).toISOString();
|
||||||
}
|
}
|
||||||
@@ -94,5 +113,10 @@ export function nextMaintenanceAt(time: string, now = new Date(), timeZone = 'UT
|
|||||||
export function maintenanceTimeInZone(schedule: MaintenanceSchedule, timeZone: string): string {
|
export function maintenanceTimeInZone(schedule: MaintenanceSchedule, timeZone: string): string {
|
||||||
if (schedule.timeZone === timeZone) return schedule.time;
|
if (schedule.timeZone === timeZone) return schedule.time;
|
||||||
const at = schedule.nextAt ?? nextMaintenanceAt(schedule.time, new Date(), schedule.timeZone);
|
const at = schedule.nextAt ?? nextMaintenanceAt(schedule.time, new Date(), schedule.timeZone);
|
||||||
return new Intl.DateTimeFormat('en-GB', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(at));
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23'
|
||||||
|
}).format(new Date(at));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
import type { ProvisioningAction } from './provisioning.js';
|
import type { ProvisioningAction } from './provisioning.js';
|
||||||
import type { LaunchRecipeSummary } from './deployment-plan.js';
|
import type { LaunchRecipeSummary } from './deployment-plan.js';
|
||||||
|
|
||||||
export const provisioningPhases = ['requested', 'eligibility', 'startup-queue', 'prepare',
|
export const provisioningPhases = [
|
||||||
'container-action', 'cleanup', 'verify-container', 'verify-game',
|
'requested',
|
||||||
'verify-map', 'verify-plutonium', 'verify-stopped'] as const;
|
'eligibility',
|
||||||
export type ProvisioningPhase = typeof provisioningPhases[number];
|
'startup-queue',
|
||||||
|
'prepare',
|
||||||
|
'container-action',
|
||||||
|
'cleanup',
|
||||||
|
'verify-container',
|
||||||
|
'verify-game',
|
||||||
|
'verify-map',
|
||||||
|
'verify-plutonium',
|
||||||
|
'verify-stopped'
|
||||||
|
] as const;
|
||||||
|
export type ProvisioningPhase = (typeof provisioningPhases)[number];
|
||||||
export type EvidenceResult = 'pass' | 'waiting' | 'fail' | 'unknown';
|
export type EvidenceResult = 'pass' | 'waiting' | 'fail' | 'unknown';
|
||||||
export type EvidenceSource = 'agent' | 'rcon' | 'telemetry' | 'combined' | 'manifest' | null;
|
export type EvidenceSource = 'agent' | 'rcon' | 'telemetry' | 'combined' | 'manifest' | null;
|
||||||
|
|
||||||
export interface ProvisioningCheck {
|
export interface ProvisioningCheck {
|
||||||
key: 'container' | 'game' | 'map' | 'plutonium-release' | 'plutonium-version' | 'occupancy' | 'stopped';
|
key:
|
||||||
|
| 'container'
|
||||||
|
| 'game'
|
||||||
|
| 'map'
|
||||||
|
| 'plutonium-release'
|
||||||
|
| 'plutonium-version'
|
||||||
|
| 'occupancy'
|
||||||
|
| 'stopped';
|
||||||
label: string;
|
label: string;
|
||||||
result: EvidenceResult;
|
result: EvidenceResult;
|
||||||
source: EvidenceSource;
|
source: EvidenceSource;
|
||||||
@@ -46,7 +63,11 @@ export interface ProvisioningProgress {
|
|||||||
blocker: ProvisioningBlocker | null;
|
blocker: ProvisioningBlocker | null;
|
||||||
checks: ProvisioningCheck[];
|
checks: ProvisioningCheck[];
|
||||||
checkpoints: ProvisioningCheckpoint[];
|
checkpoints: ProvisioningCheckpoint[];
|
||||||
command: { state: 'attempted' | 'accepted' | 'rejected' | 'uncertain'; at: string; message: string } | null;
|
command: {
|
||||||
|
state: 'attempted' | 'accepted' | 'rejected' | 'uncertain';
|
||||||
|
at: string;
|
||||||
|
message: string;
|
||||||
|
} | null;
|
||||||
recovery: RecoveryObservation | null;
|
recovery: RecoveryObservation | null;
|
||||||
recipeStatus: 'queued' | 'attempted' | null;
|
recipeStatus: 'queued' | 'attempted' | null;
|
||||||
}
|
}
|
||||||
@@ -96,15 +117,30 @@ export interface ProvisioningCapacity {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
export interface ProvisioningDetail {
|
export interface ProvisioningDetail {
|
||||||
|
generationRecovery?: {
|
||||||
|
expectedGeneration: number;
|
||||||
|
replacementGeneration: number;
|
||||||
|
observedGeneration: number | null;
|
||||||
|
available: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
} | null;
|
||||||
canOverrideSafety?: boolean;
|
canOverrideSafety?: boolean;
|
||||||
safetyOverrideUnavailableReason?: string | null;
|
safetyOverrideUnavailableReason?: string | null;
|
||||||
safetyOverride?: { actorId: string; actorName: string; confirmedAt: string } | null;
|
safetyOverride?: { actorId: string; actorName: string; confirmedAt: string } | null;
|
||||||
canCancel?: boolean;
|
canCancel?: boolean;
|
||||||
canCancelCreation?: boolean;
|
canCancelCreation?: boolean;
|
||||||
|
canRetryWithRebuild?: boolean;
|
||||||
cancellation?: { cleanupId: string; state: string; stage: string; error: string | null } | null;
|
cancellation?: { cleanupId: string; state: string; stage: string; error: string | null } | null;
|
||||||
backend?: 'native';
|
backend?: 'native';
|
||||||
workerActivity?: WorkerActivity[];
|
workerActivity?: WorkerActivity[];
|
||||||
agentOperation?: { id: string; hostId: string; state: string; updatedAt: string; createdAt: string; error: string | null } | null;
|
agentOperation?: {
|
||||||
|
id: string;
|
||||||
|
hostId: string;
|
||||||
|
state: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
error: string | null;
|
||||||
|
} | null;
|
||||||
deployment: ProvisioningDeployment;
|
deployment: ProvisioningDeployment;
|
||||||
events: ProvisioningEvent[];
|
events: ProvisioningEvent[];
|
||||||
activity: { total: number; latestId: number | null; failureExcerpt: ProvisioningEvent | null };
|
activity: { total: number; latestId: number | null; failureExcerpt: ProvisioningEvent | null };
|
||||||
@@ -121,7 +157,11 @@ export interface ProvisioningDetail {
|
|||||||
warningMessages: string[];
|
warningMessages: string[];
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
export interface WorkerActivity { key: string; label: string; startedAt: string }
|
export interface WorkerActivity {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
startedAt: string;
|
||||||
|
}
|
||||||
export interface ProvisioningEventPage {
|
export interface ProvisioningEventPage {
|
||||||
events: ProvisioningEvent[];
|
events: ProvisioningEvent[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -1,22 +1,102 @@
|
|||||||
import type { ProvisioningAction } from './provisioning.js';
|
import { provisioningStates, type ProvisioningAction } from './provisioning.js';
|
||||||
import type { ProvisioningDeployment } from './provisioning-detail.js';
|
import type { ProvisioningDeployment } from './provisioning-detail.js';
|
||||||
import type { ProvisioningSource } from './provisioning-detail.js';
|
import type { ProvisioningSource } from './provisioning-detail.js';
|
||||||
|
|
||||||
export const provisioningListStatuses = ['pending', 'running', 'awaiting-health', 'done', 'attention', 'failed', 'interrupted', 'warning', 'cancelled'] as const;
|
export const provisioningListStatuses = [
|
||||||
export const provisioningListSources = ['manual', 'campaign', 'beta', 'maintenance', 'rebalance', 'system'] as const;
|
...provisioningStates,
|
||||||
|
// Retain the combined filter for existing links and API clients.
|
||||||
|
'pending',
|
||||||
|
'attention'
|
||||||
|
] as const;
|
||||||
|
export const provisioningListSources = [
|
||||||
|
'manual',
|
||||||
|
'campaign',
|
||||||
|
'beta',
|
||||||
|
'maintenance',
|
||||||
|
'recovery',
|
||||||
|
'autoscaling',
|
||||||
|
'rebalance',
|
||||||
|
'system'
|
||||||
|
] as const;
|
||||||
|
export type ProvisioningListSource = (typeof provisioningListSources)[number];
|
||||||
|
export const provisioningSourceLabels: Record<ProvisioningListSource, string> = {
|
||||||
|
manual: 'Manual',
|
||||||
|
campaign: 'Campaign',
|
||||||
|
beta: 'Beta',
|
||||||
|
maintenance: 'Maintenance',
|
||||||
|
recovery: 'Recovery',
|
||||||
|
autoscaling: 'Autoscaling',
|
||||||
|
rebalance: 'Rebalance',
|
||||||
|
system: 'System'
|
||||||
|
};
|
||||||
|
export const provisioningSourceKinds = {
|
||||||
|
campaign: ['campaign'],
|
||||||
|
beta: ['beta'],
|
||||||
|
maintenance: ['maintenance'],
|
||||||
|
recovery: ['watchdog'],
|
||||||
|
autoscaling: ['autoscaling'],
|
||||||
|
rebalance: ['rebalance']
|
||||||
|
} as const satisfies Record<
|
||||||
|
Exclude<ProvisioningListSource, 'manual' | 'system'>,
|
||||||
|
readonly ProvisioningSource['kind'][]
|
||||||
|
>;
|
||||||
|
|
||||||
export type ProvisioningListRow = Pick<ProvisioningDeployment,
|
/** Record titles and incident summaries belong in expanded details, not the source column. */
|
||||||
'id' | 'server' | 'server_deleted_at' | 'action' | 'state' | 'stage' | 'started_at' |
|
export function provisioningSourceLabel(
|
||||||
'finished_at' | 'triggered_by' | 'error' | 'warnings' | 'server_exists'> & {
|
row: Pick<ProvisioningListRow, 'sources' | 'triggered_by'>
|
||||||
canCancelRemoval: number; canCancel?: number; sources?: ProvisioningSource[];
|
): string {
|
||||||
phase?: string | null; phaseStartedAt?: string | null; waitCode?: string | null;
|
const kind = row.sources?.[0]?.kind;
|
||||||
};
|
return provisioningSourceLabels[
|
||||||
|
kind === 'watchdog'
|
||||||
|
? 'recovery'
|
||||||
|
: (kind ?? (row.triggered_by === 'ui' ? 'manual' : 'system'))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provisioningMatchesSource(
|
||||||
|
row: Pick<ProvisioningListRow, 'sources' | 'triggered_by'>,
|
||||||
|
source: ProvisioningListSource
|
||||||
|
): boolean {
|
||||||
|
const sources = row.sources ?? [];
|
||||||
|
if (source === 'manual' || source === 'system')
|
||||||
|
return (
|
||||||
|
!sources.length &&
|
||||||
|
(source === 'manual' ? row.triggered_by === 'ui' : row.triggered_by !== 'ui')
|
||||||
|
);
|
||||||
|
const kinds: readonly ProvisioningSource['kind'][] = provisioningSourceKinds[source];
|
||||||
|
return sources.some((item) => kinds.includes(item.kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProvisioningListRow = Pick<
|
||||||
|
ProvisioningDeployment,
|
||||||
|
| 'id'
|
||||||
|
| 'server'
|
||||||
|
| 'server_deleted_at'
|
||||||
|
| 'action'
|
||||||
|
| 'state'
|
||||||
|
| 'stage'
|
||||||
|
| 'started_at'
|
||||||
|
| 'finished_at'
|
||||||
|
| 'triggered_by'
|
||||||
|
| 'requested_by'
|
||||||
|
| 'error'
|
||||||
|
| 'warnings'
|
||||||
|
| 'server_exists'
|
||||||
|
> & {
|
||||||
|
canCancelRemoval: number;
|
||||||
|
canCancel?: number;
|
||||||
|
sources?: ProvisioningSource[];
|
||||||
|
phase?: string | null;
|
||||||
|
phaseStartedAt?: string | null;
|
||||||
|
waitCode?: string | null;
|
||||||
|
waitMessage?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ProvisioningListOptions {
|
export interface ProvisioningListOptions {
|
||||||
view?: 'active' | 'history' | 'all';
|
view?: 'active' | 'history' | 'all';
|
||||||
action?: ProvisioningAction;
|
action?: ProvisioningAction;
|
||||||
status?: typeof provisioningListStatuses[number];
|
status?: (typeof provisioningListStatuses)[number];
|
||||||
source?: typeof provisioningListSources[number];
|
source?: ProvisioningListSource;
|
||||||
server?: string;
|
server?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
|||||||
+36
-6
@@ -1,17 +1,47 @@
|
|||||||
export const provisioningActions = ['create', 'start', 'restart', 'rebuild', 'stop', 'destroy'] as const;
|
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
|
||||||
export type ProvisioningAction = typeof provisioningActions[number];
|
export const provisioningStates = [
|
||||||
|
'queued',
|
||||||
|
'waiting',
|
||||||
|
'running',
|
||||||
|
'awaiting-health',
|
||||||
|
'done',
|
||||||
|
'warning',
|
||||||
|
'failed',
|
||||||
|
'interrupted',
|
||||||
|
'cancelled'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const provisioningActions = [
|
||||||
|
'create',
|
||||||
|
'start',
|
||||||
|
'restart',
|
||||||
|
'rebuild',
|
||||||
|
'stop',
|
||||||
|
'destroy'
|
||||||
|
] as const;
|
||||||
|
export type ProvisioningAction = (typeof provisioningActions)[number];
|
||||||
export type RuntimeAction = Exclude<ProvisioningAction, 'create' | 'destroy'>;
|
export type RuntimeAction = Exclude<ProvisioningAction, 'create' | 'destroy'>;
|
||||||
export const actionLabels: Record<ProvisioningAction, string> = {
|
export const actionLabels: Record<ProvisioningAction, string> = {
|
||||||
create: 'Create', start: 'Start', restart: 'Restart', rebuild: 'Rebuild', stop: 'Stop', destroy: 'Destroy'
|
create: 'Create',
|
||||||
|
start: 'Start',
|
||||||
|
restart: 'Restart',
|
||||||
|
rebuild: 'Rebuild',
|
||||||
|
stop: 'Stop',
|
||||||
|
destroy: 'Destroy'
|
||||||
};
|
};
|
||||||
export const actionCompleted: Record<ProvisioningAction, string> = {
|
export const actionCompleted: Record<ProvisioningAction, string> = {
|
||||||
create: 'Server creation completed', start: 'Server started', restart: 'Server restarted',
|
create: 'Server creation completed',
|
||||||
rebuild: 'Server rebuilt', stop: 'Server stopped', destroy: 'Server destroyed'
|
start: 'Server started',
|
||||||
|
restart: 'Server restarted',
|
||||||
|
rebuild: 'Server rebuilt',
|
||||||
|
stop: 'Server stopped',
|
||||||
|
destroy: 'Server destroyed'
|
||||||
};
|
};
|
||||||
export const runtimeActions: RuntimeAction[] = ['start', 'restart', 'rebuild', 'stop'];
|
export const runtimeActions: RuntimeAction[] = ['start', 'restart', 'rebuild', 'stop'];
|
||||||
export const runtimeDescriptions: Record<RuntimeAction, string> = {
|
export const runtimeDescriptions: Record<RuntimeAction, string> = {
|
||||||
start: 'Start the existing container and verify its health.',
|
start: 'Start the existing container and verify its health.',
|
||||||
restart: 'Restart the container after the server is empty, then verify its health.',
|
restart: 'Restart the container after the server is empty, then verify its health.',
|
||||||
rebuild: 'Remove the instance and delete all of its server files, then create a fresh instance in the same server slot and port with the latest promoted setup. No instance data is retained. Uses the key saved separately in Keys. Waits until empty, then verifies health and map.',
|
rebuild:
|
||||||
|
'Remove the instance and delete all of its server files, then create a fresh instance in the same server slot and port with the latest promoted setup. No instance data is retained. Uses the key saved separately in Keys. Waits until empty, then verifies health and map.',
|
||||||
stop: 'Stop the container after the server is empty. Its files, key, and registration are retained so you can start it again.'
|
stop: 'Stop the container after the server is empty. Its files, key, and registration are retained so you can start it again.'
|
||||||
};
|
};
|
||||||
|
|||||||
+57
-17
@@ -4,15 +4,26 @@ export interface RebalanceSettings {
|
|||||||
maxMoves: number;
|
maxMoves: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RebalanceHostOption { id: string; name: string; eligible: boolean; reason: string | null }
|
export interface RebalanceHostOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
eligible: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
export interface RebalanceHostProjection {
|
export interface RebalanceHostProjection {
|
||||||
id: string; name: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
before: { cpuPercent: number; memoryPercent: number; instances: number };
|
before: { cpuPercent: number; memoryPercent: number; instances: number };
|
||||||
after: { cpuPercent: number; memoryPercent: number; instances: number };
|
after: { cpuPercent: number; memoryPercent: number; instances: number };
|
||||||
}
|
}
|
||||||
export interface RebalanceCandidate {
|
export interface RebalanceCandidate {
|
||||||
server: string; instanceId: string; generation: number;
|
server: string;
|
||||||
sourceHostId: string; sourceHostName: string; targetHostId: string; targetHostName: string;
|
instanceId: string;
|
||||||
|
generation: number;
|
||||||
|
sourceHostId: string;
|
||||||
|
sourceHostName: string;
|
||||||
|
targetHostId: string;
|
||||||
|
targetHostName: string;
|
||||||
/** Estimated reduction in the source/destination pair's peak utilization, in percentage points. */
|
/** Estimated reduction in the source/destination pair's peak utilization, in percentage points. */
|
||||||
improvement: number;
|
improvement: number;
|
||||||
}
|
}
|
||||||
@@ -23,26 +34,55 @@ export interface RebalancePlan {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
export interface RebalancePreview extends RebalancePlan {
|
export interface RebalancePreview extends RebalancePlan {
|
||||||
id: string; settings: RebalanceSettings; createdAt: string; expiresAt: string;
|
id: string;
|
||||||
|
settings: RebalanceSettings;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
export interface RebalanceMove extends RebalanceCandidate {
|
export interface RebalanceMove extends RebalanceCandidate {
|
||||||
id: string; state: 'planned' | 'moving' | 'done' | 'skipped' | 'failed';
|
id: string;
|
||||||
operationId: string | null; message: string; updatedAt: string;
|
state: 'planned' | 'moving' | 'done' | 'skipped' | 'failed';
|
||||||
|
operationId: string | null;
|
||||||
|
message: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
export interface RebalanceRun {
|
export interface RebalanceRun {
|
||||||
id: string; settings: RebalanceSettings;
|
id: string;
|
||||||
|
settings: RebalanceSettings;
|
||||||
state: 'running' | 'stopping' | 'done' | 'stopped' | 'failed';
|
state: 'running' | 'stopping' | 'done' | 'stopped' | 'failed';
|
||||||
createdAt: string; updatedAt: string; message: string; moves: RebalanceMove[];
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
message: string;
|
||||||
|
moves: RebalanceMove[];
|
||||||
|
}
|
||||||
|
export interface RebalanceReport {
|
||||||
|
hosts: RebalanceHostOption[];
|
||||||
|
runs: RebalanceRun[];
|
||||||
}
|
}
|
||||||
export interface RebalanceReport { hosts: RebalanceHostOption[]; runs: RebalanceRun[] }
|
|
||||||
|
|
||||||
export function rebalanceSettings(input: unknown): RebalanceSettings {
|
export function rebalanceSettings(input: unknown): RebalanceSettings {
|
||||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Provide rebalance settings.');
|
if (!input || typeof input !== 'object' || Array.isArray(input))
|
||||||
|
throw new Error('Provide rebalance settings.');
|
||||||
const value = input as Record<string, unknown>;
|
const value = input as Record<string, unknown>;
|
||||||
if (!Array.isArray(value.hostIds) || value.hostIds.length < 2 || value.hostIds.length > 100
|
if (
|
||||||
|| value.hostIds.some(id => typeof id !== 'string' || !id || id.length > 100)
|
!Array.isArray(value.hostIds) ||
|
||||||
|| new Set(value.hostIds).size !== value.hostIds.length) throw new Error('Choose between 2 and 100 different hosts.');
|
value.hostIds.length < 2 ||
|
||||||
if (!['all', 't5', 't6'].includes(value.game as string)) throw new Error('Choose a supported game.');
|
value.hostIds.length > 100 ||
|
||||||
if (!Number.isInteger(value.maxMoves) || (value.maxMoves as number) < 1 || (value.maxMoves as number) > 20) throw new Error('Choose a move limit between 1 and 20.');
|
value.hostIds.some((id) => typeof id !== 'string' || !id || id.length > 100) ||
|
||||||
return { hostIds: [...value.hostIds as string[]].sort(), game: value.game as RebalanceSettings['game'], maxMoves: value.maxMoves as number };
|
new Set(value.hostIds).size !== value.hostIds.length
|
||||||
|
)
|
||||||
|
throw new Error('Choose between 2 and 100 different hosts.');
|
||||||
|
if (!['all', 't5', 't6'].includes(value.game as string))
|
||||||
|
throw new Error('Choose a supported game.');
|
||||||
|
if (
|
||||||
|
!Number.isInteger(value.maxMoves) ||
|
||||||
|
(value.maxMoves as number) < 1 ||
|
||||||
|
(value.maxMoves as number) > 20
|
||||||
|
)
|
||||||
|
throw new Error('Choose a move limit between 1 and 20.');
|
||||||
|
return {
|
||||||
|
hostIds: [...(value.hostIds as string[])].sort(),
|
||||||
|
game: value.game as RebalanceSettings['game'],
|
||||||
|
maxMoves: value.maxMoves as number
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-20
@@ -2,20 +2,35 @@ import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from './map-aliases.js';
|
|||||||
import { gameTitle } from './ui/server-labels.js';
|
import { gameTitle } from './ui/server-labels.js';
|
||||||
|
|
||||||
export interface LibraryVersion {
|
export interface LibraryVersion {
|
||||||
id: number; version: number; sha256: string; size: number;
|
id: number;
|
||||||
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
version: number;
|
||||||
|
release_version?: string | null;
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
state: 'untested' | 'tested' | 'test-failed';
|
||||||
|
uploaded_at: string;
|
||||||
}
|
}
|
||||||
export interface LibraryScript {
|
export interface LibraryScript {
|
||||||
id: number; name: string; target_rel_path: string;
|
id: number;
|
||||||
scope_type: 'game' | 'map' | null; scope_value: string | null;
|
name: string;
|
||||||
|
target_rel_path: string;
|
||||||
|
scope_type: 'game' | 'map' | null;
|
||||||
|
scope_value: string | null;
|
||||||
current_version_id: number | null;
|
current_version_id: number | null;
|
||||||
currentVersion: LibraryVersion | null; latestVersion: LibraryVersion | null;
|
currentVersion: LibraryVersion | null;
|
||||||
versionCount: number; servers: string[]; runningBetaId: number | null;
|
latestVersion: LibraryVersion | null;
|
||||||
|
versionCount: number;
|
||||||
|
servers: string[];
|
||||||
|
runningBetaId: number | null;
|
||||||
}
|
}
|
||||||
export type ScriptStatus = 'untested' | 'testing' | 'tested' | 'failed' | 'promoted' | 'empty';
|
export type ScriptStatus = 'untested' | 'testing' | 'tested' | 'failed' | 'promoted' | 'empty';
|
||||||
export const statusLabels: Record<ScriptStatus, string> = {
|
export const statusLabels: Record<ScriptStatus, string> = {
|
||||||
untested: 'Needs testing', testing: 'Beta active', tested: 'Test passed',
|
untested: 'Needs testing',
|
||||||
failed: 'Test failed', promoted: 'Promoted', empty: 'No versions'
|
testing: 'Beta active',
|
||||||
|
tested: 'Test passed',
|
||||||
|
failed: 'Test failed',
|
||||||
|
promoted: 'Promoted',
|
||||||
|
empty: 'No versions'
|
||||||
};
|
};
|
||||||
export function scriptStatus(s: LibraryScript): ScriptStatus {
|
export function scriptStatus(s: LibraryScript): ScriptStatus {
|
||||||
if (s.runningBetaId !== null) return 'testing';
|
if (s.runningBetaId !== null) return 'testing';
|
||||||
@@ -23,18 +38,34 @@ export function scriptStatus(s: LibraryScript): ScriptStatus {
|
|||||||
if (s.latestVersion.id === s.current_version_id) return 'promoted';
|
if (s.latestVersion.id === s.current_version_id) return 'promoted';
|
||||||
return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state;
|
return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state;
|
||||||
}
|
}
|
||||||
export function canTest(s: LibraryScript): boolean {
|
export function canTest(s: {
|
||||||
return s.runningBetaId === null && s.latestVersion !== null && s.latestVersion.id !== s.current_version_id;
|
runningBetaId: number | null;
|
||||||
|
latestVersion: { id: number } | null;
|
||||||
|
current_version_id: number | null;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
s.runningBetaId === null &&
|
||||||
|
s.latestVersion !== null &&
|
||||||
|
s.latestVersion.id !== s.current_version_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
export function mapLabel(canonical: string): string {
|
export function mapLabel(canonical: string): string {
|
||||||
const matches = [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].filter(m => m.canonical === canonical);
|
const matches = [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].filter((m) => m.canonical === canonical);
|
||||||
return matches.length > 1 ? `${matches[0].friendly} + ${matches.length - 1} variants` : matches[0]?.friendly ?? canonical;
|
return matches.length > 1
|
||||||
|
? `${matches[0].friendly} + ${matches.length - 1} variants`
|
||||||
|
: (matches[0]?.friendly ?? canonical);
|
||||||
}
|
}
|
||||||
export function scopeLabel(s: Pick<LibraryScript, 'scope_type' | 'scope_value'>): string {
|
export function scopeLabel(s: Pick<LibraryScript, 'scope_type' | 'scope_value'>): string {
|
||||||
return s.scope_type === 'game' ? gameTitle(s.scope_value)
|
return s.scope_type === 'game'
|
||||||
: s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : 'Unscoped';
|
? gameTitle(s.scope_value)
|
||||||
|
: s.scope_type === 'map'
|
||||||
|
? mapLabel(s.scope_value ?? '')
|
||||||
|
: 'Unscoped';
|
||||||
|
}
|
||||||
|
export interface BetaSelection {
|
||||||
|
scriptId: number;
|
||||||
|
versionId: number;
|
||||||
}
|
}
|
||||||
export interface BetaSelection { scriptId: number; versionId: number; }
|
|
||||||
export function betaSelectionHref(selections: BetaSelection[]): string {
|
export function betaSelectionHref(selections: BetaSelection[]): string {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`);
|
for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`);
|
||||||
@@ -42,13 +73,21 @@ export function betaSelectionHref(selections: BetaSelection[]): string {
|
|||||||
}
|
}
|
||||||
export function parseBetaSelection(query: URLSearchParams): BetaSelection[] {
|
export function parseBetaSelection(query: URLSearchParams): BetaSelection[] {
|
||||||
const values = query.getAll('artifact');
|
const values = query.getAll('artifact');
|
||||||
if (!values.length && query.has('pluginId')) values.push(`${query.get('pluginId')}:${query.get('versionId')}`);
|
if (!values.length && query.has('pluginId'))
|
||||||
|
values.push(`${query.get('pluginId')}:${query.get('versionId')}`);
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
return values.map(value => {
|
return values.map((value) => {
|
||||||
if (!/^[1-9]\d*:[1-9]\d*$/.test(value)) throw new Error('The beta selection link is invalid. Choose artifacts below.');
|
if (!/^[1-9]\d*:[1-9]\d*$/.test(value))
|
||||||
|
throw new Error('The beta selection link is invalid. Choose artifacts below.');
|
||||||
const [scriptId, versionId] = value.split(':').map(Number);
|
const [scriptId, versionId] = value.split(':').map(Number);
|
||||||
if (!Number.isSafeInteger(scriptId) || !Number.isSafeInteger(versionId) || seen.has(scriptId)) {
|
if (
|
||||||
throw new Error('The beta selection link contains invalid or repeated artifacts. Choose artifacts below.');
|
!Number.isSafeInteger(scriptId) ||
|
||||||
|
!Number.isSafeInteger(versionId) ||
|
||||||
|
seen.has(scriptId)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The beta selection link contains invalid or repeated artifacts. Choose artifacts below.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
seen.add(scriptId);
|
seen.add(scriptId);
|
||||||
return { scriptId, versionId };
|
return { scriptId, versionId };
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** TWS source releases use three numeric components, without tags or suffixes. */
|
||||||
|
export function scriptVersionError(value: unknown): string {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) return 'A script release version is required.';
|
||||||
|
if (value.length > 80 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value.trim()))
|
||||||
|
return 'Use MAJOR.MINOR.PATCH for the script release version, such as 1.2.0.';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ignore non-script components (for example native plugins) in a TWS manifest. */
|
||||||
|
export function parseScriptVersions(text: string): Record<string, string> {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Choose a valid versions.json file.');
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data))
|
||||||
|
throw new Error('versions.json must map script filenames to release versions.');
|
||||||
|
const entries = Object.entries(data).filter(([name]) => name.endsWith('.gsc'));
|
||||||
|
if (!entries.length) throw new Error('versions.json contains no .gsc filenames.');
|
||||||
|
for (const [name, value] of entries) {
|
||||||
|
if (name.includes('/') || name.includes('\\'))
|
||||||
|
throw new Error(`Use an exact filename in versions.json: ${name}`);
|
||||||
|
const error = scriptVersionError(value);
|
||||||
|
if (error) throw new Error(`${name}: ${error}`);
|
||||||
|
}
|
||||||
|
return Object.fromEntries(entries.map(([name, value]) => [name, (value as string).trim()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uploaded filenames must never resolve inherited object properties. */
|
||||||
|
export function manifestVersion(versions: Record<string, string>, filename: string): string {
|
||||||
|
return Object.hasOwn(versions, filename) ? versions[filename] : '';
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user