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:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 09:33:25 +02:00
1 parent 56d2078e6a
commit e66f89f49e
214 files changed
+27077

No files matched your search

+35
View File
@@ -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"]
+15
View File
@@ -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"
]
}
+179
View File
@@ -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
);
+16
View File
@@ -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);
+15
View File
@@ -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);
+6
View File
@@ -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
);
+9
View File
@@ -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);
+35
View File
@@ -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"
}
}
+59
View File
@@ -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;
}
+52
View File
@@ -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]);
}
+59
View File
@@ -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);
});
+46
View File
@@ -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 };
}
+54
View File
@@ -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;
}
+28
View File
@@ -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();
}
}
+44
View File
@@ -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 };
}
+79
View File
@@ -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 };
},
);
};
}
+22
View File
@@ -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');
}
+81
View File
@@ -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);
}
}
+72
View File
@@ -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 })]);
}
});
}
+138
View File
@@ -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)),
};
}
+93
View File
@@ -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 };
}
+122
View File
@@ -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);
+56
View File
@@ -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;
}
+100
View File
@@ -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],
);
});
+88
View File
@@ -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)],
);
});
+31
View File
@@ -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],
);
});
+124
View File
@@ -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 };
});
});
}
+45
View File
@@ -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);
});
}
+42
View File
@@ -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);
}
});
}
+20
View File
@@ -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 });
}
+23
View File
@@ -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;
});
}
+50
View File
@@ -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 })) };
});
}
+193
View File
@@ -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,
};
}
+136
View File
@@ -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,
};
});
}
+42
View File
@@ -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.',
];
+19
View File
@@ -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 });
+71
View File
@@ -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;
}
+115
View File
@@ -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;
}
}
+67
View File
@@ -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();
}
}
+95
View File
@@ -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();
+104
View File
@@ -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;
}
}
+35
View File
@@ -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;
+172
View File
@@ -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;
}
+110
View File
@@ -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);
});
});
+29
View File
@@ -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"]
}
@@ -0,0 +1,5 @@
{
quest.3BC0A50886A3222B.title: "Punch a Tree"
quest.3BC0A50886A3222B.quest_desc: ["Line one", "Line two"]
quest.33218EFDD498E9DB.title: "From Lang"
}
@@ -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: { }
}
@@ -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
+227
View File
@@ -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');
});
});
+21
View File
@@ -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 };
}
+26
View File
@@ -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;
}
+16
View File
@@ -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;
+34
View File
@@ -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 };
}
+128
View File
@@ -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',
);
});
});
+111
View File
@@ -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();
});
});
+116
View File
@@ -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]);
});
});
+68
View File
@@ -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]);
});
});
+172
View File
@@ -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');
});
});
+182
View File
@@ -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' });
});
});
+36
View File
@@ -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' }]);
});
});
+75
View File
@@ -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']);
});
});
+54
View File
@@ -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);
}
});
});
+45
View File
@@ -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);
});
});
+190
View File
@@ -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);
});
});
+1
View File
@@ -0,0 +1 @@
{ "extends": "../tsconfig.base.json", "include": ["src", "test"] }
+13
View File
@@ -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'],
});
+12
View File
@@ -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',
},
},
});