feat: ATM10-Statistikportal
Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1): - mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit, FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff - backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT, FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots, Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker - web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server, Datenquellen, Verwaltung - shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client - deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56d2078e6a
commit
e66f89f49e
214 files changed
+27077
No files matched your search
@@ -0,0 +1,35 @@
|
||||
# Build context: repository root (docker build -f backend/Dockerfile .)
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /repo
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json shared/
|
||||
COPY backend/package.json backend/
|
||||
COPY web/package.json web/
|
||||
COPY tools/simulator/package.json tools/simulator/
|
||||
RUN npm ci --workspace backend --include-workspace-root
|
||||
COPY tsconfig.base.json ./
|
||||
COPY shared shared
|
||||
COPY backend backend
|
||||
RUN npm run build -w backend
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /repo
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json shared/
|
||||
COPY backend/package.json backend/
|
||||
COPY web/package.json web/
|
||||
COPY tools/simulator/package.json tools/simulator/
|
||||
RUN npm ci --workspace backend --omit=dev && npm cache clean --force
|
||||
|
||||
FROM node:22-alpine
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=deps /repo/node_modules ./node_modules
|
||||
COPY --from=build /repo/backend/dist ./dist
|
||||
COPY backend/migrations ./migrations
|
||||
COPY backend/config ./config
|
||||
COPY backend/package.json ./package.json
|
||||
USER node
|
||||
EXPOSE 3001
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"tech": [
|
||||
"mekanism", "mekanismgenerators", "powah", "modern_industrialization", "oritech", "create",
|
||||
"immersiveengineering", "appliedenergistics2", "refinedstorage", "draconicevolution",
|
||||
"integrateddynamics", "pneumaticcraft"
|
||||
],
|
||||
"magic": [
|
||||
"ars_nouveau", "ars_elemental", "ars_additions", "irons_spellbooks", "occultism", "theurgy",
|
||||
"rootsclassic", "forbidden_arcanus", "evilcraft", "reliquary"
|
||||
],
|
||||
"farming": [
|
||||
"mysticalagriculture", "mysticalagradditions", "productivebees", "botanypots",
|
||||
"pamhc2crops", "pamhc2trees", "pamhc2foodcore", "pamhc2foodextended", "farmersdelight", "herbsandharvest"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
-- Kernschema (Spec §6)
|
||||
|
||||
CREATE TABLE players (
|
||||
uuid uuid PRIMARY KEY,
|
||||
name text,
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen timestamptz,
|
||||
hidden boolean NOT NULL DEFAULT false
|
||||
);
|
||||
CREATE INDEX players_name_lower ON players (lower(name));
|
||||
|
||||
CREATE TABLE name_history (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
seen_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (uuid, name)
|
||||
);
|
||||
CREATE INDEX name_history_name_lower ON name_history (lower(name));
|
||||
|
||||
CREATE TABLE teams (
|
||||
team_id uuid PRIMARY KEY,
|
||||
name text,
|
||||
type text
|
||||
);
|
||||
|
||||
CREATE TABLE team_members (
|
||||
team_id uuid NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
uuid uuid NOT NULL,
|
||||
PRIMARY KEY (team_id, uuid)
|
||||
);
|
||||
CREATE INDEX team_members_uuid ON team_members (uuid);
|
||||
|
||||
CREATE TABLE events (
|
||||
event_id uuid PRIMARY KEY,
|
||||
server_id text NOT NULL,
|
||||
type text NOT NULL,
|
||||
ts timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
source text NOT NULL CHECK (source IN ('push', 'sftp')),
|
||||
payload jsonb NOT NULL
|
||||
);
|
||||
CREATE INDEX events_ts ON events (ts);
|
||||
CREATE INDEX events_source_received ON events (source, received_at DESC);
|
||||
CREATE INDEX events_player ON events ((payload->>'uuid'), ts);
|
||||
CREATE INDEX events_type_ts ON events (type, ts);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id bigserial PRIMARY KEY,
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
started_at timestamptz NOT NULL,
|
||||
ended_at timestamptz,
|
||||
end_reason text
|
||||
);
|
||||
CREATE INDEX sessions_open ON sessions (uuid) WHERE ended_at IS NULL;
|
||||
CREATE INDEX sessions_uuid_started ON sessions (uuid, started_at DESC);
|
||||
|
||||
CREATE TABLE stat_current (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
category text NOT NULL,
|
||||
key text NOT NULL,
|
||||
value bigint NOT NULL,
|
||||
observed_at timestamptz NOT NULL,
|
||||
source text NOT NULL,
|
||||
PRIMARY KEY (uuid, category, key)
|
||||
);
|
||||
CREATE INDEX stat_current_board ON stat_current (category, key, value DESC);
|
||||
|
||||
CREATE TABLE stat_snapshots (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
metric text NOT NULL,
|
||||
bucket timestamptz NOT NULL,
|
||||
granularity text NOT NULL CHECK (granularity IN ('hour', 'day')),
|
||||
value bigint NOT NULL,
|
||||
PRIMARY KEY (uuid, metric, granularity, bucket)
|
||||
);
|
||||
CREATE INDEX stat_snapshots_bucket ON stat_snapshots (granularity, bucket);
|
||||
|
||||
CREATE TABLE deaths (
|
||||
event_id uuid PRIMARY KEY,
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
ts timestamptz NOT NULL,
|
||||
dimension text NOT NULL,
|
||||
cause text NOT NULL,
|
||||
killer_type text,
|
||||
killer_name text
|
||||
);
|
||||
CREATE INDEX deaths_uuid ON deaths (uuid, ts DESC);
|
||||
|
||||
CREATE TABLE kills (
|
||||
event_id uuid PRIMARY KEY,
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
ts timestamptz NOT NULL,
|
||||
dimension text NOT NULL,
|
||||
victim_type text NOT NULL,
|
||||
is_boss boolean NOT NULL,
|
||||
weapon text
|
||||
);
|
||||
CREATE INDEX kills_uuid ON kills (uuid, ts DESC);
|
||||
|
||||
CREATE TABLE advancements (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
advancement_id text NOT NULL,
|
||||
achieved_at timestamptz NOT NULL,
|
||||
title text,
|
||||
PRIMARY KEY (uuid, advancement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE dimension_visits (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
dimension text NOT NULL,
|
||||
first_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (uuid, dimension)
|
||||
);
|
||||
|
||||
CREATE TABLE quest_defs (
|
||||
quest_id text PRIMARY KEY,
|
||||
chapter_id text,
|
||||
chapter_title text,
|
||||
title text,
|
||||
def_hash text
|
||||
);
|
||||
|
||||
CREATE TABLE quest_progress (
|
||||
team_id uuid NOT NULL,
|
||||
quest_id text NOT NULL,
|
||||
completed_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (team_id, quest_id)
|
||||
);
|
||||
|
||||
CREATE TABLE server_info (
|
||||
id bigserial PRIMARY KEY,
|
||||
started_at timestamptz NOT NULL,
|
||||
mc_version text NOT NULL,
|
||||
neoforge_version text NOT NULL,
|
||||
java_version text NOT NULL,
|
||||
mods jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE server_health (
|
||||
bucket timestamptz PRIMARY KEY,
|
||||
mspt_avg real NOT NULL,
|
||||
mspt_p95 real NOT NULL,
|
||||
mspt_max real NOT NULL,
|
||||
tps real NOT NULL,
|
||||
players integer NOT NULL,
|
||||
chunks integer NOT NULL,
|
||||
heap_used_mb real NOT NULL,
|
||||
heap_max_mb real NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_state (
|
||||
path text PRIMARY KEY,
|
||||
size bigint NOT NULL,
|
||||
mtime bigint NOT NULL,
|
||||
"offset" bigint NOT NULL DEFAULT 0,
|
||||
hash text,
|
||||
synced_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE admin_users (
|
||||
id bigserial PRIMARY KEY,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE admin_sessions (
|
||||
id text PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
csrf text NOT NULL,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ingest_tokens (
|
||||
id bigserial PRIMARY KEY,
|
||||
hash text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Zuletzt bekannte Inhalte synchronisierter Dateien (Quest-Konfiguration, Teams) und Fehlerprotokoll
|
||||
|
||||
CREATE TABLE file_cache (
|
||||
path text PRIMARY KEY,
|
||||
sha256 text NOT NULL,
|
||||
content text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE app_errors (
|
||||
id bigserial PRIMARY KEY,
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
source text NOT NULL,
|
||||
message text NOT NULL
|
||||
);
|
||||
CREATE INDEX app_errors_at ON app_errors (at DESC);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Stundenaggregate der Servergesundheit (Minutenwerte werden nach 14 Tagen verdichtet)
|
||||
CREATE TABLE server_health_hourly (
|
||||
bucket timestamptz PRIMARY KEY,
|
||||
mspt_avg real NOT NULL,
|
||||
mspt_p95 real NOT NULL,
|
||||
mspt_max real NOT NULL,
|
||||
tps real NOT NULL,
|
||||
players integer NOT NULL,
|
||||
chunks integer NOT NULL,
|
||||
heap_used_mb real NOT NULL,
|
||||
heap_max_mb real NOT NULL,
|
||||
samples integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX stat_snapshots_metric ON stat_snapshots (metric, granularity, uuid, bucket DESC);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Manuell angestoßene SFTP-Synchronisationen (vom Worker abgearbeitet)
|
||||
CREATE TABLE sync_requests (
|
||||
id bigserial PRIMARY KEY,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
done_at timestamptz
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Herkunft jeder Sitzung (Mod oder Log-Fallback), damit sich beide Quellen nicht doppeln
|
||||
ALTER TABLE sessions ADD COLUMN source text NOT NULL DEFAULT 'push';
|
||||
|
||||
-- Zeitpunkt der Beobachtung des aktuellen Namens: verspätete ältere Ereignisse ändern ihn nicht mehr
|
||||
ALTER TABLE players ADD COLUMN name_seen_at timestamptz;
|
||||
UPDATE players p SET name_seen_at = (SELECT max(seen_at) FROM name_history h WHERE h.uuid = p.uuid AND h.name = p.name);
|
||||
|
||||
-- "letzte Aktualisierung" ohne Tabellenscan
|
||||
CREATE INDEX events_received ON events (received_at DESC);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@atm/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"dev:worker": "tsx watch src/worker.ts",
|
||||
"cli": "tsx src/cli.ts",
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ssh2-sftp-client": "^9.0.6",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atm/shared": "*",
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/helmet": "^13.1.2",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.12.5",
|
||||
"pg": "^8.23.1",
|
||||
"pino": "^9.14.0",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import cookie from '@fastify/cookie';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Config } from './config.ts';
|
||||
import { ingestRoutes } from './ingest/route.ts';
|
||||
import { LiveHub } from './live/hub.ts';
|
||||
import { installLiveNotifier } from './live/notifier.ts';
|
||||
import './pipeline/projections/index.ts';
|
||||
import { liveRoutes } from './routes/live.ts';
|
||||
import { adminRoutes } from './routes/admin.ts';
|
||||
import { publicRoutes } from './routes/public/index.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
liveHub: LiveHub;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppDeps {
|
||||
pool: Pool;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: deps.config.LOG_LEVEL,
|
||||
redact: ['req.headers.authorization', 'req.headers["x-atm-signature"]', 'req.headers.cookie'],
|
||||
},
|
||||
trustProxy: deps.config.TRUST_PROXY,
|
||||
bodyLimit: 1024 * 1024,
|
||||
});
|
||||
|
||||
await app.register(helmet, { contentSecurityPolicy: false, crossOriginResourcePolicy: { policy: 'same-site' } });
|
||||
await app.register(rateLimit, { global: false });
|
||||
await app.register(cookie);
|
||||
|
||||
const hub = new LiveHub(deps.config.DATABASE_URL, app.log);
|
||||
void hub.start();
|
||||
const uninstallNotifier = installLiveNotifier(deps.pool);
|
||||
app.decorate('liveHub', hub);
|
||||
app.addHook('onClose', async () => {
|
||||
uninstallNotifier();
|
||||
await hub.stop();
|
||||
});
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
await deps.pool.query('SELECT 1');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await app.register(ingestRoutes(deps));
|
||||
await app.register(publicRoutes, deps);
|
||||
await app.register(liveRoutes, { hub });
|
||||
await app.register(adminRoutes, deps);
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export const SESSION_TTL_MS = 12 * 3600_000;
|
||||
|
||||
export async function createAdmin(pool: Pool, username: string, password: string): Promise<void> {
|
||||
if (password.length < 12) throw new Error('password must be at least 12 characters');
|
||||
const hash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
await pool.query(
|
||||
`INSERT INTO admin_users(username, password_hash) VALUES ($1, $2)
|
||||
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash`,
|
||||
[username, hash],
|
||||
);
|
||||
}
|
||||
|
||||
// Verifying against a dummy hash for unknown users keeps response times uniform (no user enumeration).
|
||||
let dummyHash: Promise<string> | null = null;
|
||||
const getDummyHash = () => (dummyHash ??= argon2.hash(randomBytes(16).toString('hex'), { type: argon2.argon2id }));
|
||||
|
||||
export async function login(pool: Pool, username: string, password: string): Promise<{ sessionId: string; csrf: string } | null> {
|
||||
const { rows } = await pool.query<{ id: number; password_hash: string }>(
|
||||
'SELECT id, password_hash FROM admin_users WHERE username = $1',
|
||||
[username],
|
||||
);
|
||||
const user = rows[0];
|
||||
const ok = await argon2.verify(user?.password_hash ?? (await getDummyHash()), password).catch(() => false);
|
||||
if (!user || !ok) return null;
|
||||
const sessionId = randomBytes(32).toString('base64url');
|
||||
const csrf = randomBytes(24).toString('base64url');
|
||||
await pool.query('DELETE FROM admin_sessions WHERE expires_at < now()');
|
||||
await pool.query(`INSERT INTO admin_sessions(id, user_id, csrf, expires_at) VALUES ($1, $2, $3, now() + make_interval(secs => $4))`, [
|
||||
sessionId,
|
||||
user.id,
|
||||
csrf,
|
||||
SESSION_TTL_MS / 1000,
|
||||
]);
|
||||
return { sessionId, csrf };
|
||||
}
|
||||
|
||||
export async function getSession(pool: Pool, sessionId: string): Promise<{ username: string; csrf: string } | null> {
|
||||
const { rows } = await pool.query<{ username: string; csrf: string }>(
|
||||
`SELECT u.username, s.csrf FROM admin_sessions s JOIN admin_users u ON u.id = s.user_id
|
||||
WHERE s.id = $1 AND s.expires_at > now()`,
|
||||
[sessionId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function logout(pool: Pool, sessionId: string): Promise<void> {
|
||||
await pool.query('DELETE FROM admin_sessions WHERE id = $1', [sessionId]);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Verwaltungs-CLI:
|
||||
// cli admin:create <benutzer> Passwort aus ADMIN_PASSWORD oder interaktiv
|
||||
// cli token:create neues Ingest-Token (wird nur einmal angezeigt)
|
||||
// cli migrate Datenbankmigrationen ausführen
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { createAdmin } from './auth/admin.ts';
|
||||
import { createPool } from './db/pool.ts';
|
||||
import { migrate } from './db/migrate.ts';
|
||||
import { createIngestToken } from './ingest/tokens.ts';
|
||||
|
||||
async function readPassword(): Promise<string> {
|
||||
if (process.env.ADMIN_PASSWORD) return process.env.ADMIN_PASSWORD;
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
// Hide typed characters.
|
||||
(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = (s: string) => {
|
||||
if (s.includes('Passwort')) process.stdout.write(s);
|
||||
};
|
||||
const pw = await rl.question('Passwort (min. 12 Zeichen): ');
|
||||
rl.close();
|
||||
process.stdout.write('\n');
|
||||
return pw;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, arg] = process.argv.slice(2);
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL is required');
|
||||
const pool = createPool(url, 2);
|
||||
try {
|
||||
await migrate(pool);
|
||||
switch (command) {
|
||||
case 'migrate':
|
||||
console.log('Migrationen ausgeführt.');
|
||||
break;
|
||||
case 'admin:create': {
|
||||
if (!arg) throw new Error('usage: admin:create <benutzer>');
|
||||
await createAdmin(pool, arg, await readPassword());
|
||||
console.log(`Admin "${arg}" angelegt bzw. Passwort aktualisiert.`);
|
||||
break;
|
||||
}
|
||||
case 'token:create': {
|
||||
const token = await createIngestToken(pool);
|
||||
console.log('Neues Ingest-Token (in config/atmstats-server.toml als token eintragen):');
|
||||
console.log(token);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log('Befehle: migrate | admin:create <benutzer> | token:create');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error((err as Error).message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const bool = z
|
||||
.enum(['true', 'false', '1', '0'])
|
||||
.transform((v) => v === 'true' || v === '1');
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.string().default('production'),
|
||||
DATABASE_URL: z.string({ required_error: 'DATABASE_URL is required' }).min(1, 'DATABASE_URL is required'),
|
||||
PORT: z.coerce.number().int().default(3001),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
PUBLIC_ORIGIN: z.string().url().optional(),
|
||||
INGEST_HMAC_SECRET: z.string().min(32, 'INGEST_HMAC_SECRET must be at least 32 characters'),
|
||||
SERVER_ID: z.string().default('atm10'),
|
||||
TRUST_PROXY: bool.default('true'),
|
||||
SFTP_HOST: z.string().optional(),
|
||||
SFTP_PORT: z.coerce.number().int().default(22),
|
||||
SFTP_USER: z.string().optional(),
|
||||
SFTP_PASSWORD: z.string().optional(),
|
||||
SFTP_ROOT: z.string().default('/'),
|
||||
/** level-name of the server (folder containing stats/, advancements/, ftbquests/) */
|
||||
SFTP_WORLD_DIR: z.string().default('world'),
|
||||
SFTP_INTERVAL_MIN: z.coerce.number().int().min(1).default(15),
|
||||
AVATAR_SOURCE: z.string().default('https://mc-heads.net/avatar/{uuid}/64'),
|
||||
/** Time zone of the game server (log timestamps) and of activity heatmaps. */
|
||||
TIMEZONE: z.string().default('Europe/Berlin'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof envSchema> & {
|
||||
sftp: { host: string; port: number; username: string; password: string; root: string } | null;
|
||||
};
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||
const r = envSchema.safeParse(env);
|
||||
if (!r.success) {
|
||||
const msg = r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
||||
throw new Error(`Invalid configuration: ${msg}`);
|
||||
}
|
||||
const c = r.data;
|
||||
const sftp =
|
||||
c.SFTP_HOST && c.SFTP_USER && c.SFTP_PASSWORD
|
||||
? { host: c.SFTP_HOST, port: c.SFTP_PORT, username: c.SFTP_USER, password: c.SFTP_PASSWORD, root: c.SFTP_ROOT }
|
||||
: null;
|
||||
return { ...c, sftp };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const MIGRATION_LOCK = 4711;
|
||||
|
||||
function migrationsDir(): string {
|
||||
// src/db -> ../../migrations (dev, tests) ; dist -> ../migrations (bundled build)
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
for (const candidate of [join(here, '..', '..', 'migrations'), join(here, '..', 'migrations')]) {
|
||||
try {
|
||||
readdirSync(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
throw new Error('migrations directory not found');
|
||||
}
|
||||
|
||||
/** Applies pending `migrations/*.sql` in name order, each in its own transaction, serialized by an advisory lock. */
|
||||
export async function migrate(pool: Pool): Promise<string[]> {
|
||||
const dir = migrationsDir();
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
|
||||
const client = await pool.connect();
|
||||
const applied: string[] = [];
|
||||
try {
|
||||
await client.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK]);
|
||||
await client.query(
|
||||
'CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())',
|
||||
);
|
||||
const { rows } = await client.query<{ name: string }>('SELECT name FROM schema_migrations');
|
||||
const done = new Set(rows.map((r) => r.name));
|
||||
for (const file of files) {
|
||||
if (done.has(file)) continue;
|
||||
const sql = readFileSync(join(dir, file), 'utf8');
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
await client.query(sql);
|
||||
await client.query('INSERT INTO schema_migrations(name) VALUES ($1)', [file]);
|
||||
await client.query('COMMIT');
|
||||
applied.push(file);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw new Error(`migration ${file} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK]).catch(() => {});
|
||||
client.release();
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import pg from 'pg';
|
||||
|
||||
// bigint (int8) columns hold Minecraft counters; they stay far below 2^53, so parse as number.
|
||||
pg.types.setTypeParser(20, (v) => Number(v));
|
||||
// numeric is used for aggregates (SUM over bigint)
|
||||
pg.types.setTypeParser(1700, (v) => Number(v));
|
||||
|
||||
export type Pool = pg.Pool;
|
||||
export type PoolClient = pg.PoolClient;
|
||||
|
||||
export function createPool(url: string, max = 10): pg.Pool {
|
||||
return new pg.Pool({ connectionString: url, max });
|
||||
}
|
||||
|
||||
export async function withTx<T>(pool: pg.Pool, fn: (c: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export const MAX_CLOCK_SKEW_MS = 5 * 60_000;
|
||||
|
||||
export type IngestAuthError = 'missing_auth' | 'bad_token' | 'timestamp_out_of_range' | 'bad_signature';
|
||||
export type IngestAuthResult = { ok: true } | { ok: false; status: 401; error: IngestAuthError };
|
||||
|
||||
export interface VerifyOpts {
|
||||
secret: string;
|
||||
now: number;
|
||||
tokenValid: (token: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function computeSignature(secret: string, ts: number | string, body: Buffer): string {
|
||||
const bodyHash = createHash('sha256').update(body).digest('hex');
|
||||
return createHmac('sha256', secret).update(`${ts}.${bodyHash}`).digest('hex');
|
||||
}
|
||||
|
||||
const fail = (error: IngestAuthError): IngestAuthResult => ({ ok: false, status: 401, error });
|
||||
|
||||
/** Verifies bearer token, timestamp window and HMAC over the bytes as transmitted (before gunzip). */
|
||||
export async function verifyIngest(
|
||||
req: { headers: Record<string, string | string[] | undefined>; rawBody: Buffer },
|
||||
opts: VerifyOpts,
|
||||
): Promise<IngestAuthResult> {
|
||||
const header = (n: string) => {
|
||||
const v = req.headers[n];
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
};
|
||||
const auth = header('authorization');
|
||||
const tsHeader = header('x-atm-timestamp');
|
||||
const sig = header('x-atm-signature');
|
||||
if (!auth?.startsWith('Bearer ') || !tsHeader || !sig) return fail('missing_auth');
|
||||
|
||||
if (!(await opts.tokenValid(auth.slice(7).trim()))) return fail('bad_token');
|
||||
|
||||
const ts = Number(tsHeader);
|
||||
if (!Number.isFinite(ts) || Math.abs(opts.now - ts) > MAX_CLOCK_SKEW_MS) return fail('timestamp_out_of_range');
|
||||
|
||||
const expected = Buffer.from(computeSignature(opts.secret, tsHeader, req.rawBody), 'hex');
|
||||
const given = Buffer.from(sig, 'hex');
|
||||
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return fail('bad_signature');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import { ingestBatchSchema } from '@atm/shared';
|
||||
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Config } from '../config.ts';
|
||||
import { processEvents, validateEvents } from '../pipeline/process.ts';
|
||||
import { verifyIngest } from './auth.ts';
|
||||
import { isValidIngestToken } from './tokens.ts';
|
||||
|
||||
export const MAX_UNPACKED_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export function ingestRoutes(deps: { pool: Pool; config: Config }): FastifyPluginAsync {
|
||||
return async (app: FastifyInstance) => {
|
||||
// Raw bytes are needed for the signature; parse JSON ourselves after verification.
|
||||
app.addContentTypeParser('application/json', { parseAs: 'buffer', bodyLimit: MAX_UNPACKED_BYTES }, (_req, body, done) =>
|
||||
done(null, body),
|
||||
);
|
||||
app.removeContentTypeParser(['text/plain']);
|
||||
|
||||
const authenticate = (headers: Record<string, string | string[] | undefined>, rawBody: Buffer) =>
|
||||
verifyIngest(
|
||||
{ headers, rawBody },
|
||||
{
|
||||
secret: deps.config.INGEST_HMAC_SECRET,
|
||||
now: Date.now(),
|
||||
tokenValid: (t) => isValidIngestToken(deps.pool, t),
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/ingest/ping', async (req, reply) => {
|
||||
const auth = await authenticate(req.headers, Buffer.alloc(0));
|
||||
if (!auth.ok) return reply.code(auth.status).send({ error: auth.error });
|
||||
return { ok: true, serverTime: Date.now(), protocol: 1 };
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/ingest',
|
||||
{ config: { rateLimit: { max: 120, timeWindow: '1 minute' } } },
|
||||
async (req, reply) => {
|
||||
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
||||
const auth = await authenticate(req.headers, raw);
|
||||
if (!auth.ok) {
|
||||
req.log.warn({ error: auth.error }, 'ingest rejected');
|
||||
return reply.code(auth.status).send({ error: auth.error });
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
const encoding = String(req.headers['content-encoding'] ?? '').toLowerCase();
|
||||
const unpacked = encoding === 'gzip' ? gunzipSync(raw, { maxOutputLength: MAX_UNPACKED_BYTES }) : raw;
|
||||
if (unpacked.length > MAX_UNPACKED_BYTES) return reply.code(413).send({ error: 'payload_too_large' });
|
||||
text = unpacked.toString('utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ERR_BUFFER_TOO_LARGE') {
|
||||
return reply.code(413).send({ error: 'payload_too_large' });
|
||||
}
|
||||
return reply.code(400).send({ error: 'bad_encoding' });
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: 'bad_json' });
|
||||
}
|
||||
const batch = ingestBatchSchema.safeParse(json);
|
||||
if (!batch.success) return reply.code(400).send({ error: 'bad_batch', detail: batch.error.issues[0]?.message });
|
||||
|
||||
const { valid, rejected } = validateEvents(batch.data.events);
|
||||
const serverId = String(req.headers['x-atm-server'] ?? deps.config.SERVER_ID).slice(0, 64);
|
||||
const result = valid.length
|
||||
? await processEvents(deps.pool, valid, 'push', serverId)
|
||||
: { accepted: [], duplicate: [], rejected: [] };
|
||||
if (rejected.length) req.log.warn({ rejected: rejected.length }, 'ingest: invalid events rejected');
|
||||
return { ...result, rejected };
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const hash = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
|
||||
/** Creates a new ingest token. The plain value is returned once and only its hash is stored. */
|
||||
export async function createIngestToken(pool: Pool): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await pool.query('INSERT INTO ingest_tokens(hash) VALUES ($1)', [hash(token)]);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function isValidIngestToken(pool: Pool, token: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query('SELECT 1 FROM ingest_tokens WHERE hash = $1 AND revoked_at IS NULL', [
|
||||
hash(token),
|
||||
]);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revokeAllIngestTokens(pool: Pool): Promise<void> {
|
||||
await pool.query('UPDATE ingest_tokens SET revoked_at = now() WHERE revoked_at IS NULL');
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { LiveEvent } from '@atm/shared/api';
|
||||
import pg from 'pg';
|
||||
import { LIVE_CHANNEL, type LiveMessage } from './notifier.ts';
|
||||
|
||||
export const RING_SIZE = 500;
|
||||
|
||||
/** Listens on the Postgres live channel and fans messages out to SSE subscribers, keeping a ring buffer for resume. */
|
||||
export class LiveHub {
|
||||
private ring: LiveEvent[] = [];
|
||||
private nextId = 1;
|
||||
private subscribers = new Set<(e: LiveEvent) => void>();
|
||||
private client: pg.Client | null = null;
|
||||
private stopped = false;
|
||||
private readyPromise: Promise<void>;
|
||||
private resolveReady!: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly connectionString: string,
|
||||
private readonly log: { warn: (o: object, msg: string) => void } = console,
|
||||
) {
|
||||
this.readyPromise = new Promise((r) => (this.resolveReady = r));
|
||||
}
|
||||
|
||||
ready(): Promise<void> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
let attempt = 0;
|
||||
while (!this.stopped) {
|
||||
try {
|
||||
const client = new pg.Client({ connectionString: this.connectionString });
|
||||
client.on('notification', (n) => {
|
||||
if (n.channel === LIVE_CHANNEL && n.payload) this.publish(JSON.parse(n.payload) as LiveMessage);
|
||||
});
|
||||
await client.connect();
|
||||
await client.query(`LISTEN ${LIVE_CHANNEL}`);
|
||||
this.client = client;
|
||||
attempt = 0;
|
||||
this.resolveReady();
|
||||
await new Promise<void>((resolve) => {
|
||||
client.on('error', () => resolve());
|
||||
client.on('end', () => resolve());
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.warn({ err }, 'live hub: connection failed');
|
||||
}
|
||||
this.client = null;
|
||||
if (this.stopped) return;
|
||||
await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** attempt++)));
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
await this.client?.end().catch(() => {});
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
publish(msg: LiveMessage): LiveEvent {
|
||||
const event: LiveEvent = { id: this.nextId++, kind: msg.kind, data: msg.data };
|
||||
this.ring.push(event);
|
||||
if (this.ring.length > RING_SIZE) this.ring.shift();
|
||||
for (const fn of this.subscribers) fn(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Events with id > lastId still in the ring buffer. */
|
||||
since(lastId: number): LiveEvent[] {
|
||||
return this.ring.filter((e) => e.id > lastId);
|
||||
}
|
||||
|
||||
lastId(): number {
|
||||
return this.nextId - 1;
|
||||
}
|
||||
|
||||
subscribe(fn: (e: LiveEvent) => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AtmEvent } from '@atm/shared';
|
||||
import type { LiveKind } from '@atm/shared/api';
|
||||
import type { Pool } from 'pg';
|
||||
import { onEventsCommitted } from '../pipeline/process.ts';
|
||||
|
||||
export const LIVE_CHANNEL = 'atm_live';
|
||||
const COUNTERS_THROTTLE_MS = 10_000;
|
||||
|
||||
export interface LiveMessage {
|
||||
kind: LiveKind;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Maps committed events to live messages. Pure; hidden players are filtered by the caller. */
|
||||
export function toLiveMessages(events: AtmEvent[]): (LiveMessage & { uuid?: string })[] {
|
||||
const out: (LiveMessage & { uuid?: string })[] = [];
|
||||
for (const e of events) {
|
||||
switch (e.type) {
|
||||
case 'player_join':
|
||||
case 'player_leave':
|
||||
out.push({ kind: 'online', uuid: e.uuid, data: { uuid: e.uuid, name: e.name, online: e.type === 'player_join', ts: e.ts } });
|
||||
break;
|
||||
case 'player_death':
|
||||
out.push({
|
||||
kind: 'death',
|
||||
uuid: e.uuid,
|
||||
data: { uuid: e.uuid, name: e.name, cause: e.cause, killerType: e.killerType, dimension: e.dimension, ts: e.ts },
|
||||
});
|
||||
break;
|
||||
case 'advancement':
|
||||
if (e.title) out.push({ kind: 'advancement', uuid: e.uuid, data: { uuid: e.uuid, name: e.name, title: e.title, ts: e.ts } });
|
||||
break;
|
||||
case 'file_sync':
|
||||
if (e.path.startsWith('world/ftbquests/')) out.push({ kind: 'quest', data: { ts: e.ts } });
|
||||
break;
|
||||
case 'stats_snapshot':
|
||||
out.push({ kind: 'counters', data: { ts: e.ts } });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes live messages via Postgres NOTIFY after each committed batch, so every API instance
|
||||
* (and events processed by the worker) reaches all SSE clients. Returns an uninstall function.
|
||||
*/
|
||||
export function installLiveNotifier(pool: Pool): () => void {
|
||||
let lastCounters = 0;
|
||||
return onEventsCommitted(async (events) => {
|
||||
let msgs = toLiveMessages(events);
|
||||
if (msgs.length === 0) return;
|
||||
const uuids = [...new Set(msgs.flatMap((m) => (m.uuid ? [m.uuid] : [])))];
|
||||
if (uuids.length) {
|
||||
const { rows } = await pool.query<{ uuid: string }>('SELECT uuid FROM players WHERE hidden AND uuid = ANY($1::uuid[])', [uuids]);
|
||||
const hidden = new Set(rows.map((r) => r.uuid));
|
||||
msgs = msgs.filter((m) => !m.uuid || !hidden.has(m.uuid));
|
||||
}
|
||||
const now = Date.now();
|
||||
let countersSent = false;
|
||||
for (const { kind, data } of msgs) {
|
||||
if (kind === 'counters') {
|
||||
if (countersSent || now - lastCounters < COUNTERS_THROTTLE_MS) continue;
|
||||
countersSent = true;
|
||||
lastCounters = now;
|
||||
}
|
||||
await pool.query('SELECT pg_notify($1, $2)', [LIVE_CHANNEL, JSON.stringify({ kind, data })]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { isCompound, parseSnbt, type SnbtValue } from './snbt.ts';
|
||||
|
||||
/** "308a693c2bd84d6b8790670aef5be601" → "308a693c-2bd8-4d6b-8790-670aef5be601" */
|
||||
export function normalizeUuid(raw: string): string {
|
||||
const hex = raw.replace(/-/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error(`not a uuid: ${raw}`);
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
export interface QuestProgress {
|
||||
teamId: string;
|
||||
name: string | null;
|
||||
/** IDs from FTB's `completed` map (quests and chapters) with completion time in ms. */
|
||||
completed: { questId: string; at: number }[];
|
||||
started: number;
|
||||
}
|
||||
|
||||
/** Parses `world/ftbquests/<team>.snbt` (FTB Quests 2101.x team data). */
|
||||
export function parseQuestProgress(text: string): QuestProgress {
|
||||
const root = parseSnbt(text);
|
||||
if (!isCompound(root) || typeof root.uuid !== 'string') throw new Error('quest progress: missing uuid');
|
||||
const completedMap = isCompound(root.completed) ? root.completed : {};
|
||||
const completed = Object.entries(completedMap)
|
||||
.filter(([id, at]) => /^[0-9A-F]{16}$/i.test(id) && typeof at === 'number')
|
||||
.map(([id, at]) => ({ questId: id.toUpperCase(), at: at as number }));
|
||||
return {
|
||||
teamId: normalizeUuid(root.uuid),
|
||||
name: typeof root.name === 'string' ? root.name : null,
|
||||
completed,
|
||||
started: isCompound(root.started) ? Object.keys(root.started).length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface QuestDef {
|
||||
chapterId: string;
|
||||
chapterTitle: string | null;
|
||||
questId: string;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
const str = (v: SnbtValue | undefined): string | null => (typeof v === 'string' && v.length > 0 ? v : null);
|
||||
|
||||
/**
|
||||
* Removes Minecraft formatting codes ("&a", "&6&l", "§r") used in ATM10 quest texts. FTB writes a literal
|
||||
* ampersand as "\&" (e.g. "Forbidden \& Arcanus"); that one is kept as "&".
|
||||
*/
|
||||
export function stripFormatting(s: string): string {
|
||||
return s
|
||||
.split('\\&')
|
||||
.map((part) => part.replace(/[&§][0-9a-fk-or]/gi, ''))
|
||||
.join('&')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const strip = (s: string | null) => (s ? stripFormatting(s) : null);
|
||||
|
||||
const EN_LANG = /\/lang\/en_us(\.snbt|\/.+\.snbt_merged)$/;
|
||||
const CHAPTER_FILE = /\/chapters\/[^/]+\.snbt$/;
|
||||
|
||||
/**
|
||||
* Quest config files worth transferring: chapter definitions and the English texts split per chapter by
|
||||
* ftbquestslangsplitter (lang/en_us/**\/*.snbt_merged, verified on the ATM10 server). Skipped: other
|
||||
* languages (~35 MB), reward tables and the combined lang/en_us.snbt (1.5 MB, same content as the split files).
|
||||
*/
|
||||
export function isQuestConfigFile(path: string): boolean {
|
||||
return /^config\/ftbquests\/quests\/(chapters\/[^/]+\.snbt|lang\/en_us\/.+\.snbt_merged)$/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds quest definitions from `config/ftbquests/quests/chapters/*.snbt`. Titles come from the English lang
|
||||
* files (`chapter.<id>.title`, `quest.<id>.title`; split `lang/en_us/**\/*.snbt_merged` or a single
|
||||
* `lang/en_us.snbt`), else from inline `title` fields; chapters fall back to their filename.
|
||||
* Quests without a title key show their task item in game; that name is not available server-side (null).
|
||||
*/
|
||||
export function parseQuestDefs(files: { path: string; content: string }[]): QuestDef[] {
|
||||
const lang = new Map<string, string>();
|
||||
for (const f of files) {
|
||||
if (!EN_LANG.test(f.path)) continue;
|
||||
const root = parseSnbt(f.content);
|
||||
if (!isCompound(root)) continue;
|
||||
for (const [k, v] of Object.entries(root)) {
|
||||
const raw = Array.isArray(v) ? v.filter((x) => typeof x === 'string').join(' ') : str(v);
|
||||
const text = raw ? stripFormatting(raw) : null;
|
||||
if (text) lang.set(k, text);
|
||||
}
|
||||
}
|
||||
const defs: QuestDef[] = [];
|
||||
const chapters = files.filter((f) => CHAPTER_FILE.test(f.path)).sort((a, b) => a.path.localeCompare(b.path));
|
||||
for (const f of chapters) {
|
||||
const root = parseSnbt(f.content);
|
||||
if (!isCompound(root)) continue;
|
||||
const chapterId = str(root.id);
|
||||
if (!chapterId) continue;
|
||||
const chapterTitle = lang.get(`chapter.${chapterId}.title`) ?? strip(str(root.title)) ?? str(root.filename);
|
||||
for (const q of Array.isArray(root.quests) ? root.quests : []) {
|
||||
if (!isCompound(q)) continue;
|
||||
const questId = str(q.id);
|
||||
if (!questId) continue;
|
||||
defs.push({
|
||||
chapterId,
|
||||
chapterTitle,
|
||||
questId: questId.toUpperCase(),
|
||||
title: lang.get(`quest.${questId}.title`) ?? strip(str(q.title)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
export interface TeamInfo {
|
||||
teamId: string;
|
||||
type: 'player' | 'party' | 'server' | string;
|
||||
name: string | null;
|
||||
/** players with rank member, officer or owner (not invited players or allies) */
|
||||
members: string[];
|
||||
}
|
||||
|
||||
const MEMBER_RANKS = new Set(['member', 'officer', 'owner']);
|
||||
|
||||
/**
|
||||
* Parses `world/ftbteams/{player,party}/<id>.snbt` (FTB Teams 2101.x). Verified on the real server for player
|
||||
* teams; party files use the same fields (`id`, `type`, `ranks`, `properties`).
|
||||
*/
|
||||
export function parseTeam(text: string): TeamInfo {
|
||||
const root = parseSnbt(text);
|
||||
if (!isCompound(root) || typeof root.id !== 'string') throw new Error('team: missing id');
|
||||
const props = isCompound(root.properties) ? root.properties : {};
|
||||
const ranks = isCompound(root.ranks) ? root.ranks : {};
|
||||
return {
|
||||
teamId: normalizeUuid(root.id),
|
||||
type: typeof root.type === 'string' ? root.type : 'unknown',
|
||||
name: str(props['ftbteams:display_name']) ?? str(root.player_name),
|
||||
members: Object.entries(ranks)
|
||||
.filter(([, rank]) => typeof rank === 'string' && MEMBER_RANKS.has(rank))
|
||||
.map(([uuid]) => normalizeUuid(uuid)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { deterministicId, type AtmEvent } from '@atm/shared';
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11,
|
||||
};
|
||||
|
||||
function tzOffsetMs(utcMs: number, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(new Date(utcMs));
|
||||
const get = (t: string) => Number(parts.find((p) => p.type === t)!.value);
|
||||
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
||||
return asUtc - Math.floor(utcMs / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** "12Oct2026 09:03:03.006" in the server's local time zone → epoch ms. */
|
||||
export function parseLogTimestamp(s: string, timeZone: string): number {
|
||||
const m = /^(\d{2})([A-Z][a-z]{2})(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s);
|
||||
if (!m || MONTHS[m[2]!] === undefined) return Number.NaN;
|
||||
const local = Date.UTC(+m[3]!, MONTHS[m[2]!]!, +m[1]!, +m[4]!, +m[5]!, +m[6]!, +m[7]!);
|
||||
let utc = local - tzOffsetMs(local, timeZone);
|
||||
utc = local - tzOffsetMs(utc, timeZone); // second pass settles DST boundaries
|
||||
return utc;
|
||||
}
|
||||
|
||||
const LINE = /^\[([^\]]+)\] \[[^\]]+\] \[([^\]]+)\]: (.*)$/;
|
||||
const NAME = '([A-Za-z0-9_]{1,16})';
|
||||
const UUID_OF = new RegExp(`^UUID of player ${NAME} is ([0-9a-f-]{36})$`);
|
||||
const JOINED = new RegExp(`^${NAME} joined the game$`);
|
||||
const LEFT = new RegExp(`^${NAME} left the game$`);
|
||||
const ADVANCEMENT = new RegExp(`^${NAME} has (?:made the advancement|reached the goal|completed the challenge) \\[(.+)\\]$`);
|
||||
const SYSTEM_LOGGER = 'net.minecraft.server.MinecraftServer/';
|
||||
|
||||
/**
|
||||
* Parses complete lines of `logs/latest.log`. Only bytes up to the last newline are consumed, so a line that is
|
||||
* still being written is read again next time. `names` maps player name → uuid and is updated from
|
||||
* "UUID of player" lines; lines for players without known uuid are skipped.
|
||||
* Event IDs derive from the absolute byte offset and line content, so re-reading yields the same IDs.
|
||||
*/
|
||||
export function parseLogChunk(
|
||||
chunk: Buffer,
|
||||
baseOffset: number,
|
||||
names: Map<string, string>,
|
||||
timeZone: string,
|
||||
): { events: AtmEvent[]; consumed: number } {
|
||||
const end = chunk.lastIndexOf(0x0a);
|
||||
if (end < 0) return { events: [], consumed: 0 };
|
||||
const events: AtmEvent[] = [];
|
||||
let offset = 0;
|
||||
while (offset <= end) {
|
||||
const nl = chunk.indexOf(0x0a, offset);
|
||||
const line = chunk.toString('utf8', offset, nl).replace(/\r$/, '');
|
||||
const lineOffset = baseOffset + offset;
|
||||
offset = nl + 1;
|
||||
|
||||
const m = LINE.exec(line);
|
||||
if (!m) continue;
|
||||
const [, time, logger, msg] = m as unknown as [string, string, string, string];
|
||||
|
||||
const uuidOf = UUID_OF.exec(msg);
|
||||
if (uuidOf) {
|
||||
names.set(uuidOf[1]!, uuidOf[2]!);
|
||||
continue;
|
||||
}
|
||||
if (logger !== SYSTEM_LOGGER) continue;
|
||||
|
||||
const id = () => deterministicId(`log:${lineOffset}:${createHash('sha256').update(line).digest('hex')}`);
|
||||
const ts = parseLogTimestamp(time, timeZone);
|
||||
if (!Number.isFinite(ts)) continue;
|
||||
|
||||
let hit: RegExpExecArray | null;
|
||||
if ((hit = JOINED.exec(msg)) || (hit = LEFT.exec(msg))) {
|
||||
const name = hit[1]!;
|
||||
const uuid = names.get(name);
|
||||
if (!uuid) continue;
|
||||
events.push({ id: id(), type: JOINED.test(msg) ? 'player_join' : 'player_leave', ts, uuid, name });
|
||||
} else if ((hit = ADVANCEMENT.exec(msg))) {
|
||||
const name = hit[1]!;
|
||||
const uuid = names.get(name);
|
||||
if (!uuid) continue;
|
||||
events.push({ id: id(), type: 'advancement', ts, uuid, name, advancementId: `log:${hit[2]}`, title: hit[2]! });
|
||||
}
|
||||
}
|
||||
return { events, consumed: end + 1 };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tolerant SNBT parser for FTB files. FTB writes "pretty" SNBT: entries separated by newlines instead of commas,
|
||||
* unquoted keys that may start with digits (team/quest IDs) or contain dots (lang keys).
|
||||
* Numbers lose their type suffix (b, s, L, f, d) and become JS numbers.
|
||||
*/
|
||||
export type SnbtValue = string | number | boolean | SnbtValue[] | { [key: string]: SnbtValue };
|
||||
|
||||
export class SnbtError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly line: number,
|
||||
readonly column: number,
|
||||
) {
|
||||
super(`${message} at line ${line}, column ${column}`);
|
||||
}
|
||||
}
|
||||
|
||||
const NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?[bBsSlLfFdD]?$/;
|
||||
const BARE = /[A-Za-z0-9_\-.+]/;
|
||||
|
||||
export function parseSnbt(text: string): SnbtValue {
|
||||
let pos = 0;
|
||||
|
||||
const error = (msg: string): never => {
|
||||
const before = text.slice(0, pos);
|
||||
const line = before.split('\n').length;
|
||||
const column = pos - before.lastIndexOf('\n');
|
||||
throw new SnbtError(msg, line, column);
|
||||
};
|
||||
|
||||
/** Skips whitespace and commas (commas are optional separators in FTB SNBT). */
|
||||
const skipSeparators = () => {
|
||||
while (pos < text.length && /[\s,]/.test(text[pos]!)) pos++;
|
||||
};
|
||||
const skipWs = () => {
|
||||
while (pos < text.length && /\s/.test(text[pos]!)) pos++;
|
||||
};
|
||||
|
||||
const quoted = (): string => {
|
||||
const q = text[pos++];
|
||||
let out = '';
|
||||
while (pos < text.length) {
|
||||
const ch = text[pos++]!;
|
||||
if (ch === '\\') {
|
||||
if (pos >= text.length) break;
|
||||
const next = text[pos++]!;
|
||||
out += next === 'n' ? '\n' : next === 't' ? '\t' : next;
|
||||
} else if (ch === q) {
|
||||
return out;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return error('unterminated string');
|
||||
};
|
||||
|
||||
const bare = (): string => {
|
||||
const start = pos;
|
||||
while (pos < text.length && BARE.test(text[pos]!)) pos++;
|
||||
if (pos === start) error(`unexpected character '${text[pos] ?? 'EOF'}'`);
|
||||
return text.slice(start, pos);
|
||||
};
|
||||
|
||||
const key = (): string => (text[pos] === '"' || text[pos] === "'" ? quoted() : bare());
|
||||
|
||||
const compound = (): SnbtValue => {
|
||||
pos++; // {
|
||||
const obj: { [k: string]: SnbtValue } = {};
|
||||
for (;;) {
|
||||
skipSeparators();
|
||||
if (pos >= text.length) error('unterminated compound');
|
||||
if (text[pos] === '}') {
|
||||
pos++;
|
||||
return obj;
|
||||
}
|
||||
const k = key();
|
||||
skipWs();
|
||||
if (text[pos] !== ':') error(`expected ':' after key '${k}'`);
|
||||
pos++;
|
||||
obj[k] = value();
|
||||
}
|
||||
};
|
||||
|
||||
const list = (): SnbtValue => {
|
||||
pos++; // [
|
||||
skipWs();
|
||||
// typed arrays: [I; 1, 2]
|
||||
if (/[IBL]/.test(text[pos] ?? '') && text[pos + 1] === ';') pos += 2;
|
||||
const arr: SnbtValue[] = [];
|
||||
for (;;) {
|
||||
skipSeparators();
|
||||
if (pos >= text.length) error('unterminated list');
|
||||
if (text[pos] === ']') {
|
||||
pos++;
|
||||
return arr;
|
||||
}
|
||||
arr.push(value());
|
||||
}
|
||||
};
|
||||
|
||||
const value = (): SnbtValue => {
|
||||
skipWs();
|
||||
const ch = text[pos];
|
||||
if (ch === undefined) return error('unexpected end of input');
|
||||
if (ch === '{') return compound();
|
||||
if (ch === '[') return list();
|
||||
if (ch === '"' || ch === "'") return quoted();
|
||||
const word = bare();
|
||||
if (word === 'true') return true;
|
||||
if (word === 'false') return false;
|
||||
if (NUMBER.test(word)) return Number(/[bBsSlLfFdD]$/.test(word) ? word.slice(0, -1) : word);
|
||||
return word;
|
||||
};
|
||||
|
||||
const result = value();
|
||||
skipWs();
|
||||
if (pos < text.length) error('trailing content');
|
||||
return result;
|
||||
}
|
||||
|
||||
export const isCompound = (v: SnbtValue | undefined): v is { [key: string]: SnbtValue } =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { deterministicId, type AtmEvent } from '@atm/shared';
|
||||
|
||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
/** `world/stats/<uuid>.json` → absolute stats_snapshot observed at the file's mtime. */
|
||||
export function statsFileToEvent(uuid: string, json: string, mtimeMs: number): AtmEvent {
|
||||
const root = JSON.parse(json) as { stats?: Record<string, Record<string, number>> };
|
||||
const stats: Record<string, Record<string, number>> = {};
|
||||
for (const [cat, entries] of Object.entries(root.stats ?? {})) {
|
||||
const clean: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(entries)) if (Number.isInteger(v) && v >= 0) clean[k] = v;
|
||||
stats[cat] = clean;
|
||||
}
|
||||
return {
|
||||
id: deterministicId(`stats:${uuid}:${mtimeMs}:${sha256(json)}`),
|
||||
type: 'stats_snapshot',
|
||||
ts: mtimeMs,
|
||||
uuid,
|
||||
name: null,
|
||||
full: true,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
/** "2026-10-12 12:42:23 +0200" → epoch ms */
|
||||
export function parseAdvancementTime(s: string): number {
|
||||
const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-])(\d{2})(\d{2})$/.exec(s);
|
||||
if (!m) return Number.NaN;
|
||||
return Date.parse(`${m[1]}T${m[2]}${m[3]}${m[4]}:${m[5]}`);
|
||||
}
|
||||
|
||||
/** `world/advancements/<uuid>.json` → one advancement event per completed, non-recipe advancement. */
|
||||
export function advancementsFileToEvents(uuid: string, json: string): AtmEvent[] {
|
||||
const root = JSON.parse(json) as Record<string, unknown>;
|
||||
const events: AtmEvent[] = [];
|
||||
for (const [id, entry] of Object.entries(root)) {
|
||||
if (id === 'DataVersion' || id.includes(':recipes/')) continue;
|
||||
const adv = entry as { done?: boolean; criteria?: Record<string, string> };
|
||||
if (!adv?.done) continue;
|
||||
const times = Object.values(adv.criteria ?? {})
|
||||
.map(parseAdvancementTime)
|
||||
.filter(Number.isFinite);
|
||||
if (times.length === 0) continue;
|
||||
events.push({
|
||||
id: deterministicId(`adv:${uuid}:${id}`),
|
||||
type: 'advancement',
|
||||
ts: Math.min(...times),
|
||||
uuid,
|
||||
name: null,
|
||||
advancementId: id,
|
||||
title: null,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { atmEventSchema, type AtmEvent, type EventOf, type EventType, type IngestResponse } from '@atm/shared';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { withTx } from '../db/pool.ts';
|
||||
|
||||
export type Source = 'push' | 'sftp';
|
||||
export type Projection<T extends EventType = EventType> = (c: PoolClient, e: EventOf<T>, source: Source) => Promise<void>;
|
||||
|
||||
const projections = new Map<EventType, Projection[]>();
|
||||
|
||||
export function registerProjection<T extends EventType>(type: T, fn: Projection<T>): void {
|
||||
const list = projections.get(type) ?? [];
|
||||
list.push(fn as unknown as Projection);
|
||||
projections.set(type, list);
|
||||
}
|
||||
|
||||
const batchEnd = new WeakMap<PoolClient, Map<string, () => Promise<void>>>();
|
||||
|
||||
/**
|
||||
* Runs `fn` once at the end of the current batch (still inside its transaction), however many events asked
|
||||
* for it under the same key. Used for derived tables rebuilt from many files (quest definitions).
|
||||
*/
|
||||
export function deferToBatchEnd(c: PoolClient, key: string, fn: () => Promise<void>): void {
|
||||
let tasks = batchEnd.get(c);
|
||||
if (!tasks) batchEnd.set(c, (tasks = new Map()));
|
||||
tasks.set(key, fn);
|
||||
}
|
||||
|
||||
export type AfterCommit =(events: AtmEvent[], source: Source) => void | Promise<void>;
|
||||
const afterCommitHooks: AfterCommit[] = [];
|
||||
|
||||
/** Called with all newly inserted events after the transaction committed (live updates, cache invalidation). */
|
||||
export function onEventsCommitted(fn: AfterCommit): () => void {
|
||||
afterCommitHooks.push(fn);
|
||||
return () => {
|
||||
const i = afterCommitHooks.indexOf(fn);
|
||||
if (i >= 0) afterCommitHooks.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Validates each raw event on its own; one bad event never discards the batch. */
|
||||
export function validateEvents(raw: unknown[]): { valid: AtmEvent[]; rejected: IngestResponse['rejected'] } {
|
||||
const valid: AtmEvent[] = [];
|
||||
const rejected: IngestResponse['rejected'] = [];
|
||||
for (const item of raw) {
|
||||
const r = atmEventSchema.safeParse(item);
|
||||
if (r.success) {
|
||||
valid.push(r.data);
|
||||
} else {
|
||||
const id = typeof (item as { id?: unknown })?.id === 'string' ? (item as { id: string }).id : null;
|
||||
const reason = r.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
|
||||
rejected.push({ id, reason: reason.slice(0, 500) });
|
||||
}
|
||||
}
|
||||
return { valid, rejected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores events idempotently (event_id is the primary key) and runs projections only for newly inserted ones,
|
||||
* all inside one transaction. Events are projected in timestamp order.
|
||||
*/
|
||||
export async function processEvents(
|
||||
pool: Pool,
|
||||
events: AtmEvent[],
|
||||
source: Source,
|
||||
serverId: string,
|
||||
): Promise<IngestResponse> {
|
||||
const sorted = [...events].sort((a, b) => a.ts - b.ts);
|
||||
const inserted = await withTx(pool, async (c) => {
|
||||
batchEnd.delete(c); // pooled clients are reused; drop leftovers of a failed batch
|
||||
const fresh: AtmEvent[] = [];
|
||||
for (const e of sorted) {
|
||||
const { rowCount } = await c.query(
|
||||
`INSERT INTO events(event_id, server_id, type, ts, source, payload)
|
||||
VALUES ($1, $2, $3, to_timestamp($4 / 1000.0), $5, $6)
|
||||
ON CONFLICT (event_id) DO NOTHING`,
|
||||
[e.id, serverId, e.type, e.ts, source, e],
|
||||
);
|
||||
if (!rowCount) continue;
|
||||
fresh.push(e);
|
||||
for (const p of projections.get(e.type) ?? []) await p(c, e, source);
|
||||
}
|
||||
const tasks = batchEnd.get(c);
|
||||
batchEnd.delete(c);
|
||||
for (const task of tasks?.values() ?? []) await task();
|
||||
return fresh;
|
||||
});
|
||||
for (const hook of afterCommitHooks) {
|
||||
try {
|
||||
await hook(inserted, source);
|
||||
} catch {
|
||||
/* hooks must not fail ingest */
|
||||
}
|
||||
}
|
||||
const freshIds = new Set(inserted.map((e) => e.id));
|
||||
return {
|
||||
accepted: events.filter((e) => freshIds.has(e.id)).map((e) => e.id),
|
||||
duplicate: events.filter((e) => !freshIds.has(e.id)).map((e) => e.id),
|
||||
rejected: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
import { ensurePlayer } from './players.ts';
|
||||
|
||||
registerProjection('advancement', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, false);
|
||||
await c.query(
|
||||
`INSERT INTO advancements(uuid, advancement_id, achieved_at, title)
|
||||
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4)
|
||||
ON CONFLICT (uuid, advancement_id) DO UPDATE SET
|
||||
achieved_at = LEAST(advancements.achieved_at, EXCLUDED.achieved_at),
|
||||
title = COALESCE(EXCLUDED.title, advancements.title)`,
|
||||
[e.uuid, e.advancementId, e.ts, e.title],
|
||||
);
|
||||
});
|
||||
|
||||
registerProjection('dimension_change', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
for (const dim of [e.from, e.to]) {
|
||||
await c.query(
|
||||
`INSERT INTO dimension_visits(uuid, dimension, first_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
|
||||
ON CONFLICT (uuid, dimension) DO UPDATE SET first_at = LEAST(dimension_visits.first_at, EXCLUDED.first_at)`,
|
||||
[e.uuid, dim, e.ts],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
import { ensurePlayer } from './players.ts';
|
||||
|
||||
registerProjection('player_death', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
await c.query(
|
||||
`INSERT INTO deaths(event_id, uuid, ts, dimension, cause, killer_type, killer_name)
|
||||
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4, $5, $6, $7) ON CONFLICT DO NOTHING`,
|
||||
[e.id, e.uuid, e.ts, e.dimension, e.cause, e.killerType, e.killerName],
|
||||
);
|
||||
});
|
||||
|
||||
registerProjection('entity_kill', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
await c.query(
|
||||
`INSERT INTO kills(event_id, uuid, ts, dimension, victim_type, is_boss, weapon)
|
||||
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4, $5, $6, $7) ON CONFLICT DO NOTHING`,
|
||||
[e.id, e.uuid, e.ts, e.dimension, e.victimType, e.isBoss, e.weapon],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { isQuestConfigFile, parseQuestDefs, parseQuestProgress, parseTeam } from '../../parsers/ftbquests.ts';
|
||||
import { deferToBatchEnd, registerProjection } from '../process.ts';
|
||||
|
||||
const QUEST_PROGRESS = /^world\/ftbquests\/[0-9a-f-]{32,36}\.snbt$/i;
|
||||
const TEAM_FILE = /^world\/ftbteams\/(party|player)\/[0-9a-f-]{32,36}\.snbt$/i;
|
||||
|
||||
export async function recordError(c: PoolClient, source: string, message: string): Promise<void> {
|
||||
await c.query('INSERT INTO app_errors(source, message) VALUES ($1, $2)', [source, message.slice(0, 2000)]);
|
||||
}
|
||||
|
||||
/** Rebuilds quest_defs from all cached chapter and English lang files (once per batch). */
|
||||
async function rebuildQuestDefs(c: PoolClient): Promise<void> {
|
||||
const files = await c.query<{ path: string; content: string }>(
|
||||
`SELECT path, content FROM file_cache WHERE path LIKE 'config/ftbquests/quests/%'`,
|
||||
);
|
||||
let defs;
|
||||
try {
|
||||
defs = parseQuestDefs(files.rows.filter((f) => isQuestConfigFile(f.path)));
|
||||
} catch (err) {
|
||||
await recordError(c, 'file_sync', `quest definitions: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
await c.query('DELETE FROM quest_defs');
|
||||
if (defs.length) {
|
||||
await c.query(
|
||||
`INSERT INTO quest_defs(quest_id, chapter_id, chapter_title, title)
|
||||
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[])
|
||||
ON CONFLICT (quest_id) DO NOTHING`,
|
||||
[defs.map((d) => d.questId), defs.map((d) => d.chapterId), defs.map((d) => d.chapterTitle), defs.map((d) => d.title)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerProjection('file_sync', async (c, e) => {
|
||||
const { rows } = await c.query<{ sha256: string }>('SELECT sha256 FROM file_cache WHERE path = $1', [e.path]);
|
||||
if (rows[0]?.sha256 === e.sha256) return; // unchanged content
|
||||
await c.query(
|
||||
`INSERT INTO file_cache(path, sha256, content, updated_at) VALUES ($1, $2, $3, to_timestamp($4 / 1000.0))
|
||||
ON CONFLICT (path) DO UPDATE SET sha256 = EXCLUDED.sha256, content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`,
|
||||
[e.path, e.sha256, e.content, e.ts],
|
||||
);
|
||||
|
||||
try {
|
||||
if (QUEST_PROGRESS.test(e.path)) {
|
||||
const p = parseQuestProgress(e.content);
|
||||
// The file is the team's complete state: quests no longer listed were reset in FTB Quests.
|
||||
await c.query(`DELETE FROM quest_progress WHERE team_id = $1 AND NOT (quest_id = ANY($2::text[]))`, [
|
||||
p.teamId,
|
||||
p.completed.map((q) => q.questId),
|
||||
]);
|
||||
if (p.completed.length) {
|
||||
await c.query(
|
||||
`INSERT INTO quest_progress(team_id, quest_id, completed_at)
|
||||
SELECT $1, t.id, to_timestamp(t.at / 1000.0) FROM unnest($2::text[], $3::bigint[]) AS t(id, at)
|
||||
ON CONFLICT (team_id, quest_id) DO UPDATE SET completed_at = EXCLUDED.completed_at`,
|
||||
[p.teamId, p.completed.map((q) => q.questId), p.completed.map((q) => q.at)],
|
||||
);
|
||||
}
|
||||
// Team names from ftbteams win; the progress file only provides a fallback ("Name#uuidprefix").
|
||||
await c.query(`INSERT INTO teams(team_id, name, type) VALUES ($1, $2, NULL) ON CONFLICT (team_id) DO NOTHING`, [
|
||||
p.teamId,
|
||||
p.name,
|
||||
]);
|
||||
} else if (isQuestConfigFile(e.path)) {
|
||||
deferToBatchEnd(c, 'quest_defs', () => rebuildQuestDefs(c));
|
||||
} else if (TEAM_FILE.test(e.path)) {
|
||||
const t = parseTeam(e.content);
|
||||
await c.query(
|
||||
`INSERT INTO teams(team_id, name, type) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (team_id) DO UPDATE SET name = COALESCE(EXCLUDED.name, teams.name), type = EXCLUDED.type`,
|
||||
[t.teamId, t.name, t.type],
|
||||
);
|
||||
// Only party membership is stored: a player outside a party plays as their own team (team id = uuid).
|
||||
if (t.type === 'party') {
|
||||
await c.query('DELETE FROM team_members WHERE team_id = $1', [t.teamId]);
|
||||
if (t.members.length) {
|
||||
// a player belongs to one party at a time
|
||||
await c.query('DELETE FROM team_members WHERE uuid = ANY($1::uuid[])', [t.members]);
|
||||
await c.query(`INSERT INTO team_members(team_id, uuid) SELECT $1, unnest($2::uuid[])`, [t.teamId, t.members]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// A malformed file must not reject the batch; it stays cached and is reported to the admin.
|
||||
await recordError(c, 'file_sync', `${e.path}: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// Side-effect imports register all projections with the pipeline.
|
||||
import './sessions.ts';
|
||||
import './combat.ts';
|
||||
import './advancements.ts';
|
||||
import './stats.ts';
|
||||
import './server.ts';
|
||||
import './files.ts';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
|
||||
/**
|
||||
* Upserts a player. A non-null name replaces the stored one only if it was observed at least as late as the
|
||||
* current name (late replays never revert a rename) and is recorded in name_history;
|
||||
* `seen` updates last_seen (only for events proving the player was online).
|
||||
*/
|
||||
export async function ensurePlayer(c: PoolClient, uuid: string, name: string | null, ts: number, seen: boolean): Promise<void> {
|
||||
await c.query(
|
||||
`INSERT INTO players(uuid, name, name_seen_at, first_seen, last_seen)
|
||||
VALUES ($1, $2, CASE WHEN $2::text IS NOT NULL THEN to_timestamp($3 / 1000.0) END,
|
||||
to_timestamp($3 / 1000.0), CASE WHEN $4 THEN to_timestamp($3 / 1000.0) END)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
name = CASE WHEN EXCLUDED.name IS NOT NULL AND (players.name_seen_at IS NULL OR EXCLUDED.name_seen_at >= players.name_seen_at)
|
||||
THEN EXCLUDED.name ELSE players.name END,
|
||||
name_seen_at = CASE WHEN EXCLUDED.name IS NOT NULL AND (players.name_seen_at IS NULL OR EXCLUDED.name_seen_at >= players.name_seen_at)
|
||||
THEN EXCLUDED.name_seen_at ELSE players.name_seen_at END,
|
||||
first_seen = LEAST(players.first_seen, EXCLUDED.first_seen),
|
||||
last_seen = CASE WHEN $4 THEN GREATEST(players.last_seen, EXCLUDED.last_seen) ELSE players.last_seen END`,
|
||||
[uuid, name, ts, seen],
|
||||
);
|
||||
if (name) {
|
||||
await c.query(
|
||||
`INSERT INTO name_history(uuid, name, seen_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
|
||||
ON CONFLICT (uuid, name) DO UPDATE SET seen_at = GREATEST(name_history.seen_at, EXCLUDED.seen_at)`,
|
||||
[uuid, name, ts],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
|
||||
registerProjection('health', async (c, e) => {
|
||||
await c.query(
|
||||
`INSERT INTO server_health(bucket, mspt_avg, mspt_p95, mspt_max, tps, players, chunks, heap_used_mb, heap_max_mb)
|
||||
VALUES (date_trunc('minute', to_timestamp($1 / 1000.0)), $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (bucket) DO UPDATE SET
|
||||
mspt_avg = EXCLUDED.mspt_avg, mspt_p95 = EXCLUDED.mspt_p95, mspt_max = EXCLUDED.mspt_max, tps = EXCLUDED.tps,
|
||||
players = EXCLUDED.players, chunks = EXCLUDED.chunks,
|
||||
heap_used_mb = EXCLUDED.heap_used_mb, heap_max_mb = EXCLUDED.heap_max_mb`,
|
||||
[e.windowStart, e.msptAvg, e.msptP95, e.msptMax, e.tps, e.players, e.chunks, e.heapUsedMb, e.heapMaxMb],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
import { ensurePlayer } from './players.ts';
|
||||
|
||||
/**
|
||||
* Push and log (sftp) both report joins. A join that falls inside a session already recorded by the other
|
||||
* source is the same visit seen twice and is skipped; only an open session of the same source is superseded.
|
||||
*/
|
||||
registerProjection('player_join', async (c, e, source) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
const { rowCount: covered } = await c.query(
|
||||
`SELECT 1 FROM sessions
|
||||
WHERE uuid = $1 AND source <> $3 AND started_at <= to_timestamp($2 / 1000.0)
|
||||
AND (ended_at IS NULL OR ended_at >= to_timestamp($2 / 1000.0))`,
|
||||
[e.uuid, e.ts, source],
|
||||
);
|
||||
if (covered) return;
|
||||
await c.query(
|
||||
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($2 / 1000.0)), end_reason = 'superseded'
|
||||
WHERE uuid = $1 AND ended_at IS NULL AND source = $3`,
|
||||
[e.uuid, e.ts, source],
|
||||
);
|
||||
await c.query(`INSERT INTO sessions(uuid, started_at, source) VALUES ($1, to_timestamp($2 / 1000.0), $3)`, [e.uuid, e.ts, source]);
|
||||
});
|
||||
|
||||
registerProjection('player_leave', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
await c.query(
|
||||
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($2 / 1000.0)), end_reason = 'leave'
|
||||
WHERE uuid = $1 AND ended_at IS NULL`,
|
||||
[e.uuid, e.ts],
|
||||
);
|
||||
});
|
||||
|
||||
registerProjection('server_stop', async (c, e) => {
|
||||
await c.query(
|
||||
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($1 / 1000.0)), end_reason = 'stop'
|
||||
WHERE ended_at IS NULL`,
|
||||
[e.ts],
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A server_start while sessions are still open means the server went down without server_stop (crash, kill).
|
||||
* Each open session ends at the last sign of life before the start: the player's last event or the last
|
||||
* health window, whichever is later — never later than the start itself.
|
||||
*/
|
||||
registerProjection('server_start', async (c, e) => {
|
||||
await c.query(
|
||||
`UPDATE sessions s SET
|
||||
end_reason = 'crash',
|
||||
ended_at = GREATEST(s.started_at, LEAST(to_timestamp($1 / 1000.0), COALESCE(GREATEST(
|
||||
(SELECT max(ev.ts) FROM events ev
|
||||
WHERE ev.payload->>'uuid' = s.uuid::text AND ev.ts >= s.started_at AND ev.ts < to_timestamp($1 / 1000.0)),
|
||||
(SELECT to_timestamp(max((ev.payload->>'windowEnd')::bigint) / 1000.0) FROM events ev
|
||||
WHERE ev.type = 'health' AND ev.ts >= s.started_at AND ev.ts < to_timestamp($1 / 1000.0))
|
||||
), s.started_at)))
|
||||
WHERE s.ended_at IS NULL`,
|
||||
[e.ts],
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO server_info(started_at, mc_version, neoforge_version, java_version, mods)
|
||||
VALUES (to_timestamp($1 / 1000.0), $2, $3, $4, $5)`,
|
||||
[e.ts, e.mcVersion, e.neoforgeVersion, e.javaVersion, JSON.stringify(e.mods)],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
import { ensurePlayer } from './players.ts';
|
||||
|
||||
/**
|
||||
* Counters are absolute. Per (uuid, category, key) the observation with the newest timestamp wins,
|
||||
* so late or repeated deliveries (push vs. SFTP) never move a value backwards in time.
|
||||
* Keys missing from a snapshot are left untouched, even for `full` snapshots.
|
||||
*/
|
||||
registerProjection('stats_snapshot', async (c, e, source) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, false);
|
||||
const cats: string[] = [];
|
||||
const keys: string[] = [];
|
||||
const vals: number[] = [];
|
||||
for (const [cat, entries] of Object.entries(e.stats)) {
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
cats.push(cat);
|
||||
keys.push(key);
|
||||
vals.push(value);
|
||||
}
|
||||
}
|
||||
if (cats.length === 0) return;
|
||||
await c.query(
|
||||
`INSERT INTO stat_current(uuid, category, key, value, observed_at, source)
|
||||
SELECT $1, t.cat, t.key, t.val, to_timestamp($5 / 1000.0), $6
|
||||
FROM unnest($2::text[], $3::text[], $4::bigint[]) AS t(cat, key, val)
|
||||
ON CONFLICT (uuid, category, key) DO UPDATE SET
|
||||
value = EXCLUDED.value, observed_at = EXCLUDED.observed_at, source = EXCLUDED.source
|
||||
WHERE stat_current.observed_at < EXCLUDED.observed_at`,
|
||||
[e.uuid, cats, keys, vals, e.ts, source],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { getSession, login, logout, SESSION_TTL_MS } from '../auth/admin.ts';
|
||||
import type { Config } from '../config.ts';
|
||||
import { createIngestToken, revokeAllIngestTokens } from '../ingest/tokens.ts';
|
||||
import { UUID_RE } from './public/shared.ts';
|
||||
|
||||
const COOKIE = 'atm_admin';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
admin?: { username: string; csrf: string; sessionId: string };
|
||||
}
|
||||
}
|
||||
|
||||
const sameToken = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance, { pool, config }: { pool: Pool; config: Config }) {
|
||||
const secure = config.NODE_ENV !== 'test' && config.NODE_ENV !== 'development';
|
||||
|
||||
app.addHook('onSend', async (_req, reply) => {
|
||||
reply.header('cache-control', 'no-store');
|
||||
});
|
||||
|
||||
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const sessionId = req.cookies[COOKIE];
|
||||
const session = sessionId ? await getSession(pool, sessionId) : null;
|
||||
if (!session || !sessionId) return reply.code(401).send({ error: 'unauthorized' });
|
||||
if (req.method !== 'GET') {
|
||||
const header = req.headers['x-csrf-token'];
|
||||
if (typeof header !== 'string' || !sameToken(header, session.csrf)) return reply.code(403).send({ error: 'csrf' });
|
||||
}
|
||||
req.admin = { ...session, sessionId };
|
||||
};
|
||||
|
||||
app.post(
|
||||
'/api/admin/login',
|
||||
{ config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
|
||||
async (req, reply) => {
|
||||
const body = z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256) }).safeParse(req.body);
|
||||
if (!body.success) return reply.code(400).send({ error: 'bad_request' });
|
||||
const s = await login(pool, body.data.username, body.data.password);
|
||||
if (!s) {
|
||||
req.log.warn({ username: body.data.username }, 'admin login failed');
|
||||
return reply.code(401).send({ error: 'invalid_credentials' });
|
||||
}
|
||||
reply.setCookie(COOKIE, s.sessionId, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'strict',
|
||||
path: '/api/admin',
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
});
|
||||
return { csrf: s.csrf, username: body.data.username };
|
||||
},
|
||||
);
|
||||
|
||||
app.register(async (protectedApp) => {
|
||||
protectedApp.addHook('preHandler', requireAdmin);
|
||||
|
||||
protectedApp.get('/api/admin/session', async (req) => ({ username: req.admin!.username, csrf: req.admin!.csrf }));
|
||||
|
||||
protectedApp.post('/api/admin/logout', async (req, reply) => {
|
||||
await logout(pool, req.admin!.sessionId);
|
||||
reply.clearCookie(COOKIE, { path: '/api/admin' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
protectedApp.get('/api/admin/status', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT (SELECT max(received_at) FROM events WHERE source = 'push') AS last_push,
|
||||
(SELECT max(received_at) FROM events WHERE source = 'sftp') AS last_sftp_event,
|
||||
(SELECT max(synced_at) FROM sync_state) AS last_sync,
|
||||
(SELECT GREATEST(reltuples, 0)::bigint FROM pg_class WHERE relname = 'events') AS events, -- estimate, no table scan
|
||||
(SELECT count(*) FROM ingest_tokens WHERE revoked_at IS NULL)::int AS active_tokens,
|
||||
(SELECT count(*) FROM sync_requests WHERE done_at IS NULL)::int AS pending_sync`,
|
||||
);
|
||||
const errors = await pool.query(`SELECT at, source, message FROM app_errors ORDER BY at DESC LIMIT 20`);
|
||||
const r = rows[0];
|
||||
return {
|
||||
push: { lastEventAt: r.last_push?.getTime() ?? null },
|
||||
sftp: {
|
||||
enabled: config.sftp !== null,
|
||||
lastSyncAt: r.last_sync?.getTime() ?? null,
|
||||
lastEventAt: r.last_sftp_event?.getTime() ?? null,
|
||||
pendingRequests: r.pending_sync,
|
||||
},
|
||||
events: r.events,
|
||||
activeTokens: r.active_tokens,
|
||||
lastErrors: errors.rows.map((e) => ({ at: e.at.getTime(), source: e.source, message: e.message })),
|
||||
};
|
||||
});
|
||||
|
||||
protectedApp.get('/api/admin/players', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, name, hidden, last_seen FROM players ORDER BY last_seen DESC NULLS LAST, name`,
|
||||
);
|
||||
return { players: rows.map((r) => ({ uuid: r.uuid, name: r.name, hidden: r.hidden, lastSeen: r.last_seen?.getTime() ?? null })) };
|
||||
});
|
||||
|
||||
protectedApp.post<{ Params: { uuid: string } }>('/api/admin/players/:uuid/hidden', async (req, reply) => {
|
||||
const body = z.object({ hidden: z.boolean() }).safeParse(req.body);
|
||||
if (!UUID_RE.test(req.params.uuid) || !body.success) return reply.code(400).send({ error: 'bad_request' });
|
||||
const { rowCount } = await pool.query('UPDATE players SET hidden = $2 WHERE uuid = $1', [req.params.uuid, body.data.hidden]);
|
||||
if (!rowCount) return reply.code(404).send({ error: 'not_found' });
|
||||
req.log.info({ admin: req.admin!.username, uuid: req.params.uuid, hidden: body.data.hidden }, 'player visibility changed');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
protectedApp.post('/api/admin/sync', async (_req, reply) => {
|
||||
await pool.query('INSERT INTO sync_requests DEFAULT VALUES');
|
||||
return reply.code(202).send({ queued: true });
|
||||
});
|
||||
|
||||
protectedApp.post('/api/admin/ingest-token/rotate', async (req) => {
|
||||
await revokeAllIngestTokens(pool);
|
||||
const token = await createIngestToken(pool);
|
||||
req.log.info({ admin: req.admin!.username }, 'ingest token rotated');
|
||||
return { token };
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LiveEvent } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { LiveHub } from '../live/hub.ts';
|
||||
|
||||
export const HEARTBEAT_MS = 25_000;
|
||||
const MAX_CLIENTS = 500;
|
||||
|
||||
const frame = (e: LiveEvent) => `id: ${e.id}\nevent: ${e.kind}\ndata: ${JSON.stringify(e.data)}\n\n`;
|
||||
|
||||
export async function liveRoutes(app: FastifyInstance, { hub }: { hub: LiveHub }) {
|
||||
let clients = 0;
|
||||
const open = new Set<{ end: () => void }>();
|
||||
// Hijacked streams are not tracked by Fastify; end them so shutdown does not hang.
|
||||
app.addHook('onClose', async () => {
|
||||
for (const res of open) res.end();
|
||||
});
|
||||
|
||||
app.get<{ Headers: { 'last-event-id'?: string } }>('/api/live', async (req, reply) => {
|
||||
if (clients >= MAX_CLIENTS) return reply.code(503).send({ error: 'too_many_clients' });
|
||||
clients++;
|
||||
reply.hijack();
|
||||
const res = reply.raw;
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
res.write(`retry: 5000\n: connected\n\n`);
|
||||
|
||||
const lastId = Number(req.headers['last-event-id']);
|
||||
if (Number.isFinite(lastId) && lastId > 0) for (const e of hub.since(lastId)) res.write(frame(e));
|
||||
|
||||
const unsubscribe = hub.subscribe((e) => res.write(frame(e)));
|
||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
|
||||
open.add(res);
|
||||
const close = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
open.delete(res);
|
||||
clients--;
|
||||
};
|
||||
req.raw.on('close', close);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { UUID_RE } from './shared.ts';
|
||||
|
||||
const TTL_MS = 24 * 3600_000;
|
||||
const MAX_ENTRIES = 500;
|
||||
const TIMEOUT_MS = 5000;
|
||||
|
||||
// 8×8 neutral grey PNG, served when the avatar service is unreachable.
|
||||
const PLACEHOLDER = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVR42mN4+vQpTsQwciUBADoNkKUl+DzzAAAAAElFTkSuQmCC',
|
||||
'base64',
|
||||
);
|
||||
|
||||
/**
|
||||
* Player heads are fetched server-side and cached, so visitors' browsers never contact third parties
|
||||
* (no visitor IPs leave the portal).
|
||||
*/
|
||||
export async function avatarRoutes(app: FastifyInstance, { source }: { source: string }) {
|
||||
const cache = new Map<string, { png: Buffer; at: number }>();
|
||||
|
||||
app.get<{ Params: { uuid: string } }>('/api/avatar/:uuid', async (req, reply) => {
|
||||
const uuid = req.params.uuid.toLowerCase();
|
||||
if (!UUID_RE.test(uuid)) return reply.code(400).send({ error: 'bad_uuid' });
|
||||
reply.header('content-type', 'image/png').header('cache-control', 'public, max-age=86400');
|
||||
|
||||
const hit = cache.get(uuid);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return reply.send(hit.png);
|
||||
try {
|
||||
const res = await fetch(source.replace('{uuid}', uuid), { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
const type = res.headers.get('content-type') ?? '';
|
||||
if (!res.ok || !type.startsWith('image/png')) throw new Error(`avatar ${res.status}`);
|
||||
const png = Buffer.from(await res.arrayBuffer());
|
||||
if (png.length > 256 * 1024) throw new Error('avatar too large');
|
||||
if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value!);
|
||||
cache.set(uuid, { png, at: Date.now() });
|
||||
return reply.send(png);
|
||||
} catch (err) {
|
||||
req.log.debug({ err }, 'avatar fetch failed');
|
||||
return reply.header('cache-control', 'public, max-age=300').send(hit?.png ?? PLACEHOLDER);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Config } from '../../config.ts';
|
||||
import { avatarRoutes } from './avatar.ts';
|
||||
import { leaderboardRoutes } from './leaderboards.ts';
|
||||
import { overviewRoutes } from './overview.ts';
|
||||
import { playerRoutes } from './players.ts';
|
||||
import { serverRoutes } from './server.ts';
|
||||
|
||||
export async function publicRoutes(app: FastifyInstance, { pool, config }: { pool: Pool; config: Config }) {
|
||||
// Short shared cache for all public read endpoints (Traefik/browsers); avatar sets its own.
|
||||
app.addHook('onSend', async (req, reply) => {
|
||||
if (req.method === 'GET' && !reply.hasHeader('cache-control')) reply.header('cache-control', 'public, max-age=15');
|
||||
});
|
||||
await overviewRoutes(app, { pool });
|
||||
await leaderboardRoutes(app, { pool });
|
||||
await playerRoutes(app, { pool, timeZone: config.TIMEZONE });
|
||||
await serverRoutes(app, { pool, config });
|
||||
await avatarRoutes(app, { source: config.AVATAR_SOURCE });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LeaderboardDto, LeaderboardsDto, Period } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getLeaderboard } from '../../stats/leaderboards.ts';
|
||||
import { BOARD_BY_ID, BOARDS, type Board } from '../../stats/metrics.ts';
|
||||
|
||||
const PERIODS: Period[] = ['all', '30d', '7d'];
|
||||
const meta = (b: Board) => ({ id: b.id, title: b.title, description: b.description, unit: b.unit });
|
||||
|
||||
export async function leaderboardRoutes(app: FastifyInstance, { pool }: { pool: Pool }) {
|
||||
app.get('/api/leaderboards', async (): Promise<LeaderboardsDto> => ({
|
||||
boards: await Promise.all(BOARDS.map(async (b) => ({ ...meta(b), top: await getLeaderboard(pool, b.id, 'all', 10) }))),
|
||||
}));
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { period?: string } }>('/api/leaderboards/:id', async (req, reply) => {
|
||||
const board = BOARD_BY_ID.get(req.params.id);
|
||||
if (!board) return reply.code(404).send({ error: 'unknown_board' });
|
||||
const period = (req.query.period ?? 'all') as Period;
|
||||
if (!PERIODS.includes(period)) return reply.code(400).send({ error: 'bad_period' });
|
||||
const dto: LeaderboardDto = { board: meta(board), period, entries: await getLeaderboard(pool, board.id, period, 100) };
|
||||
return dto;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { OnlineDto, OverviewDto } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { hours, ms, QUEST_FILTER } from './shared.ts';
|
||||
|
||||
export async function overviewRoutes(app: FastifyInstance, { pool }: { pool: Pool }) {
|
||||
app.get('/api/overview', async (): Promise<OverviewDto> => {
|
||||
const { rows } = await pool.query(
|
||||
`WITH visible AS (SELECT uuid FROM players WHERE NOT hidden),
|
||||
stat AS (
|
||||
SELECT key, sum(value) AS total FROM stat_current
|
||||
WHERE category = 'minecraft:custom' AND key IN ('minecraft:play_time', 'minecraft:deaths', 'minecraft:mob_kills')
|
||||
AND uuid IN (SELECT uuid FROM visible)
|
||||
GROUP BY key)
|
||||
SELECT
|
||||
(SELECT count(*) FROM sessions WHERE ended_at IS NULL AND uuid IN (SELECT uuid FROM visible))::int AS online,
|
||||
(SELECT count(*) FROM visible)::int AS total_players,
|
||||
COALESCE((SELECT total FROM stat WHERE key = 'minecraft:play_time'), 0) AS play_ticks,
|
||||
COALESCE((SELECT total FROM stat WHERE key = 'minecraft:deaths'), 0) AS deaths,
|
||||
COALESCE((SELECT total FROM stat WHERE key = 'minecraft:mob_kills'), 0) AS mob_kills,
|
||||
(SELECT count(*) FROM quest_progress qp WHERE ${QUEST_FILTER})::int AS quests,
|
||||
(SELECT count(*) FROM advancements WHERE uuid IN (SELECT uuid FROM visible))::int AS advancements,
|
||||
(SELECT received_at FROM events ORDER BY received_at DESC LIMIT 1) AS last_update`,
|
||||
);
|
||||
const r = rows[0];
|
||||
return {
|
||||
online: r.online,
|
||||
totalPlayers: r.total_players,
|
||||
totalPlayHours: hours(r.play_ticks),
|
||||
totalDeaths: r.deaths,
|
||||
totalMobKills: r.mob_kills,
|
||||
questsCompleted: r.quests,
|
||||
advancements: r.advancements,
|
||||
lastUpdate: ms(r.last_update),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/online', async (): Promise<OnlineDto> => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.uuid, p.name, s.started_at,
|
||||
(SELECT e.payload->>'to' FROM events e
|
||||
WHERE e.type = 'dimension_change' AND e.payload->>'uuid' = s.uuid::text AND e.ts >= s.started_at
|
||||
ORDER BY e.ts DESC LIMIT 1) AS dimension
|
||||
FROM sessions s JOIN players p ON p.uuid = s.uuid AND NOT p.hidden
|
||||
WHERE s.ended_at IS NULL
|
||||
ORDER BY s.started_at`,
|
||||
);
|
||||
return { players: rows.map((r) => ({ uuid: r.uuid, name: r.name, since: r.started_at.getTime(), dimension: r.dimension })) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { ITEM_CATEGORIES, type HistoryDto, type ItemCategory, type ItemsDto, type PlayerListDto, type PlayerProfileDto } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getPlayerRanks } from '../../stats/leaderboards.ts';
|
||||
import { BOARD_BY_ID, metricSql, toDisplay } from '../../stats/metrics.ts';
|
||||
import { hours, ms, QUEST_FILTER, resolvePlayer } from './shared.ts';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const RANGES = { '7d': { days: 7, granularity: 'hour' }, '30d': { days: 30, granularity: 'day' }, all: { days: null, granularity: 'day' } } as const;
|
||||
|
||||
export async function playerRoutes(app: FastifyInstance, { pool, timeZone }: { pool: Pool; timeZone: string }) {
|
||||
app.get<{ Querystring: { search?: string; page?: string } }>('/api/players', async (req): Promise<PlayerListDto> => {
|
||||
const search = (req.query.search ?? '').trim().slice(0, 16);
|
||||
const page = Math.max(0, Number.parseInt(req.query.page ?? '0', 10) || 0);
|
||||
const like = `%${search.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.uuid, p.name, p.last_seen,
|
||||
COALESCE(sc.value, 0) AS play_ticks,
|
||||
EXISTS (SELECT 1 FROM sessions s WHERE s.uuid = p.uuid AND s.ended_at IS NULL) AS online,
|
||||
count(*) OVER ()::int AS total
|
||||
FROM players p
|
||||
LEFT JOIN stat_current sc ON sc.uuid = p.uuid AND sc.category = 'minecraft:custom' AND sc.key = 'minecraft:play_time'
|
||||
WHERE NOT p.hidden AND p.name IS NOT NULL AND ($1 = '' OR p.name ILIKE $2)
|
||||
ORDER BY online DESC, p.last_seen DESC NULLS LAST, p.name
|
||||
LIMIT ${PAGE_SIZE} OFFSET $3`,
|
||||
[search, like, page * PAGE_SIZE],
|
||||
);
|
||||
return {
|
||||
total: rows[0]?.total ?? 0,
|
||||
players: rows.map((r) => ({ uuid: r.uuid, name: r.name, lastSeen: ms(r.last_seen), playHours: hours(r.play_ticks), online: r.online })),
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/players/:id', async (req, reply) => {
|
||||
const uuid = await resolvePlayer(pool, req.params.id);
|
||||
if (!uuid) return reply.code(404).send({ error: 'not_found' });
|
||||
return getProfile(pool, uuid, timeZone);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { metric?: string; range?: string } }>(
|
||||
'/api/players/:id/history',
|
||||
async (req, reply) => {
|
||||
const uuid = await resolvePlayer(pool, req.params.id);
|
||||
if (!uuid) return reply.code(404).send({ error: 'not_found' });
|
||||
const board = BOARD_BY_ID.get(req.query.metric ?? 'playtime');
|
||||
const range = RANGES[(req.query.range ?? '30d') as keyof typeof RANGES];
|
||||
if (!board || !range) return reply.code(400).send({ error: 'bad_query' });
|
||||
const { rows } = await pool.query<{ bucket: Date; value: number }>(
|
||||
`SELECT bucket, value FROM stat_snapshots
|
||||
WHERE uuid = $1 AND metric = $2 AND granularity = $3 AND ($4::int IS NULL OR bucket >= now() - make_interval(days => $4::int))
|
||||
ORDER BY bucket`,
|
||||
[uuid, board.id, range.granularity, range.days],
|
||||
);
|
||||
const current = await pool.query<{ value: number }>(`SELECT value FROM (${metricSql(board.metric)}) v WHERE v.uuid = $1`, [uuid]);
|
||||
const points = rows.map((r) => ({ t: r.bucket.getTime(), v: toDisplay(board.unit, r.value) }));
|
||||
if (current.rows[0]) points.push({ t: Date.now(), v: toDisplay(board.unit, current.rows[0].value) });
|
||||
const dto: HistoryDto = { metric: board.id, unit: board.unit, points };
|
||||
return dto;
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { category?: string } }>('/api/players/:id/items', async (req, reply) => {
|
||||
const uuid = await resolvePlayer(pool, req.params.id);
|
||||
if (!uuid) return reply.code(404).send({ error: 'not_found' });
|
||||
const category = (req.query.category ?? 'mined') as ItemCategory;
|
||||
if (!ITEM_CATEGORIES.includes(category)) return reply.code(400).send({ error: 'bad_category' });
|
||||
const { rows } = await pool.query<{ ns: string; key: string; value: number; ns_total: number }>(
|
||||
`SELECT ns, key, value, ns_total FROM (
|
||||
SELECT split_part(key, ':', 1) AS ns, key, value,
|
||||
sum(value) OVER (PARTITION BY split_part(key, ':', 1)) AS ns_total,
|
||||
row_number() OVER (PARTITION BY split_part(key, ':', 1) ORDER BY value DESC, key) AS rn
|
||||
FROM stat_current WHERE uuid = $1 AND category = $2 AND value > 0) t
|
||||
WHERE rn <= 20 ORDER BY ns_total DESC, ns, value DESC`,
|
||||
[uuid, `minecraft:${category}`],
|
||||
);
|
||||
const mods: ItemsDto['mods'] = [];
|
||||
for (const r of rows) {
|
||||
let mod = mods.at(-1);
|
||||
if (mod?.namespace !== r.ns) {
|
||||
mod = { namespace: r.ns, total: r.ns_total, items: [] };
|
||||
mods.push(mod);
|
||||
}
|
||||
mod.items.push({ key: r.key, value: r.value });
|
||||
}
|
||||
const dto: ItemsDto = { category, mods };
|
||||
return dto;
|
||||
});
|
||||
}
|
||||
|
||||
async function getProfile(pool: Pool, uuid: string, timeZone: string): Promise<PlayerProfileDto> {
|
||||
const q = async <T extends Record<string, unknown>>(sql: string, params: unknown[] = [uuid]) => (await pool.query<T>(sql, params)).rows;
|
||||
|
||||
const [player] = await q<{ name: string | null; first_seen: Date; last_seen: Date | null }>(
|
||||
'SELECT name, first_seen, last_seen FROM players WHERE uuid = $1',
|
||||
);
|
||||
const [sess] = await q<{ n: number; online: boolean; minutes: number }>(
|
||||
`SELECT count(*)::int AS n, bool_or(ended_at IS NULL) AS online,
|
||||
COALESCE(sum(extract(epoch FROM COALESCE(ended_at, now()) - started_at) / 60), 0) AS minutes
|
||||
FROM sessions WHERE uuid = $1`,
|
||||
);
|
||||
const custom = await q<{ key: string; value: number }>(
|
||||
`SELECT key, value FROM stat_current WHERE uuid = $1 AND category = 'minecraft:custom' ORDER BY key`,
|
||||
);
|
||||
const general: Record<string, number> = {};
|
||||
const movement: Record<string, number> = {};
|
||||
for (const r of custom) (r.key.endsWith('_one_cm') ? movement : general)[r.key] = r.value;
|
||||
const playTicks = general['minecraft:play_time'];
|
||||
|
||||
const counted = (category: string) =>
|
||||
q<{ key: string; value: number }>(
|
||||
`SELECT key, value FROM stat_current WHERE uuid = $1 AND category = $2 AND value > 0 ORDER BY value DESC, key LIMIT 25`,
|
||||
[uuid, category],
|
||||
);
|
||||
|
||||
const team = await q<{ team_id: string; name: string | null }>(
|
||||
`SELECT COALESCE(tm.team_id, $1::uuid) AS team_id, t.name
|
||||
FROM (SELECT $1::uuid AS uuid) p
|
||||
LEFT JOIN team_members tm ON tm.uuid = p.uuid
|
||||
LEFT JOIN teams t ON t.team_id = COALESCE(tm.team_id, p.uuid)
|
||||
LIMIT 1`,
|
||||
);
|
||||
const teamId = team[0]!.team_id;
|
||||
const [questCount] = await q<{ completed: number; total: number }>(
|
||||
`SELECT (SELECT count(*) FROM quest_progress qp WHERE qp.team_id = $1 AND ${QUEST_FILTER})::int AS completed,
|
||||
(SELECT count(*) FROM quest_defs)::int AS total`,
|
||||
[teamId],
|
||||
);
|
||||
const chapters = await q<{ chapter_id: string; title: string | null; completed: number; total: number }>(
|
||||
`SELECT d.chapter_id, max(d.chapter_title) AS title, count(qp.quest_id)::int AS completed, count(*)::int AS total
|
||||
FROM quest_defs d LEFT JOIN quest_progress qp ON qp.quest_id = d.quest_id AND qp.team_id = $1
|
||||
GROUP BY d.chapter_id ORDER BY max(d.chapter_title)`,
|
||||
[teamId],
|
||||
);
|
||||
const recentQuests = await q<{ quest_id: string; title: string; chapter_title: string | null; completed_at: Date }>(
|
||||
`SELECT d.quest_id, d.title, d.chapter_title, qp.completed_at
|
||||
FROM quest_progress qp JOIN quest_defs d ON d.quest_id = qp.quest_id
|
||||
WHERE qp.team_id = $1 AND d.title IS NOT NULL
|
||||
ORDER BY qp.completed_at DESC LIMIT 10`,
|
||||
[teamId],
|
||||
);
|
||||
|
||||
const heat = await q<{ dow: number; hour: number; minutes: number }>(
|
||||
`SELECT (extract(isodow FROM h AT TIME ZONE $2)::int - 1) AS dow, extract(hour FROM h AT TIME ZONE $2)::int AS hour,
|
||||
sum(extract(epoch FROM LEAST(COALESCE(s.ended_at, now()), h + interval '1 hour') - GREATEST(s.started_at, h)) / 60) AS minutes
|
||||
FROM sessions s,
|
||||
generate_series(date_trunc('hour', s.started_at), COALESCE(s.ended_at, now()), interval '1 hour') AS h
|
||||
WHERE s.uuid = $1 AND s.started_at > now() - interval '90 days'
|
||||
GROUP BY 1, 2`,
|
||||
[uuid, timeZone],
|
||||
);
|
||||
const activityHeatmap = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0));
|
||||
for (const r of heat) activityHeatmap[r.dow]![r.hour] = Math.round(r.minutes);
|
||||
|
||||
return {
|
||||
uuid,
|
||||
name: player!.name,
|
||||
firstSeen: player!.first_seen.getTime(),
|
||||
lastSeen: ms(player!.last_seen),
|
||||
online: sess?.online ?? false,
|
||||
playHours: playTicks !== undefined ? hours(playTicks) : Math.round(((sess?.minutes ?? 0) / 60) * 100) / 100,
|
||||
sessions: sess?.n ?? 0,
|
||||
ranks: await getPlayerRanks(pool, uuid),
|
||||
general,
|
||||
movement,
|
||||
deathsByCause: (
|
||||
await q<{ cause: string; count: number }>(
|
||||
'SELECT cause, count(*)::int AS count FROM deaths WHERE uuid = $1 GROUP BY cause ORDER BY count DESC, cause',
|
||||
)
|
||||
).map((r) => ({ cause: r.cause, count: r.count })),
|
||||
killedBy: (await counted('minecraft:killed_by')).map((r) => ({ entity: r.key, count: r.value })),
|
||||
killsByType: (await counted('minecraft:killed')).map((r) => ({ entity: r.key, count: r.value })),
|
||||
bossKills: (
|
||||
await q<{ victim_type: string; ts: Date }>('SELECT victim_type, ts FROM kills WHERE uuid = $1 AND is_boss ORDER BY ts DESC LIMIT 50')
|
||||
).map((r) => ({ entity: r.victim_type, at: r.ts.getTime() })),
|
||||
dimensions: (
|
||||
await q<{ dimension: string; first_at: Date }>('SELECT dimension, first_at FROM dimension_visits WHERE uuid = $1 ORDER BY first_at')
|
||||
).map((r) => ({ dimension: r.dimension, firstAt: r.first_at.getTime() })),
|
||||
advancements: (
|
||||
await q<{ advancement_id: string; title: string | null; achieved_at: Date }>(
|
||||
'SELECT advancement_id, title, achieved_at FROM advancements WHERE uuid = $1 ORDER BY achieved_at DESC LIMIT 200',
|
||||
)
|
||||
).map((r) => ({ id: r.advancement_id, title: r.title, at: r.achieved_at.getTime() })),
|
||||
quests: {
|
||||
teamId,
|
||||
teamName: team[0]!.name,
|
||||
completed: questCount!.completed,
|
||||
total: questCount!.total > 0 ? questCount!.total : null,
|
||||
chapters: chapters.map((c) => ({ chapterId: c.chapter_id, title: c.title, completed: c.completed, total: c.total })),
|
||||
recent: recentQuests.map((r) => ({ questId: r.quest_id, title: r.title, chapterTitle: r.chapter_title, at: r.completed_at.getTime() })),
|
||||
},
|
||||
activityHeatmap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ModsDto, QuestsDto, ServerDto, ServerInfoDto, SourcesDto } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Config } from '../../config.ts';
|
||||
import { MOD_CATEGORIES } from '../../stats/metrics.ts';
|
||||
import { LIMITATIONS, ms, QUEST_FILTER } from './shared.ts';
|
||||
|
||||
const RANGE_HOURS = { '24h': 24, '7d': 168, '30d': 720 } as const;
|
||||
const PUSH_FRESH_MS = 10 * 60_000;
|
||||
|
||||
const categoryOf = (ns: string): string | null =>
|
||||
(Object.entries(MOD_CATEGORIES).find(([, list]) => list.includes(ns))?.[0] as string | undefined) ?? null;
|
||||
|
||||
async function latestServerInfo(pool: Pool): Promise<{ info: ServerInfoDto; mods: { id: string; name: string; version: string }[] } | null> {
|
||||
const { rows } = await pool.query('SELECT * FROM server_info ORDER BY started_at DESC LIMIT 1');
|
||||
const r = rows[0];
|
||||
if (!r) return null;
|
||||
const mods = r.mods as { id: string; name: string; version: string }[];
|
||||
return {
|
||||
info: { startedAt: r.started_at.getTime(), mcVersion: r.mc_version, neoforgeVersion: r.neoforge_version, javaVersion: r.java_version, modCount: mods.length },
|
||||
mods,
|
||||
};
|
||||
}
|
||||
|
||||
export async function serverRoutes(app: FastifyInstance, { pool, config }: { pool: Pool; config: Config }) {
|
||||
app.get('/api/mods', async (): Promise<ModsDto> => {
|
||||
const latest = await latestServerInfo(pool);
|
||||
const { rows } = await pool.query<{ ns: string; activity: number }>(
|
||||
`SELECT split_part(key, ':', 1) AS ns, sum(value) AS activity
|
||||
FROM stat_current sc JOIN players p ON p.uuid = sc.uuid AND NOT p.hidden
|
||||
WHERE category IN ('minecraft:mined', 'minecraft:crafted', 'minecraft:used', 'minecraft:picked_up')
|
||||
GROUP BY 1`,
|
||||
);
|
||||
const activity = new Map(rows.map((r) => [r.ns, r.activity]));
|
||||
const known = latest?.mods ?? [];
|
||||
const mods = known
|
||||
.map((m) => ({ id: m.id, name: m.name, version: m.version, activity: activity.get(m.id) ?? 0, category: categoryOf(m.id) }))
|
||||
.sort((a, b) => b.activity - a.activity || a.name.localeCompare(b.name));
|
||||
const byCategory = new Map<string, number>();
|
||||
for (const [ns, value] of activity) {
|
||||
if (ns === 'minecraft') continue;
|
||||
const cat = categoryOf(ns) ?? 'other';
|
||||
byCategory.set(cat, (byCategory.get(cat) ?? 0) + value);
|
||||
}
|
||||
return {
|
||||
server: latest?.info ?? null,
|
||||
mods,
|
||||
categories: [...byCategory].map(([category, a]) => ({ category, activity: a })).sort((a, b) => b.activity - a.activity),
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { range?: string } }>('/api/server', async (req, reply): Promise<ServerDto | undefined> => {
|
||||
const hours = RANGE_HOURS[(req.query.range ?? '24h') as keyof typeof RANGE_HOURS];
|
||||
if (!hours) return reply.code(400).send({ error: 'bad_range' });
|
||||
// Minute values for the last 14 days, hourly aggregates beyond; long ranges are bucketed to keep payloads small.
|
||||
const step = hours <= 24 ? 'minute' : 'hour';
|
||||
const { rows } = await pool.query(
|
||||
`SELECT date_trunc($2, bucket) AS t, avg(mspt_avg) AS mspt_avg, max(mspt_p95) AS mspt_p95, avg(tps) AS tps,
|
||||
max(players) AS players, avg(heap_used_mb) AS heap_used_mb, max(heap_max_mb) AS heap_max_mb
|
||||
FROM (SELECT bucket, mspt_avg, mspt_p95, tps, players, heap_used_mb, heap_max_mb FROM server_health
|
||||
UNION ALL
|
||||
SELECT bucket, mspt_avg, mspt_p95, tps, players, heap_used_mb, heap_max_mb FROM server_health_hourly) h
|
||||
WHERE bucket >= now() - make_interval(hours => $1)
|
||||
GROUP BY 1 ORDER BY 1`,
|
||||
[hours, step],
|
||||
);
|
||||
return {
|
||||
info: (await latestServerInfo(pool))?.info ?? null,
|
||||
health: rows.map((r) => ({
|
||||
t: r.t.getTime(),
|
||||
msptAvg: Math.round(r.mspt_avg * 10) / 10,
|
||||
msptP95: Math.round(r.mspt_p95 * 10) / 10,
|
||||
tps: Math.round(r.tps * 10) / 10,
|
||||
players: r.players,
|
||||
heapUsedMb: Math.round(r.heap_used_mb),
|
||||
heapMaxMb: Math.round(r.heap_max_mb),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/quests', async (): Promise<QuestsDto> => {
|
||||
const defs = await pool.query<{ n: number }>('SELECT count(*)::int AS n FROM quest_defs');
|
||||
const chapters = await pool.query(
|
||||
`WITH per_team AS (
|
||||
SELECT d.chapter_id, qp.team_id, count(*) AS done
|
||||
FROM quest_defs d JOIN quest_progress qp ON qp.quest_id = d.quest_id GROUP BY 1, 2),
|
||||
totals AS (SELECT chapter_id, max(chapter_title) AS title, count(*)::int AS total FROM quest_defs GROUP BY 1)
|
||||
SELECT t.chapter_id, t.title, t.total,
|
||||
COALESCE(max(pt.done), 0)::int AS max_completed,
|
||||
count(pt.team_id) FILTER (WHERE pt.done >= t.total)::int AS teams_finished
|
||||
FROM totals t LEFT JOIN per_team pt ON pt.chapter_id = t.chapter_id
|
||||
GROUP BY t.chapter_id, t.title, t.total ORDER BY t.title NULLS LAST`,
|
||||
);
|
||||
// Teams: explicit members, or the solo player whose uuid is the team id. Teams of hidden players are omitted.
|
||||
const teams = await pool.query(
|
||||
`SELECT qp.team_id, t.name, count(*)::int AS completed,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('uuid', p.uuid, 'name', p.name)) FROM team_members tm
|
||||
JOIN players p ON p.uuid = tm.uuid AND NOT p.hidden WHERE tm.team_id = qp.team_id),
|
||||
(SELECT json_agg(json_build_object('uuid', p.uuid, 'name', p.name)) FROM players p
|
||||
WHERE p.uuid = qp.team_id AND NOT p.hidden)) AS members
|
||||
FROM quest_progress qp LEFT JOIN teams t ON t.team_id = qp.team_id
|
||||
WHERE ${QUEST_FILTER}
|
||||
GROUP BY qp.team_id, t.name ORDER BY completed DESC`,
|
||||
);
|
||||
return {
|
||||
defsKnown: defs.rows[0]!.n > 0,
|
||||
chapters: chapters.rows.map((r) => ({
|
||||
chapterId: r.chapter_id,
|
||||
title: r.title,
|
||||
total: r.total,
|
||||
maxCompleted: r.max_completed,
|
||||
teamsFinished: r.teams_finished,
|
||||
})),
|
||||
teams: teams.rows
|
||||
.filter((r) => r.members)
|
||||
.map((r) => ({ teamId: r.team_id, name: r.name, members: r.members, completed: r.completed })),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/sources', async (): Promise<SourcesDto> => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT (SELECT max(received_at) FROM events WHERE source = 'push') AS last_push,
|
||||
(SELECT max(started_at) FROM server_info) AS last_start,
|
||||
(SELECT max(synced_at) FROM sync_state) AS last_sync,
|
||||
(SELECT message FROM app_errors WHERE source = 'sftp' AND at > now() - interval '1 day' ORDER BY at DESC LIMIT 1) AS sftp_error`,
|
||||
);
|
||||
const r = rows[0];
|
||||
const lastPush = ms(r.last_push);
|
||||
return {
|
||||
push: { lastEventAt: lastPush, lastServerStart: ms(r.last_start), reachable: lastPush !== null && Date.now() - lastPush < PUSH_FRESH_MS },
|
||||
sftp: { enabled: config.sftp !== null, lastSyncAt: ms(r.last_sync), lastError: r.sftp_error },
|
||||
limitations: LIMITATIONS,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
export const PLAYTIME_TICKS_PER_HOUR = 72_000;
|
||||
|
||||
export const ms = (d: Date | null | undefined): number | null => (d ? d.getTime() : null);
|
||||
export const hours = (ticks: number) => Math.round((ticks / PLAYTIME_TICKS_PER_HOUR) * 100) / 100;
|
||||
|
||||
/** Resolves uuid, current name or a former name (newest first) to a visible player's uuid. */
|
||||
export async function resolvePlayer(pool: Pool, idOrName: string): Promise<string | null> {
|
||||
if (UUID_RE.test(idOrName)) {
|
||||
const { rows } = await pool.query('SELECT uuid FROM players WHERE uuid = $1 AND NOT hidden', [idOrName]);
|
||||
return rows[0]?.uuid ?? null;
|
||||
}
|
||||
if (!/^[A-Za-z0-9_]{1,16}$/.test(idOrName)) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid FROM (
|
||||
SELECT uuid, 0 AS prio, last_seen AS seen FROM players WHERE lower(name) = lower($1)
|
||||
UNION ALL
|
||||
SELECT uuid, 1, seen_at FROM name_history WHERE lower(name) = lower($1)
|
||||
) m JOIN players p USING (uuid)
|
||||
WHERE NOT p.hidden
|
||||
ORDER BY prio, seen DESC NULLS LAST LIMIT 1`,
|
||||
[idOrName],
|
||||
);
|
||||
return rows[0]?.uuid ?? null;
|
||||
}
|
||||
|
||||
/** Quest IDs that count: all completed IDs, or only known quests once definitions exist. */
|
||||
export const QUEST_FILTER = `(NOT EXISTS (SELECT 1 FROM quest_defs) OR qp.quest_id IN (SELECT quest_id FROM quest_defs))`;
|
||||
|
||||
export const LIMITATIONS: string[] = [
|
||||
'Energieproduktion und -verbrauch von Maschinen (Mekanism, Powah …) werden nicht erfasst: Es gibt dafür keine verlässliche serverseitige Quelle ohne ungeprüfte Mod-Interna.',
|
||||
'Inhalte von AE2- und Refined-Storage-Netzwerken werden nicht ausgewertet.',
|
||||
'Mana, Zauber und Rituale (Ars Nouveau, Iron’s Spellbooks, Occultism) sind nicht verfügbar; Magie-Aktivität stammt aus hergestellten und benutzten Items.',
|
||||
'MineColonies-Kolonien werden nicht ausgewertet.',
|
||||
'Die Diamantenbank zählt abgebaute Diamanterze, nicht den Besitz.',
|
||||
'Quest-Fortschritt wird pro FTB-Team gespeichert: Mitglieder einer Party teilen sich ihren Fortschritt, wie im Spiel.',
|
||||
'Rund ein Drittel der ATM10-Quests hat keinen eigenen Titel (im Spiel heißen sie nach ihrem Aufgaben-Item). Sie zählen mit, erscheinen aber nicht namentlich.',
|
||||
'Zähler aus world/stats werden vom Server nur beim Speichern geschrieben; dazwischen liefert die Mod alle 5 Minuten Live-Werte.',
|
||||
'Todesorte werden aus Datenschutzgründen nicht gespeichert.',
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildApp } from './app.ts';
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createPool } from './db/pool.ts';
|
||||
import { migrate } from './db/migrate.ts';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createPool(config.DATABASE_URL);
|
||||
await migrate(pool);
|
||||
const app = await buildApp({ pool, config });
|
||||
|
||||
const shutdown = async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
await app.listen({ port: config.PORT, host: config.HOST });
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { BOARD_BY_ID, BOARDS, metricSql, toDisplay, type Board } from './metrics.ts';
|
||||
|
||||
export type Period = 'all' | '30d' | '7d';
|
||||
export const PERIOD_DAYS: Record<Exclude<Period, 'all'>, number> = { '30d': 30, '7d': 7 };
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
uuid: string;
|
||||
name: string | null;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value per visible player for a board. For a period the value is the growth since the period start:
|
||||
* current value minus the last daily snapshot at or before the start (or the earliest snapshot if none).
|
||||
*/
|
||||
function boardValuesSql(board: Board, period: Period): string {
|
||||
const cur = metricSql(board.metric);
|
||||
if (period === 'all') {
|
||||
return `SELECT c.uuid, c.value FROM (${cur}) c`;
|
||||
}
|
||||
return `SELECT c.uuid, c.value - COALESCE(
|
||||
(SELECT s.value FROM stat_snapshots s WHERE s.uuid = c.uuid AND s.metric = $1 AND s.granularity = 'day'
|
||||
AND s.bucket <= $2 ORDER BY s.bucket DESC LIMIT 1),
|
||||
(SELECT s.value FROM stat_snapshots s WHERE s.uuid = c.uuid AND s.metric = $1 AND s.granularity = 'day'
|
||||
ORDER BY s.bucket ASC LIMIT 1),
|
||||
0) AS value
|
||||
FROM (${cur}) c`;
|
||||
}
|
||||
|
||||
export async function getLeaderboard(
|
||||
pool: Pool,
|
||||
boardId: string,
|
||||
period: Period,
|
||||
limit = 10,
|
||||
now: Date = new Date(),
|
||||
): Promise<LeaderboardEntry[]> {
|
||||
const board = BOARD_BY_ID.get(boardId);
|
||||
if (!board) throw new Error(`unknown board ${boardId}`);
|
||||
const params: unknown[] = [];
|
||||
if (period !== 'all') params.push(board.id, new Date(now.getTime() - PERIOD_DAYS[period] * 86_400_000));
|
||||
params.push(limit);
|
||||
const { rows } = await pool.query<{ rank: number; uuid: string; name: string | null; value: number }>(
|
||||
`SELECT rank() OVER (ORDER BY v.value DESC)::int AS rank, v.uuid, p.name, v.value
|
||||
FROM (${boardValuesSql(board, period)}) v
|
||||
JOIN players p ON p.uuid = v.uuid AND NOT p.hidden
|
||||
WHERE v.value > 0
|
||||
ORDER BY v.value DESC, p.name ASC NULLS LAST
|
||||
LIMIT $${params.length}`,
|
||||
params,
|
||||
);
|
||||
return rows.map((r) => ({ ...r, value: toDisplay(board.unit, r.value) }));
|
||||
}
|
||||
|
||||
/** Rank and display value of one player on every board where the player has a positive value. */
|
||||
export async function getPlayerRanks(pool: Pool, uuid: string): Promise<Record<string, { rank: number; value: number }>> {
|
||||
const out: Record<string, { rank: number; value: number }> = {};
|
||||
for (const board of BOARDS) {
|
||||
const { rows } = await pool.query<{ rank: number; value: number }>(
|
||||
`SELECT rank, value FROM (
|
||||
SELECT v.uuid, v.value, rank() OVER (ORDER BY v.value DESC)::int AS rank
|
||||
FROM (${metricSql(board.metric)}) v JOIN players p ON p.uuid = v.uuid AND NOT p.hidden
|
||||
WHERE v.value > 0) r
|
||||
WHERE r.uuid = $1`,
|
||||
[uuid],
|
||||
);
|
||||
if (rows[0]) out[board.id] = { rank: rows[0].rank, value: toDisplay(board.unit, rows[0].value) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type MetricDef =
|
||||
| { kind: 'stat'; category: string; key: string }
|
||||
| { kind: 'sum'; categories: string[]; namespaces?: string[]; keySuffix?: string; excludeKeys?: string[] }
|
||||
| { kind: 'sumKeys'; category: string; keys: string[] }
|
||||
| { kind: 'quests' }
|
||||
| { kind: 'advancements' };
|
||||
|
||||
export type Unit = 'hours' | 'count' | 'km' | 'blocks';
|
||||
|
||||
export interface Board {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
unit: Unit;
|
||||
metric: MetricDef;
|
||||
}
|
||||
|
||||
export type ModCategories = Record<'tech' | 'magic' | 'farming', string[]>;
|
||||
|
||||
function loadCategories(): ModCategories {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
for (const p of [join(here, '..', '..', 'config', 'mod-categories.json'), join(here, '..', 'config', 'mod-categories.json')]) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as ModCategories;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
throw new Error('config/mod-categories.json not found');
|
||||
}
|
||||
|
||||
export const MOD_CATEGORIES: ModCategories = loadCategories();
|
||||
|
||||
const CUSTOM = 'minecraft:custom';
|
||||
const ACTIVITY = ['minecraft:crafted', 'minecraft:used'];
|
||||
|
||||
export const BOARDS: Board[] = [
|
||||
{ id: 'playtime', title: 'Dauerzocker', description: 'Meiste Spielzeit', unit: 'hours', metric: { kind: 'stat', category: CUSTOM, key: 'minecraft:play_time' } },
|
||||
{ id: 'deaths', title: 'Friedhof', description: 'Häufigste Tode', unit: 'count', metric: { kind: 'stat', category: CUSTOM, key: 'minecraft:deaths' } },
|
||||
{ id: 'mobkills', title: 'Monsterjäger', description: 'Meiste getötete Mobs', unit: 'count', metric: { kind: 'stat', category: CUSTOM, key: 'minecraft:mob_kills' } },
|
||||
{ id: 'mined', title: 'Abrissbirne', description: 'Meiste abgebaute Blöcke', unit: 'blocks', metric: { kind: 'sum', categories: ['minecraft:mined'] } },
|
||||
{
|
||||
id: 'distance',
|
||||
title: 'Weltenbummler',
|
||||
description: 'Weiteste zurückgelegte Strecke (ohne Fallen)',
|
||||
unit: 'km',
|
||||
metric: { kind: 'sum', categories: [CUSTOM], keySuffix: '_one_cm', excludeKeys: ['minecraft:fall_one_cm'] },
|
||||
},
|
||||
{ id: 'flight', title: 'Flieger', description: 'Weiteste Strecke im Flug', unit: 'km', metric: { kind: 'stat', category: CUSTOM, key: 'minecraft:fly_one_cm' } },
|
||||
{
|
||||
id: 'diamonds',
|
||||
title: 'Diamantenbank',
|
||||
description: 'Meiste abgebaute Diamanterze (Abbau, nicht Besitz)',
|
||||
unit: 'count',
|
||||
metric: { kind: 'sumKeys', category: 'minecraft:mined', keys: ['minecraft:diamond_ore', 'minecraft:deepslate_diamond_ore'] },
|
||||
},
|
||||
{ id: 'tech', title: 'Techniker', description: 'Hergestellte und benutzte Tech-Items', unit: 'count', metric: { kind: 'sum', categories: ACTIVITY, namespaces: MOD_CATEGORIES.tech } },
|
||||
{ id: 'magic', title: 'Magier', description: 'Hergestellte und benutzte Magie-Items', unit: 'count', metric: { kind: 'sum', categories: ACTIVITY, namespaces: MOD_CATEGORIES.magic } },
|
||||
{ id: 'farming', title: 'Farmer', description: 'Hergestellte und benutzte Farm-Items', unit: 'count', metric: { kind: 'sum', categories: ACTIVITY, namespaces: MOD_CATEGORIES.farming } },
|
||||
{ id: 'quests', title: 'Questmeister', description: 'Abgeschlossene FTB-Quests des Teams', unit: 'count', metric: { kind: 'quests' } },
|
||||
{ id: 'advancements', title: 'Abenteurer', description: 'Erreichte Advancements', unit: 'count', metric: { kind: 'advancements' } },
|
||||
{ id: 'loot', title: 'Schatzjäger', description: 'Geplünderte Lootr-Truhen', unit: 'count', metric: { kind: 'stat', category: CUSTOM, key: 'lootr:looted_stat' } },
|
||||
{ id: 'worldtier', title: 'Weltenstufe', description: 'Aktivierte Apotheosis-Weltenstufen', unit: 'count', metric: { kind: 'stat', category: CUSTOM, key: 'apotheosis:world_tiers_activated' } },
|
||||
];
|
||||
|
||||
export const BOARD_BY_ID = new Map(BOARDS.map((b) => [b.id, b]));
|
||||
|
||||
/** Board definitions are code constants, never user input; literals are still escaped. */
|
||||
const lit = (s: string) => `'${s.replace(/'/g, "''")}'`;
|
||||
const list = (xs: string[]) => xs.map(lit).join(', ');
|
||||
|
||||
/** SQL yielding `(uuid, value)` with the raw metric value for every player that has data. */
|
||||
export function metricSql(def: MetricDef): string {
|
||||
switch (def.kind) {
|
||||
case 'stat':
|
||||
return `SELECT uuid, value FROM stat_current WHERE category = ${lit(def.category)} AND key = ${lit(def.key)}`;
|
||||
case 'sumKeys':
|
||||
return `SELECT uuid, sum(value) AS value FROM stat_current
|
||||
WHERE category = ${lit(def.category)} AND key IN (${list(def.keys)}) GROUP BY uuid`;
|
||||
case 'sum': {
|
||||
const where = [`category IN (${list(def.categories)})`];
|
||||
if (def.namespaces?.length) where.push(`split_part(key, ':', 1) IN (${list(def.namespaces)})`);
|
||||
if (def.keySuffix) where.push(`key LIKE ${lit(`%${def.keySuffix}`)}`);
|
||||
if (def.excludeKeys?.length) where.push(`key NOT IN (${list(def.excludeKeys)})`);
|
||||
return `SELECT uuid, sum(value) AS value FROM stat_current WHERE ${where.join(' AND ')} GROUP BY uuid`;
|
||||
}
|
||||
case 'quests':
|
||||
// FTB stores progress per team; players without team membership form a solo team with their own uuid.
|
||||
// The `completed` map also lists chapters, so once quest definitions are known only real quests count.
|
||||
return `SELECT p.uuid, count(DISTINCT qp.quest_id) AS value
|
||||
FROM players p
|
||||
LEFT JOIN team_members tm ON tm.uuid = p.uuid
|
||||
JOIN quest_progress qp ON qp.team_id = COALESCE(tm.team_id, p.uuid)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM quest_defs) OR qp.quest_id IN (SELECT quest_id FROM quest_defs)
|
||||
GROUP BY p.uuid`;
|
||||
case 'advancements':
|
||||
return `SELECT uuid, count(*) AS value FROM advancements GROUP BY uuid`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw counter → display value for the board's unit. */
|
||||
export function toDisplay(unit: Unit, raw: number): number {
|
||||
switch (unit) {
|
||||
case 'hours':
|
||||
return Math.round((raw / 72_000) * 100) / 100;
|
||||
case 'km':
|
||||
return Math.round((raw / 100_000) * 100) / 100;
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { BOARDS, metricSql } from './metrics.ts';
|
||||
|
||||
export const HOURLY_RETENTION_DAYS = 30;
|
||||
export const HEALTH_MINUTE_RETENTION_DAYS = 14;
|
||||
|
||||
/** Stores every board's raw value per player for the hour/day bucket containing `at`. Re-running overwrites. */
|
||||
export async function takeSnapshots(pool: Pool, granularity: 'hour' | 'day', at: Date): Promise<number> {
|
||||
let written = 0;
|
||||
for (const board of BOARDS) {
|
||||
const { rowCount } = await pool.query(
|
||||
`INSERT INTO stat_snapshots(uuid, metric, bucket, granularity, value)
|
||||
SELECT v.uuid, $1, date_trunc($2, $3::timestamptz), $2, v.value FROM (${metricSql(board.metric)}) v
|
||||
ON CONFLICT (uuid, metric, granularity, bucket) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[board.id, granularity, at],
|
||||
);
|
||||
written += rowCount ?? 0;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
export const BULKY_EVENT_RETENTION_DAYS = 30;
|
||||
/** Already projected, large or frequent; their content lives on in stat_current, server_health and file_cache. */
|
||||
const BULKY_EVENT_TYPES = ['health', 'stats_snapshot', 'file_sync'];
|
||||
|
||||
export async function applyRetention(pool: Pool, now: Date): Promise<void> {
|
||||
await pool.query(`DELETE FROM stat_snapshots WHERE granularity = 'hour' AND bucket < $1`, [
|
||||
new Date(now.getTime() - HOURLY_RETENTION_DAYS * 86_400_000),
|
||||
]);
|
||||
// Keep the raw log of player events (joins, deaths, advancements …); drop bulky ones after 30 days.
|
||||
// Re-delivery after that window is not expected (the mod's outbox expires after 7 days).
|
||||
await pool.query(`DELETE FROM events WHERE type = ANY($1::text[]) AND received_at < $2`, [
|
||||
BULKY_EVENT_TYPES,
|
||||
new Date(now.getTime() - BULKY_EVENT_RETENTION_DAYS * 86_400_000),
|
||||
]);
|
||||
const cutoff = new Date(now.getTime() - HEALTH_MINUTE_RETENTION_DAYS * 86_400_000);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`INSERT INTO server_health_hourly(bucket, mspt_avg, mspt_p95, mspt_max, tps, players, chunks, heap_used_mb, heap_max_mb, samples)
|
||||
SELECT date_trunc('hour', bucket), avg(mspt_avg), max(mspt_p95), max(mspt_max), avg(tps), max(players), max(chunks),
|
||||
avg(heap_used_mb), max(heap_max_mb), count(*)
|
||||
FROM server_health WHERE bucket < $1 GROUP BY 1
|
||||
ON CONFLICT (bucket) DO UPDATE SET
|
||||
mspt_avg = (server_health_hourly.mspt_avg * server_health_hourly.samples + EXCLUDED.mspt_avg * EXCLUDED.samples)
|
||||
/ (server_health_hourly.samples + EXCLUDED.samples),
|
||||
mspt_p95 = GREATEST(server_health_hourly.mspt_p95, EXCLUDED.mspt_p95),
|
||||
mspt_max = GREATEST(server_health_hourly.mspt_max, EXCLUDED.mspt_max),
|
||||
tps = (server_health_hourly.tps * server_health_hourly.samples + EXCLUDED.tps * EXCLUDED.samples)
|
||||
/ (server_health_hourly.samples + EXCLUDED.samples),
|
||||
players = GREATEST(server_health_hourly.players, EXCLUDED.players),
|
||||
chunks = GREATEST(server_health_hourly.chunks, EXCLUDED.chunks),
|
||||
heap_used_mb = EXCLUDED.heap_used_mb,
|
||||
heap_max_mb = GREATEST(server_health_hourly.heap_max_mb, EXCLUDED.heap_max_mb),
|
||||
samples = server_health_hourly.samples + EXCLUDED.samples`,
|
||||
[cutoff],
|
||||
);
|
||||
await client.query('DELETE FROM server_health WHERE bucket < $1', [cutoff]);
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import pino from 'pino';
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createPool } from './db/pool.ts';
|
||||
import { migrate } from './db/migrate.ts';
|
||||
import { installLiveNotifier } from './live/notifier.ts';
|
||||
import './pipeline/projections/index.ts';
|
||||
import { applyRetention, takeSnapshots } from './stats/snapshots.ts';
|
||||
import { SftpFileSource } from './worker/fileSource.ts';
|
||||
import { LOCKS, runJob } from './worker/scheduler.ts';
|
||||
import { closeStaleSessions, syncOnce } from './worker/sync.ts';
|
||||
|
||||
const TICK_MS = 30_000;
|
||||
|
||||
const config = loadConfig();
|
||||
const log = pino({ level: config.LOG_LEVEL });
|
||||
const pool = createPool(config.DATABASE_URL, 4);
|
||||
await migrate(pool);
|
||||
installLiveNotifier(pool);
|
||||
|
||||
let lastSftp = 0;
|
||||
let lastHour = '';
|
||||
let lastRetentionDay = '';
|
||||
let stopping = false;
|
||||
|
||||
async function sftpDue(now: number): Promise<{ due: boolean; requestIds: number[] }> {
|
||||
if (!config.sftp) return { due: false, requestIds: [] };
|
||||
const { rows } = await pool.query<{ id: number }>('SELECT id FROM sync_requests WHERE done_at IS NULL');
|
||||
const requestIds = rows.map((r) => r.id);
|
||||
return { due: requestIds.length > 0 || now - lastSftp >= config.SFTP_INTERVAL_MIN * 60_000, requestIds };
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
const now = new Date();
|
||||
|
||||
const sftp = await sftpDue(now.getTime());
|
||||
if (sftp.due && config.sftp) {
|
||||
lastSftp = now.getTime();
|
||||
const src = new SftpFileSource(config.sftp);
|
||||
const result = await runJob(pool, 'sftp', LOCKS.sftp, async () => {
|
||||
try {
|
||||
const r = await syncOnce(pool, src, { timeZone: config.TIMEZONE, serverId: config.SERVER_ID, worldDir: config.SFTP_WORLD_DIR });
|
||||
log.info(r, 'sftp sync done');
|
||||
} finally {
|
||||
await src.close();
|
||||
}
|
||||
});
|
||||
if (result === 'error') log.warn('sftp sync failed, see admin status');
|
||||
if (sftp.requestIds.length) {
|
||||
await pool.query('UPDATE sync_requests SET done_at = now() WHERE id = ANY($1::bigint[])', [sftp.requestIds]);
|
||||
}
|
||||
}
|
||||
|
||||
// Hourly and daily snapshots are idempotent per bucket; the daily bucket ends up holding the day's last value.
|
||||
const hour = now.toISOString().slice(0, 13);
|
||||
if (hour !== lastHour) {
|
||||
lastHour = hour;
|
||||
await runJob(pool, 'snapshots', LOCKS.snapshots, async () => {
|
||||
await takeSnapshots(pool, 'hour', now);
|
||||
await takeSnapshots(pool, 'day', now);
|
||||
});
|
||||
}
|
||||
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
if (day !== lastRetentionDay && now.getUTCHours() >= 3) {
|
||||
lastRetentionDay = day;
|
||||
await runJob(pool, 'retention', LOCKS.retention, () => applyRetention(pool, now));
|
||||
}
|
||||
|
||||
await runJob(pool, 'staleSessions', LOCKS.staleSessions, async () => {
|
||||
const closed = await closeStaleSessions(pool, now);
|
||||
if (closed) log.info({ closed }, 'closed stale sessions');
|
||||
});
|
||||
}
|
||||
|
||||
async function loop() {
|
||||
log.info({ sftp: config.sftp ? `${config.sftp.host}:${config.sftp.port}` : 'disabled' }, 'worker started');
|
||||
while (!stopping) {
|
||||
try {
|
||||
await tick();
|
||||
} catch (err) {
|
||||
log.error({ err }, 'worker tick failed');
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, TICK_MS));
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
stopping = true;
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
};
|
||||
process.on('SIGTERM', stop);
|
||||
process.on('SIGINT', stop);
|
||||
|
||||
await loop();
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { join, posix } from 'node:path';
|
||||
import SftpClient from 'ssh2-sftp-client';
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
/** Read-only view of the game server's directory tree. Paths are relative and '/'-separated. */
|
||||
export interface FileSource {
|
||||
list(dir: string): Promise<FileEntry[]>;
|
||||
/** Reads `length` bytes from `start` (default: to end of file). */
|
||||
read(path: string, start?: number, length?: number): Promise<Buffer>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function collect(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export class LocalFileSource implements FileSource {
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
async list(dir: string): Promise<FileEntry[]> {
|
||||
const abs = join(this.root, dir);
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(abs);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return Promise.all(
|
||||
names.map(async (name) => {
|
||||
const s = await stat(join(abs, name));
|
||||
return { name, size: s.size, mtimeMs: Math.floor(s.mtimeMs), isDir: s.isDirectory() };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async read(path: string, start = 0, length?: number): Promise<Buffer> {
|
||||
const end = length !== undefined ? start + length - 1 : undefined;
|
||||
return collect(createReadStream(join(this.root, path), { start, end }));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
export interface SftpOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
root: string;
|
||||
}
|
||||
|
||||
/** SFTP access to the Mine-Hoster server; one connection per sync run. */
|
||||
export class SftpFileSource implements FileSource {
|
||||
private client = new SftpClient('atm-stats');
|
||||
private connected: Promise<unknown> | null = null;
|
||||
|
||||
constructor(private readonly opts: SftpOptions) {}
|
||||
|
||||
private abs(p: string) {
|
||||
return posix.join(this.opts.root || '/', p);
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.connected ??= this.client.connect({
|
||||
host: this.opts.host,
|
||||
port: this.opts.port,
|
||||
username: this.opts.username,
|
||||
password: this.opts.password,
|
||||
readyTimeout: 20_000,
|
||||
retries: 1,
|
||||
});
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
async list(dir: string): Promise<FileEntry[]> {
|
||||
await this.connect();
|
||||
const path = this.abs(dir);
|
||||
if (!(await this.client.exists(path))) return [];
|
||||
const entries = await this.client.list(path);
|
||||
return entries.map((e) => ({ name: e.name, size: e.size, mtimeMs: e.modifyTime, isDir: e.type === 'd' }));
|
||||
}
|
||||
|
||||
async read(path: string, start = 0, length?: number): Promise<Buffer> {
|
||||
await this.connect();
|
||||
const end = length !== undefined ? start + length - 1 : undefined;
|
||||
const stream = this.client.createReadStream(this.abs(path), { start, end });
|
||||
return collect(stream);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.connected) await this.client.end().catch(() => {});
|
||||
this.connected = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export type JobResult = 'ok' | 'locked' | 'error';
|
||||
|
||||
/**
|
||||
* Runs `fn` while holding a session-level advisory lock, so a job never runs twice at once
|
||||
* (e.g. two worker containers). Failures are logged to app_errors with the job name as source.
|
||||
*/
|
||||
export async function runJob(pool: Pool, name: string, lockId: number, fn: () => Promise<unknown>): Promise<JobResult> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const { rows } = await client.query<{ locked: boolean }>('SELECT pg_try_advisory_lock($1) AS locked', [lockId]);
|
||||
if (!rows[0]!.locked) return 'locked';
|
||||
try {
|
||||
await fn();
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
await pool
|
||||
.query('INSERT INTO app_errors(source, message) VALUES ($1, $2)', [name, String((err as Error).message ?? err).slice(0, 2000)])
|
||||
.catch(() => {});
|
||||
return 'error';
|
||||
} finally {
|
||||
await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export const LOCKS = {
|
||||
sftp: 820_001,
|
||||
snapshots: 820_002,
|
||||
retention: 820_003,
|
||||
staleSessions: 820_004,
|
||||
} as const;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { deterministicId, type AtmEvent } from '@atm/shared';
|
||||
import type { Pool } from 'pg';
|
||||
import { isQuestConfigFile } from '../parsers/ftbquests.ts';
|
||||
import { parseLogChunk } from '../parsers/log.ts';
|
||||
import { advancementsFileToEvents, statsFileToEvent } from '../parsers/vanilla.ts';
|
||||
import { processEvents } from '../pipeline/process.ts';
|
||||
import type { FileEntry, FileSource } from './fileSource.ts';
|
||||
|
||||
export interface SyncOptions {
|
||||
timeZone: string;
|
||||
serverId: string;
|
||||
worldDir: string;
|
||||
}
|
||||
|
||||
const UUID_FILE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(json|snbt)$/i;
|
||||
const PUSH_FRESH_MS = 10 * 60_000;
|
||||
const LOG_PATH = 'logs/latest.log';
|
||||
const LOG_HEAD_BYTES = 256;
|
||||
const MAX_FILE_BYTES = 1_500_000;
|
||||
const BATCH = 200;
|
||||
|
||||
const sha256 = (b: Buffer | string) => createHash('sha256').update(b).digest('hex');
|
||||
|
||||
interface State {
|
||||
size: number;
|
||||
mtime: number;
|
||||
offset: number;
|
||||
hash: string | null;
|
||||
}
|
||||
|
||||
async function listRecursive(src: FileSource, dir: string): Promise<(FileEntry & { path: string })[]> {
|
||||
const out: (FileEntry & { path: string })[] = [];
|
||||
for (const e of await src.list(dir)) {
|
||||
const path = `${dir}/${e.name}`;
|
||||
if (e.isDir) out.push(...(await listRecursive(src, path)));
|
||||
else out.push({ ...e, path });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls changed files from the game server and feeds them through the same pipeline as the mod's pushes.
|
||||
* Unchanged files (same size and mtime) are not read; latest.log is read incrementally from the stored offset.
|
||||
*/
|
||||
export async function syncOnce(pool: Pool, src: FileSource, opts: SyncOptions): Promise<{ files: number; events: number }> {
|
||||
const stateRows = await pool.query<State & { path: string }>('SELECT path, size, mtime, "offset", hash FROM sync_state');
|
||||
const state = new Map(stateRows.rows.map((r) => [r.path, r as State]));
|
||||
const changed = (path: string, e: FileEntry) => {
|
||||
const s = state.get(path);
|
||||
return !s || s.size !== e.size || s.mtime !== e.mtimeMs;
|
||||
};
|
||||
const updates: { path: string; size: number; mtime: number; offset: number; hash: string | null }[] = [];
|
||||
const events: AtmEvent[] = [];
|
||||
const w = opts.worldDir;
|
||||
// Players whose advancements come from world/advancements: their log advancements would be counted twice.
|
||||
const withAdvancementFile = new Set<string>();
|
||||
|
||||
// Vanilla stats and advancements: one JSON per player uuid.
|
||||
for (const [dir, convert] of [
|
||||
[`${w}/stats`, (uuid: string, text: string, e: FileEntry) => [statsFileToEvent(uuid, text, e.mtimeMs)]],
|
||||
[`${w}/advancements`, (uuid: string, text: string) => advancementsFileToEvents(uuid, text)],
|
||||
] as const) {
|
||||
for (const e of await src.list(dir)) {
|
||||
const m = UUID_FILE.exec(e.name);
|
||||
const path = `${dir}/${e.name}`;
|
||||
if (m && dir.endsWith('/advancements')) withAdvancementFile.add(m[1]!.toLowerCase());
|
||||
if (!m || e.isDir || !changed(path, e)) continue;
|
||||
const text = (await src.read(path)).toString('utf8');
|
||||
try {
|
||||
events.push(...convert(m[1]!.toLowerCase(), text, e));
|
||||
} catch (err) {
|
||||
await pool.query('INSERT INTO app_errors(source, message) VALUES ($1, $2)', ['sftp', `${path}: ${(err as Error).message}`]);
|
||||
}
|
||||
updates.push({ path, size: e.size, mtime: e.mtimeMs, offset: 0, hash: null });
|
||||
}
|
||||
}
|
||||
|
||||
// FTB files are forwarded verbatim as file_sync, exactly like the mod does.
|
||||
// Quest config: only chapters and the English lang files (other languages are ~35 MB and unused).
|
||||
const ftbFiles = [
|
||||
...(await listRecursive(src, `${w}/ftbquests`)).filter((f) => f.name.endsWith('.snbt')),
|
||||
...(await listRecursive(src, `${w}/ftbteams`)).filter((f) => f.name.endsWith('.snbt')),
|
||||
...(await listRecursive(src, 'config/ftbquests/quests/chapters')),
|
||||
...(await listRecursive(src, 'config/ftbquests/quests/lang/en_us')),
|
||||
].filter((f) => !f.path.startsWith('config/') || isQuestConfigFile(f.path));
|
||||
for (const f of ftbFiles) {
|
||||
if (!changed(f.path, f) || f.size > MAX_FILE_BYTES) continue;
|
||||
const content = (await src.read(f.path)).toString('utf8');
|
||||
const hash = sha256(content);
|
||||
// The pipeline expects world-relative paths to start with "world/"; keep that contract for custom world dirs.
|
||||
const path = f.path.startsWith(`${w}/`) ? `world/${f.path.slice(w.length + 1)}` : f.path;
|
||||
events.push({ id: deterministicId(`file:${path}:${hash}`), type: 'file_sync', ts: f.mtimeMs, path, sha256: hash, content });
|
||||
updates.push({ path: f.path, size: f.size, mtime: f.mtimeMs, offset: 0, hash });
|
||||
}
|
||||
|
||||
// latest.log, incrementally; a different head means the file was rotated.
|
||||
const log = (await src.list('logs')).find((e) => e.name === 'latest.log');
|
||||
if (log) {
|
||||
const prev = state.get(LOG_PATH);
|
||||
const head = sha256(await src.read(LOG_PATH, 0, LOG_HEAD_BYTES));
|
||||
let offset = prev && prev.hash === head && log.size >= prev.offset ? prev.offset : 0;
|
||||
if (log.size > offset) {
|
||||
const chunk = await src.read(LOG_PATH, offset);
|
||||
const names = new Map(
|
||||
(await pool.query<{ name: string; uuid: string }>('SELECT name, uuid FROM players WHERE name IS NOT NULL')).rows.map((r) => [
|
||||
r.name,
|
||||
r.uuid,
|
||||
]),
|
||||
);
|
||||
const parsed = parseLogChunk(chunk, offset, names, opts.timeZone);
|
||||
events.push(...(await filterLogEvents(pool, parsed.events, withAdvancementFile)));
|
||||
offset += parsed.consumed;
|
||||
}
|
||||
if (!prev || prev.offset !== offset || prev.hash !== head || prev.size !== log.size) {
|
||||
updates.push({ path: LOG_PATH, size: log.size, mtime: log.mtimeMs, offset, hash: head });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < events.length; i += BATCH) {
|
||||
await processEvents(pool, events.slice(i, i + BATCH), 'sftp', opts.serverId);
|
||||
}
|
||||
for (const u of updates) {
|
||||
await pool.query(
|
||||
`INSERT INTO sync_state(path, size, mtime, "offset", hash, synced_at) VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (path) DO UPDATE SET size = $2, mtime = $3, "offset" = $4, hash = $5, synced_at = now()`,
|
||||
[u.path, u.size, u.mtime, u.offset, u.hash],
|
||||
);
|
||||
}
|
||||
// Marks the time of the last successful run for the status pages, even when no file changed.
|
||||
await pool.query(`INSERT INTO sync_state(path, size, mtime) VALUES ('__last_run__', 0, 0)
|
||||
ON CONFLICT (path) DO UPDATE SET synced_at = now()`);
|
||||
return { files: updates.length, events: events.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-derived events are a fallback: sessions only while the mod is not pushing (otherwise they would be
|
||||
* counted twice), advancements only for players without a synced advancements file (log titles cannot be
|
||||
* mapped to advancement ids) and only if the player has none with the same title yet.
|
||||
*/
|
||||
async function filterLogEvents(pool: Pool, events: AtmEvent[], withAdvancementFile: Set<string>): Promise<AtmEvent[]> {
|
||||
const { rows } = await pool.query<{ fresh: boolean }>(
|
||||
`SELECT COALESCE(max(received_at) > now() - make_interval(secs => $1), false) AS fresh FROM events WHERE source = 'push'`,
|
||||
[PUSH_FRESH_MS / 1000],
|
||||
);
|
||||
const pushFresh = rows[0]!.fresh;
|
||||
const titled = await pool.query<{ uuid: string; title: string }>('SELECT uuid, title FROM advancements WHERE title IS NOT NULL');
|
||||
const known = new Set(titled.rows.map((r) => `${r.uuid}|${r.title}`));
|
||||
return events.filter((e) => {
|
||||
if (e.type === 'player_join' || e.type === 'player_leave') return !pushFresh;
|
||||
if (e.type === 'advancement') return !withAdvancementFile.has(e.uuid) && !known.has(`${e.uuid}|${e.title}`);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the mod reported health before but has been silent for 10 minutes, the server (or the mod) is down:
|
||||
* open sessions that began while the mod was still reporting end at the last health window. Sessions started
|
||||
* later come from the log fallback and stay open. Does nothing when push was never used.
|
||||
*/
|
||||
export async function closeStaleSessions(pool: Pool, now: Date): Promise<number> {
|
||||
const { rowCount } = await pool.query(
|
||||
`WITH last AS (
|
||||
SELECT to_timestamp((payload->>'windowEnd')::bigint / 1000.0) AS at FROM events
|
||||
WHERE type = 'health' ORDER BY ts DESC LIMIT 1)
|
||||
UPDATE sessions s SET ended_at = GREATEST(s.started_at, last.at), end_reason = 'stale'
|
||||
FROM last
|
||||
WHERE s.ended_at IS NULL AND s.started_at <= last.at AND last.at < $1::timestamptz - interval '10 minutes'`,
|
||||
[now],
|
||||
);
|
||||
return rowCount ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createAdmin } from '../src/auth/admin.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, ev } from './helpers/events.ts';
|
||||
import { signedRequest } from './helpers/sign.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app, token } = await makeApp(pool));
|
||||
await createAdmin(pool, 'lucas', 'correct horse battery staple');
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE })], 'push', 'atm10');
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
async function login(password = 'correct horse battery staple') {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/admin/login', payload: { username: 'lucas', password } });
|
||||
const cookie = res.cookies.find((c) => c.name === 'atm_admin');
|
||||
return { res, cookie: cookie ? `atm_admin=${cookie.value}` : '', csrf: res.statusCode === 200 ? res.json().csrf : '' };
|
||||
}
|
||||
|
||||
describe('admin', () => {
|
||||
it('login with wrong password 401', async () => {
|
||||
const { res } = await login('wrong');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('login sets an httpOnly strict cookie and the session endpoint works', async () => {
|
||||
const { res, cookie, csrf } = await login();
|
||||
expect(res.statusCode).toBe(200);
|
||||
const raw = res.cookies.find((c) => c.name === 'atm_admin')!;
|
||||
expect(raw.httpOnly).toBe(true);
|
||||
expect(raw.sameSite).toBe('Strict');
|
||||
const s = await app.inject({ method: 'GET', url: '/api/admin/session', headers: { cookie } });
|
||||
expect(s.json()).toEqual({ username: 'lucas', csrf });
|
||||
});
|
||||
|
||||
it('admin routes require a session', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/api/admin/status' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('mutating route without csrf 403', async () => {
|
||||
const { cookie } = await login();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/admin/players/${ALICE.uuid}/hidden`,
|
||||
headers: { cookie },
|
||||
payload: { hidden: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('hide player then profile 404, unhide restores it', async () => {
|
||||
const { cookie, csrf } = await login();
|
||||
const hide = (hidden: boolean) =>
|
||||
app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/admin/players/${ALICE.uuid}/hidden`,
|
||||
headers: { cookie, 'x-csrf-token': csrf },
|
||||
payload: { hidden },
|
||||
});
|
||||
expect((await hide(true)).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'GET', url: `/api/players/${ALICE.uuid}` })).statusCode).toBe(404);
|
||||
const list = await app.inject({ method: 'GET', url: '/api/admin/players', headers: { cookie } });
|
||||
expect(list.json().players[0]).toMatchObject({ uuid: ALICE.uuid, hidden: true });
|
||||
await hide(false);
|
||||
expect((await app.inject({ method: 'GET', url: `/api/players/${ALICE.uuid}` })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('rotate token invalidates old token for ingest', async () => {
|
||||
const { cookie, csrf } = await login();
|
||||
const rot = await app.inject({ method: 'POST', url: '/api/admin/ingest-token/rotate', headers: { cookie, 'x-csrf-token': csrf } });
|
||||
expect(rot.statusCode).toBe(200);
|
||||
const fresh = rot.json().token as string;
|
||||
const send = (t: string) => {
|
||||
const { headers, payload } = signedRequest({ protocol: 1, events: [ev('server_stop', {})] }, { token: t });
|
||||
return app.inject({ method: 'POST', url: '/api/ingest', headers, payload });
|
||||
};
|
||||
expect((await send(token)).statusCode).toBe(401);
|
||||
expect((await send(fresh)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('status lists errors and a sync request is queued', async () => {
|
||||
const { cookie, csrf } = await login();
|
||||
await pool.query(`INSERT INTO app_errors(source, message) VALUES ('sftp', 'connection refused')`);
|
||||
const status = await app.inject({ method: 'GET', url: '/api/admin/status', headers: { cookie } });
|
||||
expect(status.json().lastErrors[0]).toMatchObject({ source: 'sftp', message: 'connection refused' });
|
||||
const sync = await app.inject({ method: 'POST', url: '/api/admin/sync', headers: { cookie, 'x-csrf-token': csrf } });
|
||||
expect(sync.statusCode).toBe(202);
|
||||
expect((await pool.query('SELECT count(*)::int AS n FROM sync_requests WHERE done_at IS NULL')).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it('logout ends the session', async () => {
|
||||
const { cookie, csrf } = await login();
|
||||
await app.inject({ method: 'POST', url: '/api/admin/logout', headers: { cookie, 'x-csrf-token': csrf } });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/admin/status', headers: { cookie } })).statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { loadConfig } from '../src/config.ts';
|
||||
import { migrate } from '../src/db/migrate.ts';
|
||||
import { closeTestPool, getTestPool } from './helpers/db.ts';
|
||||
|
||||
afterAll(closeTestPool);
|
||||
|
||||
describe('database', () => {
|
||||
it('migrate is idempotent', async () => {
|
||||
const pool = await getTestPool();
|
||||
await migrate(pool);
|
||||
await migrate(pool);
|
||||
const { rows } = await pool.query(`SELECT to_regclass('public.events') AS t`);
|
||||
expect(rows[0].t).toBe('events');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
it('loadConfig rejects missing DATABASE_URL', () => {
|
||||
expect(() => loadConfig({ INGEST_HMAC_SECRET: 'x'.repeat(32) })).toThrow(/DATABASE_URL/);
|
||||
});
|
||||
|
||||
it('loadConfig applies defaults', () => {
|
||||
const c = loadConfig({ DATABASE_URL: 'postgres://x', INGEST_HMAC_SECRET: 'x'.repeat(32) });
|
||||
expect(c.PORT).toBe(3001);
|
||||
expect(c.SFTP_INTERVAL_MIN).toBe(15);
|
||||
expect(c.sftp).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"minecraft:recipes/misc/charcoal": {
|
||||
"criteria": {
|
||||
"has_log": "2026-10-09 18:10:02 +0200"
|
||||
},
|
||||
"done": true
|
||||
},
|
||||
"minecraft:story/root": {
|
||||
"criteria": {
|
||||
"crafting_table": "2026-10-09 18:11:40 +0200"
|
||||
},
|
||||
"done": true
|
||||
},
|
||||
"minecraft:story/enter_the_nether": {
|
||||
"criteria": {
|
||||
"entered_nether": "2026-10-12 12:42:23 +0200"
|
||||
},
|
||||
"done": true
|
||||
},
|
||||
"minecraft:adventure/adventuring_time": {
|
||||
"criteria": {
|
||||
"minecraft:plains": "2026-10-09 18:20:00 +0200"
|
||||
},
|
||||
"done": false
|
||||
},
|
||||
"DataVersion": 3955
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
default_hide_dependency_lines: false
|
||||
filename: "getting_started"
|
||||
group: ""
|
||||
icon: "minecraft:oak_sapling"
|
||||
id: "1A2B3C4D5E6F7081"
|
||||
order_index: 0
|
||||
quests: [
|
||||
{
|
||||
id: "3BC0A50886A3222B"
|
||||
tasks: [{
|
||||
id: "7F00000000000001"
|
||||
item: "minecraft:oak_log"
|
||||
type: "item"
|
||||
}]
|
||||
x: 0.0d
|
||||
y: 0.0d
|
||||
}
|
||||
{
|
||||
id: "33218EFDD498E9DB"
|
||||
title: "Inline Title"
|
||||
x: 1.5d
|
||||
y: 0.0d
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
chapter.1A2B3C4D5E6F7081.title: "Getting Started"
|
||||
chapter.1A2B3C4D5E6F7081.chapter_subtitle: ["A handwritten test chapter"]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
quest.3BC0A50886A3222B.title: "Punch a Tree"
|
||||
quest.3BC0A50886A3222B.quest_desc: ["Line one", "Line two"]
|
||||
quest.33218EFDD498E9DB.title: "From Lang"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
id: "5e0c1a2b-0000-4000-8000-00000000abcd"
|
||||
type: "party"
|
||||
ranks: {
|
||||
308a693c-2bd8-4d6b-8790-670aef5be601: "owner"
|
||||
c25d9ff0-dba3-48f0-b414-fad2b63473a9: "member"
|
||||
0b7f3a6e-1111-4222-8333-444455556666: "invited"
|
||||
7d1e2f3a-aaaa-4bbb-8ccc-dddddddddddd: "ally"
|
||||
}
|
||||
properties: {
|
||||
"ftbteams:display_name": "Die Techniker"
|
||||
"ftbteams:free_to_join": 0b
|
||||
}
|
||||
message_history: [ ]
|
||||
extra: { }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
id: "308a693c-2bd8-4d6b-8790-670aef5be601"
|
||||
type: "player"
|
||||
player_name: "CDOGamer"
|
||||
ranks: {
|
||||
308a693c-2bd8-4d6b-8790-670aef5be601: "owner"
|
||||
}
|
||||
properties: {
|
||||
"ftbteams:display_name": "CDOGamer"
|
||||
"ftbteams:description": ""
|
||||
"ftbteams:color": "#FF6A59"
|
||||
"ftbteams:free_to_join": 0b
|
||||
}
|
||||
message_history: [ ]
|
||||
extra: { }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"stats":{"minecraft:custom":{"minecraft:interact_with_crafting_table":375,"minecraft:open_barrel":22,"minecraft:leave_game":11,"minecraft:time_since_death":93480,"minecraft:play_time":1175736,"minecraft:deaths":8,"minecraft:jump":8719,"minecraft:interact_with_furnace":85,"minecraft:interact_with_cartography_table":1,"minecraft:sleep_in_bed":7,"minecraft:damage_dealt":18793,"waystones:waystone_activated":1,"minecraft:damage_dealt_absorbed":121,"minecraft:sneak_time":21567,"minecraft:total_world_time":1175736,"apotheosis:world_tiers_activated":1,"minecraft:time_since_rest":93509,"minecraft:undead_killed":57,"minecraft:swim_one_cm":203284,"minecraft:fly_one_cm":3233729,"minecraft:open_chest":1929,"minecraft:fall_one_cm":524999,"lootr:looted_stat":67,"minecraft:rite_of_silent_bond":2,"minecraft:damage_resisted":0,"minecraft:sprint_one_cm":1967504,"minecraft:drop":70,"minecraft:talked_to_villager":6,"minecraft:walk_on_water_one_cm":51311,"minecraft:crouch_one_cm":75087,"minecraft:mob_kills":100,"minecraft:damage_dealt_resisted":136,"minecraft:climb_one_cm":5945,"minecraft:walk_one_cm":1890607,"minecraft:walk_under_water_one_cm":26190,"minecraft:boat_one_cm":622649,"minecraft:damage_taken":7023},"minecraft:killed":{"minecraft:cave_spider":2,"minecraft:skeleton":12,"minecraft:zombie":32,"minecraft:enderman":2,"minecraft:spider":16,"minecraft:silverfish":5,"irons_spellbooks:summoned_skeleton":1,"artifacts:mimic":2,"minecraft:zombie_villager":5,"irons_spellbooks:necromancer":2,"minecraft:creeper":2,"irons_spellbooks:summoned_zombie":10,"auroral:auroral_nautilus":2,"minecraft:drowned":2},"minecraft:picked_up":{"silentgear:raw_crimson_iron":8,"herbsandharvest:cauliflower_seeds":16,"minecraft:diamond_sword":2,"herbsandharvest:pineapple":7,"minecraft:enchanted_book":1,"herbsandharvest:turnip_seeds":25,"oritech:raw_uranium":14,"mysticalagriculture:prosperity_seed_base":2,"minecraft:amethyst_block":1,"minecraft:string":11,"minecraft:cobbled_deepslate":1929,"mysticalagriculture:inferium_essence":3143,"minecraft:gold_nugget":31,"mekanism:basic_universal_cable":2,"minecraft:spruce_planks":95,"mekanism:basic_logistical_transporter":5,"rootsclassic:verdant_sprig":1,"mysticalagriculture:prosperity_shard":709,"silentgems:aquamarine":10,"mysticalagriculture:inferium_seeds":47,"minecraft:black_dye":3,"create:scoria":2,"farmersdelight:onion":1,"mysticalagradditions:supremium_apple":2,"minecraft:blue_dye":3,"alltheores:raw_iridium":3,"supplementaries:ash":63,"minecraft:iron_bars":2,"minecraft:flint":8,"mysticalagriculture:netherite_essence":1,"minecraft:map":1,"silentgems:opal":5,"artifacts:shock_pendant":1,"bhc:red_heart":14,"sushigocrafting:wasabi_seeds":3,"theurgy:sal_ammoniac_crystal":36,"herbsandharvest:pineapple_seeds":22,"sophisticatedstorage:chest":1,"forbidden_arcanus:rune":17,"mysticalagriculture:soulium_dust":7,"minecraft:glowstone_dust":16,"minecraft:moss_block":2,"alltheores:fluorite":20,"silentgear:fine_silk":1,"mekanism:metallurgic_infuser":2,"xycraft_world:xychorium_gem_red":29,"minecraft:trapped_chest":1,"xycraft_world:xychorium_gem_dark":6,"minecraft:barrel":3,"mekanism:basic_mechanical_pipe":3,"rootsclassic:blackcurrant":1,"minecolonies:mint":1,"herbsandharvest:green_bean":4,"minecraft:granite":27,"minecraft:bread":1,"alltheores:raw_platinum":12,"alltheores:osmium_ingot":38,"minecraft:crafting_table":1,"minecraft:clay_ball":80,"minecraft:leather_leggings":2,"sushigocrafting:soy_seeds":3,"powah:energizing_rod_starter":1,"alltheores:peridot":3,"minecraft:cartography_table":1,"minecraft:poppy":7,"minecraft:jungle_sapling":1,"minecraft:iron_nugget":11,"minecraft:spruce_slab":2,"minecraft:chest":4,"alltheores:raw_zinc":98,"minecraft:spruce_fence":6,"minecraft:white_dye":3,"minecraft:carrot":190,"minecraft:redstone":373,"mekanism:formulaic_assemblicator":1,"minecraft:hay_block":2,"mysticalagriculture:diamond_seeds":3,"alltheores:raw_osmium":178,"minecraft:white_wool":22,"powah:capacitor_basic_tiny":4,"mysticalagriculture:fertilized_essence":7,"silentgems:black_diamond":12,"minecraft:yellow_dye":3,"alltheores:cinnabar":2,"minecraft:crafter":3,"biomeswevegone:kovan_flower":35,"minecraft:ancient_debris":11,"silentgems:alexandrite":4,"tombstone:gift":4,"pamhc2crops:tealeafitem":1,"sophisticatedbackpacks:upgrade_base":3,"minecraft:stone_bricks":16,"herbsandharvest:raspberry_seeds":8,"minecraft:stone_pickaxe":2,"minecraft:gray_bed":1,"mysticalagriculture:wood_essence":9,"silentgems:turquoise":1,"forbidden_arcanus:darkstone":309,"pamhc2crops:zucchiniitem":1,"modern_industrialization:raw_tungsten":22,"minecraft:dark_oak_slab":1,"mysticalagriculture:iron_essence":8,"herbsandharvest:green_pepper_seeds":15,"minecraft:gravel":124,"powah:uraninite_raw":29,"minecraft:spruce_sapling":2,"minecraft:spruce_log":22,"silentgear:bort":11,"forbidden_arcanus:arcane_crystal":7,"immersiveengineering:seed":9,"refinedstorage:autocrafter":1,"minecraft:short_grass":2,"minecraft:redstone_ore":6,"alltheores:raw_silver":35,"mysticalagriculture:diamoLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"stats":{"minecraft:killed":{"minecraft:spider":2,"minecraft:skeleton":3,"minecraft:chicken":3,"livingthings:shark":1,"irons_spellbooks:summoned_skeleton":1,"irons_spellbooks:necromancer":1,"minecraft:creeper":1,"minecraft:sheep":1,"minecraft:trader_llama":3,"minecraft:pillager":1,"minecraft:zombie":6,"irons_spellbooks:summoned_zombie":7,"livingthings:crab":4,"creeperoverhaul:spruce_creeper":1,"minecraft:enderman":5},"minecraft:custom":{"lootr:looted_stat":3,"minecraft:rite_of_silent_bond":2,"minecraft:interact_with_crafting_table":76,"minecraft:open_barrel":13,"minecraft:leave_game":7,"minecraft:time_since_death":671500,"minecraft:sprint_one_cm":1836705,"minecraft:drop":28,"minecraft:talked_to_villager":8,"minecraft:play_time":672734,"minecraft:deaths":1,"minecraft:jump":3882,"minecraft:walk_on_water_one_cm":9307,"minecraft:raider_killed":1,"minecraft:interact_with_furnace":322,"minecraft:sleep_in_bed":19,"minecraft:damage_dealt":8713,"minecraft:crouch_one_cm":60617,"minecraft:mob_kills":41,"minecraft:damage_dealt_resisted":0,"minecraft:climb_one_cm":19858,"minecraft:walk_one_cm":1367248,"minecraft:sneak_time":21896,"minecraft:walk_under_water_one_cm":13119,"minecraft:boat_one_cm":77411,"minecraft:total_world_time":672734,"minecraft:elyra_diary":1,"minecraft:time_since_rest":49269,"minecraft:damage_taken":2472,"minecraft:undead_killed":11,"minecraft:swim_one_cm":19389,"minecraft:fly_one_cm":661009,"minecraft:open_chest":1327,"minecraft:fall_one_cm":58825},"minecraft:mined":{"mekanismgenerators:wind_generator":4,"chipped:large_diamond_leaded_glass":13,"minecraft:white_bed":2,"minecraft:oak_sapling":7,"mekanism:energized_smelter":1,"herbsandharvest:cauliflower_crop":7,"minecraft:granite":44,"minecraft:grass_block":623,"ars_nouveau:sourceberry_bush":16,"mysticalagriculture:inferium_ore":8,"herbsandharvest:pineapple_crop":15,"trophymanager:trophy":16,"xycraft_world:xychorium_ore_stone_green":5,"herbsandharvest:fruit_leaves":1,"biomeswevegone:zelkova_leaves":2,"minecraft:iron_ore":23,"minecraft:fire":2,"herbsandharvest:broccoli_crop":3,"minecraft:deepslate_lapis_ore":2,"silentgear:deepslate_bort_ore":1,"minecraft:brown_bed":3,"minecraft:enchanting_table":1,"minecraft:dark_oak_stairs":2,"alltheores:fluorite_ore":11,"xycraft_world:xychorium_ore_stone_light":4,"xycraft_world:xychorium_ore_deepslate_green":4,"minecraft:deepslate_redstone_ore":3,"mekanism:enrichment_chamber":1,"minecraft:torch":58,"silentgems:garnet_ore":1,"minecraft:chest":25,"minecraft:spruce_wall_sign":1,"herbsandharvest:green_bean_crop":4,"minecraft:cobblestone":2,"productivebees:dark_oak_wood_nest":11,"pamhc2crops:shadedgarden":7,"minecraft:oak_planks":223,"minecraft:sugar_cane":1,"alltheores:deepslate_nickel_ore":8,"minecraft:wall_torch":25,"biomeswevegone:zelkova_log":1,"minecraft:deepslate_copper_ore":10,"herbsandharvest:zucchini_crop":6,"biomeswevegone:kovan_flower":1,"minecraft:dark_oak_slab":32,"minecraft:dark_oak_door":1,"minecraft:oak_log":207,"minecraft:dark_oak_sapling":4,"minecraft:sand":369,"minecraft:dirt":253,"powah:magmator_starter":1,"herbsandharvest:turnip_crop":14,"mekanism:basic_universal_cable":17,"minecraft:deepslate":168,"minecraft:oak_leaves":39,"mysticalagriculture:deepslate_inferium_ore":7,"minecraft:tuff":14,"ars_elemental:yellow_archwood_leaves":2,"trashcans:item_trash_can":1,"farmersdelight:onions":6,"minecraft:dark_oak_leaves":29,"minecraft:deepslate_diamond_ore":5,"sophisticatedstorage:iron_chest":12,"minecraft:dark_oak_log":684,"xycraft_world:xychorium_ore_stone_blue":4,"minecraft:ladder":10,"minecraft:spruce_fence":22,"alltheores:deepslate_silver_ore":1,"minecraft:spruce_leaves":118,"minecraft:moss_block":1,"xycraft_world:xychorium_ore_deepslate_light":1,"minecraft:large_fern":45,"herbsandharvest:green_pepper_crop":4,"minecraft:furnace":4,"minecraft:carrots":59,"minecraft:andesite":98,"minecraft:potatoes":1,"minecraft:deepslate_iron_ore":14,"alltheores:silver_ore":2,"sophisticatedstorage:iron_barrel":2,"minecraft:stone":1583,"ars_nouveau:purple_archwood_log":53,"deeperdarker:ancient_vase":1,"minecraft:crafting_table":9,"actuallyadditions:black_quartz_ore":4,"herbsandharvest:raspberry_crop":4,"minecraft:short_grass":7,"xycraft_world:xychorium_ore_stone_dark":7,"sophisticatedstorage:copper_chest":2,"minecraft:copper_ore":28,"minecraft:deepslate_coal_ore":13,"theurgy:sal_ammoniac_ore":8,"minecraft:lapis_ore":8,"mekanism:metallurgic_infuser":2,"ars_elemental:yellow_archwood_log":26,"actuallyadditions:flax":1,"minecraft:diorite":41,"supplementaries:urn":2,"ars_nouveau:purple_archwood_leaves":2,"modern_industrialization:antimony_ore":2,"minecraft:spruce_log":446,"minecraft:coal_ore":147,"minecraft:gravel":107,"minecraft:spruce_fence_gate":1,"minecraft:stone_bricks":68,"minecraft:gold_ore":8,"minecraft:fern":8,"herbsandharvest:lime_fruit_sapling":1,"minecraft:dark_oak_planks":1},"minecraft:picked_up":{"minecolonies:supplycampdeployer":1,"livingthings:crab":6,"herbsandharvest:cauliflower_seeds":30,"sophisticatedstorageLine truncated
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { isQuestConfigFile, parseQuestDefs, parseQuestProgress, parseTeam, stripFormatting } from '../src/parsers/ftbquests.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
|
||||
const dir = join(import.meta.dirname, 'fixtures', 'ftbquests');
|
||||
const read = (p: string) => readFileSync(join(dir, p), 'utf8');
|
||||
const ALICE_FILE = '308a693c-2bd8-4d6b-8790-670aef5be601.snbt';
|
||||
|
||||
describe('parseQuestProgress', () => {
|
||||
it('parses real progress file 308a…', () => {
|
||||
const p = parseQuestProgress(read(ALICE_FILE));
|
||||
expect(p.teamId).toBe('308a693c-2bd8-4d6b-8790-670aef5be601');
|
||||
expect(p.name).toBe('CDOGamer#308a693c');
|
||||
expect(p.completed.length).toBeGreaterThan(0);
|
||||
for (const q of p.completed) {
|
||||
expect(q.questId).toMatch(/^[0-9A-F]{16}$/);
|
||||
expect(q.at).toBeGreaterThan(1.7e12);
|
||||
}
|
||||
expect(p.completed.find((q) => q.questId === '3BC0A50886A3222B')?.at).toBe(1791559740996);
|
||||
expect(p.started).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('parses the second real file', () => {
|
||||
expect(parseQuestProgress(read('c25d9ff0-dba3-48f0-b414-fad2b63473a9.snbt')).teamId).toBe(
|
||||
'c25d9ff0-dba3-48f0-b414-fad2b63473a9',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const CHAPTER = { path: 'config/ftbquests/quests/chapters/getting_started.snbt', content: read('config/chapters/getting_started.snbt') };
|
||||
const LANG_CHAPTERS = { path: 'config/ftbquests/quests/lang/en_us/chapter.snbt_merged', content: read('config/lang/en_us/chapter.snbt_merged') };
|
||||
const LANG_QUESTS = {
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapters/getting_started.snbt_merged',
|
||||
content: read('config/lang/en_us/chapters/getting_started.snbt_merged'),
|
||||
};
|
||||
|
||||
describe('parseQuestDefs', () => {
|
||||
it('reads chapters with titles from the split en_us lang files (ftbquestslangsplitter layout)', () => {
|
||||
const defs = parseQuestDefs([CHAPTER, LANG_CHAPTERS, LANG_QUESTS]);
|
||||
expect(defs).toEqual([
|
||||
{ chapterId: '1A2B3C4D5E6F7081', chapterTitle: 'Getting Started', questId: '3BC0A50886A3222B', title: 'Punch a Tree' },
|
||||
{ chapterId: '1A2B3C4D5E6F7081', chapterTitle: 'Getting Started', questId: '33218EFDD498E9DB', title: 'From Lang' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('also reads a single en_us.snbt and ignores other languages', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{ path: 'config/ftbquests/quests/lang/en_us.snbt', content: '{ quest.3BC0A50886A3222B.title: "Single File" }' },
|
||||
{ path: 'config/ftbquests/quests/lang/de_de/chapters/getting_started.snbt_merged', content: '{ quest.3BC0A50886A3222B.title: "Baum schlagen" }' },
|
||||
]);
|
||||
expect(defs[0]!.title).toBe('Single File');
|
||||
});
|
||||
|
||||
it('falls back to inline title and filename when no lang file exists', () => {
|
||||
const defs = parseQuestDefs([CHAPTER]);
|
||||
expect(defs.map((d) => [d.chapterTitle, d.title])).toEqual([
|
||||
['getting_started', null],
|
||||
['getting_started', 'Inline Title'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQuestConfigFile', () => {
|
||||
it('keeps chapters and English texts, skips other languages, reward tables and the big combined file', () => {
|
||||
const wanted = [
|
||||
'config/ftbquests/quests/chapters/aether.snbt',
|
||||
'config/ftbquests/quests/lang/en_us/chapter.snbt_merged',
|
||||
'config/ftbquests/quests/lang/en_us/chapters/aether.snbt_merged',
|
||||
];
|
||||
const skipped = [
|
||||
'config/ftbquests/quests/lang/de_de/chapters/aether.snbt_merged',
|
||||
'config/ftbquests/quests/lang/en_us.snbt',
|
||||
'config/ftbquests/quests/reward_tables/loot.snbt',
|
||||
'config/ftbquests/quests/data.snbt',
|
||||
];
|
||||
expect(wanted.filter(isQuestConfigFile)).toEqual(wanted);
|
||||
expect(skipped.filter(isQuestConfigFile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTeam', () => {
|
||||
const team = (p: string) => readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', p), 'utf8');
|
||||
|
||||
it('reads a player team (real structure)', () => {
|
||||
expect(parseTeam(team('player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt'))).toEqual({
|
||||
teamId: '308a693c-2bd8-4d6b-8790-670aef5be601',
|
||||
type: 'player',
|
||||
name: 'CDOGamer',
|
||||
members: ['308a693c-2bd8-4d6b-8790-670aef5be601'],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a party team; invited players and allies are not members', () => {
|
||||
const t = parseTeam(team('party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt'));
|
||||
expect(t.type).toBe('party');
|
||||
expect(t.name).toBe('Die Techniker');
|
||||
expect(t.members.sort()).toEqual(['308a693c-2bd8-4d6b-8790-670aef5be601', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file_sync projection', () => {
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const fileEvent = (path: string, content: string) => ({
|
||||
id: randomUUID(),
|
||||
type: 'file_sync' as const,
|
||||
ts: Date.now(),
|
||||
path,
|
||||
sha256: createHash('sha256').update(content).digest('hex'),
|
||||
content,
|
||||
});
|
||||
|
||||
it('stores quest progress for the team', async () => {
|
||||
await processEvents(pool, [fileEvent(`world/ftbquests/${ALICE_FILE}`, read(ALICE_FILE))], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM quest_progress WHERE team_id = $1`, [
|
||||
'308a693c-2bd8-4d6b-8790-670aef5be601',
|
||||
]);
|
||||
expect(rows[0].n).toBe(parseQuestProgress(read(ALICE_FILE)).completed.length);
|
||||
});
|
||||
|
||||
it('rebuilds quest_defs from all cached config files, also across batches', async () => {
|
||||
await processEvents(pool, [fileEvent(CHAPTER.path, CHAPTER.content)], 'push', 'atm10');
|
||||
await processEvents(pool, [fileEvent(LANG_CHAPTERS.path, LANG_CHAPTERS.content), fileEvent(LANG_QUESTS.path, LANG_QUESTS.content)], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT quest_id, title, chapter_title FROM quest_defs ORDER BY quest_id`);
|
||||
expect(rows).toEqual([
|
||||
{ quest_id: '33218EFDD498E9DB', title: 'From Lang', chapter_title: 'Getting Started' },
|
||||
{ quest_id: '3BC0A50886A3222B', title: 'Punch a Tree', chapter_title: 'Getting Started' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('party membership decides whose quest progress a player gets', async () => {
|
||||
const party = readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', 'party', '5e0c1a2b-0000-4000-8000-00000000abcd.snbt'), 'utf8');
|
||||
await processEvents(pool, [fileEvent('world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt', party)], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT t.name, t.type, array_agg(m.uuid::text ORDER BY m.uuid) AS members
|
||||
FROM teams t JOIN team_members m USING (team_id) GROUP BY t.name, t.type`);
|
||||
expect(rows).toEqual([
|
||||
{ name: 'Die Techniker', type: 'party', members: ['308a693c-2bd8-4d6b-8790-670aef5be601', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9'] },
|
||||
]);
|
||||
// leaving the party: a newer file without Bob
|
||||
const smaller = party.replace(/\t\tc25d9ff0-dba3-48f0-b414-fad2b63473a9: "member"\n/, '');
|
||||
await processEvents(pool, [fileEvent('world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt', smaller)], 'push', 'atm10');
|
||||
const after = await pool.query(`SELECT uuid::text FROM team_members`);
|
||||
expect(after.rows.map((r) => r.uuid)).toEqual(['308a693c-2bd8-4d6b-8790-670aef5be601']);
|
||||
});
|
||||
|
||||
it('player team files set the team name but no party membership', async () => {
|
||||
const player = readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', 'player', `${ALICE_FILE}`), 'utf8');
|
||||
await processEvents(pool, [fileEvent(`world/ftbteams/player/${ALICE_FILE}`, player)], 'push', 'atm10');
|
||||
expect((await pool.query(`SELECT name, type FROM teams`)).rows).toEqual([{ name: 'CDOGamer', type: 'player' }]);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM team_members`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it('a broken file is cached but does not fail the batch', async () => {
|
||||
const res = await processEvents(pool, [fileEvent(`world/ftbquests/${ALICE_FILE}`, '{ broken')], 'push', 'atm10');
|
||||
expect(res.accepted).toHaveLength(1);
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM app_errors`);
|
||||
expect(rows[0].n).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quest reset', () => {
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
|
||||
it('quests missing from a newer progress file are no longer completed', async () => {
|
||||
const file = (completed: string) =>
|
||||
`{\n\tversion: 1\n\tuuid: "308a693c2bd84d6b8790670aef5be601"\n\tname: "T"\n\tcompleted: {\n${completed}\n\t}\n}\n`;
|
||||
const send = (content: string) =>
|
||||
processEvents(
|
||||
pool,
|
||||
[{ id: randomUUID(), type: 'file_sync', ts: Date.now(), path: `world/ftbquests/${ALICE_FILE}`, sha256: createHash('sha256').update(content).digest('hex'), content }],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
await send(file('\t\tAAAAAAAAAAAAAAAA: 1L\n\t\tBBBBBBBBBBBBBBBB: 2L'));
|
||||
await send(file('\t\tBBBBBBBBBBBBBBBB: 2L'));
|
||||
const { rows } = await pool.query(`SELECT quest_id FROM quest_progress`);
|
||||
expect(rows.map((r) => r.quest_id)).toEqual(['BBBBBBBBBBBBBBBB']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting codes', () => {
|
||||
it('strips Minecraft colour and style codes from chapter and quest titles', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapters/getting_started.snbt_merged',
|
||||
content: '{ chapter.1A2B3C4D5E6F7081.title: "&aChapter 4&r: &dCreative" \n quest.3BC0A50886A3222B.title: "&6&lATM Star§r" }',
|
||||
},
|
||||
]);
|
||||
expect(defs[0]).toMatchObject({ chapterTitle: 'Chapter 4: Creative', title: 'ATM Star' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaped ampersand', () => {
|
||||
it('keeps a literal "&" that FTB escapes as backslash-ampersand', () => {
|
||||
// real lang file: "Forbidden \\& Arcanus" in SNBT, i.e. the parsed string contains a backslash before "&"
|
||||
expect(stripFormatting('Forbidden \\& Arcanus')).toBe('Forbidden & Arcanus');
|
||||
expect(stripFormatting('&6Gold \\&b &lBold')).toBe('Gold &b Bold');
|
||||
});
|
||||
|
||||
it('decodes the escape through the real SNBT path', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapter.snbt_merged',
|
||||
content: '{ chapter.1A2B3C4D5E6F7081.title: "Forbidden \\\\& Arcanus" }',
|
||||
},
|
||||
]);
|
||||
expect(defs[0]!.chapterTitle).toBe('Forbidden & Arcanus');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { buildApp } from '../../src/app.ts';
|
||||
import { loadConfig } from '../../src/config.ts';
|
||||
import { createIngestToken } from '../../src/ingest/tokens.ts';
|
||||
import { TEST_SECRET } from './sign.ts';
|
||||
|
||||
export function testConfig() {
|
||||
return loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
DATABASE_URL: process.env.TEST_DATABASE_URL,
|
||||
INGEST_HMAC_SECRET: TEST_SECRET,
|
||||
LOG_LEVEL: 'silent',
|
||||
});
|
||||
}
|
||||
|
||||
export async function makeApp(pool: Pool): Promise<{ app: FastifyInstance; token: string }> {
|
||||
const app = await buildApp({ pool, config: testConfig() });
|
||||
const token = await createIngestToken(pool);
|
||||
return { app, token };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { createPool } from '../../src/db/pool.ts';
|
||||
import { migrate } from '../../src/db/migrate.ts';
|
||||
|
||||
let pool: Pool | undefined;
|
||||
|
||||
export async function getTestPool(): Promise<Pool> {
|
||||
if (!pool) {
|
||||
pool = createPool(process.env.TEST_DATABASE_URL!);
|
||||
await migrate(pool);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function resetDb(p: Pool): Promise<void> {
|
||||
const { rows } = await p.query<{ tablename: string }>(
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'schema_migrations'`,
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
await p.query(`TRUNCATE ${rows.map((r) => `"${r.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
|
||||
export async function closeTestPool(): Promise<void> {
|
||||
await pool?.end();
|
||||
pool = undefined;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AtmEvent, EventOf, EventType } from '@atm/shared';
|
||||
|
||||
export const ALICE = { uuid: '308a693c-2bd8-4d6b-8790-670aef5be601', name: 'CDOGamer' };
|
||||
export const BOB = { uuid: 'c25d9ff0-dba3-48f0-b414-fad2b63473a9', name: 'Bob' };
|
||||
|
||||
type Payload<T extends EventType> = Omit<EventOf<T>, 'id' | 'type' | 'ts'> & { ts?: number; id?: string };
|
||||
|
||||
/** Test factory: ev('player_join', { ...ALICE, ts }) */
|
||||
export function ev<T extends EventType>(type: T, payload: Payload<T>): AtmEvent {
|
||||
const { ts, id, ...rest } = payload as Payload<T>;
|
||||
return { id: id ?? randomUUID(), type, ts: ts ?? Date.now(), ...rest } as unknown as AtmEvent;
|
||||
}
|
||||
|
||||
export const T0 = Date.UTC(2026, 9, 12, 9, 0, 0);
|
||||
export const MIN = 60_000;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
export const TEST_SECRET = 'test-hmac-secret-0123456789abcdef0123';
|
||||
|
||||
export function signature(secret: string, ts: number, body: Buffer): string {
|
||||
const bodyHash = createHash('sha256').update(body).digest('hex');
|
||||
return createHmac('sha256', secret).update(`${ts}.${bodyHash}`).digest('hex');
|
||||
}
|
||||
|
||||
export interface SignOpts {
|
||||
token: string;
|
||||
secret?: string;
|
||||
ts?: number;
|
||||
gzip?: boolean;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
/** Builds headers + payload exactly as the mod sends them: signature covers the transmitted (gzipped) bytes. */
|
||||
export function signedRequest(body: unknown, opts: SignOpts): { headers: Record<string, string>; payload: Buffer } {
|
||||
const raw = Buffer.from(typeof body === 'string' ? body : JSON.stringify(body));
|
||||
const gzip = opts.gzip ?? true;
|
||||
const payload = gzip ? gzipSync(raw) : raw;
|
||||
const ts = opts.ts ?? Date.now();
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `Bearer ${opts.token}`,
|
||||
'content-type': 'application/json',
|
||||
'x-atm-server': opts.serverId ?? 'atm10',
|
||||
'x-atm-timestamp': String(ts),
|
||||
'x-atm-signature': signature(opts.secret ?? TEST_SECRET, ts, payload),
|
||||
};
|
||||
if (gzip) headers['content-encoding'] = 'gzip';
|
||||
return { headers, payload };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { ALICE, ev } from './helpers/events.ts';
|
||||
import { signature, signedRequest, TEST_SECRET } from './helpers/sign.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app, token } = await makeApp(pool));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
function post(body: unknown, opts: Partial<Parameters<typeof signedRequest>[1]> = {}) {
|
||||
const { headers, payload } = signedRequest(body, { token, ...opts });
|
||||
return app.inject({ method: 'POST', url: '/api/ingest', headers, payload });
|
||||
}
|
||||
|
||||
const batch = () => ({
|
||||
protocol: 1,
|
||||
events: [ev('player_join', { ...ALICE }), ev('player_leave', { ...ALICE })],
|
||||
});
|
||||
|
||||
describe('POST /api/ingest', () => {
|
||||
it('accepts valid batch', async () => {
|
||||
const b = batch();
|
||||
const res = await post(b);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accepted.sort()).toEqual(b.events.map((e) => e.id).sort());
|
||||
const { rows } = await pool.query(`SELECT source FROM events`);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.every((r) => r.source === 'push')).toBe(true);
|
||||
});
|
||||
|
||||
it('same batch twice is idempotent', async () => {
|
||||
const b = batch();
|
||||
await post(b);
|
||||
const res = await post(b);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accepted).toEqual([]);
|
||||
expect(res.json().duplicate.sort()).toEqual(b.events.map((e) => e.id).sort());
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM events`);
|
||||
expect(rows[0].n).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects wrong token', async () => {
|
||||
const res = await post(batch(), { token: 'nope' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: 'bad_token' });
|
||||
});
|
||||
|
||||
it('rejects missing auth', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/ingest', payload: batch() });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: 'missing_auth' });
|
||||
});
|
||||
|
||||
it('rejects bad signature', async () => {
|
||||
const res = await post(batch(), { secret: 'another-secret-another-secret-123' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: 'bad_signature' });
|
||||
});
|
||||
|
||||
it('rejects timestamp 6 minutes old', async () => {
|
||||
const res = await post(batch(), { ts: Date.now() - 6 * 60_000 });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: 'timestamp_out_of_range' });
|
||||
});
|
||||
|
||||
it('rejects invalid event but accepts the rest', async () => {
|
||||
const good = ev('player_join', { ...ALICE });
|
||||
const bad = { id: '0199d6a0-0000-7000-8000-0000000000ff', type: 'player_join', ts: Date.now(), name: 'x' };
|
||||
const res = await post({ protocol: 1, events: [good, bad] });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accepted).toEqual([good.id]);
|
||||
expect(res.json().rejected).toHaveLength(1);
|
||||
expect(res.json().rejected[0].id).toBe(bad.id);
|
||||
expect(res.json().rejected[0].reason).toMatch(/uuid/);
|
||||
});
|
||||
|
||||
it('rejects body over 2 MB unpacked', async () => {
|
||||
const huge = JSON.stringify({ protocol: 1, events: [ev('server_stop', {})], pad: 'x'.repeat(2_200_000) });
|
||||
const res = await post(huge);
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
|
||||
it('accepts uncompressed body too', async () => {
|
||||
const res = await post(batch(), { gzip: false });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/ingest/ping', () => {
|
||||
it('answers with server time for a valid signature', async () => {
|
||||
const ts = Date.now();
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/ingest/ping',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-atm-timestamp': String(ts),
|
||||
'x-atm-signature': signature(TEST_SECRET, ts, Buffer.alloc(0)),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().ok).toBe(true);
|
||||
expect(typeof res.json().serverTime).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signature vector (shared with the mod)', () => {
|
||||
it('matches the constant used in SignerTest.java', () => {
|
||||
expect(signature('test-secret', 1760000000000, Buffer.from('hello'))).toBe(
|
||||
'c9f85674a0d206a6d8e53290fb21af4933f8c8451fb8b2aeefcdf3d2314ef043',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { getLeaderboard, getPlayerRanks } from '../src/stats/leaderboards.ts';
|
||||
import { BOARDS } from '../src/stats/metrics.ts';
|
||||
import { takeSnapshots } from '../src/stats/snapshots.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
async function stats(who: { uuid: string; name: string }, stats: Record<string, Record<string, number>>, ts = T0) {
|
||||
await processEvents(pool, [ev('stats_snapshot', { uuid: who.uuid, name: who.name, full: false, ts, stats })], 'push', 'atm10');
|
||||
}
|
||||
|
||||
describe('boards', () => {
|
||||
it('defines the 14 boards from the spec', () => {
|
||||
expect(BOARDS.map((b) => b.id)).toEqual([
|
||||
'playtime', 'deaths', 'mobkills', 'mined', 'distance', 'flight', 'diamonds',
|
||||
'tech', 'magic', 'farming', 'quests', 'advancements', 'loot', 'worldtier',
|
||||
]);
|
||||
});
|
||||
|
||||
it('playtime converts ticks to hours', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:play_time': 1175736 } });
|
||||
const [top] = await getLeaderboard(pool, 'playtime', 'all');
|
||||
expect(top).toMatchObject({ rank: 1, uuid: ALICE.uuid, name: ALICE.name });
|
||||
expect(top!.value).toBeCloseTo(16.33, 2);
|
||||
});
|
||||
|
||||
it('distance sums all *_one_cm keys except falling, in km', async () => {
|
||||
await stats(ALICE, {
|
||||
'minecraft:custom': {
|
||||
'minecraft:walk_one_cm': 150_000,
|
||||
'minecraft:fly_one_cm': 50_000,
|
||||
'minecraft:fall_one_cm': 999_999,
|
||||
'minecraft:jump': 5,
|
||||
},
|
||||
});
|
||||
const [top] = await getLeaderboard(pool, 'distance', 'all');
|
||||
expect(top!.value).toBe(2);
|
||||
});
|
||||
|
||||
it('tech sums crafted+used of tech namespaces only', async () => {
|
||||
await stats(ALICE, {
|
||||
'minecraft:crafted': { 'mekanism:steel_casing': 3, 'minecraft:stick': 100, 'ars_nouveau:source_gem': 9 },
|
||||
'minecraft:used': { 'powah:energy_cell_basic': 2 },
|
||||
'minecraft:mined': { 'mekanism:osmium_ore': 50 },
|
||||
});
|
||||
const [top] = await getLeaderboard(pool, 'tech', 'all');
|
||||
expect(top!.value).toBe(5);
|
||||
});
|
||||
|
||||
it('hidden player excluded', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 3 } });
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:deaths': 9 } });
|
||||
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
|
||||
const rows = await getLeaderboard(pool, 'deaths', 'all');
|
||||
expect(rows.map((r) => r.uuid)).toEqual([ALICE.uuid]);
|
||||
});
|
||||
|
||||
it('ties share rank, ordered by name; zero values omitted', async () => {
|
||||
const carl = { uuid: randomUUID(), name: 'Carl' };
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:mob_kills': 7 } });
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:mob_kills': 7 } });
|
||||
await stats(carl, { 'minecraft:custom': { 'minecraft:mob_kills': 0 } });
|
||||
const rows = await getLeaderboard(pool, 'mobkills', 'all');
|
||||
expect(rows.map((r) => [r.rank, r.name])).toEqual([
|
||||
[1, 'Bob'],
|
||||
[1, 'CDOGamer'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('7d period subtracts the daily snapshot at the period start', async () => {
|
||||
const now = new Date(T0 + 10 * DAY);
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 4 } }, T0);
|
||||
await takeSnapshots(pool, 'day', new Date(T0 + 2 * DAY));
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 10 } }, T0 + 9 * DAY);
|
||||
const [top] = await getLeaderboard(pool, 'deaths', '7d', 10, now);
|
||||
expect(top!.value).toBe(6);
|
||||
});
|
||||
|
||||
it('quests uses team progress and counts only known quests when defs exist', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:jump': 1 } });
|
||||
await pool.query(
|
||||
`INSERT INTO quest_progress(team_id, quest_id, completed_at) VALUES ($1, 'AAAAAAAAAAAAAAAA', now()), ($1, 'BBBBBBBBBBBBBBBB', now())`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
expect((await getLeaderboard(pool, 'quests', 'all'))[0]!.value).toBe(2);
|
||||
await pool.query(`INSERT INTO quest_defs(quest_id, chapter_id) VALUES ('AAAAAAAAAAAAAAAA', 'C')`);
|
||||
expect((await getLeaderboard(pool, 'quests', 'all'))[0]!.value).toBe(1);
|
||||
});
|
||||
|
||||
it('player ranks cover every board the player appears on', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 3, 'minecraft:play_time': 72000 } });
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:deaths': 9 } });
|
||||
const ranks = await getPlayerRanks(pool, ALICE.uuid);
|
||||
expect(ranks.deaths).toEqual({ rank: 2, value: 3 });
|
||||
expect(ranks.playtime).toEqual({ rank: 1, value: 1 });
|
||||
expect(ranks.magic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app } = await makeApp(pool));
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const addr = app.server.address();
|
||||
base = `http://127.0.0.1:${typeof addr === 'object' && addr ? addr.port : 0}`;
|
||||
await app.liveHub.ready();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
interface Sse {
|
||||
id: number;
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Opens /api/live and collects parsed SSE messages until `until` returns true. */
|
||||
async function collect(until: (msgs: Sse[]) => boolean, headers: Record<string, string> = {}, trigger?: () => Promise<void>) {
|
||||
const ctrl = new AbortController();
|
||||
const res = await fetch(`${base}/api/live`, { headers, signal: ctrl.signal });
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||
const reader = res.body!.getReader();
|
||||
const msgs: Sse[] = [];
|
||||
let buf = '';
|
||||
const timeout = setTimeout(() => ctrl.abort(), 8000);
|
||||
let triggered = false;
|
||||
try {
|
||||
for (;;) {
|
||||
if (!triggered && trigger) {
|
||||
triggered = true;
|
||||
// wait for the stream to be registered before triggering
|
||||
const first = await reader.read();
|
||||
buf += new TextDecoder().decode(first.value);
|
||||
await trigger();
|
||||
}
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
||||
const block = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
const fields = Object.fromEntries(
|
||||
block
|
||||
.split('\n')
|
||||
.filter((l) => !l.startsWith(':'))
|
||||
.map((l) => [l.slice(0, l.indexOf(':')), l.slice(l.indexOf(':') + 1).trim()]),
|
||||
);
|
||||
if (fields.event) msgs.push({ id: Number(fields.id), event: fields.event, data: JSON.parse(fields.data ?? '{}') });
|
||||
}
|
||||
if (until(msgs)) return msgs;
|
||||
const { value, done } = await reader.read();
|
||||
if (done) return msgs;
|
||||
buf += new TextDecoder().decode(value);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
ctrl.abort();
|
||||
}
|
||||
}
|
||||
|
||||
const death = (who: typeof ALICE) =>
|
||||
ev('player_death', { ...who, dimension: 'minecraft:overworld', cause: 'minecraft:fall', killerType: null, killerName: null });
|
||||
|
||||
describe('GET /api/live', () => {
|
||||
it('delivers death event after ingest', async () => {
|
||||
const msgs = await collect(
|
||||
(m) => m.some((x) => x.event === 'death'),
|
||||
{},
|
||||
async () => {
|
||||
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
|
||||
},
|
||||
);
|
||||
const d = msgs.find((m) => m.event === 'death')!;
|
||||
expect(d.data).toMatchObject({ uuid: ALICE.uuid, name: ALICE.name, cause: 'minecraft:fall' });
|
||||
});
|
||||
|
||||
it('resumes from Last-Event-ID', async () => {
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE })], 'push', 'atm10');
|
||||
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const all = app.liveHub.since(0);
|
||||
expect(all.map((e) => e.kind)).toEqual(['online', 'death']);
|
||||
const msgs = await collect((m) => m.length >= 1, { 'last-event-id': String(all[0]!.id) });
|
||||
expect(msgs.map((m) => m.event)).toEqual(['death']);
|
||||
});
|
||||
|
||||
it('hidden player not broadcast', async () => {
|
||||
await processEvents(pool, [ev('player_join', { ...BOB })], 'push', 'atm10');
|
||||
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
|
||||
const msgs = await collect(
|
||||
(m) => m.some((x) => x.event === 'death'),
|
||||
{ 'last-event-id': String(app.liveHub.lastId()) },
|
||||
async () => {
|
||||
await processEvents(pool, [death(BOB)], 'push', 'atm10');
|
||||
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
|
||||
},
|
||||
);
|
||||
const deaths = msgs.filter((m) => m.event === 'death');
|
||||
expect(deaths.map((d) => d.data.uuid)).toEqual([ALICE.uuid]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseLogChunk, parseLogTimestamp } from '../src/parsers/log.ts';
|
||||
|
||||
const log = readFileSync(join(import.meta.dirname, 'fixtures', 'latest.anon.log'));
|
||||
const ALICE = '308a693c-2bd8-4d6b-8790-670aef5be601';
|
||||
const TZ = 'Europe/Berlin';
|
||||
|
||||
describe('parseLogTimestamp', () => {
|
||||
it('interprets log time in the server time zone', () => {
|
||||
// 12 Oct 2026 is CEST (UTC+2)
|
||||
expect(parseLogTimestamp('12Oct2026 09:03:03.006', TZ)).toBe(Date.parse('2026-10-12T07:03:03.006Z'));
|
||||
// 1 Dec 2026 is CET (UTC+1)
|
||||
expect(parseLogTimestamp('01Dec2026 00:00:00.000', TZ)).toBe(Date.parse('2026-11-30T23:00:00.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLogChunk', () => {
|
||||
it('finds join of CDOGamer with uuid 308a…', () => {
|
||||
const { events } = parseLogChunk(log, 0, new Map(), TZ);
|
||||
const join = events.find((e) => e.type === 'player_join');
|
||||
expect(join).toMatchObject({ type: 'player_join', uuid: ALICE, name: 'CDOGamer' });
|
||||
expect(join!.ts).toBe(parseLogTimestamp('12Oct2026 09:03:03.006', TZ));
|
||||
expect(events.filter((e) => e.type === 'player_leave').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('finds advancement Infusing Efficiency and goals', () => {
|
||||
const { events } = parseLogChunk(log, 0, new Map(), TZ);
|
||||
const titles = events.flatMap((e) => (e.type === 'advancement' ? [e.title] : []));
|
||||
expect(titles).toContain('Infusing Efficiency');
|
||||
expect(titles).toContain('The Factory Must Grow!');
|
||||
const adv = events.find((e) => e.type === 'advancement')!;
|
||||
expect(adv).toMatchObject({ uuid: ALICE, advancementId: 'log:Infusing Efficiency' });
|
||||
});
|
||||
|
||||
it('resolves names known from earlier chunks', () => {
|
||||
const line = Buffer.from('[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob left the game\r\n');
|
||||
const known = new Map([['Bob', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9']]);
|
||||
expect(parseLogChunk(line, 0, known, TZ).events[0]).toMatchObject({ type: 'player_leave', name: 'Bob' });
|
||||
expect(parseLogChunk(line, 0, new Map(), TZ).events).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores chat lines that imitate system messages', () => {
|
||||
const line = Buffer.from(
|
||||
'[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: <CDOGamer> Bob joined the game\n',
|
||||
);
|
||||
expect(parseLogChunk(line, 0, new Map([['Bob', ALICE]]), TZ).events).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores incomplete last line', () => {
|
||||
const text = Buffer.from(
|
||||
'[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob left the game\n' +
|
||||
'[12Oct2026 10:00:01.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob joined the ga',
|
||||
);
|
||||
const r = parseLogChunk(text, 100, new Map([['Bob', ALICE]]), TZ);
|
||||
expect(r.events).toHaveLength(1);
|
||||
expect(r.consumed).toBe(text.indexOf('\n') + 1);
|
||||
});
|
||||
|
||||
it('same chunk twice yields same ids; ids depend on absolute offset', () => {
|
||||
const a = parseLogChunk(log, 0, new Map(), TZ).events.map((e) => e.id);
|
||||
const b = parseLogChunk(log, 0, new Map(), TZ).events.map((e) => e.id);
|
||||
const c = parseLogChunk(log, 10, new Map(), TZ).events.map((e) => e.id);
|
||||
expect(a).toEqual(b);
|
||||
expect(a[0]).not.toBe(c[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev, MIN, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const push = (...events: Parameters<typeof processEvents>[1]) => processEvents(pool, events, 'push', 'atm10');
|
||||
const ms = (d: Date) => d.getTime();
|
||||
|
||||
describe('sessions', () => {
|
||||
it('join then leave creates closed session with duration', async () => {
|
||||
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('player_leave', { ...ALICE, ts: T0 + 30 * MIN }));
|
||||
const { rows } = await pool.query('SELECT started_at, ended_at, end_reason FROM sessions');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(ms(rows[0].ended_at) - ms(rows[0].started_at)).toBe(30 * MIN);
|
||||
expect(rows[0].end_reason).toBe('leave');
|
||||
});
|
||||
|
||||
it('closes dangling sessions on server_start', async () => {
|
||||
await push(
|
||||
ev('player_join', { ...ALICE, ts: T0 }),
|
||||
ev('health', {
|
||||
ts: T0 + 10 * MIN,
|
||||
windowStart: T0 + 9 * MIN,
|
||||
windowEnd: T0 + 10 * MIN,
|
||||
msptAvg: 30,
|
||||
msptP95: 40,
|
||||
msptMax: 50,
|
||||
tps: 20,
|
||||
players: 1,
|
||||
chunks: 100,
|
||||
heapUsedMb: 1000,
|
||||
heapMaxMb: 4000,
|
||||
}),
|
||||
);
|
||||
await push(
|
||||
ev('server_start', { ts: T0 + 60 * MIN, mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21', mods: [] }),
|
||||
);
|
||||
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
|
||||
expect(ms(rows[0].ended_at)).toBe(T0 + 10 * MIN);
|
||||
expect(rows[0].end_reason).toBe('crash');
|
||||
const info = await pool.query('SELECT mc_version FROM server_info');
|
||||
expect(info.rows[0].mc_version).toBe('1.21.1');
|
||||
});
|
||||
|
||||
it('server_stop closes open sessions', async () => {
|
||||
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('server_stop', { ts: T0 + 5 * MIN }));
|
||||
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
|
||||
expect(ms(rows[0].ended_at)).toBe(T0 + 5 * MIN);
|
||||
expect(rows[0].end_reason).toBe('stop');
|
||||
});
|
||||
|
||||
it('second join closes the previous open session as superseded', async () => {
|
||||
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('player_join', { ...ALICE, ts: T0 + 5 * MIN }));
|
||||
const { rows } = await pool.query('SELECT end_reason FROM sessions ORDER BY started_at');
|
||||
expect(rows.map((r) => r.end_reason)).toEqual(['superseded', null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('players', () => {
|
||||
it('rename updates name and history', async () => {
|
||||
await push(ev('player_join', { uuid: ALICE.uuid, name: 'OldName', ts: T0 }));
|
||||
await push(ev('player_join', { uuid: ALICE.uuid, name: 'NewName', ts: T0 + MIN }));
|
||||
const p = await pool.query('SELECT name FROM players WHERE uuid = $1', [ALICE.uuid]);
|
||||
expect(p.rows[0].name).toBe('NewName');
|
||||
const h = await pool.query('SELECT name FROM name_history WHERE uuid = $1 ORDER BY seen_at', [ALICE.uuid]);
|
||||
expect(h.rows.map((r) => r.name)).toEqual(['OldName', 'NewName']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
const snap = (ts: number, value: number, source: 'push' | 'sftp' = 'push') =>
|
||||
processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { uuid: ALICE.uuid, name: ALICE.name, full: false, ts, stats: { 'minecraft:custom': { 'minecraft:deaths': value } } })],
|
||||
source,
|
||||
'atm10',
|
||||
);
|
||||
const deaths = async () =>
|
||||
(await pool.query(`SELECT value FROM stat_current WHERE key = 'minecraft:deaths'`)).rows[0]?.value;
|
||||
|
||||
it('newer observation wins even if smaller', async () => {
|
||||
await snap(T0 + 2 * MIN, 5);
|
||||
await snap(T0 + 1 * MIN, 9, 'sftp');
|
||||
expect(await deaths()).toBe(5);
|
||||
await snap(T0 + 3 * MIN, 3);
|
||||
expect(await deaths()).toBe(3);
|
||||
});
|
||||
|
||||
it('full snapshot does not zero missing keys', async () => {
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { uuid: ALICE.uuid, name: null, full: false, ts: T0, stats: { 'minecraft:custom': { 'minecraft:jump': 7 } } })],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { uuid: ALICE.uuid, name: null, full: true, ts: T0 + MIN, stats: { 'minecraft:custom': { 'minecraft:deaths': 1 } } })],
|
||||
'sftp',
|
||||
'atm10',
|
||||
);
|
||||
const { rows } = await pool.query(`SELECT key, value FROM stat_current ORDER BY key`);
|
||||
expect(rows).toEqual([
|
||||
{ key: 'minecraft:deaths', value: 1 },
|
||||
{ key: 'minecraft:jump', value: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates unknown player without name and fills the name later', async () => {
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { uuid: BOB.uuid, name: null, full: true, ts: T0, stats: { 'minecraft:custom': { 'minecraft:jump': 1 } } })],
|
||||
'sftp',
|
||||
'atm10',
|
||||
);
|
||||
expect((await pool.query('SELECT name FROM players')).rows[0].name).toBeNull();
|
||||
await push(ev('player_join', { ...BOB, ts: T0 + MIN }));
|
||||
expect((await pool.query('SELECT name FROM players')).rows[0].name).toBe('Bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancements, combat, dimensions, health', () => {
|
||||
it('advancement keeps earliest timestamp', async () => {
|
||||
await push(ev('advancement', { ...ALICE, ts: T0 + MIN, advancementId: 'a:b', title: null }));
|
||||
await push(ev('advancement', { ...ALICE, ts: T0, advancementId: 'a:b', title: 'Title' }));
|
||||
const { rows } = await pool.query('SELECT achieved_at, title FROM advancements');
|
||||
expect(ms(rows[0].achieved_at)).toBe(T0);
|
||||
expect(rows[0].title).toBe('Title');
|
||||
});
|
||||
|
||||
it('death and boss kill are stored', async () => {
|
||||
await push(
|
||||
ev('player_death', { ...ALICE, ts: T0, dimension: 'minecraft:the_nether', cause: 'minecraft:lava', killerType: null, killerName: null }),
|
||||
ev('entity_kill', { ...ALICE, ts: T0, dimension: 'minecraft:the_end', victimType: 'minecraft:ender_dragon', isBoss: true, weapon: null }),
|
||||
);
|
||||
expect((await pool.query('SELECT cause FROM deaths')).rows[0].cause).toBe('minecraft:lava');
|
||||
expect((await pool.query('SELECT is_boss FROM kills')).rows[0].is_boss).toBe(true);
|
||||
});
|
||||
|
||||
it('dimension visits keep first visit', async () => {
|
||||
await push(ev('dimension_change', { ...ALICE, ts: T0, from: 'minecraft:overworld', to: 'minecraft:the_nether' }));
|
||||
await push(ev('dimension_change', { ...ALICE, ts: T0 + MIN, from: 'minecraft:overworld', to: 'minecraft:the_nether' }));
|
||||
const { rows } = await pool.query('SELECT dimension, first_at FROM dimension_visits ORDER BY dimension');
|
||||
expect(rows.map((r) => r.dimension)).toEqual(['minecraft:overworld', 'minecraft:the_nether']);
|
||||
expect(ms(rows[1].first_at)).toBe(T0);
|
||||
});
|
||||
|
||||
it('health stored per minute bucket', async () => {
|
||||
const h = { msptAvg: 30, msptP95: 40, msptMax: 50, tps: 20, players: 1, chunks: 100, heapUsedMb: 1000, heapMaxMb: 4000 };
|
||||
await push(ev('health', { ...h, ts: T0 + MIN, windowStart: T0 + 5_000, windowEnd: T0 + MIN }));
|
||||
const { rows } = await pool.query('SELECT bucket, tps FROM server_health');
|
||||
expect(ms(rows[0].bucket)).toBe(T0);
|
||||
expect(rows[0].tps).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review fixes', () => {
|
||||
it('a late event with an older name does not revert a rename', async () => {
|
||||
await push(ev('player_join', { uuid: ALICE.uuid, name: 'NewName', ts: T0 + 10 * MIN }));
|
||||
await push(ev('player_leave', { uuid: ALICE.uuid, name: 'OldName', ts: T0 }));
|
||||
expect((await pool.query('SELECT name FROM players WHERE uuid = $1', [ALICE.uuid])).rows[0].name).toBe('NewName');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev, MIN, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app } = await makeApp(pool));
|
||||
const now = Date.now();
|
||||
await processEvents(
|
||||
pool,
|
||||
[
|
||||
ev('player_join', { uuid: ALICE.uuid, name: 'OldAlice', ts: now - 3 * DAY }),
|
||||
ev('player_leave', { uuid: ALICE.uuid, name: 'OldAlice', ts: now - 3 * DAY + 30 * MIN }),
|
||||
ev('player_join', { ...ALICE, ts: now - 10 * MIN }),
|
||||
ev('dimension_change', { ...ALICE, ts: now - 5 * MIN, from: 'minecraft:overworld', to: 'minecraft:the_nether' }),
|
||||
ev('player_join', { ...BOB, ts: now - 2 * MIN }),
|
||||
ev('stats_snapshot', {
|
||||
...ALICE,
|
||||
full: false,
|
||||
ts: now - MIN,
|
||||
stats: {
|
||||
'minecraft:custom': { 'minecraft:play_time': 144000, 'minecraft:deaths': 2, 'minecraft:mob_kills': 5, 'minecraft:walk_one_cm': 1000 },
|
||||
'minecraft:mined': { 'mekanism:osmium_ore': 30, 'mekanism:steel_casing': 1, 'minecraft:stone': 99 },
|
||||
'minecraft:killed': { 'minecraft:zombie': 4 },
|
||||
},
|
||||
}),
|
||||
ev('stats_snapshot', { ...BOB, full: false, ts: now - MIN, stats: { 'minecraft:custom': { 'minecraft:deaths': 7 } } }),
|
||||
ev('player_death', { ...ALICE, ts: now - 4 * MIN, dimension: 'minecraft:the_nether', cause: 'minecraft:lava', killerType: null, killerName: null }),
|
||||
ev('advancement', { ...ALICE, ts: now - 3 * MIN, advancementId: 'minecraft:story/enter_the_nether', title: 'We Need to Go Deeper' }),
|
||||
],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
const get = async (url: string) => {
|
||||
const res = await app.inject({ method: 'GET', url });
|
||||
return { status: res.statusCode, body: res.statusCode < 500 ? res.json() : res.body, headers: res.headers };
|
||||
};
|
||||
|
||||
describe('public api', () => {
|
||||
it('overview counts', async () => {
|
||||
const { status, body, headers } = await get('/api/overview');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ online: 2, totalPlayers: 2, totalPlayHours: 2, totalDeaths: 9, totalMobKills: 5, advancements: 1 });
|
||||
expect(headers['cache-control']).toBe('public, max-age=15');
|
||||
});
|
||||
|
||||
it('online lists open sessions only, with current dimension', async () => {
|
||||
const { body } = await get('/api/online');
|
||||
expect(body.players.map((p: { name: string }) => p.name).sort()).toEqual(['Bob', 'CDOGamer']);
|
||||
expect(body.players.find((p: { name: string }) => p.name === 'CDOGamer').dimension).toBe('minecraft:the_nether');
|
||||
});
|
||||
|
||||
it('profile by uuid, current name and old name', async () => {
|
||||
for (const key of [ALICE.uuid, 'cdogamer', 'OldAlice']) {
|
||||
const { status, body } = await get(`/api/players/${key}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body.uuid).toBe(ALICE.uuid);
|
||||
}
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}`);
|
||||
expect(body).toMatchObject({ name: 'CDOGamer', online: true, playHours: 2, sessions: 2 });
|
||||
expect(body.movement).toEqual({ 'minecraft:walk_one_cm': 1000 });
|
||||
expect(body.general['minecraft:deaths']).toBe(2);
|
||||
expect(body.deathsByCause).toEqual([{ cause: 'minecraft:lava', count: 1 }]);
|
||||
expect(body.killsByType).toEqual([{ entity: 'minecraft:zombie', count: 4 }]);
|
||||
expect(body.advancements[0].title).toBe('We Need to Go Deeper');
|
||||
expect(body.ranks.deaths).toEqual({ rank: 2, value: 2 });
|
||||
expect(body.activityHeatmap).toHaveLength(7);
|
||||
expect(body.activityHeatmap[0]).toHaveLength(24);
|
||||
expect(body.activityHeatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBeGreaterThanOrEqual(39);
|
||||
});
|
||||
|
||||
it('unknown player is 404', async () => {
|
||||
expect((await get('/api/players/Nobody')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('hidden player is 404 and absent from leaderboards, online and overview', async () => {
|
||||
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
|
||||
expect((await get(`/api/players/${BOB.uuid}`)).status).toBe(404);
|
||||
expect((await get('/api/online')).body.players.map((p: { uuid: string }) => p.uuid)).toEqual([ALICE.uuid]);
|
||||
const lb = (await get('/api/leaderboards/deaths')).body;
|
||||
expect(lb.entries.map((e: { uuid: string }) => e.uuid)).toEqual([ALICE.uuid]);
|
||||
expect((await get('/api/overview')).body).toMatchObject({ online: 1, totalPlayers: 1, totalDeaths: 2 });
|
||||
expect((await get('/api/players?search=bo')).body.players).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaderboards list has every board with top entries', async () => {
|
||||
const { body } = await get('/api/leaderboards');
|
||||
expect(body.boards).toHaveLength(14);
|
||||
expect(body.boards.find((b: { id: string }) => b.id === 'deaths').top[0].name).toBe('Bob');
|
||||
expect((await get('/api/leaderboards/nope')).status).toBe(404);
|
||||
expect((await get('/api/leaderboards/deaths?period=1y')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('player search and paging', async () => {
|
||||
const { body } = await get('/api/players?search=cdo');
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.players[0]).toMatchObject({ name: 'CDOGamer', online: true, playHours: 2 });
|
||||
});
|
||||
|
||||
it('items grouped by namespace', async () => {
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}/items?category=mined`);
|
||||
expect(body.mods.map((m: { namespace: string; total: number }) => [m.namespace, m.total])).toEqual([
|
||||
['minecraft', 99],
|
||||
['mekanism', 31],
|
||||
]);
|
||||
expect((await get(`/api/players/${ALICE.uuid}/items?category=bogus`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('history returns daily points and the current value', async () => {
|
||||
await pool.query(
|
||||
`INSERT INTO stat_snapshots(uuid, metric, bucket, granularity, value) VALUES ($1, 'deaths', date_trunc('day', now() - interval '2 days'), 'day', 1)`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}/history?metric=deaths&range=30d`);
|
||||
expect(body.unit).toBe('count');
|
||||
expect(body.points.map((p: { v: number }) => p.v)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('server and mods expose versions from server_start', async () => {
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('server_start', { ts: T0, mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21.0.5', mods: [{ id: 'mekanism', version: '10.7.19', name: 'Mekanism' }] })],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
const mods = (await get('/api/mods')).body;
|
||||
expect(mods.server).toMatchObject({ mcVersion: '1.21.1', neoforgeVersion: '21.1.219', modCount: 1 });
|
||||
expect(mods.mods[0]).toMatchObject({ id: 'mekanism', activity: 31, category: 'tech' });
|
||||
expect((await get('/api/server?range=24h')).body.info.javaVersion).toBe('21.0.5');
|
||||
});
|
||||
|
||||
it('quests and sources respond', async () => {
|
||||
expect((await get('/api/quests')).body).toMatchObject({ defsKnown: false, chapters: [] });
|
||||
const src = (await get('/api/sources')).body;
|
||||
expect(src.push.reachable).toBe(true);
|
||||
expect(src.sftp.enabled).toBe(false);
|
||||
expect(src.limitations.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('avatar rejects invalid uuid with 400', async () => {
|
||||
expect((await get('/api/avatar/not-a-uuid')).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quest titles in the profile', () => {
|
||||
it('lists recently completed quests with titles, newest first, only known quests', async () => {
|
||||
await pool.query(
|
||||
`INSERT INTO quest_defs(quest_id, chapter_id, chapter_title, title) VALUES
|
||||
('AAAAAAAAAAAAAAAA', 'C1', 'Getting Started', 'Punch a Tree'),
|
||||
('BBBBBBBBBBBBBBBB', 'C1', 'Getting Started', NULL),
|
||||
('CCCCCCCCCCCCCCCC', 'C1', 'Getting Started', 'Craft a Table')`,
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO quest_progress(team_id, quest_id, completed_at) VALUES
|
||||
($1, 'AAAAAAAAAAAAAAAA', now() - interval '2 hours'),
|
||||
($1, 'BBBBBBBBBBBBBBBB', now() - interval '1 hour'),
|
||||
($1, 'CCCCCCCCCCCCCCCC', now() - interval '30 minutes'),
|
||||
($1, 'DDDDDDDDDDDDDDDD', now())`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}`);
|
||||
expect(body.quests.completed).toBe(3);
|
||||
expect(body.quests.recent.map((q: { title: string }) => q.title)).toEqual(['Craft a Table', 'Punch a Tree']);
|
||||
expect(body.quests.recent[0]).toMatchObject({ chapterTitle: 'Getting Started' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runJob } from '../src/worker/scheduler.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
describe('runJob', () => {
|
||||
it('never runs the same job twice concurrently', async () => {
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
const job = async () => {
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
running--;
|
||||
};
|
||||
const results = await Promise.all([runJob(pool, 'x', 900_001, job), runJob(pool, 'x', 900_001, job)]);
|
||||
expect(maxRunning).toBe(1);
|
||||
expect(results.sort()).toEqual(['locked', 'ok']);
|
||||
});
|
||||
|
||||
it('records failures in app_errors', async () => {
|
||||
const r = await runJob(pool, 'sftp', 900_002, async () => {
|
||||
throw new Error('connection refused');
|
||||
});
|
||||
expect(r).toBe('error');
|
||||
const { rows } = await pool.query('SELECT source, message FROM app_errors');
|
||||
expect(rows).toEqual([{ source: 'sftp', message: 'connection refused' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { applyRetention, takeSnapshots } from '../src/stats/snapshots.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, ev, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { ...ALICE, full: false, ts: T0, stats: { 'minecraft:custom': { 'minecraft:play_time': 72000 } } })],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const count = async (granularity: string) =>
|
||||
(await pool.query(`SELECT count(*)::int AS n FROM stat_snapshots WHERE granularity = $1`, [granularity])).rows[0].n;
|
||||
|
||||
describe('snapshots', () => {
|
||||
it('takeSnapshots idempotent', async () => {
|
||||
await takeSnapshots(pool, 'hour', new Date(T0 + 1000));
|
||||
const first = await count('hour');
|
||||
await takeSnapshots(pool, 'hour', new Date(T0 + 2000));
|
||||
expect(await count('hour')).toBe(first);
|
||||
const { rows } = await pool.query(`SELECT value FROM stat_snapshots WHERE metric = 'playtime'`);
|
||||
expect(rows[0].value).toBe(72000);
|
||||
});
|
||||
|
||||
it('retention deletes hourly older than 30 days but keeps daily', async () => {
|
||||
await takeSnapshots(pool, 'hour', new Date(T0));
|
||||
await takeSnapshots(pool, 'day', new Date(T0));
|
||||
await applyRetention(pool, new Date(T0 + 31 * DAY));
|
||||
expect(await count('hour')).toBe(0);
|
||||
expect(await count('day')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('retention folds health minutes older than 14 days into hourly aggregates', async () => {
|
||||
for (const m of [0, 1, 2]) {
|
||||
await pool.query(
|
||||
`INSERT INTO server_health VALUES (to_timestamp($1 / 1000.0), $2, 50, 100, 20, 2, 500, 1000, 4000)`,
|
||||
[T0 + m * 60_000, 10 * (m + 1)],
|
||||
);
|
||||
}
|
||||
await applyRetention(pool, new Date(T0 + 15 * DAY));
|
||||
expect((await pool.query('SELECT count(*)::int AS n FROM server_health')).rows[0].n).toBe(0);
|
||||
const { rows } = await pool.query('SELECT mspt_avg, samples FROM server_health_hourly');
|
||||
expect(rows).toEqual([{ mspt_avg: 20, samples: 3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('events retention', () => {
|
||||
it('drops bulky projected events after 30 days but keeps player events', async () => {
|
||||
const old = T0 - 40 * DAY;
|
||||
await processEvents(
|
||||
pool,
|
||||
[
|
||||
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 0, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: old, windowEnd: old, ts: old }),
|
||||
ev('player_join', { ...ALICE, ts: old }),
|
||||
],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
await pool.query(`UPDATE events SET received_at = to_timestamp($1 / 1000.0)`, [old]);
|
||||
await applyRetention(pool, new Date(T0));
|
||||
const { rows } = await pool.query(`SELECT type FROM events ORDER BY type`);
|
||||
expect(rows.map((r) => r.type)).toEqual(['player_join']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSnbt, SnbtError } from '../src/parsers/snbt.ts';
|
||||
|
||||
describe('parseSnbt', () => {
|
||||
it('parses newline-separated compound', () => {
|
||||
expect(parseSnbt('{\n\tversion: 1\n\tlock: false\n\tname: "CDOGamer#308a693c"\n}')).toEqual({
|
||||
version: 1,
|
||||
lock: false,
|
||||
name: 'CDOGamer#308a693c',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses comma-separated compound', () => {
|
||||
expect(parseSnbt('{a: 1, b: "x", c: {d: 2b}}')).toEqual({ a: 1, b: 'x', c: { d: 2 } });
|
||||
});
|
||||
|
||||
it('parses suffixes 1L 2b 1.5f 0.0d -3s', () => {
|
||||
expect(parseSnbt('[1L, 2b, 1.5f, 0.0d, -3s, 1791559740996L]')).toEqual([1, 2, 1.5, 0, -3, 1791559740996]);
|
||||
});
|
||||
|
||||
it('parses keys that start with digits and quoted keys with colons', () => {
|
||||
expect(
|
||||
parseSnbt('{ 0000000000000001: 5L\n 308a693c2bd84d6b: { x: 1 }\n "a:B": 2L\n quest.ABC.title: "t" }'),
|
||||
).toEqual({ '0000000000000001': 5, '308a693c2bd84d6b': { x: 1 }, 'a:B': 2, 'quest.ABC.title': 't' });
|
||||
});
|
||||
|
||||
it('parses typed arrays and empty containers', () => {
|
||||
expect(parseSnbt('{a: [I; 1, 2], b: [L; 3L], c: [B; 1b], d: [], e: { }}')).toEqual({
|
||||
a: [1, 2],
|
||||
b: [3],
|
||||
c: [1],
|
||||
d: [],
|
||||
e: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses string escapes and single quotes', () => {
|
||||
expect(parseSnbt(`["a\\"b", 'c\\'d', "\\\\"]`)).toEqual(['a"b', "c'd", '\\']);
|
||||
});
|
||||
|
||||
it('keeps unquoted non-numeric words as strings', () => {
|
||||
expect(parseSnbt('{type: item, id: 3BC0A50886A3222B}')).toEqual({ type: 'item', id: '3BC0A50886A3222B' });
|
||||
});
|
||||
|
||||
it('reports line on error', () => {
|
||||
try {
|
||||
parseSnbt('{\n a: 1\n b: {\n}');
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SnbtError);
|
||||
expect((e as SnbtError).line).toBe(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { advancementsFileToEvents, statsFileToEvent } from '../src/parsers/vanilla.ts';
|
||||
|
||||
const fx = (p: string) => readFileSync(join(import.meta.dirname, 'fixtures', p), 'utf8');
|
||||
const ALICE = '308a693c-2bd8-4d6b-8790-670aef5be601';
|
||||
|
||||
describe('statsFileToEvent', () => {
|
||||
it('stats file maps play_time', () => {
|
||||
const e = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1760300000000);
|
||||
expect(e.type).toBe('stats_snapshot');
|
||||
if (e.type !== 'stats_snapshot') return;
|
||||
expect(e.full).toBe(true);
|
||||
expect(e.ts).toBe(1760300000000);
|
||||
expect(e.name).toBeNull();
|
||||
expect(e.stats['minecraft:custom']?.['minecraft:play_time']).toBe(1175736);
|
||||
expect(e.stats['minecraft:mined']?.['mekanism:enrichment_chamber']).toBe(2);
|
||||
});
|
||||
|
||||
it('id stable for same content', () => {
|
||||
const a = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1);
|
||||
const b = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1);
|
||||
const c = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 2);
|
||||
expect(a.id).toBe(b.id);
|
||||
expect(a.id).not.toBe(c.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancementsFileToEvents', () => {
|
||||
it('emits done, non-recipe advancements with earliest criterion time', () => {
|
||||
const events = advancementsFileToEvents(ALICE, fx(`advancements/${ALICE}.json`));
|
||||
const ids = events.map((e) => (e.type === 'advancement' ? e.advancementId : ''));
|
||||
expect(ids.sort()).toEqual(['minecraft:story/enter_the_nether', 'minecraft:story/root']);
|
||||
const nether = events.find((e) => e.type === 'advancement' && e.advancementId === 'minecraft:story/enter_the_nether')!;
|
||||
expect(nether.ts).toBe(Date.parse('2026-10-12T12:42:23+02:00'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancement names', () => {
|
||||
it('file-derived advancements carry no name so they never overwrite the player name', () => {
|
||||
const events = advancementsFileToEvents(ALICE, fx(`advancements/${ALICE}.json`));
|
||||
expect(events.every((e) => e.type === 'advancement' && e.name === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { appendFileSync, cpSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { LocalFileSource } from '../src/worker/fileSource.ts';
|
||||
import { closeStaleSessions, syncOnce } from '../src/worker/sync.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, ev, MIN } from './helpers/events.ts';
|
||||
|
||||
const FX = join(import.meta.dirname, 'fixtures');
|
||||
const OPTS = { timeZone: 'Europe/Berlin', serverId: 'atm10', worldDir: 'world' };
|
||||
let pool: Pool;
|
||||
let root: string;
|
||||
|
||||
function makeServerDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'atm-sftp-'));
|
||||
mkdirSync(join(dir, 'world', 'stats'), { recursive: true });
|
||||
mkdirSync(join(dir, 'world', 'advancements'), { recursive: true });
|
||||
mkdirSync(join(dir, 'world', 'ftbquests'), { recursive: true });
|
||||
mkdirSync(join(dir, 'config', 'ftbquests', 'quests', 'chapters'), { recursive: true });
|
||||
mkdirSync(join(dir, 'logs'), { recursive: true });
|
||||
cpSync(join(FX, 'stats'), join(dir, 'world', 'stats'), { recursive: true });
|
||||
cpSync(join(FX, 'advancements'), join(dir, 'world', 'advancements'), { recursive: true });
|
||||
cpSync(join(FX, 'ftbquests', '308a693c-2bd8-4d6b-8790-670aef5be601.snbt'), join(dir, 'world', 'ftbquests', '308a693c-2bd8-4d6b-8790-670aef5be601.snbt'));
|
||||
cpSync(join(FX, 'ftbquests', 'config', 'chapters', 'getting_started.snbt'), join(dir, 'config', 'ftbquests', 'quests', 'chapters', 'getting_started.snbt'));
|
||||
cpSync(join(FX, 'latest.anon.log'), join(dir, 'logs', 'latest.log'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
const LINE = (t: string, msg: string) => `[12Oct2026 ${t}] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: ${msg}\r\n`;
|
||||
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
root = makeServerDir();
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const count = async (sql: string) => (await pool.query(sql)).rows[0].n as number;
|
||||
|
||||
describe('syncOnce', () => {
|
||||
it('first sync imports stats, advancements, quests, quest config and log', async () => {
|
||||
const r = await syncOnce(pool, new LocalFileSource(root), OPTS);
|
||||
expect(r.files).toBe(6);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM stat_current WHERE key = 'minecraft:play_time'`)).toBe(2);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id = 'minecraft:story/root'`)).toBe(1);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM quest_progress`)).toBeGreaterThan(0);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM quest_defs`)).toBe(2);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM sessions`)).toBeGreaterThan(0);
|
||||
expect((await pool.query(`SELECT name FROM players WHERE uuid = $1`, [ALICE.uuid])).rows[0].name).toBe('CDOGamer');
|
||||
expect(await count(`SELECT count(*)::int AS n FROM events WHERE source = 'sftp'`)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('second sync without changes reads nothing', async () => {
|
||||
const src = new LocalFileSource(root);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
const spy = vi.spyOn(src, 'read');
|
||||
const r = await syncOnce(pool, src, OPTS);
|
||||
expect(r.files).toBe(0);
|
||||
// only the 256-byte head of latest.log is read to detect rotation
|
||||
expect(spy.mock.calls.every(([path, start, length]) => path === 'logs/latest.log' && start === 0 && length === 256)).toBe(true);
|
||||
});
|
||||
|
||||
it('appended log lines are imported incrementally', async () => {
|
||||
// log advancements only count for players without a synced advancements file
|
||||
rmSync(join(root, 'world', 'advancements', `${ALICE.uuid}.json`));
|
||||
const src = new LocalFileSource(root);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
const before = await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id LIKE 'log:%'`);
|
||||
appendFileSync(join(root, 'logs', 'latest.log'), LINE('23:50:00.000', 'CDOGamer has made the advancement [Brand New Title]'));
|
||||
const r = await syncOnce(pool, src, OPTS);
|
||||
expect(r.files).toBe(1);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id LIKE 'log:%'`)).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('rotated log resets offset', async () => {
|
||||
rmSync(join(root, 'world', 'advancements', `${ALICE.uuid}.json`));
|
||||
const src = new LocalFileSource(root);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
writeFileSync(
|
||||
join(root, 'logs', 'latest.log'),
|
||||
LINE('23:58:00.000', 'UUID of player CDOGamer is 308a693c-2bd8-4d6b-8790-670aef5be601').replace('MinecraftServer', 'ServerLoginPacketListenerImpl') +
|
||||
LINE('23:58:01.000', 'CDOGamer has made the advancement [After Rotation]'),
|
||||
);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE title = 'After Rotation'`)).toBe(1);
|
||||
const { rows } = await pool.query(`SELECT "offset", size FROM sync_state WHERE path = 'logs/latest.log'`);
|
||||
expect(rows[0].offset).toBe(rows[0].size);
|
||||
});
|
||||
|
||||
it('log joins skipped while push is fresh', async () => {
|
||||
await processEvents(pool, [ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 0, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: Date.now() - MIN, windowEnd: Date.now() })], 'push', 'atm10');
|
||||
await syncOnce(pool, new LocalFileSource(root), OPTS);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM sessions`)).toBe(0);
|
||||
});
|
||||
|
||||
it('log advancement skipped when the player already has it with that title', async () => {
|
||||
await processEvents(pool, [ev('advancement', { ...ALICE, advancementId: 'mysticalagriculture:infusion', title: 'Infusing Efficiency' })], 'push', 'atm10');
|
||||
await syncOnce(pool, new LocalFileSource(root), OPTS);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE title = 'Infusing Efficiency'`)).toBe(1);
|
||||
});
|
||||
|
||||
it('changed stats file is re-imported with its new mtime', async () => {
|
||||
const src = new LocalFileSource(root);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
const f = join(root, 'world', 'stats', '308a693c-2bd8-4d6b-8790-670aef5be601.json');
|
||||
writeFileSync(f, JSON.stringify({ stats: { 'minecraft:custom': { 'minecraft:play_time': 2_000_000 } }, DataVersion: 3955 }));
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
utimesSync(f, future, future);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
const { rows } = await pool.query(`SELECT value FROM stat_current WHERE uuid = $1 AND key = 'minecraft:play_time'`, [ALICE.uuid]);
|
||||
expect(rows[0].value).toBe(2_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeStaleSessions', () => {
|
||||
it('closes open sessions when the mod stopped reporting health', async () => {
|
||||
const now = Date.now();
|
||||
await processEvents(
|
||||
pool,
|
||||
[
|
||||
ev('player_join', { ...ALICE, ts: now - 60 * MIN }),
|
||||
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 1, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: now - 31 * MIN, windowEnd: now - 30 * MIN, ts: now - 30 * MIN }),
|
||||
],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
expect(await closeStaleSessions(pool, new Date(now))).toBe(1);
|
||||
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
|
||||
expect(rows[0].end_reason).toBe('stale');
|
||||
expect(rows[0].ended_at.getTime()).toBe(now - 30 * MIN);
|
||||
});
|
||||
|
||||
it('keeps sessions while health is fresh or when push was never used', async () => {
|
||||
const now = Date.now();
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE, ts: now - 60 * MIN })], 'sftp', 'atm10');
|
||||
expect(await closeStaleSessions(pool, new Date(now))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review fixes: log fallback', () => {
|
||||
const health = (end: number) =>
|
||||
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 1, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: end - MIN, windowEnd: end, ts: end });
|
||||
|
||||
it('a log session that started after the mod went silent stays open', async () => {
|
||||
const now = Date.now();
|
||||
await processEvents(pool, [health(now - 60 * MIN)], 'push', 'atm10');
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE, ts: now - 20 * MIN })], 'sftp', 'atm10');
|
||||
expect(await closeStaleSessions(pool, new Date(now))).toBe(0);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM sessions WHERE ended_at IS NULL`)).toBe(1);
|
||||
});
|
||||
|
||||
it('late push join/leave inside an existing log session do not create a second session', async () => {
|
||||
const t = Date.now() - 120 * MIN;
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE, ts: t }), ev('player_leave', { ...ALICE, ts: t + 60 * MIN })], 'sftp', 'atm10');
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE, ts: t + 1000 }), ev('player_leave', { ...ALICE, ts: t + 60 * MIN + 1000 })], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT started_at, ended_at FROM sessions`);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ended_at.getTime() - rows[0].started_at.getTime()).toBeGreaterThanOrEqual(60 * MIN);
|
||||
});
|
||||
|
||||
it('pull-only: advancements from file and log are counted once', async () => {
|
||||
await syncOnce(pool, new LocalFileSource(root), OPTS);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE uuid = '${ALICE.uuid}' AND advancement_id LIKE 'log:%'`)).toBe(0);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE uuid = '${ALICE.uuid}'`)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quest config and teams via SFTP', () => {
|
||||
it('reads chapters, English split lang files and team files; skips other languages', async () => {
|
||||
const lang = join(root, 'config', 'ftbquests', 'quests', 'lang');
|
||||
mkdirSync(join(lang, 'en_us', 'chapters'), { recursive: true });
|
||||
mkdirSync(join(lang, 'de_de', 'chapters'), { recursive: true });
|
||||
cpSync(join(FX, 'ftbquests', 'config', 'lang', 'en_us'), join(lang, 'en_us'), { recursive: true });
|
||||
writeFileSync(join(lang, 'de_de', 'chapters', 'getting_started.snbt_merged'), '{ quest.3BC0A50886A3222B.title: "Baum schlagen" }');
|
||||
mkdirSync(join(root, 'world', 'ftbteams'), { recursive: true });
|
||||
cpSync(join(FX, 'ftbteams'), join(root, 'world', 'ftbteams'), { recursive: true });
|
||||
|
||||
const src = new LocalFileSource(root);
|
||||
const spy = vi.spyOn(src, 'read');
|
||||
await syncOnce(pool, src, OPTS);
|
||||
expect(spy.mock.calls.some(([p]) => String(p).includes('de_de'))).toBe(false);
|
||||
const { rows } = await pool.query(`SELECT title, chapter_title FROM quest_defs WHERE quest_id = '3BC0A50886A3222B'`);
|
||||
expect(rows).toEqual([{ title: 'Punch a Tree', chapter_title: 'Getting Started' }]);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM team_members`)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../tsconfig.base.json", "include": ["src", "test"] }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/server.ts', 'src/worker.ts', 'src/cli.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node22',
|
||||
platform: 'node',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
// The workspace package ships TypeScript sources; bundle it, keep real npm dependencies external.
|
||||
noExternal: ['@atm/shared'],
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
fileParallelism: false,
|
||||
testTimeout: 20_000,
|
||||
hookTimeout: 30_000,
|
||||
env: {
|
||||
TEST_DATABASE_URL: process.env.TEST_DATABASE_URL ?? 'postgres://postgres:postgres@localhost:54329/atm_test',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user