feat: ATM10-Statistikportal
Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1): - mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit, FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff - backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT, FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots, Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker - web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server, Datenquellen, Verwaltung - shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client - deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56d2078e6a
commit
e66f89f49e
214 files changed
+27077
No files matched your search
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/dist
|
||||
mod
|
||||
server-snapshot
|
||||
.superpowers
|
||||
.git
|
||||
**/test-results
|
||||
**/playwright-report
|
||||
@@ -0,0 +1,5 @@
|
||||
* text=auto eol=lf
|
||||
*.jar binary
|
||||
*.png binary
|
||||
*.bat text eol=crlf
|
||||
backend/test/fixtures/** -text
|
||||
@@ -11,3 +11,4 @@ mod/run/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.superpowers/
|
||||
@@ -0,0 +1,71 @@
|
||||
# ATM10 Stats
|
||||
|
||||
Statistikportal für einen All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1) bei Mine-Hoster:
|
||||
Serverstatus mit Live-Ticker, 14 Ranglisten, Spielerprofile mit Verläufen und Aktivitäts-Heatmap,
|
||||
ATM10-Auswertungen (Aktivität je Mod und Bereich, FTB-Quests) und Serverleistung (TPS, Tickdauer, Speicher).
|
||||
|
||||
```
|
||||
Mine-Hoster (ATM10) eigener Server (DMZ, Docker Compose hinter Traefik)
|
||||
┌───────────────────────┐ HTTPS (signiert) ┌─────────────────────────────────────────────┐
|
||||
│ atmstats-Mod │ ───────────────────▶ │ api Fastify: Ingest, REST, SSE, Admin │
|
||||
│ (nur serverseitig) │ │ worker SFTP-Abgleich, Snapshots, Retention │
|
||||
└───────────────────────┘ │ web Next.js-Dashboard │
|
||||
▲ world/stats, ftbquests, logs │ db PostgreSQL 16 + tägliches Backup │
|
||||
└──────────── SFTP (Fallback) ◀─────────────┴─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Inhalt des Repositorys
|
||||
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `mod/` | NeoForge-Mod `atmstats` (Java 21): erfasst Ereignisse, puffert sie auf der Festplatte, sendet signiert per HTTPS |
|
||||
| `backend/` | API und Worker (TypeScript, Fastify, PostgreSQL) |
|
||||
| `web/` | Dashboard (Next.js, TypeScript, Tailwind, Recharts) |
|
||||
| `shared/` | Protokoll-Schemas (Zod) und API-Typen, von Backend, Web und Simulator genutzt; Beispiel-Ereignisse in `shared/fixtures/protocol` |
|
||||
| `deploy/` | Docker Compose (Traefik-Labels), `.env.example`, Backup-Skript |
|
||||
| `tools/` | Simulator für Testdaten, Log-Anonymisierer, RCON-Client für lokale Tests |
|
||||
| `docs/` | Architektur, Datenquellen und Einschränkungen, Betrieb |
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- **[docs/OPERATIONS.md](docs/OPERATIONS.md)**: Installation auf dem DMZ-Server, Traefik/OPNsense, Mod bei Mine-Hoster einspielen, Backups, Fehlerbilder
|
||||
- **[docs/DATA-SOURCES.md](docs/DATA-SOURCES.md)**: welche Statistik woher kommt, wie aktuell sie ist und was nicht erfasst wird
|
||||
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Komponenten, Protokoll, Zuverlässigkeit, Datenmodell
|
||||
- [mod/README.md](mod/README.md): Mod bauen und konfigurieren
|
||||
- Design und Plan: `docs/superpowers/specs/`, `docs/superpowers/plans/`
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
Voraussetzungen: Node 22, Docker, für die Mod JDK 21.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
docker compose -f deploy/docker-compose.test.yml up -d # Postgres auf localhost:54329
|
||||
|
||||
# Backend (eigene Datenbank für die Entwicklung anlegen)
|
||||
docker exec deploy-db-test-1 createdb -U postgres atm_dev
|
||||
export DATABASE_URL=postgres://postgres:postgres@localhost:54329/atm_dev
|
||||
export INGEST_HMAC_SECRET=$(openssl rand -hex 32) NODE_ENV=development
|
||||
npm run cli -w backend -- token:create # Token für Mod oder Simulator
|
||||
npm run dev -w backend # API auf :3001
|
||||
npm run dev:worker -w backend # optional: Snapshots, SFTP
|
||||
|
||||
# Frontend
|
||||
npm run dev -w web # http://localhost:3000
|
||||
|
||||
# Testdaten
|
||||
npm run sim -w tools/simulator -- --token <token> --secret $INGEST_HMAC_SECRET --players 6 --minutes 300 --realtime
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test -w shared # Protokoll-Schemas
|
||||
npm test -w backend # API, Pipeline, Parser, Worker (braucht die Test-Datenbank)
|
||||
npm test -w web # Komponenten und Formatierung
|
||||
./mod/gradlew -p mod test # Mod-Kern: Journal, Versand, Signatur, Serialisierung
|
||||
cd web && npx playwright test # E2E gegen einen laufenden Stack (E2E_BASE_URL)
|
||||
```
|
||||
|
||||
Die Mod-Tests prüfen die Java-Serialisierung gegen dieselben Beispiel-Ereignisse, die das Backend validiert,
|
||||
und die HMAC-Signatur gegen denselben Testvektor. So bleiben Mod und Backend kompatibel.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Build context: repository root (docker build -f backend/Dockerfile .)
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /repo
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json shared/
|
||||
COPY backend/package.json backend/
|
||||
COPY web/package.json web/
|
||||
COPY tools/simulator/package.json tools/simulator/
|
||||
RUN npm ci --workspace backend --include-workspace-root
|
||||
COPY tsconfig.base.json ./
|
||||
COPY shared shared
|
||||
COPY backend backend
|
||||
RUN npm run build -w backend
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /repo
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json shared/
|
||||
COPY backend/package.json backend/
|
||||
COPY web/package.json web/
|
||||
COPY tools/simulator/package.json tools/simulator/
|
||||
RUN npm ci --workspace backend --omit=dev && npm cache clean --force
|
||||
|
||||
FROM node:22-alpine
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=deps /repo/node_modules ./node_modules
|
||||
COPY --from=build /repo/backend/dist ./dist
|
||||
COPY backend/migrations ./migrations
|
||||
COPY backend/config ./config
|
||||
COPY backend/package.json ./package.json
|
||||
USER node
|
||||
EXPOSE 3001
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"tech": [
|
||||
"mekanism", "mekanismgenerators", "powah", "modern_industrialization", "oritech", "create",
|
||||
"immersiveengineering", "appliedenergistics2", "refinedstorage", "draconicevolution",
|
||||
"integrateddynamics", "pneumaticcraft"
|
||||
],
|
||||
"magic": [
|
||||
"ars_nouveau", "ars_elemental", "ars_additions", "irons_spellbooks", "occultism", "theurgy",
|
||||
"rootsclassic", "forbidden_arcanus", "evilcraft", "reliquary"
|
||||
],
|
||||
"farming": [
|
||||
"mysticalagriculture", "mysticalagradditions", "productivebees", "botanypots",
|
||||
"pamhc2crops", "pamhc2trees", "pamhc2foodcore", "pamhc2foodextended", "farmersdelight", "herbsandharvest"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
-- Kernschema (Spec §6)
|
||||
|
||||
CREATE TABLE players (
|
||||
uuid uuid PRIMARY KEY,
|
||||
name text,
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen timestamptz,
|
||||
hidden boolean NOT NULL DEFAULT false
|
||||
);
|
||||
CREATE INDEX players_name_lower ON players (lower(name));
|
||||
|
||||
CREATE TABLE name_history (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
seen_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (uuid, name)
|
||||
);
|
||||
CREATE INDEX name_history_name_lower ON name_history (lower(name));
|
||||
|
||||
CREATE TABLE teams (
|
||||
team_id uuid PRIMARY KEY,
|
||||
name text,
|
||||
type text
|
||||
);
|
||||
|
||||
CREATE TABLE team_members (
|
||||
team_id uuid NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
uuid uuid NOT NULL,
|
||||
PRIMARY KEY (team_id, uuid)
|
||||
);
|
||||
CREATE INDEX team_members_uuid ON team_members (uuid);
|
||||
|
||||
CREATE TABLE events (
|
||||
event_id uuid PRIMARY KEY,
|
||||
server_id text NOT NULL,
|
||||
type text NOT NULL,
|
||||
ts timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
source text NOT NULL CHECK (source IN ('push', 'sftp')),
|
||||
payload jsonb NOT NULL
|
||||
);
|
||||
CREATE INDEX events_ts ON events (ts);
|
||||
CREATE INDEX events_source_received ON events (source, received_at DESC);
|
||||
CREATE INDEX events_player ON events ((payload->>'uuid'), ts);
|
||||
CREATE INDEX events_type_ts ON events (type, ts);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id bigserial PRIMARY KEY,
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
started_at timestamptz NOT NULL,
|
||||
ended_at timestamptz,
|
||||
end_reason text
|
||||
);
|
||||
CREATE INDEX sessions_open ON sessions (uuid) WHERE ended_at IS NULL;
|
||||
CREATE INDEX sessions_uuid_started ON sessions (uuid, started_at DESC);
|
||||
|
||||
CREATE TABLE stat_current (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
category text NOT NULL,
|
||||
key text NOT NULL,
|
||||
value bigint NOT NULL,
|
||||
observed_at timestamptz NOT NULL,
|
||||
source text NOT NULL,
|
||||
PRIMARY KEY (uuid, category, key)
|
||||
);
|
||||
CREATE INDEX stat_current_board ON stat_current (category, key, value DESC);
|
||||
|
||||
CREATE TABLE stat_snapshots (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
metric text NOT NULL,
|
||||
bucket timestamptz NOT NULL,
|
||||
granularity text NOT NULL CHECK (granularity IN ('hour', 'day')),
|
||||
value bigint NOT NULL,
|
||||
PRIMARY KEY (uuid, metric, granularity, bucket)
|
||||
);
|
||||
CREATE INDEX stat_snapshots_bucket ON stat_snapshots (granularity, bucket);
|
||||
|
||||
CREATE TABLE deaths (
|
||||
event_id uuid PRIMARY KEY,
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
ts timestamptz NOT NULL,
|
||||
dimension text NOT NULL,
|
||||
cause text NOT NULL,
|
||||
killer_type text,
|
||||
killer_name text
|
||||
);
|
||||
CREATE INDEX deaths_uuid ON deaths (uuid, ts DESC);
|
||||
|
||||
CREATE TABLE kills (
|
||||
event_id uuid PRIMARY KEY,
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
ts timestamptz NOT NULL,
|
||||
dimension text NOT NULL,
|
||||
victim_type text NOT NULL,
|
||||
is_boss boolean NOT NULL,
|
||||
weapon text
|
||||
);
|
||||
CREATE INDEX kills_uuid ON kills (uuid, ts DESC);
|
||||
|
||||
CREATE TABLE advancements (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
advancement_id text NOT NULL,
|
||||
achieved_at timestamptz NOT NULL,
|
||||
title text,
|
||||
PRIMARY KEY (uuid, advancement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE dimension_visits (
|
||||
uuid uuid NOT NULL REFERENCES players(uuid) ON DELETE CASCADE,
|
||||
dimension text NOT NULL,
|
||||
first_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (uuid, dimension)
|
||||
);
|
||||
|
||||
CREATE TABLE quest_defs (
|
||||
quest_id text PRIMARY KEY,
|
||||
chapter_id text,
|
||||
chapter_title text,
|
||||
title text,
|
||||
def_hash text
|
||||
);
|
||||
|
||||
CREATE TABLE quest_progress (
|
||||
team_id uuid NOT NULL,
|
||||
quest_id text NOT NULL,
|
||||
completed_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (team_id, quest_id)
|
||||
);
|
||||
|
||||
CREATE TABLE server_info (
|
||||
id bigserial PRIMARY KEY,
|
||||
started_at timestamptz NOT NULL,
|
||||
mc_version text NOT NULL,
|
||||
neoforge_version text NOT NULL,
|
||||
java_version text NOT NULL,
|
||||
mods jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE server_health (
|
||||
bucket timestamptz PRIMARY KEY,
|
||||
mspt_avg real NOT NULL,
|
||||
mspt_p95 real NOT NULL,
|
||||
mspt_max real NOT NULL,
|
||||
tps real NOT NULL,
|
||||
players integer NOT NULL,
|
||||
chunks integer NOT NULL,
|
||||
heap_used_mb real NOT NULL,
|
||||
heap_max_mb real NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_state (
|
||||
path text PRIMARY KEY,
|
||||
size bigint NOT NULL,
|
||||
mtime bigint NOT NULL,
|
||||
"offset" bigint NOT NULL DEFAULT 0,
|
||||
hash text,
|
||||
synced_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE admin_users (
|
||||
id bigserial PRIMARY KEY,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE admin_sessions (
|
||||
id text PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
csrf text NOT NULL,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ingest_tokens (
|
||||
id bigserial PRIMARY KEY,
|
||||
hash text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Zuletzt bekannte Inhalte synchronisierter Dateien (Quest-Konfiguration, Teams) und Fehlerprotokoll
|
||||
|
||||
CREATE TABLE file_cache (
|
||||
path text PRIMARY KEY,
|
||||
sha256 text NOT NULL,
|
||||
content text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE app_errors (
|
||||
id bigserial PRIMARY KEY,
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
source text NOT NULL,
|
||||
message text NOT NULL
|
||||
);
|
||||
CREATE INDEX app_errors_at ON app_errors (at DESC);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Stundenaggregate der Servergesundheit (Minutenwerte werden nach 14 Tagen verdichtet)
|
||||
CREATE TABLE server_health_hourly (
|
||||
bucket timestamptz PRIMARY KEY,
|
||||
mspt_avg real NOT NULL,
|
||||
mspt_p95 real NOT NULL,
|
||||
mspt_max real NOT NULL,
|
||||
tps real NOT NULL,
|
||||
players integer NOT NULL,
|
||||
chunks integer NOT NULL,
|
||||
heap_used_mb real NOT NULL,
|
||||
heap_max_mb real NOT NULL,
|
||||
samples integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX stat_snapshots_metric ON stat_snapshots (metric, granularity, uuid, bucket DESC);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Manuell angestoßene SFTP-Synchronisationen (vom Worker abgearbeitet)
|
||||
CREATE TABLE sync_requests (
|
||||
id bigserial PRIMARY KEY,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
done_at timestamptz
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Herkunft jeder Sitzung (Mod oder Log-Fallback), damit sich beide Quellen nicht doppeln
|
||||
ALTER TABLE sessions ADD COLUMN source text NOT NULL DEFAULT 'push';
|
||||
|
||||
-- Zeitpunkt der Beobachtung des aktuellen Namens: verspätete ältere Ereignisse ändern ihn nicht mehr
|
||||
ALTER TABLE players ADD COLUMN name_seen_at timestamptz;
|
||||
UPDATE players p SET name_seen_at = (SELECT max(seen_at) FROM name_history h WHERE h.uuid = p.uuid AND h.name = p.name);
|
||||
|
||||
-- "letzte Aktualisierung" ohne Tabellenscan
|
||||
CREATE INDEX events_received ON events (received_at DESC);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@atm/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"dev:worker": "tsx watch src/worker.ts",
|
||||
"cli": "tsx src/cli.ts",
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ssh2-sftp-client": "^9.0.6",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atm/shared": "*",
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/helmet": "^13.1.2",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.12.5",
|
||||
"pg": "^8.23.1",
|
||||
"pino": "^9.14.0",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import cookie from '@fastify/cookie';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Config } from './config.ts';
|
||||
import { ingestRoutes } from './ingest/route.ts';
|
||||
import { LiveHub } from './live/hub.ts';
|
||||
import { installLiveNotifier } from './live/notifier.ts';
|
||||
import './pipeline/projections/index.ts';
|
||||
import { liveRoutes } from './routes/live.ts';
|
||||
import { adminRoutes } from './routes/admin.ts';
|
||||
import { publicRoutes } from './routes/public/index.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
liveHub: LiveHub;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppDeps {
|
||||
pool: Pool;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: deps.config.LOG_LEVEL,
|
||||
redact: ['req.headers.authorization', 'req.headers["x-atm-signature"]', 'req.headers.cookie'],
|
||||
},
|
||||
trustProxy: deps.config.TRUST_PROXY,
|
||||
bodyLimit: 1024 * 1024,
|
||||
});
|
||||
|
||||
await app.register(helmet, { contentSecurityPolicy: false, crossOriginResourcePolicy: { policy: 'same-site' } });
|
||||
await app.register(rateLimit, { global: false });
|
||||
await app.register(cookie);
|
||||
|
||||
const hub = new LiveHub(deps.config.DATABASE_URL, app.log);
|
||||
void hub.start();
|
||||
const uninstallNotifier = installLiveNotifier(deps.pool);
|
||||
app.decorate('liveHub', hub);
|
||||
app.addHook('onClose', async () => {
|
||||
uninstallNotifier();
|
||||
await hub.stop();
|
||||
});
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
await deps.pool.query('SELECT 1');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await app.register(ingestRoutes(deps));
|
||||
await app.register(publicRoutes, deps);
|
||||
await app.register(liveRoutes, { hub });
|
||||
await app.register(adminRoutes, deps);
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export const SESSION_TTL_MS = 12 * 3600_000;
|
||||
|
||||
export async function createAdmin(pool: Pool, username: string, password: string): Promise<void> {
|
||||
if (password.length < 12) throw new Error('password must be at least 12 characters');
|
||||
const hash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
await pool.query(
|
||||
`INSERT INTO admin_users(username, password_hash) VALUES ($1, $2)
|
||||
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash`,
|
||||
[username, hash],
|
||||
);
|
||||
}
|
||||
|
||||
// Verifying against a dummy hash for unknown users keeps response times uniform (no user enumeration).
|
||||
let dummyHash: Promise<string> | null = null;
|
||||
const getDummyHash = () => (dummyHash ??= argon2.hash(randomBytes(16).toString('hex'), { type: argon2.argon2id }));
|
||||
|
||||
export async function login(pool: Pool, username: string, password: string): Promise<{ sessionId: string; csrf: string } | null> {
|
||||
const { rows } = await pool.query<{ id: number; password_hash: string }>(
|
||||
'SELECT id, password_hash FROM admin_users WHERE username = $1',
|
||||
[username],
|
||||
);
|
||||
const user = rows[0];
|
||||
const ok = await argon2.verify(user?.password_hash ?? (await getDummyHash()), password).catch(() => false);
|
||||
if (!user || !ok) return null;
|
||||
const sessionId = randomBytes(32).toString('base64url');
|
||||
const csrf = randomBytes(24).toString('base64url');
|
||||
await pool.query('DELETE FROM admin_sessions WHERE expires_at < now()');
|
||||
await pool.query(`INSERT INTO admin_sessions(id, user_id, csrf, expires_at) VALUES ($1, $2, $3, now() + make_interval(secs => $4))`, [
|
||||
sessionId,
|
||||
user.id,
|
||||
csrf,
|
||||
SESSION_TTL_MS / 1000,
|
||||
]);
|
||||
return { sessionId, csrf };
|
||||
}
|
||||
|
||||
export async function getSession(pool: Pool, sessionId: string): Promise<{ username: string; csrf: string } | null> {
|
||||
const { rows } = await pool.query<{ username: string; csrf: string }>(
|
||||
`SELECT u.username, s.csrf FROM admin_sessions s JOIN admin_users u ON u.id = s.user_id
|
||||
WHERE s.id = $1 AND s.expires_at > now()`,
|
||||
[sessionId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function logout(pool: Pool, sessionId: string): Promise<void> {
|
||||
await pool.query('DELETE FROM admin_sessions WHERE id = $1', [sessionId]);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Verwaltungs-CLI:
|
||||
// cli admin:create <benutzer> Passwort aus ADMIN_PASSWORD oder interaktiv
|
||||
// cli token:create neues Ingest-Token (wird nur einmal angezeigt)
|
||||
// cli migrate Datenbankmigrationen ausführen
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { createAdmin } from './auth/admin.ts';
|
||||
import { createPool } from './db/pool.ts';
|
||||
import { migrate } from './db/migrate.ts';
|
||||
import { createIngestToken } from './ingest/tokens.ts';
|
||||
|
||||
async function readPassword(): Promise<string> {
|
||||
if (process.env.ADMIN_PASSWORD) return process.env.ADMIN_PASSWORD;
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
// Hide typed characters.
|
||||
(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = (s: string) => {
|
||||
if (s.includes('Passwort')) process.stdout.write(s);
|
||||
};
|
||||
const pw = await rl.question('Passwort (min. 12 Zeichen): ');
|
||||
rl.close();
|
||||
process.stdout.write('\n');
|
||||
return pw;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, arg] = process.argv.slice(2);
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL is required');
|
||||
const pool = createPool(url, 2);
|
||||
try {
|
||||
await migrate(pool);
|
||||
switch (command) {
|
||||
case 'migrate':
|
||||
console.log('Migrationen ausgeführt.');
|
||||
break;
|
||||
case 'admin:create': {
|
||||
if (!arg) throw new Error('usage: admin:create <benutzer>');
|
||||
await createAdmin(pool, arg, await readPassword());
|
||||
console.log(`Admin "${arg}" angelegt bzw. Passwort aktualisiert.`);
|
||||
break;
|
||||
}
|
||||
case 'token:create': {
|
||||
const token = await createIngestToken(pool);
|
||||
console.log('Neues Ingest-Token (in config/atmstats-server.toml als token eintragen):');
|
||||
console.log(token);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log('Befehle: migrate | admin:create <benutzer> | token:create');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error((err as Error).message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const bool = z
|
||||
.enum(['true', 'false', '1', '0'])
|
||||
.transform((v) => v === 'true' || v === '1');
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.string().default('production'),
|
||||
DATABASE_URL: z.string({ required_error: 'DATABASE_URL is required' }).min(1, 'DATABASE_URL is required'),
|
||||
PORT: z.coerce.number().int().default(3001),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
PUBLIC_ORIGIN: z.string().url().optional(),
|
||||
INGEST_HMAC_SECRET: z.string().min(32, 'INGEST_HMAC_SECRET must be at least 32 characters'),
|
||||
SERVER_ID: z.string().default('atm10'),
|
||||
TRUST_PROXY: bool.default('true'),
|
||||
SFTP_HOST: z.string().optional(),
|
||||
SFTP_PORT: z.coerce.number().int().default(22),
|
||||
SFTP_USER: z.string().optional(),
|
||||
SFTP_PASSWORD: z.string().optional(),
|
||||
SFTP_ROOT: z.string().default('/'),
|
||||
/** level-name of the server (folder containing stats/, advancements/, ftbquests/) */
|
||||
SFTP_WORLD_DIR: z.string().default('world'),
|
||||
SFTP_INTERVAL_MIN: z.coerce.number().int().min(1).default(15),
|
||||
AVATAR_SOURCE: z.string().default('https://mc-heads.net/avatar/{uuid}/64'),
|
||||
/** Time zone of the game server (log timestamps) and of activity heatmaps. */
|
||||
TIMEZONE: z.string().default('Europe/Berlin'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof envSchema> & {
|
||||
sftp: { host: string; port: number; username: string; password: string; root: string } | null;
|
||||
};
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||
const r = envSchema.safeParse(env);
|
||||
if (!r.success) {
|
||||
const msg = r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
||||
throw new Error(`Invalid configuration: ${msg}`);
|
||||
}
|
||||
const c = r.data;
|
||||
const sftp =
|
||||
c.SFTP_HOST && c.SFTP_USER && c.SFTP_PASSWORD
|
||||
? { host: c.SFTP_HOST, port: c.SFTP_PORT, username: c.SFTP_USER, password: c.SFTP_PASSWORD, root: c.SFTP_ROOT }
|
||||
: null;
|
||||
return { ...c, sftp };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const MIGRATION_LOCK = 4711;
|
||||
|
||||
function migrationsDir(): string {
|
||||
// src/db -> ../../migrations (dev, tests) ; dist -> ../migrations (bundled build)
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
for (const candidate of [join(here, '..', '..', 'migrations'), join(here, '..', 'migrations')]) {
|
||||
try {
|
||||
readdirSync(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
throw new Error('migrations directory not found');
|
||||
}
|
||||
|
||||
/** Applies pending `migrations/*.sql` in name order, each in its own transaction, serialized by an advisory lock. */
|
||||
export async function migrate(pool: Pool): Promise<string[]> {
|
||||
const dir = migrationsDir();
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
|
||||
const client = await pool.connect();
|
||||
const applied: string[] = [];
|
||||
try {
|
||||
await client.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK]);
|
||||
await client.query(
|
||||
'CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())',
|
||||
);
|
||||
const { rows } = await client.query<{ name: string }>('SELECT name FROM schema_migrations');
|
||||
const done = new Set(rows.map((r) => r.name));
|
||||
for (const file of files) {
|
||||
if (done.has(file)) continue;
|
||||
const sql = readFileSync(join(dir, file), 'utf8');
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
await client.query(sql);
|
||||
await client.query('INSERT INTO schema_migrations(name) VALUES ($1)', [file]);
|
||||
await client.query('COMMIT');
|
||||
applied.push(file);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw new Error(`migration ${file} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK]).catch(() => {});
|
||||
client.release();
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import pg from 'pg';
|
||||
|
||||
// bigint (int8) columns hold Minecraft counters; they stay far below 2^53, so parse as number.
|
||||
pg.types.setTypeParser(20, (v) => Number(v));
|
||||
// numeric is used for aggregates (SUM over bigint)
|
||||
pg.types.setTypeParser(1700, (v) => Number(v));
|
||||
|
||||
export type Pool = pg.Pool;
|
||||
export type PoolClient = pg.PoolClient;
|
||||
|
||||
export function createPool(url: string, max = 10): pg.Pool {
|
||||
return new pg.Pool({ connectionString: url, max });
|
||||
}
|
||||
|
||||
export async function withTx<T>(pool: pg.Pool, fn: (c: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export const MAX_CLOCK_SKEW_MS = 5 * 60_000;
|
||||
|
||||
export type IngestAuthError = 'missing_auth' | 'bad_token' | 'timestamp_out_of_range' | 'bad_signature';
|
||||
export type IngestAuthResult = { ok: true } | { ok: false; status: 401; error: IngestAuthError };
|
||||
|
||||
export interface VerifyOpts {
|
||||
secret: string;
|
||||
now: number;
|
||||
tokenValid: (token: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function computeSignature(secret: string, ts: number | string, body: Buffer): string {
|
||||
const bodyHash = createHash('sha256').update(body).digest('hex');
|
||||
return createHmac('sha256', secret).update(`${ts}.${bodyHash}`).digest('hex');
|
||||
}
|
||||
|
||||
const fail = (error: IngestAuthError): IngestAuthResult => ({ ok: false, status: 401, error });
|
||||
|
||||
/** Verifies bearer token, timestamp window and HMAC over the bytes as transmitted (before gunzip). */
|
||||
export async function verifyIngest(
|
||||
req: { headers: Record<string, string | string[] | undefined>; rawBody: Buffer },
|
||||
opts: VerifyOpts,
|
||||
): Promise<IngestAuthResult> {
|
||||
const header = (n: string) => {
|
||||
const v = req.headers[n];
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
};
|
||||
const auth = header('authorization');
|
||||
const tsHeader = header('x-atm-timestamp');
|
||||
const sig = header('x-atm-signature');
|
||||
if (!auth?.startsWith('Bearer ') || !tsHeader || !sig) return fail('missing_auth');
|
||||
|
||||
if (!(await opts.tokenValid(auth.slice(7).trim()))) return fail('bad_token');
|
||||
|
||||
const ts = Number(tsHeader);
|
||||
if (!Number.isFinite(ts) || Math.abs(opts.now - ts) > MAX_CLOCK_SKEW_MS) return fail('timestamp_out_of_range');
|
||||
|
||||
const expected = Buffer.from(computeSignature(opts.secret, tsHeader, req.rawBody), 'hex');
|
||||
const given = Buffer.from(sig, 'hex');
|
||||
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return fail('bad_signature');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import { ingestBatchSchema } from '@atm/shared';
|
||||
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Config } from '../config.ts';
|
||||
import { processEvents, validateEvents } from '../pipeline/process.ts';
|
||||
import { verifyIngest } from './auth.ts';
|
||||
import { isValidIngestToken } from './tokens.ts';
|
||||
|
||||
export const MAX_UNPACKED_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export function ingestRoutes(deps: { pool: Pool; config: Config }): FastifyPluginAsync {
|
||||
return async (app: FastifyInstance) => {
|
||||
// Raw bytes are needed for the signature; parse JSON ourselves after verification.
|
||||
app.addContentTypeParser('application/json', { parseAs: 'buffer', bodyLimit: MAX_UNPACKED_BYTES }, (_req, body, done) =>
|
||||
done(null, body),
|
||||
);
|
||||
app.removeContentTypeParser(['text/plain']);
|
||||
|
||||
const authenticate = (headers: Record<string, string | string[] | undefined>, rawBody: Buffer) =>
|
||||
verifyIngest(
|
||||
{ headers, rawBody },
|
||||
{
|
||||
secret: deps.config.INGEST_HMAC_SECRET,
|
||||
now: Date.now(),
|
||||
tokenValid: (t) => isValidIngestToken(deps.pool, t),
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/ingest/ping', async (req, reply) => {
|
||||
const auth = await authenticate(req.headers, Buffer.alloc(0));
|
||||
if (!auth.ok) return reply.code(auth.status).send({ error: auth.error });
|
||||
return { ok: true, serverTime: Date.now(), protocol: 1 };
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/ingest',
|
||||
{ config: { rateLimit: { max: 120, timeWindow: '1 minute' } } },
|
||||
async (req, reply) => {
|
||||
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
||||
const auth = await authenticate(req.headers, raw);
|
||||
if (!auth.ok) {
|
||||
req.log.warn({ error: auth.error }, 'ingest rejected');
|
||||
return reply.code(auth.status).send({ error: auth.error });
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
const encoding = String(req.headers['content-encoding'] ?? '').toLowerCase();
|
||||
const unpacked = encoding === 'gzip' ? gunzipSync(raw, { maxOutputLength: MAX_UNPACKED_BYTES }) : raw;
|
||||
if (unpacked.length > MAX_UNPACKED_BYTES) return reply.code(413).send({ error: 'payload_too_large' });
|
||||
text = unpacked.toString('utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ERR_BUFFER_TOO_LARGE') {
|
||||
return reply.code(413).send({ error: 'payload_too_large' });
|
||||
}
|
||||
return reply.code(400).send({ error: 'bad_encoding' });
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: 'bad_json' });
|
||||
}
|
||||
const batch = ingestBatchSchema.safeParse(json);
|
||||
if (!batch.success) return reply.code(400).send({ error: 'bad_batch', detail: batch.error.issues[0]?.message });
|
||||
|
||||
const { valid, rejected } = validateEvents(batch.data.events);
|
||||
const serverId = String(req.headers['x-atm-server'] ?? deps.config.SERVER_ID).slice(0, 64);
|
||||
const result = valid.length
|
||||
? await processEvents(deps.pool, valid, 'push', serverId)
|
||||
: { accepted: [], duplicate: [], rejected: [] };
|
||||
if (rejected.length) req.log.warn({ rejected: rejected.length }, 'ingest: invalid events rejected');
|
||||
return { ...result, rejected };
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const hash = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
|
||||
/** Creates a new ingest token. The plain value is returned once and only its hash is stored. */
|
||||
export async function createIngestToken(pool: Pool): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await pool.query('INSERT INTO ingest_tokens(hash) VALUES ($1)', [hash(token)]);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function isValidIngestToken(pool: Pool, token: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query('SELECT 1 FROM ingest_tokens WHERE hash = $1 AND revoked_at IS NULL', [
|
||||
hash(token),
|
||||
]);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revokeAllIngestTokens(pool: Pool): Promise<void> {
|
||||
await pool.query('UPDATE ingest_tokens SET revoked_at = now() WHERE revoked_at IS NULL');
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { LiveEvent } from '@atm/shared/api';
|
||||
import pg from 'pg';
|
||||
import { LIVE_CHANNEL, type LiveMessage } from './notifier.ts';
|
||||
|
||||
export const RING_SIZE = 500;
|
||||
|
||||
/** Listens on the Postgres live channel and fans messages out to SSE subscribers, keeping a ring buffer for resume. */
|
||||
export class LiveHub {
|
||||
private ring: LiveEvent[] = [];
|
||||
private nextId = 1;
|
||||
private subscribers = new Set<(e: LiveEvent) => void>();
|
||||
private client: pg.Client | null = null;
|
||||
private stopped = false;
|
||||
private readyPromise: Promise<void>;
|
||||
private resolveReady!: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly connectionString: string,
|
||||
private readonly log: { warn: (o: object, msg: string) => void } = console,
|
||||
) {
|
||||
this.readyPromise = new Promise((r) => (this.resolveReady = r));
|
||||
}
|
||||
|
||||
ready(): Promise<void> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
let attempt = 0;
|
||||
while (!this.stopped) {
|
||||
try {
|
||||
const client = new pg.Client({ connectionString: this.connectionString });
|
||||
client.on('notification', (n) => {
|
||||
if (n.channel === LIVE_CHANNEL && n.payload) this.publish(JSON.parse(n.payload) as LiveMessage);
|
||||
});
|
||||
await client.connect();
|
||||
await client.query(`LISTEN ${LIVE_CHANNEL}`);
|
||||
this.client = client;
|
||||
attempt = 0;
|
||||
this.resolveReady();
|
||||
await new Promise<void>((resolve) => {
|
||||
client.on('error', () => resolve());
|
||||
client.on('end', () => resolve());
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.warn({ err }, 'live hub: connection failed');
|
||||
}
|
||||
this.client = null;
|
||||
if (this.stopped) return;
|
||||
await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** attempt++)));
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
await this.client?.end().catch(() => {});
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
publish(msg: LiveMessage): LiveEvent {
|
||||
const event: LiveEvent = { id: this.nextId++, kind: msg.kind, data: msg.data };
|
||||
this.ring.push(event);
|
||||
if (this.ring.length > RING_SIZE) this.ring.shift();
|
||||
for (const fn of this.subscribers) fn(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Events with id > lastId still in the ring buffer. */
|
||||
since(lastId: number): LiveEvent[] {
|
||||
return this.ring.filter((e) => e.id > lastId);
|
||||
}
|
||||
|
||||
lastId(): number {
|
||||
return this.nextId - 1;
|
||||
}
|
||||
|
||||
subscribe(fn: (e: LiveEvent) => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AtmEvent } from '@atm/shared';
|
||||
import type { LiveKind } from '@atm/shared/api';
|
||||
import type { Pool } from 'pg';
|
||||
import { onEventsCommitted } from '../pipeline/process.ts';
|
||||
|
||||
export const LIVE_CHANNEL = 'atm_live';
|
||||
const COUNTERS_THROTTLE_MS = 10_000;
|
||||
|
||||
export interface LiveMessage {
|
||||
kind: LiveKind;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Maps committed events to live messages. Pure; hidden players are filtered by the caller. */
|
||||
export function toLiveMessages(events: AtmEvent[]): (LiveMessage & { uuid?: string })[] {
|
||||
const out: (LiveMessage & { uuid?: string })[] = [];
|
||||
for (const e of events) {
|
||||
switch (e.type) {
|
||||
case 'player_join':
|
||||
case 'player_leave':
|
||||
out.push({ kind: 'online', uuid: e.uuid, data: { uuid: e.uuid, name: e.name, online: e.type === 'player_join', ts: e.ts } });
|
||||
break;
|
||||
case 'player_death':
|
||||
out.push({
|
||||
kind: 'death',
|
||||
uuid: e.uuid,
|
||||
data: { uuid: e.uuid, name: e.name, cause: e.cause, killerType: e.killerType, dimension: e.dimension, ts: e.ts },
|
||||
});
|
||||
break;
|
||||
case 'advancement':
|
||||
if (e.title) out.push({ kind: 'advancement', uuid: e.uuid, data: { uuid: e.uuid, name: e.name, title: e.title, ts: e.ts } });
|
||||
break;
|
||||
case 'file_sync':
|
||||
if (e.path.startsWith('world/ftbquests/')) out.push({ kind: 'quest', data: { ts: e.ts } });
|
||||
break;
|
||||
case 'stats_snapshot':
|
||||
out.push({ kind: 'counters', data: { ts: e.ts } });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes live messages via Postgres NOTIFY after each committed batch, so every API instance
|
||||
* (and events processed by the worker) reaches all SSE clients. Returns an uninstall function.
|
||||
*/
|
||||
export function installLiveNotifier(pool: Pool): () => void {
|
||||
let lastCounters = 0;
|
||||
return onEventsCommitted(async (events) => {
|
||||
let msgs = toLiveMessages(events);
|
||||
if (msgs.length === 0) return;
|
||||
const uuids = [...new Set(msgs.flatMap((m) => (m.uuid ? [m.uuid] : [])))];
|
||||
if (uuids.length) {
|
||||
const { rows } = await pool.query<{ uuid: string }>('SELECT uuid FROM players WHERE hidden AND uuid = ANY($1::uuid[])', [uuids]);
|
||||
const hidden = new Set(rows.map((r) => r.uuid));
|
||||
msgs = msgs.filter((m) => !m.uuid || !hidden.has(m.uuid));
|
||||
}
|
||||
const now = Date.now();
|
||||
let countersSent = false;
|
||||
for (const { kind, data } of msgs) {
|
||||
if (kind === 'counters') {
|
||||
if (countersSent || now - lastCounters < COUNTERS_THROTTLE_MS) continue;
|
||||
countersSent = true;
|
||||
lastCounters = now;
|
||||
}
|
||||
await pool.query('SELECT pg_notify($1, $2)', [LIVE_CHANNEL, JSON.stringify({ kind, data })]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { isCompound, parseSnbt, type SnbtValue } from './snbt.ts';
|
||||
|
||||
/** "308a693c2bd84d6b8790670aef5be601" → "308a693c-2bd8-4d6b-8790-670aef5be601" */
|
||||
export function normalizeUuid(raw: string): string {
|
||||
const hex = raw.replace(/-/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error(`not a uuid: ${raw}`);
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
export interface QuestProgress {
|
||||
teamId: string;
|
||||
name: string | null;
|
||||
/** IDs from FTB's `completed` map (quests and chapters) with completion time in ms. */
|
||||
completed: { questId: string; at: number }[];
|
||||
started: number;
|
||||
}
|
||||
|
||||
/** Parses `world/ftbquests/<team>.snbt` (FTB Quests 2101.x team data). */
|
||||
export function parseQuestProgress(text: string): QuestProgress {
|
||||
const root = parseSnbt(text);
|
||||
if (!isCompound(root) || typeof root.uuid !== 'string') throw new Error('quest progress: missing uuid');
|
||||
const completedMap = isCompound(root.completed) ? root.completed : {};
|
||||
const completed = Object.entries(completedMap)
|
||||
.filter(([id, at]) => /^[0-9A-F]{16}$/i.test(id) && typeof at === 'number')
|
||||
.map(([id, at]) => ({ questId: id.toUpperCase(), at: at as number }));
|
||||
return {
|
||||
teamId: normalizeUuid(root.uuid),
|
||||
name: typeof root.name === 'string' ? root.name : null,
|
||||
completed,
|
||||
started: isCompound(root.started) ? Object.keys(root.started).length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface QuestDef {
|
||||
chapterId: string;
|
||||
chapterTitle: string | null;
|
||||
questId: string;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
const str = (v: SnbtValue | undefined): string | null => (typeof v === 'string' && v.length > 0 ? v : null);
|
||||
|
||||
/**
|
||||
* Removes Minecraft formatting codes ("&a", "&6&l", "§r") used in ATM10 quest texts. FTB writes a literal
|
||||
* ampersand as "\&" (e.g. "Forbidden \& Arcanus"); that one is kept as "&".
|
||||
*/
|
||||
export function stripFormatting(s: string): string {
|
||||
return s
|
||||
.split('\\&')
|
||||
.map((part) => part.replace(/[&§][0-9a-fk-or]/gi, ''))
|
||||
.join('&')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const strip = (s: string | null) => (s ? stripFormatting(s) : null);
|
||||
|
||||
const EN_LANG = /\/lang\/en_us(\.snbt|\/.+\.snbt_merged)$/;
|
||||
const CHAPTER_FILE = /\/chapters\/[^/]+\.snbt$/;
|
||||
|
||||
/**
|
||||
* Quest config files worth transferring: chapter definitions and the English texts split per chapter by
|
||||
* ftbquestslangsplitter (lang/en_us/**\/*.snbt_merged, verified on the ATM10 server). Skipped: other
|
||||
* languages (~35 MB), reward tables and the combined lang/en_us.snbt (1.5 MB, same content as the split files).
|
||||
*/
|
||||
export function isQuestConfigFile(path: string): boolean {
|
||||
return /^config\/ftbquests\/quests\/(chapters\/[^/]+\.snbt|lang\/en_us\/.+\.snbt_merged)$/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds quest definitions from `config/ftbquests/quests/chapters/*.snbt`. Titles come from the English lang
|
||||
* files (`chapter.<id>.title`, `quest.<id>.title`; split `lang/en_us/**\/*.snbt_merged` or a single
|
||||
* `lang/en_us.snbt`), else from inline `title` fields; chapters fall back to their filename.
|
||||
* Quests without a title key show their task item in game; that name is not available server-side (null).
|
||||
*/
|
||||
export function parseQuestDefs(files: { path: string; content: string }[]): QuestDef[] {
|
||||
const lang = new Map<string, string>();
|
||||
for (const f of files) {
|
||||
if (!EN_LANG.test(f.path)) continue;
|
||||
const root = parseSnbt(f.content);
|
||||
if (!isCompound(root)) continue;
|
||||
for (const [k, v] of Object.entries(root)) {
|
||||
const raw = Array.isArray(v) ? v.filter((x) => typeof x === 'string').join(' ') : str(v);
|
||||
const text = raw ? stripFormatting(raw) : null;
|
||||
if (text) lang.set(k, text);
|
||||
}
|
||||
}
|
||||
const defs: QuestDef[] = [];
|
||||
const chapters = files.filter((f) => CHAPTER_FILE.test(f.path)).sort((a, b) => a.path.localeCompare(b.path));
|
||||
for (const f of chapters) {
|
||||
const root = parseSnbt(f.content);
|
||||
if (!isCompound(root)) continue;
|
||||
const chapterId = str(root.id);
|
||||
if (!chapterId) continue;
|
||||
const chapterTitle = lang.get(`chapter.${chapterId}.title`) ?? strip(str(root.title)) ?? str(root.filename);
|
||||
for (const q of Array.isArray(root.quests) ? root.quests : []) {
|
||||
if (!isCompound(q)) continue;
|
||||
const questId = str(q.id);
|
||||
if (!questId) continue;
|
||||
defs.push({
|
||||
chapterId,
|
||||
chapterTitle,
|
||||
questId: questId.toUpperCase(),
|
||||
title: lang.get(`quest.${questId}.title`) ?? strip(str(q.title)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
export interface TeamInfo {
|
||||
teamId: string;
|
||||
type: 'player' | 'party' | 'server' | string;
|
||||
name: string | null;
|
||||
/** players with rank member, officer or owner (not invited players or allies) */
|
||||
members: string[];
|
||||
}
|
||||
|
||||
const MEMBER_RANKS = new Set(['member', 'officer', 'owner']);
|
||||
|
||||
/**
|
||||
* Parses `world/ftbteams/{player,party}/<id>.snbt` (FTB Teams 2101.x). Verified on the real server for player
|
||||
* teams; party files use the same fields (`id`, `type`, `ranks`, `properties`).
|
||||
*/
|
||||
export function parseTeam(text: string): TeamInfo {
|
||||
const root = parseSnbt(text);
|
||||
if (!isCompound(root) || typeof root.id !== 'string') throw new Error('team: missing id');
|
||||
const props = isCompound(root.properties) ? root.properties : {};
|
||||
const ranks = isCompound(root.ranks) ? root.ranks : {};
|
||||
return {
|
||||
teamId: normalizeUuid(root.id),
|
||||
type: typeof root.type === 'string' ? root.type : 'unknown',
|
||||
name: str(props['ftbteams:display_name']) ?? str(root.player_name),
|
||||
members: Object.entries(ranks)
|
||||
.filter(([, rank]) => typeof rank === 'string' && MEMBER_RANKS.has(rank))
|
||||
.map(([uuid]) => normalizeUuid(uuid)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { deterministicId, type AtmEvent } from '@atm/shared';
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11,
|
||||
};
|
||||
|
||||
function tzOffsetMs(utcMs: number, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(new Date(utcMs));
|
||||
const get = (t: string) => Number(parts.find((p) => p.type === t)!.value);
|
||||
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
||||
return asUtc - Math.floor(utcMs / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** "12Oct2026 09:03:03.006" in the server's local time zone → epoch ms. */
|
||||
export function parseLogTimestamp(s: string, timeZone: string): number {
|
||||
const m = /^(\d{2})([A-Z][a-z]{2})(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s);
|
||||
if (!m || MONTHS[m[2]!] === undefined) return Number.NaN;
|
||||
const local = Date.UTC(+m[3]!, MONTHS[m[2]!]!, +m[1]!, +m[4]!, +m[5]!, +m[6]!, +m[7]!);
|
||||
let utc = local - tzOffsetMs(local, timeZone);
|
||||
utc = local - tzOffsetMs(utc, timeZone); // second pass settles DST boundaries
|
||||
return utc;
|
||||
}
|
||||
|
||||
const LINE = /^\[([^\]]+)\] \[[^\]]+\] \[([^\]]+)\]: (.*)$/;
|
||||
const NAME = '([A-Za-z0-9_]{1,16})';
|
||||
const UUID_OF = new RegExp(`^UUID of player ${NAME} is ([0-9a-f-]{36})$`);
|
||||
const JOINED = new RegExp(`^${NAME} joined the game$`);
|
||||
const LEFT = new RegExp(`^${NAME} left the game$`);
|
||||
const ADVANCEMENT = new RegExp(`^${NAME} has (?:made the advancement|reached the goal|completed the challenge) \\[(.+)\\]$`);
|
||||
const SYSTEM_LOGGER = 'net.minecraft.server.MinecraftServer/';
|
||||
|
||||
/**
|
||||
* Parses complete lines of `logs/latest.log`. Only bytes up to the last newline are consumed, so a line that is
|
||||
* still being written is read again next time. `names` maps player name → uuid and is updated from
|
||||
* "UUID of player" lines; lines for players without known uuid are skipped.
|
||||
* Event IDs derive from the absolute byte offset and line content, so re-reading yields the same IDs.
|
||||
*/
|
||||
export function parseLogChunk(
|
||||
chunk: Buffer,
|
||||
baseOffset: number,
|
||||
names: Map<string, string>,
|
||||
timeZone: string,
|
||||
): { events: AtmEvent[]; consumed: number } {
|
||||
const end = chunk.lastIndexOf(0x0a);
|
||||
if (end < 0) return { events: [], consumed: 0 };
|
||||
const events: AtmEvent[] = [];
|
||||
let offset = 0;
|
||||
while (offset <= end) {
|
||||
const nl = chunk.indexOf(0x0a, offset);
|
||||
const line = chunk.toString('utf8', offset, nl).replace(/\r$/, '');
|
||||
const lineOffset = baseOffset + offset;
|
||||
offset = nl + 1;
|
||||
|
||||
const m = LINE.exec(line);
|
||||
if (!m) continue;
|
||||
const [, time, logger, msg] = m as unknown as [string, string, string, string];
|
||||
|
||||
const uuidOf = UUID_OF.exec(msg);
|
||||
if (uuidOf) {
|
||||
names.set(uuidOf[1]!, uuidOf[2]!);
|
||||
continue;
|
||||
}
|
||||
if (logger !== SYSTEM_LOGGER) continue;
|
||||
|
||||
const id = () => deterministicId(`log:${lineOffset}:${createHash('sha256').update(line).digest('hex')}`);
|
||||
const ts = parseLogTimestamp(time, timeZone);
|
||||
if (!Number.isFinite(ts)) continue;
|
||||
|
||||
let hit: RegExpExecArray | null;
|
||||
if ((hit = JOINED.exec(msg)) || (hit = LEFT.exec(msg))) {
|
||||
const name = hit[1]!;
|
||||
const uuid = names.get(name);
|
||||
if (!uuid) continue;
|
||||
events.push({ id: id(), type: JOINED.test(msg) ? 'player_join' : 'player_leave', ts, uuid, name });
|
||||
} else if ((hit = ADVANCEMENT.exec(msg))) {
|
||||
const name = hit[1]!;
|
||||
const uuid = names.get(name);
|
||||
if (!uuid) continue;
|
||||
events.push({ id: id(), type: 'advancement', ts, uuid, name, advancementId: `log:${hit[2]}`, title: hit[2]! });
|
||||
}
|
||||
}
|
||||
return { events, consumed: end + 1 };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tolerant SNBT parser for FTB files. FTB writes "pretty" SNBT: entries separated by newlines instead of commas,
|
||||
* unquoted keys that may start with digits (team/quest IDs) or contain dots (lang keys).
|
||||
* Numbers lose their type suffix (b, s, L, f, d) and become JS numbers.
|
||||
*/
|
||||
export type SnbtValue = string | number | boolean | SnbtValue[] | { [key: string]: SnbtValue };
|
||||
|
||||
export class SnbtError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly line: number,
|
||||
readonly column: number,
|
||||
) {
|
||||
super(`${message} at line ${line}, column ${column}`);
|
||||
}
|
||||
}
|
||||
|
||||
const NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?[bBsSlLfFdD]?$/;
|
||||
const BARE = /[A-Za-z0-9_\-.+]/;
|
||||
|
||||
export function parseSnbt(text: string): SnbtValue {
|
||||
let pos = 0;
|
||||
|
||||
const error = (msg: string): never => {
|
||||
const before = text.slice(0, pos);
|
||||
const line = before.split('\n').length;
|
||||
const column = pos - before.lastIndexOf('\n');
|
||||
throw new SnbtError(msg, line, column);
|
||||
};
|
||||
|
||||
/** Skips whitespace and commas (commas are optional separators in FTB SNBT). */
|
||||
const skipSeparators = () => {
|
||||
while (pos < text.length && /[\s,]/.test(text[pos]!)) pos++;
|
||||
};
|
||||
const skipWs = () => {
|
||||
while (pos < text.length && /\s/.test(text[pos]!)) pos++;
|
||||
};
|
||||
|
||||
const quoted = (): string => {
|
||||
const q = text[pos++];
|
||||
let out = '';
|
||||
while (pos < text.length) {
|
||||
const ch = text[pos++]!;
|
||||
if (ch === '\\') {
|
||||
if (pos >= text.length) break;
|
||||
const next = text[pos++]!;
|
||||
out += next === 'n' ? '\n' : next === 't' ? '\t' : next;
|
||||
} else if (ch === q) {
|
||||
return out;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return error('unterminated string');
|
||||
};
|
||||
|
||||
const bare = (): string => {
|
||||
const start = pos;
|
||||
while (pos < text.length && BARE.test(text[pos]!)) pos++;
|
||||
if (pos === start) error(`unexpected character '${text[pos] ?? 'EOF'}'`);
|
||||
return text.slice(start, pos);
|
||||
};
|
||||
|
||||
const key = (): string => (text[pos] === '"' || text[pos] === "'" ? quoted() : bare());
|
||||
|
||||
const compound = (): SnbtValue => {
|
||||
pos++; // {
|
||||
const obj: { [k: string]: SnbtValue } = {};
|
||||
for (;;) {
|
||||
skipSeparators();
|
||||
if (pos >= text.length) error('unterminated compound');
|
||||
if (text[pos] === '}') {
|
||||
pos++;
|
||||
return obj;
|
||||
}
|
||||
const k = key();
|
||||
skipWs();
|
||||
if (text[pos] !== ':') error(`expected ':' after key '${k}'`);
|
||||
pos++;
|
||||
obj[k] = value();
|
||||
}
|
||||
};
|
||||
|
||||
const list = (): SnbtValue => {
|
||||
pos++; // [
|
||||
skipWs();
|
||||
// typed arrays: [I; 1, 2]
|
||||
if (/[IBL]/.test(text[pos] ?? '') && text[pos + 1] === ';') pos += 2;
|
||||
const arr: SnbtValue[] = [];
|
||||
for (;;) {
|
||||
skipSeparators();
|
||||
if (pos >= text.length) error('unterminated list');
|
||||
if (text[pos] === ']') {
|
||||
pos++;
|
||||
return arr;
|
||||
}
|
||||
arr.push(value());
|
||||
}
|
||||
};
|
||||
|
||||
const value = (): SnbtValue => {
|
||||
skipWs();
|
||||
const ch = text[pos];
|
||||
if (ch === undefined) return error('unexpected end of input');
|
||||
if (ch === '{') return compound();
|
||||
if (ch === '[') return list();
|
||||
if (ch === '"' || ch === "'") return quoted();
|
||||
const word = bare();
|
||||
if (word === 'true') return true;
|
||||
if (word === 'false') return false;
|
||||
if (NUMBER.test(word)) return Number(/[bBsSlLfFdD]$/.test(word) ? word.slice(0, -1) : word);
|
||||
return word;
|
||||
};
|
||||
|
||||
const result = value();
|
||||
skipWs();
|
||||
if (pos < text.length) error('trailing content');
|
||||
return result;
|
||||
}
|
||||
|
||||
export const isCompound = (v: SnbtValue | undefined): v is { [key: string]: SnbtValue } =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { deterministicId, type AtmEvent } from '@atm/shared';
|
||||
|
||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
/** `world/stats/<uuid>.json` → absolute stats_snapshot observed at the file's mtime. */
|
||||
export function statsFileToEvent(uuid: string, json: string, mtimeMs: number): AtmEvent {
|
||||
const root = JSON.parse(json) as { stats?: Record<string, Record<string, number>> };
|
||||
const stats: Record<string, Record<string, number>> = {};
|
||||
for (const [cat, entries] of Object.entries(root.stats ?? {})) {
|
||||
const clean: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(entries)) if (Number.isInteger(v) && v >= 0) clean[k] = v;
|
||||
stats[cat] = clean;
|
||||
}
|
||||
return {
|
||||
id: deterministicId(`stats:${uuid}:${mtimeMs}:${sha256(json)}`),
|
||||
type: 'stats_snapshot',
|
||||
ts: mtimeMs,
|
||||
uuid,
|
||||
name: null,
|
||||
full: true,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
/** "2026-10-12 12:42:23 +0200" → epoch ms */
|
||||
export function parseAdvancementTime(s: string): number {
|
||||
const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-])(\d{2})(\d{2})$/.exec(s);
|
||||
if (!m) return Number.NaN;
|
||||
return Date.parse(`${m[1]}T${m[2]}${m[3]}${m[4]}:${m[5]}`);
|
||||
}
|
||||
|
||||
/** `world/advancements/<uuid>.json` → one advancement event per completed, non-recipe advancement. */
|
||||
export function advancementsFileToEvents(uuid: string, json: string): AtmEvent[] {
|
||||
const root = JSON.parse(json) as Record<string, unknown>;
|
||||
const events: AtmEvent[] = [];
|
||||
for (const [id, entry] of Object.entries(root)) {
|
||||
if (id === 'DataVersion' || id.includes(':recipes/')) continue;
|
||||
const adv = entry as { done?: boolean; criteria?: Record<string, string> };
|
||||
if (!adv?.done) continue;
|
||||
const times = Object.values(adv.criteria ?? {})
|
||||
.map(parseAdvancementTime)
|
||||
.filter(Number.isFinite);
|
||||
if (times.length === 0) continue;
|
||||
events.push({
|
||||
id: deterministicId(`adv:${uuid}:${id}`),
|
||||
type: 'advancement',
|
||||
ts: Math.min(...times),
|
||||
uuid,
|
||||
name: null,
|
||||
advancementId: id,
|
||||
title: null,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { atmEventSchema, type AtmEvent, type EventOf, type EventType, type IngestResponse } from '@atm/shared';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { withTx } from '../db/pool.ts';
|
||||
|
||||
export type Source = 'push' | 'sftp';
|
||||
export type Projection<T extends EventType = EventType> = (c: PoolClient, e: EventOf<T>, source: Source) => Promise<void>;
|
||||
|
||||
const projections = new Map<EventType, Projection[]>();
|
||||
|
||||
export function registerProjection<T extends EventType>(type: T, fn: Projection<T>): void {
|
||||
const list = projections.get(type) ?? [];
|
||||
list.push(fn as unknown as Projection);
|
||||
projections.set(type, list);
|
||||
}
|
||||
|
||||
const batchEnd = new WeakMap<PoolClient, Map<string, () => Promise<void>>>();
|
||||
|
||||
/**
|
||||
* Runs `fn` once at the end of the current batch (still inside its transaction), however many events asked
|
||||
* for it under the same key. Used for derived tables rebuilt from many files (quest definitions).
|
||||
*/
|
||||
export function deferToBatchEnd(c: PoolClient, key: string, fn: () => Promise<void>): void {
|
||||
let tasks = batchEnd.get(c);
|
||||
if (!tasks) batchEnd.set(c, (tasks = new Map()));
|
||||
tasks.set(key, fn);
|
||||
}
|
||||
|
||||
export type AfterCommit =(events: AtmEvent[], source: Source) => void | Promise<void>;
|
||||
const afterCommitHooks: AfterCommit[] = [];
|
||||
|
||||
/** Called with all newly inserted events after the transaction committed (live updates, cache invalidation). */
|
||||
export function onEventsCommitted(fn: AfterCommit): () => void {
|
||||
afterCommitHooks.push(fn);
|
||||
return () => {
|
||||
const i = afterCommitHooks.indexOf(fn);
|
||||
if (i >= 0) afterCommitHooks.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Validates each raw event on its own; one bad event never discards the batch. */
|
||||
export function validateEvents(raw: unknown[]): { valid: AtmEvent[]; rejected: IngestResponse['rejected'] } {
|
||||
const valid: AtmEvent[] = [];
|
||||
const rejected: IngestResponse['rejected'] = [];
|
||||
for (const item of raw) {
|
||||
const r = atmEventSchema.safeParse(item);
|
||||
if (r.success) {
|
||||
valid.push(r.data);
|
||||
} else {
|
||||
const id = typeof (item as { id?: unknown })?.id === 'string' ? (item as { id: string }).id : null;
|
||||
const reason = r.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
|
||||
rejected.push({ id, reason: reason.slice(0, 500) });
|
||||
}
|
||||
}
|
||||
return { valid, rejected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores events idempotently (event_id is the primary key) and runs projections only for newly inserted ones,
|
||||
* all inside one transaction. Events are projected in timestamp order.
|
||||
*/
|
||||
export async function processEvents(
|
||||
pool: Pool,
|
||||
events: AtmEvent[],
|
||||
source: Source,
|
||||
serverId: string,
|
||||
): Promise<IngestResponse> {
|
||||
const sorted = [...events].sort((a, b) => a.ts - b.ts);
|
||||
const inserted = await withTx(pool, async (c) => {
|
||||
batchEnd.delete(c); // pooled clients are reused; drop leftovers of a failed batch
|
||||
const fresh: AtmEvent[] = [];
|
||||
for (const e of sorted) {
|
||||
const { rowCount } = await c.query(
|
||||
`INSERT INTO events(event_id, server_id, type, ts, source, payload)
|
||||
VALUES ($1, $2, $3, to_timestamp($4 / 1000.0), $5, $6)
|
||||
ON CONFLICT (event_id) DO NOTHING`,
|
||||
[e.id, serverId, e.type, e.ts, source, e],
|
||||
);
|
||||
if (!rowCount) continue;
|
||||
fresh.push(e);
|
||||
for (const p of projections.get(e.type) ?? []) await p(c, e, source);
|
||||
}
|
||||
const tasks = batchEnd.get(c);
|
||||
batchEnd.delete(c);
|
||||
for (const task of tasks?.values() ?? []) await task();
|
||||
return fresh;
|
||||
});
|
||||
for (const hook of afterCommitHooks) {
|
||||
try {
|
||||
await hook(inserted, source);
|
||||
} catch {
|
||||
/* hooks must not fail ingest */
|
||||
}
|
||||
}
|
||||
const freshIds = new Set(inserted.map((e) => e.id));
|
||||
return {
|
||||
accepted: events.filter((e) => freshIds.has(e.id)).map((e) => e.id),
|
||||
duplicate: events.filter((e) => !freshIds.has(e.id)).map((e) => e.id),
|
||||
rejected: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
import { ensurePlayer } from './players.ts';
|
||||
|
||||
registerProjection('advancement', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, false);
|
||||
await c.query(
|
||||
`INSERT INTO advancements(uuid, advancement_id, achieved_at, title)
|
||||
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4)
|
||||
ON CONFLICT (uuid, advancement_id) DO UPDATE SET
|
||||
achieved_at = LEAST(advancements.achieved_at, EXCLUDED.achieved_at),
|
||||
title = COALESCE(EXCLUDED.title, advancements.title)`,
|
||||
[e.uuid, e.advancementId, e.ts, e.title],
|
||||
);
|
||||
});
|
||||
|
||||
registerProjection('dimension_change', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
for (const dim of [e.from, e.to]) {
|
||||
await c.query(
|
||||
`INSERT INTO dimension_visits(uuid, dimension, first_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
|
||||
ON CONFLICT (uuid, dimension) DO UPDATE SET first_at = LEAST(dimension_visits.first_at, EXCLUDED.first_at)`,
|
||||
[e.uuid, dim, e.ts],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
import { ensurePlayer } from './players.ts';
|
||||
|
||||
registerProjection('player_death', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
await c.query(
|
||||
`INSERT INTO deaths(event_id, uuid, ts, dimension, cause, killer_type, killer_name)
|
||||
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4, $5, $6, $7) ON CONFLICT DO NOTHING`,
|
||||
[e.id, e.uuid, e.ts, e.dimension, e.cause, e.killerType, e.killerName],
|
||||
);
|
||||
});
|
||||
|
||||
registerProjection('entity_kill', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
await c.query(
|
||||
`INSERT INTO kills(event_id, uuid, ts, dimension, victim_type, is_boss, weapon)
|
||||
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4, $5, $6, $7) ON CONFLICT DO NOTHING`,
|
||||
[e.id, e.uuid, e.ts, e.dimension, e.victimType, e.isBoss, e.weapon],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { isQuestConfigFile, parseQuestDefs, parseQuestProgress, parseTeam } from '../../parsers/ftbquests.ts';
|
||||
import { deferToBatchEnd, registerProjection } from '../process.ts';
|
||||
|
||||
const QUEST_PROGRESS = /^world\/ftbquests\/[0-9a-f-]{32,36}\.snbt$/i;
|
||||
const TEAM_FILE = /^world\/ftbteams\/(party|player)\/[0-9a-f-]{32,36}\.snbt$/i;
|
||||
|
||||
export async function recordError(c: PoolClient, source: string, message: string): Promise<void> {
|
||||
await c.query('INSERT INTO app_errors(source, message) VALUES ($1, $2)', [source, message.slice(0, 2000)]);
|
||||
}
|
||||
|
||||
/** Rebuilds quest_defs from all cached chapter and English lang files (once per batch). */
|
||||
async function rebuildQuestDefs(c: PoolClient): Promise<void> {
|
||||
const files = await c.query<{ path: string; content: string }>(
|
||||
`SELECT path, content FROM file_cache WHERE path LIKE 'config/ftbquests/quests/%'`,
|
||||
);
|
||||
let defs;
|
||||
try {
|
||||
defs = parseQuestDefs(files.rows.filter((f) => isQuestConfigFile(f.path)));
|
||||
} catch (err) {
|
||||
await recordError(c, 'file_sync', `quest definitions: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
await c.query('DELETE FROM quest_defs');
|
||||
if (defs.length) {
|
||||
await c.query(
|
||||
`INSERT INTO quest_defs(quest_id, chapter_id, chapter_title, title)
|
||||
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[])
|
||||
ON CONFLICT (quest_id) DO NOTHING`,
|
||||
[defs.map((d) => d.questId), defs.map((d) => d.chapterId), defs.map((d) => d.chapterTitle), defs.map((d) => d.title)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerProjection('file_sync', async (c, e) => {
|
||||
const { rows } = await c.query<{ sha256: string }>('SELECT sha256 FROM file_cache WHERE path = $1', [e.path]);
|
||||
if (rows[0]?.sha256 === e.sha256) return; // unchanged content
|
||||
await c.query(
|
||||
`INSERT INTO file_cache(path, sha256, content, updated_at) VALUES ($1, $2, $3, to_timestamp($4 / 1000.0))
|
||||
ON CONFLICT (path) DO UPDATE SET sha256 = EXCLUDED.sha256, content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`,
|
||||
[e.path, e.sha256, e.content, e.ts],
|
||||
);
|
||||
|
||||
try {
|
||||
if (QUEST_PROGRESS.test(e.path)) {
|
||||
const p = parseQuestProgress(e.content);
|
||||
// The file is the team's complete state: quests no longer listed were reset in FTB Quests.
|
||||
await c.query(`DELETE FROM quest_progress WHERE team_id = $1 AND NOT (quest_id = ANY($2::text[]))`, [
|
||||
p.teamId,
|
||||
p.completed.map((q) => q.questId),
|
||||
]);
|
||||
if (p.completed.length) {
|
||||
await c.query(
|
||||
`INSERT INTO quest_progress(team_id, quest_id, completed_at)
|
||||
SELECT $1, t.id, to_timestamp(t.at / 1000.0) FROM unnest($2::text[], $3::bigint[]) AS t(id, at)
|
||||
ON CONFLICT (team_id, quest_id) DO UPDATE SET completed_at = EXCLUDED.completed_at`,
|
||||
[p.teamId, p.completed.map((q) => q.questId), p.completed.map((q) => q.at)],
|
||||
);
|
||||
}
|
||||
// Team names from ftbteams win; the progress file only provides a fallback ("Name#uuidprefix").
|
||||
await c.query(`INSERT INTO teams(team_id, name, type) VALUES ($1, $2, NULL) ON CONFLICT (team_id) DO NOTHING`, [
|
||||
p.teamId,
|
||||
p.name,
|
||||
]);
|
||||
} else if (isQuestConfigFile(e.path)) {
|
||||
deferToBatchEnd(c, 'quest_defs', () => rebuildQuestDefs(c));
|
||||
} else if (TEAM_FILE.test(e.path)) {
|
||||
const t = parseTeam(e.content);
|
||||
await c.query(
|
||||
`INSERT INTO teams(team_id, name, type) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (team_id) DO UPDATE SET name = COALESCE(EXCLUDED.name, teams.name), type = EXCLUDED.type`,
|
||||
[t.teamId, t.name, t.type],
|
||||
);
|
||||
// Only party membership is stored: a player outside a party plays as their own team (team id = uuid).
|
||||
if (t.type === 'party') {
|
||||
await c.query('DELETE FROM team_members WHERE team_id = $1', [t.teamId]);
|
||||
if (t.members.length) {
|
||||
// a player belongs to one party at a time
|
||||
await c.query('DELETE FROM team_members WHERE uuid = ANY($1::uuid[])', [t.members]);
|
||||
await c.query(`INSERT INTO team_members(team_id, uuid) SELECT $1, unnest($2::uuid[])`, [t.teamId, t.members]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// A malformed file must not reject the batch; it stays cached and is reported to the admin.
|
||||
await recordError(c, 'file_sync', `${e.path}: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// Side-effect imports register all projections with the pipeline.
|
||||
import './sessions.ts';
|
||||
import './combat.ts';
|
||||
import './advancements.ts';
|
||||
import './stats.ts';
|
||||
import './server.ts';
|
||||
import './files.ts';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
|
||||
/**
|
||||
* Upserts a player. A non-null name replaces the stored one only if it was observed at least as late as the
|
||||
* current name (late replays never revert a rename) and is recorded in name_history;
|
||||
* `seen` updates last_seen (only for events proving the player was online).
|
||||
*/
|
||||
export async function ensurePlayer(c: PoolClient, uuid: string, name: string | null, ts: number, seen: boolean): Promise<void> {
|
||||
await c.query(
|
||||
`INSERT INTO players(uuid, name, name_seen_at, first_seen, last_seen)
|
||||
VALUES ($1, $2, CASE WHEN $2::text IS NOT NULL THEN to_timestamp($3 / 1000.0) END,
|
||||
to_timestamp($3 / 1000.0), CASE WHEN $4 THEN to_timestamp($3 / 1000.0) END)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
name = CASE WHEN EXCLUDED.name IS NOT NULL AND (players.name_seen_at IS NULL OR EXCLUDED.name_seen_at >= players.name_seen_at)
|
||||
THEN EXCLUDED.name ELSE players.name END,
|
||||
name_seen_at = CASE WHEN EXCLUDED.name IS NOT NULL AND (players.name_seen_at IS NULL OR EXCLUDED.name_seen_at >= players.name_seen_at)
|
||||
THEN EXCLUDED.name_seen_at ELSE players.name_seen_at END,
|
||||
first_seen = LEAST(players.first_seen, EXCLUDED.first_seen),
|
||||
last_seen = CASE WHEN $4 THEN GREATEST(players.last_seen, EXCLUDED.last_seen) ELSE players.last_seen END`,
|
||||
[uuid, name, ts, seen],
|
||||
);
|
||||
if (name) {
|
||||
await c.query(
|
||||
`INSERT INTO name_history(uuid, name, seen_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
|
||||
ON CONFLICT (uuid, name) DO UPDATE SET seen_at = GREATEST(name_history.seen_at, EXCLUDED.seen_at)`,
|
||||
[uuid, name, ts],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
|
||||
registerProjection('health', async (c, e) => {
|
||||
await c.query(
|
||||
`INSERT INTO server_health(bucket, mspt_avg, mspt_p95, mspt_max, tps, players, chunks, heap_used_mb, heap_max_mb)
|
||||
VALUES (date_trunc('minute', to_timestamp($1 / 1000.0)), $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (bucket) DO UPDATE SET
|
||||
mspt_avg = EXCLUDED.mspt_avg, mspt_p95 = EXCLUDED.mspt_p95, mspt_max = EXCLUDED.mspt_max, tps = EXCLUDED.tps,
|
||||
players = EXCLUDED.players, chunks = EXCLUDED.chunks,
|
||||
heap_used_mb = EXCLUDED.heap_used_mb, heap_max_mb = EXCLUDED.heap_max_mb`,
|
||||
[e.windowStart, e.msptAvg, e.msptP95, e.msptMax, e.tps, e.players, e.chunks, e.heapUsedMb, e.heapMaxMb],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
import { ensurePlayer } from './players.ts';
|
||||
|
||||
/**
|
||||
* Push and log (sftp) both report joins. A join that falls inside a session already recorded by the other
|
||||
* source is the same visit seen twice and is skipped; only an open session of the same source is superseded.
|
||||
*/
|
||||
registerProjection('player_join', async (c, e, source) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
const { rowCount: covered } = await c.query(
|
||||
`SELECT 1 FROM sessions
|
||||
WHERE uuid = $1 AND source <> $3 AND started_at <= to_timestamp($2 / 1000.0)
|
||||
AND (ended_at IS NULL OR ended_at >= to_timestamp($2 / 1000.0))`,
|
||||
[e.uuid, e.ts, source],
|
||||
);
|
||||
if (covered) return;
|
||||
await c.query(
|
||||
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($2 / 1000.0)), end_reason = 'superseded'
|
||||
WHERE uuid = $1 AND ended_at IS NULL AND source = $3`,
|
||||
[e.uuid, e.ts, source],
|
||||
);
|
||||
await c.query(`INSERT INTO sessions(uuid, started_at, source) VALUES ($1, to_timestamp($2 / 1000.0), $3)`, [e.uuid, e.ts, source]);
|
||||
});
|
||||
|
||||
registerProjection('player_leave', async (c, e) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
|
||||
await c.query(
|
||||
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($2 / 1000.0)), end_reason = 'leave'
|
||||
WHERE uuid = $1 AND ended_at IS NULL`,
|
||||
[e.uuid, e.ts],
|
||||
);
|
||||
});
|
||||
|
||||
registerProjection('server_stop', async (c, e) => {
|
||||
await c.query(
|
||||
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($1 / 1000.0)), end_reason = 'stop'
|
||||
WHERE ended_at IS NULL`,
|
||||
[e.ts],
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A server_start while sessions are still open means the server went down without server_stop (crash, kill).
|
||||
* Each open session ends at the last sign of life before the start: the player's last event or the last
|
||||
* health window, whichever is later — never later than the start itself.
|
||||
*/
|
||||
registerProjection('server_start', async (c, e) => {
|
||||
await c.query(
|
||||
`UPDATE sessions s SET
|
||||
end_reason = 'crash',
|
||||
ended_at = GREATEST(s.started_at, LEAST(to_timestamp($1 / 1000.0), COALESCE(GREATEST(
|
||||
(SELECT max(ev.ts) FROM events ev
|
||||
WHERE ev.payload->>'uuid' = s.uuid::text AND ev.ts >= s.started_at AND ev.ts < to_timestamp($1 / 1000.0)),
|
||||
(SELECT to_timestamp(max((ev.payload->>'windowEnd')::bigint) / 1000.0) FROM events ev
|
||||
WHERE ev.type = 'health' AND ev.ts >= s.started_at AND ev.ts < to_timestamp($1 / 1000.0))
|
||||
), s.started_at)))
|
||||
WHERE s.ended_at IS NULL`,
|
||||
[e.ts],
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO server_info(started_at, mc_version, neoforge_version, java_version, mods)
|
||||
VALUES (to_timestamp($1 / 1000.0), $2, $3, $4, $5)`,
|
||||
[e.ts, e.mcVersion, e.neoforgeVersion, e.javaVersion, JSON.stringify(e.mods)],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { registerProjection } from '../process.ts';
|
||||
import { ensurePlayer } from './players.ts';
|
||||
|
||||
/**
|
||||
* Counters are absolute. Per (uuid, category, key) the observation with the newest timestamp wins,
|
||||
* so late or repeated deliveries (push vs. SFTP) never move a value backwards in time.
|
||||
* Keys missing from a snapshot are left untouched, even for `full` snapshots.
|
||||
*/
|
||||
registerProjection('stats_snapshot', async (c, e, source) => {
|
||||
await ensurePlayer(c, e.uuid, e.name, e.ts, false);
|
||||
const cats: string[] = [];
|
||||
const keys: string[] = [];
|
||||
const vals: number[] = [];
|
||||
for (const [cat, entries] of Object.entries(e.stats)) {
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
cats.push(cat);
|
||||
keys.push(key);
|
||||
vals.push(value);
|
||||
}
|
||||
}
|
||||
if (cats.length === 0) return;
|
||||
await c.query(
|
||||
`INSERT INTO stat_current(uuid, category, key, value, observed_at, source)
|
||||
SELECT $1, t.cat, t.key, t.val, to_timestamp($5 / 1000.0), $6
|
||||
FROM unnest($2::text[], $3::text[], $4::bigint[]) AS t(cat, key, val)
|
||||
ON CONFLICT (uuid, category, key) DO UPDATE SET
|
||||
value = EXCLUDED.value, observed_at = EXCLUDED.observed_at, source = EXCLUDED.source
|
||||
WHERE stat_current.observed_at < EXCLUDED.observed_at`,
|
||||
[e.uuid, cats, keys, vals, e.ts, source],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { getSession, login, logout, SESSION_TTL_MS } from '../auth/admin.ts';
|
||||
import type { Config } from '../config.ts';
|
||||
import { createIngestToken, revokeAllIngestTokens } from '../ingest/tokens.ts';
|
||||
import { UUID_RE } from './public/shared.ts';
|
||||
|
||||
const COOKIE = 'atm_admin';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
admin?: { username: string; csrf: string; sessionId: string };
|
||||
}
|
||||
}
|
||||
|
||||
const sameToken = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance, { pool, config }: { pool: Pool; config: Config }) {
|
||||
const secure = config.NODE_ENV !== 'test' && config.NODE_ENV !== 'development';
|
||||
|
||||
app.addHook('onSend', async (_req, reply) => {
|
||||
reply.header('cache-control', 'no-store');
|
||||
});
|
||||
|
||||
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const sessionId = req.cookies[COOKIE];
|
||||
const session = sessionId ? await getSession(pool, sessionId) : null;
|
||||
if (!session || !sessionId) return reply.code(401).send({ error: 'unauthorized' });
|
||||
if (req.method !== 'GET') {
|
||||
const header = req.headers['x-csrf-token'];
|
||||
if (typeof header !== 'string' || !sameToken(header, session.csrf)) return reply.code(403).send({ error: 'csrf' });
|
||||
}
|
||||
req.admin = { ...session, sessionId };
|
||||
};
|
||||
|
||||
app.post(
|
||||
'/api/admin/login',
|
||||
{ config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
|
||||
async (req, reply) => {
|
||||
const body = z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256) }).safeParse(req.body);
|
||||
if (!body.success) return reply.code(400).send({ error: 'bad_request' });
|
||||
const s = await login(pool, body.data.username, body.data.password);
|
||||
if (!s) {
|
||||
req.log.warn({ username: body.data.username }, 'admin login failed');
|
||||
return reply.code(401).send({ error: 'invalid_credentials' });
|
||||
}
|
||||
reply.setCookie(COOKIE, s.sessionId, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'strict',
|
||||
path: '/api/admin',
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
});
|
||||
return { csrf: s.csrf, username: body.data.username };
|
||||
},
|
||||
);
|
||||
|
||||
app.register(async (protectedApp) => {
|
||||
protectedApp.addHook('preHandler', requireAdmin);
|
||||
|
||||
protectedApp.get('/api/admin/session', async (req) => ({ username: req.admin!.username, csrf: req.admin!.csrf }));
|
||||
|
||||
protectedApp.post('/api/admin/logout', async (req, reply) => {
|
||||
await logout(pool, req.admin!.sessionId);
|
||||
reply.clearCookie(COOKIE, { path: '/api/admin' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
protectedApp.get('/api/admin/status', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT (SELECT max(received_at) FROM events WHERE source = 'push') AS last_push,
|
||||
(SELECT max(received_at) FROM events WHERE source = 'sftp') AS last_sftp_event,
|
||||
(SELECT max(synced_at) FROM sync_state) AS last_sync,
|
||||
(SELECT GREATEST(reltuples, 0)::bigint FROM pg_class WHERE relname = 'events') AS events, -- estimate, no table scan
|
||||
(SELECT count(*) FROM ingest_tokens WHERE revoked_at IS NULL)::int AS active_tokens,
|
||||
(SELECT count(*) FROM sync_requests WHERE done_at IS NULL)::int AS pending_sync`,
|
||||
);
|
||||
const errors = await pool.query(`SELECT at, source, message FROM app_errors ORDER BY at DESC LIMIT 20`);
|
||||
const r = rows[0];
|
||||
return {
|
||||
push: { lastEventAt: r.last_push?.getTime() ?? null },
|
||||
sftp: {
|
||||
enabled: config.sftp !== null,
|
||||
lastSyncAt: r.last_sync?.getTime() ?? null,
|
||||
lastEventAt: r.last_sftp_event?.getTime() ?? null,
|
||||
pendingRequests: r.pending_sync,
|
||||
},
|
||||
events: r.events,
|
||||
activeTokens: r.active_tokens,
|
||||
lastErrors: errors.rows.map((e) => ({ at: e.at.getTime(), source: e.source, message: e.message })),
|
||||
};
|
||||
});
|
||||
|
||||
protectedApp.get('/api/admin/players', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, name, hidden, last_seen FROM players ORDER BY last_seen DESC NULLS LAST, name`,
|
||||
);
|
||||
return { players: rows.map((r) => ({ uuid: r.uuid, name: r.name, hidden: r.hidden, lastSeen: r.last_seen?.getTime() ?? null })) };
|
||||
});
|
||||
|
||||
protectedApp.post<{ Params: { uuid: string } }>('/api/admin/players/:uuid/hidden', async (req, reply) => {
|
||||
const body = z.object({ hidden: z.boolean() }).safeParse(req.body);
|
||||
if (!UUID_RE.test(req.params.uuid) || !body.success) return reply.code(400).send({ error: 'bad_request' });
|
||||
const { rowCount } = await pool.query('UPDATE players SET hidden = $2 WHERE uuid = $1', [req.params.uuid, body.data.hidden]);
|
||||
if (!rowCount) return reply.code(404).send({ error: 'not_found' });
|
||||
req.log.info({ admin: req.admin!.username, uuid: req.params.uuid, hidden: body.data.hidden }, 'player visibility changed');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
protectedApp.post('/api/admin/sync', async (_req, reply) => {
|
||||
await pool.query('INSERT INTO sync_requests DEFAULT VALUES');
|
||||
return reply.code(202).send({ queued: true });
|
||||
});
|
||||
|
||||
protectedApp.post('/api/admin/ingest-token/rotate', async (req) => {
|
||||
await revokeAllIngestTokens(pool);
|
||||
const token = await createIngestToken(pool);
|
||||
req.log.info({ admin: req.admin!.username }, 'ingest token rotated');
|
||||
return { token };
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LiveEvent } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { LiveHub } from '../live/hub.ts';
|
||||
|
||||
export const HEARTBEAT_MS = 25_000;
|
||||
const MAX_CLIENTS = 500;
|
||||
|
||||
const frame = (e: LiveEvent) => `id: ${e.id}\nevent: ${e.kind}\ndata: ${JSON.stringify(e.data)}\n\n`;
|
||||
|
||||
export async function liveRoutes(app: FastifyInstance, { hub }: { hub: LiveHub }) {
|
||||
let clients = 0;
|
||||
const open = new Set<{ end: () => void }>();
|
||||
// Hijacked streams are not tracked by Fastify; end them so shutdown does not hang.
|
||||
app.addHook('onClose', async () => {
|
||||
for (const res of open) res.end();
|
||||
});
|
||||
|
||||
app.get<{ Headers: { 'last-event-id'?: string } }>('/api/live', async (req, reply) => {
|
||||
if (clients >= MAX_CLIENTS) return reply.code(503).send({ error: 'too_many_clients' });
|
||||
clients++;
|
||||
reply.hijack();
|
||||
const res = reply.raw;
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
res.write(`retry: 5000\n: connected\n\n`);
|
||||
|
||||
const lastId = Number(req.headers['last-event-id']);
|
||||
if (Number.isFinite(lastId) && lastId > 0) for (const e of hub.since(lastId)) res.write(frame(e));
|
||||
|
||||
const unsubscribe = hub.subscribe((e) => res.write(frame(e)));
|
||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
|
||||
open.add(res);
|
||||
const close = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
open.delete(res);
|
||||
clients--;
|
||||
};
|
||||
req.raw.on('close', close);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { UUID_RE } from './shared.ts';
|
||||
|
||||
const TTL_MS = 24 * 3600_000;
|
||||
const MAX_ENTRIES = 500;
|
||||
const TIMEOUT_MS = 5000;
|
||||
|
||||
// 8×8 neutral grey PNG, served when the avatar service is unreachable.
|
||||
const PLACEHOLDER = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVR42mN4+vQpTsQwciUBADoNkKUl+DzzAAAAAElFTkSuQmCC',
|
||||
'base64',
|
||||
);
|
||||
|
||||
/**
|
||||
* Player heads are fetched server-side and cached, so visitors' browsers never contact third parties
|
||||
* (no visitor IPs leave the portal).
|
||||
*/
|
||||
export async function avatarRoutes(app: FastifyInstance, { source }: { source: string }) {
|
||||
const cache = new Map<string, { png: Buffer; at: number }>();
|
||||
|
||||
app.get<{ Params: { uuid: string } }>('/api/avatar/:uuid', async (req, reply) => {
|
||||
const uuid = req.params.uuid.toLowerCase();
|
||||
if (!UUID_RE.test(uuid)) return reply.code(400).send({ error: 'bad_uuid' });
|
||||
reply.header('content-type', 'image/png').header('cache-control', 'public, max-age=86400');
|
||||
|
||||
const hit = cache.get(uuid);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return reply.send(hit.png);
|
||||
try {
|
||||
const res = await fetch(source.replace('{uuid}', uuid), { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
const type = res.headers.get('content-type') ?? '';
|
||||
if (!res.ok || !type.startsWith('image/png')) throw new Error(`avatar ${res.status}`);
|
||||
const png = Buffer.from(await res.arrayBuffer());
|
||||
if (png.length > 256 * 1024) throw new Error('avatar too large');
|
||||
if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value!);
|
||||
cache.set(uuid, { png, at: Date.now() });
|
||||
return reply.send(png);
|
||||
} catch (err) {
|
||||
req.log.debug({ err }, 'avatar fetch failed');
|
||||
return reply.header('cache-control', 'public, max-age=300').send(hit?.png ?? PLACEHOLDER);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Config } from '../../config.ts';
|
||||
import { avatarRoutes } from './avatar.ts';
|
||||
import { leaderboardRoutes } from './leaderboards.ts';
|
||||
import { overviewRoutes } from './overview.ts';
|
||||
import { playerRoutes } from './players.ts';
|
||||
import { serverRoutes } from './server.ts';
|
||||
|
||||
export async function publicRoutes(app: FastifyInstance, { pool, config }: { pool: Pool; config: Config }) {
|
||||
// Short shared cache for all public read endpoints (Traefik/browsers); avatar sets its own.
|
||||
app.addHook('onSend', async (req, reply) => {
|
||||
if (req.method === 'GET' && !reply.hasHeader('cache-control')) reply.header('cache-control', 'public, max-age=15');
|
||||
});
|
||||
await overviewRoutes(app, { pool });
|
||||
await leaderboardRoutes(app, { pool });
|
||||
await playerRoutes(app, { pool, timeZone: config.TIMEZONE });
|
||||
await serverRoutes(app, { pool, config });
|
||||
await avatarRoutes(app, { source: config.AVATAR_SOURCE });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LeaderboardDto, LeaderboardsDto, Period } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getLeaderboard } from '../../stats/leaderboards.ts';
|
||||
import { BOARD_BY_ID, BOARDS, type Board } from '../../stats/metrics.ts';
|
||||
|
||||
const PERIODS: Period[] = ['all', '30d', '7d'];
|
||||
const meta = (b: Board) => ({ id: b.id, title: b.title, description: b.description, unit: b.unit });
|
||||
|
||||
export async function leaderboardRoutes(app: FastifyInstance, { pool }: { pool: Pool }) {
|
||||
app.get('/api/leaderboards', async (): Promise<LeaderboardsDto> => ({
|
||||
boards: await Promise.all(BOARDS.map(async (b) => ({ ...meta(b), top: await getLeaderboard(pool, b.id, 'all', 10) }))),
|
||||
}));
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { period?: string } }>('/api/leaderboards/:id', async (req, reply) => {
|
||||
const board = BOARD_BY_ID.get(req.params.id);
|
||||
if (!board) return reply.code(404).send({ error: 'unknown_board' });
|
||||
const period = (req.query.period ?? 'all') as Period;
|
||||
if (!PERIODS.includes(period)) return reply.code(400).send({ error: 'bad_period' });
|
||||
const dto: LeaderboardDto = { board: meta(board), period, entries: await getLeaderboard(pool, board.id, period, 100) };
|
||||
return dto;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { OnlineDto, OverviewDto } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { hours, ms, QUEST_FILTER } from './shared.ts';
|
||||
|
||||
export async function overviewRoutes(app: FastifyInstance, { pool }: { pool: Pool }) {
|
||||
app.get('/api/overview', async (): Promise<OverviewDto> => {
|
||||
const { rows } = await pool.query(
|
||||
`WITH visible AS (SELECT uuid FROM players WHERE NOT hidden),
|
||||
stat AS (
|
||||
SELECT key, sum(value) AS total FROM stat_current
|
||||
WHERE category = 'minecraft:custom' AND key IN ('minecraft:play_time', 'minecraft:deaths', 'minecraft:mob_kills')
|
||||
AND uuid IN (SELECT uuid FROM visible)
|
||||
GROUP BY key)
|
||||
SELECT
|
||||
(SELECT count(*) FROM sessions WHERE ended_at IS NULL AND uuid IN (SELECT uuid FROM visible))::int AS online,
|
||||
(SELECT count(*) FROM visible)::int AS total_players,
|
||||
COALESCE((SELECT total FROM stat WHERE key = 'minecraft:play_time'), 0) AS play_ticks,
|
||||
COALESCE((SELECT total FROM stat WHERE key = 'minecraft:deaths'), 0) AS deaths,
|
||||
COALESCE((SELECT total FROM stat WHERE key = 'minecraft:mob_kills'), 0) AS mob_kills,
|
||||
(SELECT count(*) FROM quest_progress qp WHERE ${QUEST_FILTER})::int AS quests,
|
||||
(SELECT count(*) FROM advancements WHERE uuid IN (SELECT uuid FROM visible))::int AS advancements,
|
||||
(SELECT received_at FROM events ORDER BY received_at DESC LIMIT 1) AS last_update`,
|
||||
);
|
||||
const r = rows[0];
|
||||
return {
|
||||
online: r.online,
|
||||
totalPlayers: r.total_players,
|
||||
totalPlayHours: hours(r.play_ticks),
|
||||
totalDeaths: r.deaths,
|
||||
totalMobKills: r.mob_kills,
|
||||
questsCompleted: r.quests,
|
||||
advancements: r.advancements,
|
||||
lastUpdate: ms(r.last_update),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/online', async (): Promise<OnlineDto> => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.uuid, p.name, s.started_at,
|
||||
(SELECT e.payload->>'to' FROM events e
|
||||
WHERE e.type = 'dimension_change' AND e.payload->>'uuid' = s.uuid::text AND e.ts >= s.started_at
|
||||
ORDER BY e.ts DESC LIMIT 1) AS dimension
|
||||
FROM sessions s JOIN players p ON p.uuid = s.uuid AND NOT p.hidden
|
||||
WHERE s.ended_at IS NULL
|
||||
ORDER BY s.started_at`,
|
||||
);
|
||||
return { players: rows.map((r) => ({ uuid: r.uuid, name: r.name, since: r.started_at.getTime(), dimension: r.dimension })) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { ITEM_CATEGORIES, type HistoryDto, type ItemCategory, type ItemsDto, type PlayerListDto, type PlayerProfileDto } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { getPlayerRanks } from '../../stats/leaderboards.ts';
|
||||
import { BOARD_BY_ID, metricSql, toDisplay } from '../../stats/metrics.ts';
|
||||
import { hours, ms, QUEST_FILTER, resolvePlayer } from './shared.ts';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const RANGES = { '7d': { days: 7, granularity: 'hour' }, '30d': { days: 30, granularity: 'day' }, all: { days: null, granularity: 'day' } } as const;
|
||||
|
||||
export async function playerRoutes(app: FastifyInstance, { pool, timeZone }: { pool: Pool; timeZone: string }) {
|
||||
app.get<{ Querystring: { search?: string; page?: string } }>('/api/players', async (req): Promise<PlayerListDto> => {
|
||||
const search = (req.query.search ?? '').trim().slice(0, 16);
|
||||
const page = Math.max(0, Number.parseInt(req.query.page ?? '0', 10) || 0);
|
||||
const like = `%${search.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.uuid, p.name, p.last_seen,
|
||||
COALESCE(sc.value, 0) AS play_ticks,
|
||||
EXISTS (SELECT 1 FROM sessions s WHERE s.uuid = p.uuid AND s.ended_at IS NULL) AS online,
|
||||
count(*) OVER ()::int AS total
|
||||
FROM players p
|
||||
LEFT JOIN stat_current sc ON sc.uuid = p.uuid AND sc.category = 'minecraft:custom' AND sc.key = 'minecraft:play_time'
|
||||
WHERE NOT p.hidden AND p.name IS NOT NULL AND ($1 = '' OR p.name ILIKE $2)
|
||||
ORDER BY online DESC, p.last_seen DESC NULLS LAST, p.name
|
||||
LIMIT ${PAGE_SIZE} OFFSET $3`,
|
||||
[search, like, page * PAGE_SIZE],
|
||||
);
|
||||
return {
|
||||
total: rows[0]?.total ?? 0,
|
||||
players: rows.map((r) => ({ uuid: r.uuid, name: r.name, lastSeen: ms(r.last_seen), playHours: hours(r.play_ticks), online: r.online })),
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/players/:id', async (req, reply) => {
|
||||
const uuid = await resolvePlayer(pool, req.params.id);
|
||||
if (!uuid) return reply.code(404).send({ error: 'not_found' });
|
||||
return getProfile(pool, uuid, timeZone);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { metric?: string; range?: string } }>(
|
||||
'/api/players/:id/history',
|
||||
async (req, reply) => {
|
||||
const uuid = await resolvePlayer(pool, req.params.id);
|
||||
if (!uuid) return reply.code(404).send({ error: 'not_found' });
|
||||
const board = BOARD_BY_ID.get(req.query.metric ?? 'playtime');
|
||||
const range = RANGES[(req.query.range ?? '30d') as keyof typeof RANGES];
|
||||
if (!board || !range) return reply.code(400).send({ error: 'bad_query' });
|
||||
const { rows } = await pool.query<{ bucket: Date; value: number }>(
|
||||
`SELECT bucket, value FROM stat_snapshots
|
||||
WHERE uuid = $1 AND metric = $2 AND granularity = $3 AND ($4::int IS NULL OR bucket >= now() - make_interval(days => $4::int))
|
||||
ORDER BY bucket`,
|
||||
[uuid, board.id, range.granularity, range.days],
|
||||
);
|
||||
const current = await pool.query<{ value: number }>(`SELECT value FROM (${metricSql(board.metric)}) v WHERE v.uuid = $1`, [uuid]);
|
||||
const points = rows.map((r) => ({ t: r.bucket.getTime(), v: toDisplay(board.unit, r.value) }));
|
||||
if (current.rows[0]) points.push({ t: Date.now(), v: toDisplay(board.unit, current.rows[0].value) });
|
||||
const dto: HistoryDto = { metric: board.id, unit: board.unit, points };
|
||||
return dto;
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { category?: string } }>('/api/players/:id/items', async (req, reply) => {
|
||||
const uuid = await resolvePlayer(pool, req.params.id);
|
||||
if (!uuid) return reply.code(404).send({ error: 'not_found' });
|
||||
const category = (req.query.category ?? 'mined') as ItemCategory;
|
||||
if (!ITEM_CATEGORIES.includes(category)) return reply.code(400).send({ error: 'bad_category' });
|
||||
const { rows } = await pool.query<{ ns: string; key: string; value: number; ns_total: number }>(
|
||||
`SELECT ns, key, value, ns_total FROM (
|
||||
SELECT split_part(key, ':', 1) AS ns, key, value,
|
||||
sum(value) OVER (PARTITION BY split_part(key, ':', 1)) AS ns_total,
|
||||
row_number() OVER (PARTITION BY split_part(key, ':', 1) ORDER BY value DESC, key) AS rn
|
||||
FROM stat_current WHERE uuid = $1 AND category = $2 AND value > 0) t
|
||||
WHERE rn <= 20 ORDER BY ns_total DESC, ns, value DESC`,
|
||||
[uuid, `minecraft:${category}`],
|
||||
);
|
||||
const mods: ItemsDto['mods'] = [];
|
||||
for (const r of rows) {
|
||||
let mod = mods.at(-1);
|
||||
if (mod?.namespace !== r.ns) {
|
||||
mod = { namespace: r.ns, total: r.ns_total, items: [] };
|
||||
mods.push(mod);
|
||||
}
|
||||
mod.items.push({ key: r.key, value: r.value });
|
||||
}
|
||||
const dto: ItemsDto = { category, mods };
|
||||
return dto;
|
||||
});
|
||||
}
|
||||
|
||||
async function getProfile(pool: Pool, uuid: string, timeZone: string): Promise<PlayerProfileDto> {
|
||||
const q = async <T extends Record<string, unknown>>(sql: string, params: unknown[] = [uuid]) => (await pool.query<T>(sql, params)).rows;
|
||||
|
||||
const [player] = await q<{ name: string | null; first_seen: Date; last_seen: Date | null }>(
|
||||
'SELECT name, first_seen, last_seen FROM players WHERE uuid = $1',
|
||||
);
|
||||
const [sess] = await q<{ n: number; online: boolean; minutes: number }>(
|
||||
`SELECT count(*)::int AS n, bool_or(ended_at IS NULL) AS online,
|
||||
COALESCE(sum(extract(epoch FROM COALESCE(ended_at, now()) - started_at) / 60), 0) AS minutes
|
||||
FROM sessions WHERE uuid = $1`,
|
||||
);
|
||||
const custom = await q<{ key: string; value: number }>(
|
||||
`SELECT key, value FROM stat_current WHERE uuid = $1 AND category = 'minecraft:custom' ORDER BY key`,
|
||||
);
|
||||
const general: Record<string, number> = {};
|
||||
const movement: Record<string, number> = {};
|
||||
for (const r of custom) (r.key.endsWith('_one_cm') ? movement : general)[r.key] = r.value;
|
||||
const playTicks = general['minecraft:play_time'];
|
||||
|
||||
const counted = (category: string) =>
|
||||
q<{ key: string; value: number }>(
|
||||
`SELECT key, value FROM stat_current WHERE uuid = $1 AND category = $2 AND value > 0 ORDER BY value DESC, key LIMIT 25`,
|
||||
[uuid, category],
|
||||
);
|
||||
|
||||
const team = await q<{ team_id: string; name: string | null }>(
|
||||
`SELECT COALESCE(tm.team_id, $1::uuid) AS team_id, t.name
|
||||
FROM (SELECT $1::uuid AS uuid) p
|
||||
LEFT JOIN team_members tm ON tm.uuid = p.uuid
|
||||
LEFT JOIN teams t ON t.team_id = COALESCE(tm.team_id, p.uuid)
|
||||
LIMIT 1`,
|
||||
);
|
||||
const teamId = team[0]!.team_id;
|
||||
const [questCount] = await q<{ completed: number; total: number }>(
|
||||
`SELECT (SELECT count(*) FROM quest_progress qp WHERE qp.team_id = $1 AND ${QUEST_FILTER})::int AS completed,
|
||||
(SELECT count(*) FROM quest_defs)::int AS total`,
|
||||
[teamId],
|
||||
);
|
||||
const chapters = await q<{ chapter_id: string; title: string | null; completed: number; total: number }>(
|
||||
`SELECT d.chapter_id, max(d.chapter_title) AS title, count(qp.quest_id)::int AS completed, count(*)::int AS total
|
||||
FROM quest_defs d LEFT JOIN quest_progress qp ON qp.quest_id = d.quest_id AND qp.team_id = $1
|
||||
GROUP BY d.chapter_id ORDER BY max(d.chapter_title)`,
|
||||
[teamId],
|
||||
);
|
||||
const recentQuests = await q<{ quest_id: string; title: string; chapter_title: string | null; completed_at: Date }>(
|
||||
`SELECT d.quest_id, d.title, d.chapter_title, qp.completed_at
|
||||
FROM quest_progress qp JOIN quest_defs d ON d.quest_id = qp.quest_id
|
||||
WHERE qp.team_id = $1 AND d.title IS NOT NULL
|
||||
ORDER BY qp.completed_at DESC LIMIT 10`,
|
||||
[teamId],
|
||||
);
|
||||
|
||||
const heat = await q<{ dow: number; hour: number; minutes: number }>(
|
||||
`SELECT (extract(isodow FROM h AT TIME ZONE $2)::int - 1) AS dow, extract(hour FROM h AT TIME ZONE $2)::int AS hour,
|
||||
sum(extract(epoch FROM LEAST(COALESCE(s.ended_at, now()), h + interval '1 hour') - GREATEST(s.started_at, h)) / 60) AS minutes
|
||||
FROM sessions s,
|
||||
generate_series(date_trunc('hour', s.started_at), COALESCE(s.ended_at, now()), interval '1 hour') AS h
|
||||
WHERE s.uuid = $1 AND s.started_at > now() - interval '90 days'
|
||||
GROUP BY 1, 2`,
|
||||
[uuid, timeZone],
|
||||
);
|
||||
const activityHeatmap = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0));
|
||||
for (const r of heat) activityHeatmap[r.dow]![r.hour] = Math.round(r.minutes);
|
||||
|
||||
return {
|
||||
uuid,
|
||||
name: player!.name,
|
||||
firstSeen: player!.first_seen.getTime(),
|
||||
lastSeen: ms(player!.last_seen),
|
||||
online: sess?.online ?? false,
|
||||
playHours: playTicks !== undefined ? hours(playTicks) : Math.round(((sess?.minutes ?? 0) / 60) * 100) / 100,
|
||||
sessions: sess?.n ?? 0,
|
||||
ranks: await getPlayerRanks(pool, uuid),
|
||||
general,
|
||||
movement,
|
||||
deathsByCause: (
|
||||
await q<{ cause: string; count: number }>(
|
||||
'SELECT cause, count(*)::int AS count FROM deaths WHERE uuid = $1 GROUP BY cause ORDER BY count DESC, cause',
|
||||
)
|
||||
).map((r) => ({ cause: r.cause, count: r.count })),
|
||||
killedBy: (await counted('minecraft:killed_by')).map((r) => ({ entity: r.key, count: r.value })),
|
||||
killsByType: (await counted('minecraft:killed')).map((r) => ({ entity: r.key, count: r.value })),
|
||||
bossKills: (
|
||||
await q<{ victim_type: string; ts: Date }>('SELECT victim_type, ts FROM kills WHERE uuid = $1 AND is_boss ORDER BY ts DESC LIMIT 50')
|
||||
).map((r) => ({ entity: r.victim_type, at: r.ts.getTime() })),
|
||||
dimensions: (
|
||||
await q<{ dimension: string; first_at: Date }>('SELECT dimension, first_at FROM dimension_visits WHERE uuid = $1 ORDER BY first_at')
|
||||
).map((r) => ({ dimension: r.dimension, firstAt: r.first_at.getTime() })),
|
||||
advancements: (
|
||||
await q<{ advancement_id: string; title: string | null; achieved_at: Date }>(
|
||||
'SELECT advancement_id, title, achieved_at FROM advancements WHERE uuid = $1 ORDER BY achieved_at DESC LIMIT 200',
|
||||
)
|
||||
).map((r) => ({ id: r.advancement_id, title: r.title, at: r.achieved_at.getTime() })),
|
||||
quests: {
|
||||
teamId,
|
||||
teamName: team[0]!.name,
|
||||
completed: questCount!.completed,
|
||||
total: questCount!.total > 0 ? questCount!.total : null,
|
||||
chapters: chapters.map((c) => ({ chapterId: c.chapter_id, title: c.title, completed: c.completed, total: c.total })),
|
||||
recent: recentQuests.map((r) => ({ questId: r.quest_id, title: r.title, chapterTitle: r.chapter_title, at: r.completed_at.getTime() })),
|
||||
},
|
||||
activityHeatmap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ModsDto, QuestsDto, ServerDto, ServerInfoDto, SourcesDto } from '@atm/shared/api';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Config } from '../../config.ts';
|
||||
import { MOD_CATEGORIES } from '../../stats/metrics.ts';
|
||||
import { LIMITATIONS, ms, QUEST_FILTER } from './shared.ts';
|
||||
|
||||
const RANGE_HOURS = { '24h': 24, '7d': 168, '30d': 720 } as const;
|
||||
const PUSH_FRESH_MS = 10 * 60_000;
|
||||
|
||||
const categoryOf = (ns: string): string | null =>
|
||||
(Object.entries(MOD_CATEGORIES).find(([, list]) => list.includes(ns))?.[0] as string | undefined) ?? null;
|
||||
|
||||
async function latestServerInfo(pool: Pool): Promise<{ info: ServerInfoDto; mods: { id: string; name: string; version: string }[] } | null> {
|
||||
const { rows } = await pool.query('SELECT * FROM server_info ORDER BY started_at DESC LIMIT 1');
|
||||
const r = rows[0];
|
||||
if (!r) return null;
|
||||
const mods = r.mods as { id: string; name: string; version: string }[];
|
||||
return {
|
||||
info: { startedAt: r.started_at.getTime(), mcVersion: r.mc_version, neoforgeVersion: r.neoforge_version, javaVersion: r.java_version, modCount: mods.length },
|
||||
mods,
|
||||
};
|
||||
}
|
||||
|
||||
export async function serverRoutes(app: FastifyInstance, { pool, config }: { pool: Pool; config: Config }) {
|
||||
app.get('/api/mods', async (): Promise<ModsDto> => {
|
||||
const latest = await latestServerInfo(pool);
|
||||
const { rows } = await pool.query<{ ns: string; activity: number }>(
|
||||
`SELECT split_part(key, ':', 1) AS ns, sum(value) AS activity
|
||||
FROM stat_current sc JOIN players p ON p.uuid = sc.uuid AND NOT p.hidden
|
||||
WHERE category IN ('minecraft:mined', 'minecraft:crafted', 'minecraft:used', 'minecraft:picked_up')
|
||||
GROUP BY 1`,
|
||||
);
|
||||
const activity = new Map(rows.map((r) => [r.ns, r.activity]));
|
||||
const known = latest?.mods ?? [];
|
||||
const mods = known
|
||||
.map((m) => ({ id: m.id, name: m.name, version: m.version, activity: activity.get(m.id) ?? 0, category: categoryOf(m.id) }))
|
||||
.sort((a, b) => b.activity - a.activity || a.name.localeCompare(b.name));
|
||||
const byCategory = new Map<string, number>();
|
||||
for (const [ns, value] of activity) {
|
||||
if (ns === 'minecraft') continue;
|
||||
const cat = categoryOf(ns) ?? 'other';
|
||||
byCategory.set(cat, (byCategory.get(cat) ?? 0) + value);
|
||||
}
|
||||
return {
|
||||
server: latest?.info ?? null,
|
||||
mods,
|
||||
categories: [...byCategory].map(([category, a]) => ({ category, activity: a })).sort((a, b) => b.activity - a.activity),
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { range?: string } }>('/api/server', async (req, reply): Promise<ServerDto | undefined> => {
|
||||
const hours = RANGE_HOURS[(req.query.range ?? '24h') as keyof typeof RANGE_HOURS];
|
||||
if (!hours) return reply.code(400).send({ error: 'bad_range' });
|
||||
// Minute values for the last 14 days, hourly aggregates beyond; long ranges are bucketed to keep payloads small.
|
||||
const step = hours <= 24 ? 'minute' : 'hour';
|
||||
const { rows } = await pool.query(
|
||||
`SELECT date_trunc($2, bucket) AS t, avg(mspt_avg) AS mspt_avg, max(mspt_p95) AS mspt_p95, avg(tps) AS tps,
|
||||
max(players) AS players, avg(heap_used_mb) AS heap_used_mb, max(heap_max_mb) AS heap_max_mb
|
||||
FROM (SELECT bucket, mspt_avg, mspt_p95, tps, players, heap_used_mb, heap_max_mb FROM server_health
|
||||
UNION ALL
|
||||
SELECT bucket, mspt_avg, mspt_p95, tps, players, heap_used_mb, heap_max_mb FROM server_health_hourly) h
|
||||
WHERE bucket >= now() - make_interval(hours => $1)
|
||||
GROUP BY 1 ORDER BY 1`,
|
||||
[hours, step],
|
||||
);
|
||||
return {
|
||||
info: (await latestServerInfo(pool))?.info ?? null,
|
||||
health: rows.map((r) => ({
|
||||
t: r.t.getTime(),
|
||||
msptAvg: Math.round(r.mspt_avg * 10) / 10,
|
||||
msptP95: Math.round(r.mspt_p95 * 10) / 10,
|
||||
tps: Math.round(r.tps * 10) / 10,
|
||||
players: r.players,
|
||||
heapUsedMb: Math.round(r.heap_used_mb),
|
||||
heapMaxMb: Math.round(r.heap_max_mb),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/quests', async (): Promise<QuestsDto> => {
|
||||
const defs = await pool.query<{ n: number }>('SELECT count(*)::int AS n FROM quest_defs');
|
||||
const chapters = await pool.query(
|
||||
`WITH per_team AS (
|
||||
SELECT d.chapter_id, qp.team_id, count(*) AS done
|
||||
FROM quest_defs d JOIN quest_progress qp ON qp.quest_id = d.quest_id GROUP BY 1, 2),
|
||||
totals AS (SELECT chapter_id, max(chapter_title) AS title, count(*)::int AS total FROM quest_defs GROUP BY 1)
|
||||
SELECT t.chapter_id, t.title, t.total,
|
||||
COALESCE(max(pt.done), 0)::int AS max_completed,
|
||||
count(pt.team_id) FILTER (WHERE pt.done >= t.total)::int AS teams_finished
|
||||
FROM totals t LEFT JOIN per_team pt ON pt.chapter_id = t.chapter_id
|
||||
GROUP BY t.chapter_id, t.title, t.total ORDER BY t.title NULLS LAST`,
|
||||
);
|
||||
// Teams: explicit members, or the solo player whose uuid is the team id. Teams of hidden players are omitted.
|
||||
const teams = await pool.query(
|
||||
`SELECT qp.team_id, t.name, count(*)::int AS completed,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('uuid', p.uuid, 'name', p.name)) FROM team_members tm
|
||||
JOIN players p ON p.uuid = tm.uuid AND NOT p.hidden WHERE tm.team_id = qp.team_id),
|
||||
(SELECT json_agg(json_build_object('uuid', p.uuid, 'name', p.name)) FROM players p
|
||||
WHERE p.uuid = qp.team_id AND NOT p.hidden)) AS members
|
||||
FROM quest_progress qp LEFT JOIN teams t ON t.team_id = qp.team_id
|
||||
WHERE ${QUEST_FILTER}
|
||||
GROUP BY qp.team_id, t.name ORDER BY completed DESC`,
|
||||
);
|
||||
return {
|
||||
defsKnown: defs.rows[0]!.n > 0,
|
||||
chapters: chapters.rows.map((r) => ({
|
||||
chapterId: r.chapter_id,
|
||||
title: r.title,
|
||||
total: r.total,
|
||||
maxCompleted: r.max_completed,
|
||||
teamsFinished: r.teams_finished,
|
||||
})),
|
||||
teams: teams.rows
|
||||
.filter((r) => r.members)
|
||||
.map((r) => ({ teamId: r.team_id, name: r.name, members: r.members, completed: r.completed })),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/sources', async (): Promise<SourcesDto> => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT (SELECT max(received_at) FROM events WHERE source = 'push') AS last_push,
|
||||
(SELECT max(started_at) FROM server_info) AS last_start,
|
||||
(SELECT max(synced_at) FROM sync_state) AS last_sync,
|
||||
(SELECT message FROM app_errors WHERE source = 'sftp' AND at > now() - interval '1 day' ORDER BY at DESC LIMIT 1) AS sftp_error`,
|
||||
);
|
||||
const r = rows[0];
|
||||
const lastPush = ms(r.last_push);
|
||||
return {
|
||||
push: { lastEventAt: lastPush, lastServerStart: ms(r.last_start), reachable: lastPush !== null && Date.now() - lastPush < PUSH_FRESH_MS },
|
||||
sftp: { enabled: config.sftp !== null, lastSyncAt: ms(r.last_sync), lastError: r.sftp_error },
|
||||
limitations: LIMITATIONS,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
export const PLAYTIME_TICKS_PER_HOUR = 72_000;
|
||||
|
||||
export const ms = (d: Date | null | undefined): number | null => (d ? d.getTime() : null);
|
||||
export const hours = (ticks: number) => Math.round((ticks / PLAYTIME_TICKS_PER_HOUR) * 100) / 100;
|
||||
|
||||
/** Resolves uuid, current name or a former name (newest first) to a visible player's uuid. */
|
||||
export async function resolvePlayer(pool: Pool, idOrName: string): Promise<string | null> {
|
||||
if (UUID_RE.test(idOrName)) {
|
||||
const { rows } = await pool.query('SELECT uuid FROM players WHERE uuid = $1 AND NOT hidden', [idOrName]);
|
||||
return rows[0]?.uuid ?? null;
|
||||
}
|
||||
if (!/^[A-Za-z0-9_]{1,16}$/.test(idOrName)) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid FROM (
|
||||
SELECT uuid, 0 AS prio, last_seen AS seen FROM players WHERE lower(name) = lower($1)
|
||||
UNION ALL
|
||||
SELECT uuid, 1, seen_at FROM name_history WHERE lower(name) = lower($1)
|
||||
) m JOIN players p USING (uuid)
|
||||
WHERE NOT p.hidden
|
||||
ORDER BY prio, seen DESC NULLS LAST LIMIT 1`,
|
||||
[idOrName],
|
||||
);
|
||||
return rows[0]?.uuid ?? null;
|
||||
}
|
||||
|
||||
/** Quest IDs that count: all completed IDs, or only known quests once definitions exist. */
|
||||
export const QUEST_FILTER = `(NOT EXISTS (SELECT 1 FROM quest_defs) OR qp.quest_id IN (SELECT quest_id FROM quest_defs))`;
|
||||
|
||||
export const LIMITATIONS: string[] = [
|
||||
'Energieproduktion und -verbrauch von Maschinen (Mekanism, Powah …) werden nicht erfasst: Es gibt dafür keine verlässliche serverseitige Quelle ohne ungeprüfte Mod-Interna.',
|
||||
'Inhalte von AE2- und Refined-Storage-Netzwerken werden nicht ausgewertet.',
|
||||
'Mana, Zauber und Rituale (Ars Nouveau, Iron’s Spellbooks, Occultism) sind nicht verfügbar; Magie-Aktivität stammt aus hergestellten und benutzten Items.',
|
||||
'MineColonies-Kolonien werden nicht ausgewertet.',
|
||||
'Die Diamantenbank zählt abgebaute Diamanterze, nicht den Besitz.',
|
||||
'Quest-Fortschritt wird pro FTB-Team gespeichert: Mitglieder einer Party teilen sich ihren Fortschritt, wie im Spiel.',
|
||||
'Rund ein Drittel der ATM10-Quests hat keinen eigenen Titel (im Spiel heißen sie nach ihrem Aufgaben-Item). Sie zählen mit, erscheinen aber nicht namentlich.',
|
||||
'Zähler aus world/stats werden vom Server nur beim Speichern geschrieben; dazwischen liefert die Mod alle 5 Minuten Live-Werte.',
|
||||
'Todesorte werden aus Datenschutzgründen nicht gespeichert.',
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildApp } from './app.ts';
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createPool } from './db/pool.ts';
|
||||
import { migrate } from './db/migrate.ts';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createPool(config.DATABASE_URL);
|
||||
await migrate(pool);
|
||||
const app = await buildApp({ pool, config });
|
||||
|
||||
const shutdown = async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
await app.listen({ port: config.PORT, host: config.HOST });
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { BOARD_BY_ID, BOARDS, metricSql, toDisplay, type Board } from './metrics.ts';
|
||||
|
||||
export type Period = 'all' | '30d' | '7d';
|
||||
export const PERIOD_DAYS: Record<Exclude<Period, 'all'>, number> = { '30d': 30, '7d': 7 };
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
uuid: string;
|
||||
name: string | null;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value per visible player for a board. For a period the value is the growth since the period start:
|
||||
* current value minus the last daily snapshot at or before the start (or the earliest snapshot if none).
|
||||
*/
|
||||
function boardValuesSql(board: Board, period: Period): string {
|
||||
const cur = metricSql(board.metric);
|
||||
if (period === 'all') {
|
||||
return `SELECT c.uuid, c.value FROM (${cur}) c`;
|
||||
}
|
||||
return `SELECT c.uuid, c.value - COALESCE(
|
||||
(SELECT s.value FROM stat_snapshots s WHERE s.uuid = c.uuid AND s.metric = $1 AND s.granularity = 'day'
|
||||
AND s.bucket <= $2 ORDER BY s.bucket DESC LIMIT 1),
|
||||
(SELECT s.value FROM stat_snapshots s WHERE s.uuid = c.uuid AND s.metric = $1 AND s.granularity = 'day'
|
||||
ORDER BY s.bucket ASC LIMIT 1),
|
||||
0) AS value
|
||||
FROM (${cur}) c`;
|
||||
}
|
||||
|
||||
export async function getLeaderboard(
|
||||
pool: Pool,
|
||||
boardId: string,
|
||||
period: Period,
|
||||
limit = 10,
|
||||
now: Date = new Date(),
|
||||
): Promise<LeaderboardEntry[]> {
|
||||
const board = BOARD_BY_ID.get(boardId);
|
||||
if (!board) throw new Error(`unknown board ${boardId}`);
|
||||
const params: unknown[] = [];
|
||||
if (period !== 'all') params.push(board.id, new Date(now.getTime() - PERIOD_DAYS[period] * 86_400_000));
|
||||
params.push(limit);
|
||||
const { rows } = await pool.query<{ rank: number; uuid: string; name: string | null; value: number }>(
|
||||
`SELECT rank() OVER (ORDER BY v.value DESC)::int AS rank, v.uuid, p.name, v.value
|
||||
FROM (${boardValuesSql(board, period)}) v
|
||||
JOIN players p ON p.uuid = v.uuid AND NOT p.hidden
|
||||
WHERE v.value > 0
|
||||
ORDER BY v.value DESC, p.name ASC NULLS LAST
|
||||
LIMIT $${params.length}`,
|
||||
params,
|
||||
);
|
||||
return rows.map((r) => ({ ...r, value: toDisplay(board.unit, r.value) }));
|
||||
}
|
||||
|
||||
/** Rank and display value of one player on every board where the player has a positive value. */
|
||||
export async function getPlayerRanks(pool: Pool, uuid: string): Promise<Record<string, { rank: number; value: number }>> {
|
||||
const out: Record<string, { rank: number; value: number }> = {};
|
||||
for (const board of BOARDS) {
|
||||
const { rows } = await pool.query<{ rank: number; value: number }>(
|
||||
`SELECT rank, value FROM (
|
||||
SELECT v.uuid, v.value, rank() OVER (ORDER BY v.value DESC)::int AS rank
|
||||
FROM (${metricSql(board.metric)}) v JOIN players p ON p.uuid = v.uuid AND NOT p.hidden
|
||||
WHERE v.value > 0) r
|
||||
WHERE r.uuid = $1`,
|
||||
[uuid],
|
||||
);
|
||||
if (rows[0]) out[board.id] = { rank: rows[0].rank, value: toDisplay(board.unit, rows[0].value) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type MetricDef =
|
||||
| { kind: 'stat'; category: string; key: string }
|
||||
| { kind: 'sum'; categories: string[]; namespaces?: string[]; keySuffix?: string; excludeKeys?: string[] }
|
||||
| { kind: 'sumKeys'; category: string; keys: string[] }
|
||||
| { kind: 'quests' }
|
||||
| { kind: 'advancements' };
|
||||
|
||||
export type Unit = 'hours' | 'count' | 'km' | 'blocks';
|
||||
|
||||
export interface Board {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
unit: Unit;
|
||||
metric: MetricDef;
|
||||
}
|
||||
|
||||
export type ModCategories = Record<'tech' | 'magic' | 'farming', string[]>;
|
||||
|
||||
function loadCategories(): ModCategories {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
for (const p of [join(here, '..', '..', 'config', 'mod-categories.json'), join(here, '..', 'config', 'mod-categories.json')]) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as ModCategories;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
throw new Error('config/mod-categories.json not found');
|
||||
}
|
||||
|
||||
export const MOD_CATEGORIES: ModCategories = loadCategories();
|
||||
|
||||
const CUSTOM = 'minecraft:custom';
|
||||
const ACTIVITY = ['minecraft:crafted', 'minecraft:used'];
|
||||
|
||||
export const BOARDS: Board[] = [
|
||||
{ id: 'playtime', title: 'Dauerzocker', description: 'Meiste Spielzeit', unit: 'hours', metric: { kind: 'stat', category: CUSTOM, key: 'minecraft:play_time' } },
|
||||
{ id: 'deaths', title: 'Friedhof', description: 'Häufigste Tode', unit: 'count', metric: { kind: 'stat', category: CUSTOM, key: 'minecraft:deaths' } },
|
||||
{ id: 'mobkills', title: 'Monsterjäger', description: 'Meiste getötete Mobs', unit: 'count', metric: { kind: 'stat', category: CUSTOM, key: 'minecraft:mob_kills' } },
|
||||
{ id: 'mined', title: 'Abrissbirne', description: 'Meiste abgebaute Blöcke', unit: 'blocks', metric: { kind: 'sum', categories: ['minecraft:mined'] } },
|
||||
{
|
||||
id: 'distance',
|
||||
title: 'Weltenbummler',
|
||||
description: 'Weiteste zurückgelegte Strecke (ohne Fallen)',
|
||||
unit: 'km',
|
||||
metric: { kind: 'sum', categories: [CUSTOM], keySuffix: '_one_cm', excludeKeys: ['minecraft:fall_one_cm'] },
|
||||
},
|
||||
{ id: 'flight', title: 'Flieger', description: 'Weiteste Strecke im Flug', unit: 'km', metric: { kind: 'stat', category: CUSTOM, key: 'minecraft:fly_one_cm' } },
|
||||
{
|
||||
id: 'diamonds',
|
||||
title: 'Diamantenbank',
|
||||
description: 'Meiste abgebaute Diamanterze (Abbau, nicht Besitz)',
|
||||
unit: 'count',
|
||||
metric: { kind: 'sumKeys', category: 'minecraft:mined', keys: ['minecraft:diamond_ore', 'minecraft:deepslate_diamond_ore'] },
|
||||
},
|
||||
{ id: 'tech', title: 'Techniker', description: 'Hergestellte und benutzte Tech-Items', unit: 'count', metric: { kind: 'sum', categories: ACTIVITY, namespaces: MOD_CATEGORIES.tech } },
|
||||
{ id: 'magic', title: 'Magier', description: 'Hergestellte und benutzte Magie-Items', unit: 'count', metric: { kind: 'sum', categories: ACTIVITY, namespaces: MOD_CATEGORIES.magic } },
|
||||
{ id: 'farming', title: 'Farmer', description: 'Hergestellte und benutzte Farm-Items', unit: 'count', metric: { kind: 'sum', categories: ACTIVITY, namespaces: MOD_CATEGORIES.farming } },
|
||||
{ id: 'quests', title: 'Questmeister', description: 'Abgeschlossene FTB-Quests des Teams', unit: 'count', metric: { kind: 'quests' } },
|
||||
{ id: 'advancements', title: 'Abenteurer', description: 'Erreichte Advancements', unit: 'count', metric: { kind: 'advancements' } },
|
||||
{ id: 'loot', title: 'Schatzjäger', description: 'Geplünderte Lootr-Truhen', unit: 'count', metric: { kind: 'stat', category: CUSTOM, key: 'lootr:looted_stat' } },
|
||||
{ id: 'worldtier', title: 'Weltenstufe', description: 'Aktivierte Apotheosis-Weltenstufen', unit: 'count', metric: { kind: 'stat', category: CUSTOM, key: 'apotheosis:world_tiers_activated' } },
|
||||
];
|
||||
|
||||
export const BOARD_BY_ID = new Map(BOARDS.map((b) => [b.id, b]));
|
||||
|
||||
/** Board definitions are code constants, never user input; literals are still escaped. */
|
||||
const lit = (s: string) => `'${s.replace(/'/g, "''")}'`;
|
||||
const list = (xs: string[]) => xs.map(lit).join(', ');
|
||||
|
||||
/** SQL yielding `(uuid, value)` with the raw metric value for every player that has data. */
|
||||
export function metricSql(def: MetricDef): string {
|
||||
switch (def.kind) {
|
||||
case 'stat':
|
||||
return `SELECT uuid, value FROM stat_current WHERE category = ${lit(def.category)} AND key = ${lit(def.key)}`;
|
||||
case 'sumKeys':
|
||||
return `SELECT uuid, sum(value) AS value FROM stat_current
|
||||
WHERE category = ${lit(def.category)} AND key IN (${list(def.keys)}) GROUP BY uuid`;
|
||||
case 'sum': {
|
||||
const where = [`category IN (${list(def.categories)})`];
|
||||
if (def.namespaces?.length) where.push(`split_part(key, ':', 1) IN (${list(def.namespaces)})`);
|
||||
if (def.keySuffix) where.push(`key LIKE ${lit(`%${def.keySuffix}`)}`);
|
||||
if (def.excludeKeys?.length) where.push(`key NOT IN (${list(def.excludeKeys)})`);
|
||||
return `SELECT uuid, sum(value) AS value FROM stat_current WHERE ${where.join(' AND ')} GROUP BY uuid`;
|
||||
}
|
||||
case 'quests':
|
||||
// FTB stores progress per team; players without team membership form a solo team with their own uuid.
|
||||
// The `completed` map also lists chapters, so once quest definitions are known only real quests count.
|
||||
return `SELECT p.uuid, count(DISTINCT qp.quest_id) AS value
|
||||
FROM players p
|
||||
LEFT JOIN team_members tm ON tm.uuid = p.uuid
|
||||
JOIN quest_progress qp ON qp.team_id = COALESCE(tm.team_id, p.uuid)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM quest_defs) OR qp.quest_id IN (SELECT quest_id FROM quest_defs)
|
||||
GROUP BY p.uuid`;
|
||||
case 'advancements':
|
||||
return `SELECT uuid, count(*) AS value FROM advancements GROUP BY uuid`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw counter → display value for the board's unit. */
|
||||
export function toDisplay(unit: Unit, raw: number): number {
|
||||
switch (unit) {
|
||||
case 'hours':
|
||||
return Math.round((raw / 72_000) * 100) / 100;
|
||||
case 'km':
|
||||
return Math.round((raw / 100_000) * 100) / 100;
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { BOARDS, metricSql } from './metrics.ts';
|
||||
|
||||
export const HOURLY_RETENTION_DAYS = 30;
|
||||
export const HEALTH_MINUTE_RETENTION_DAYS = 14;
|
||||
|
||||
/** Stores every board's raw value per player for the hour/day bucket containing `at`. Re-running overwrites. */
|
||||
export async function takeSnapshots(pool: Pool, granularity: 'hour' | 'day', at: Date): Promise<number> {
|
||||
let written = 0;
|
||||
for (const board of BOARDS) {
|
||||
const { rowCount } = await pool.query(
|
||||
`INSERT INTO stat_snapshots(uuid, metric, bucket, granularity, value)
|
||||
SELECT v.uuid, $1, date_trunc($2, $3::timestamptz), $2, v.value FROM (${metricSql(board.metric)}) v
|
||||
ON CONFLICT (uuid, metric, granularity, bucket) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[board.id, granularity, at],
|
||||
);
|
||||
written += rowCount ?? 0;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
export const BULKY_EVENT_RETENTION_DAYS = 30;
|
||||
/** Already projected, large or frequent; their content lives on in stat_current, server_health and file_cache. */
|
||||
const BULKY_EVENT_TYPES = ['health', 'stats_snapshot', 'file_sync'];
|
||||
|
||||
export async function applyRetention(pool: Pool, now: Date): Promise<void> {
|
||||
await pool.query(`DELETE FROM stat_snapshots WHERE granularity = 'hour' AND bucket < $1`, [
|
||||
new Date(now.getTime() - HOURLY_RETENTION_DAYS * 86_400_000),
|
||||
]);
|
||||
// Keep the raw log of player events (joins, deaths, advancements …); drop bulky ones after 30 days.
|
||||
// Re-delivery after that window is not expected (the mod's outbox expires after 7 days).
|
||||
await pool.query(`DELETE FROM events WHERE type = ANY($1::text[]) AND received_at < $2`, [
|
||||
BULKY_EVENT_TYPES,
|
||||
new Date(now.getTime() - BULKY_EVENT_RETENTION_DAYS * 86_400_000),
|
||||
]);
|
||||
const cutoff = new Date(now.getTime() - HEALTH_MINUTE_RETENTION_DAYS * 86_400_000);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`INSERT INTO server_health_hourly(bucket, mspt_avg, mspt_p95, mspt_max, tps, players, chunks, heap_used_mb, heap_max_mb, samples)
|
||||
SELECT date_trunc('hour', bucket), avg(mspt_avg), max(mspt_p95), max(mspt_max), avg(tps), max(players), max(chunks),
|
||||
avg(heap_used_mb), max(heap_max_mb), count(*)
|
||||
FROM server_health WHERE bucket < $1 GROUP BY 1
|
||||
ON CONFLICT (bucket) DO UPDATE SET
|
||||
mspt_avg = (server_health_hourly.mspt_avg * server_health_hourly.samples + EXCLUDED.mspt_avg * EXCLUDED.samples)
|
||||
/ (server_health_hourly.samples + EXCLUDED.samples),
|
||||
mspt_p95 = GREATEST(server_health_hourly.mspt_p95, EXCLUDED.mspt_p95),
|
||||
mspt_max = GREATEST(server_health_hourly.mspt_max, EXCLUDED.mspt_max),
|
||||
tps = (server_health_hourly.tps * server_health_hourly.samples + EXCLUDED.tps * EXCLUDED.samples)
|
||||
/ (server_health_hourly.samples + EXCLUDED.samples),
|
||||
players = GREATEST(server_health_hourly.players, EXCLUDED.players),
|
||||
chunks = GREATEST(server_health_hourly.chunks, EXCLUDED.chunks),
|
||||
heap_used_mb = EXCLUDED.heap_used_mb,
|
||||
heap_max_mb = GREATEST(server_health_hourly.heap_max_mb, EXCLUDED.heap_max_mb),
|
||||
samples = server_health_hourly.samples + EXCLUDED.samples`,
|
||||
[cutoff],
|
||||
);
|
||||
await client.query('DELETE FROM server_health WHERE bucket < $1', [cutoff]);
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import pino from 'pino';
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createPool } from './db/pool.ts';
|
||||
import { migrate } from './db/migrate.ts';
|
||||
import { installLiveNotifier } from './live/notifier.ts';
|
||||
import './pipeline/projections/index.ts';
|
||||
import { applyRetention, takeSnapshots } from './stats/snapshots.ts';
|
||||
import { SftpFileSource } from './worker/fileSource.ts';
|
||||
import { LOCKS, runJob } from './worker/scheduler.ts';
|
||||
import { closeStaleSessions, syncOnce } from './worker/sync.ts';
|
||||
|
||||
const TICK_MS = 30_000;
|
||||
|
||||
const config = loadConfig();
|
||||
const log = pino({ level: config.LOG_LEVEL });
|
||||
const pool = createPool(config.DATABASE_URL, 4);
|
||||
await migrate(pool);
|
||||
installLiveNotifier(pool);
|
||||
|
||||
let lastSftp = 0;
|
||||
let lastHour = '';
|
||||
let lastRetentionDay = '';
|
||||
let stopping = false;
|
||||
|
||||
async function sftpDue(now: number): Promise<{ due: boolean; requestIds: number[] }> {
|
||||
if (!config.sftp) return { due: false, requestIds: [] };
|
||||
const { rows } = await pool.query<{ id: number }>('SELECT id FROM sync_requests WHERE done_at IS NULL');
|
||||
const requestIds = rows.map((r) => r.id);
|
||||
return { due: requestIds.length > 0 || now - lastSftp >= config.SFTP_INTERVAL_MIN * 60_000, requestIds };
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
const now = new Date();
|
||||
|
||||
const sftp = await sftpDue(now.getTime());
|
||||
if (sftp.due && config.sftp) {
|
||||
lastSftp = now.getTime();
|
||||
const src = new SftpFileSource(config.sftp);
|
||||
const result = await runJob(pool, 'sftp', LOCKS.sftp, async () => {
|
||||
try {
|
||||
const r = await syncOnce(pool, src, { timeZone: config.TIMEZONE, serverId: config.SERVER_ID, worldDir: config.SFTP_WORLD_DIR });
|
||||
log.info(r, 'sftp sync done');
|
||||
} finally {
|
||||
await src.close();
|
||||
}
|
||||
});
|
||||
if (result === 'error') log.warn('sftp sync failed, see admin status');
|
||||
if (sftp.requestIds.length) {
|
||||
await pool.query('UPDATE sync_requests SET done_at = now() WHERE id = ANY($1::bigint[])', [sftp.requestIds]);
|
||||
}
|
||||
}
|
||||
|
||||
// Hourly and daily snapshots are idempotent per bucket; the daily bucket ends up holding the day's last value.
|
||||
const hour = now.toISOString().slice(0, 13);
|
||||
if (hour !== lastHour) {
|
||||
lastHour = hour;
|
||||
await runJob(pool, 'snapshots', LOCKS.snapshots, async () => {
|
||||
await takeSnapshots(pool, 'hour', now);
|
||||
await takeSnapshots(pool, 'day', now);
|
||||
});
|
||||
}
|
||||
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
if (day !== lastRetentionDay && now.getUTCHours() >= 3) {
|
||||
lastRetentionDay = day;
|
||||
await runJob(pool, 'retention', LOCKS.retention, () => applyRetention(pool, now));
|
||||
}
|
||||
|
||||
await runJob(pool, 'staleSessions', LOCKS.staleSessions, async () => {
|
||||
const closed = await closeStaleSessions(pool, now);
|
||||
if (closed) log.info({ closed }, 'closed stale sessions');
|
||||
});
|
||||
}
|
||||
|
||||
async function loop() {
|
||||
log.info({ sftp: config.sftp ? `${config.sftp.host}:${config.sftp.port}` : 'disabled' }, 'worker started');
|
||||
while (!stopping) {
|
||||
try {
|
||||
await tick();
|
||||
} catch (err) {
|
||||
log.error({ err }, 'worker tick failed');
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, TICK_MS));
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
stopping = true;
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
};
|
||||
process.on('SIGTERM', stop);
|
||||
process.on('SIGINT', stop);
|
||||
|
||||
await loop();
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { join, posix } from 'node:path';
|
||||
import SftpClient from 'ssh2-sftp-client';
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
/** Read-only view of the game server's directory tree. Paths are relative and '/'-separated. */
|
||||
export interface FileSource {
|
||||
list(dir: string): Promise<FileEntry[]>;
|
||||
/** Reads `length` bytes from `start` (default: to end of file). */
|
||||
read(path: string, start?: number, length?: number): Promise<Buffer>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function collect(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export class LocalFileSource implements FileSource {
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
async list(dir: string): Promise<FileEntry[]> {
|
||||
const abs = join(this.root, dir);
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(abs);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return Promise.all(
|
||||
names.map(async (name) => {
|
||||
const s = await stat(join(abs, name));
|
||||
return { name, size: s.size, mtimeMs: Math.floor(s.mtimeMs), isDir: s.isDirectory() };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async read(path: string, start = 0, length?: number): Promise<Buffer> {
|
||||
const end = length !== undefined ? start + length - 1 : undefined;
|
||||
return collect(createReadStream(join(this.root, path), { start, end }));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
export interface SftpOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
root: string;
|
||||
}
|
||||
|
||||
/** SFTP access to the Mine-Hoster server; one connection per sync run. */
|
||||
export class SftpFileSource implements FileSource {
|
||||
private client = new SftpClient('atm-stats');
|
||||
private connected: Promise<unknown> | null = null;
|
||||
|
||||
constructor(private readonly opts: SftpOptions) {}
|
||||
|
||||
private abs(p: string) {
|
||||
return posix.join(this.opts.root || '/', p);
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.connected ??= this.client.connect({
|
||||
host: this.opts.host,
|
||||
port: this.opts.port,
|
||||
username: this.opts.username,
|
||||
password: this.opts.password,
|
||||
readyTimeout: 20_000,
|
||||
retries: 1,
|
||||
});
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
async list(dir: string): Promise<FileEntry[]> {
|
||||
await this.connect();
|
||||
const path = this.abs(dir);
|
||||
if (!(await this.client.exists(path))) return [];
|
||||
const entries = await this.client.list(path);
|
||||
return entries.map((e) => ({ name: e.name, size: e.size, mtimeMs: e.modifyTime, isDir: e.type === 'd' }));
|
||||
}
|
||||
|
||||
async read(path: string, start = 0, length?: number): Promise<Buffer> {
|
||||
await this.connect();
|
||||
const end = length !== undefined ? start + length - 1 : undefined;
|
||||
const stream = this.client.createReadStream(this.abs(path), { start, end });
|
||||
return collect(stream);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.connected) await this.client.end().catch(() => {});
|
||||
this.connected = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export type JobResult = 'ok' | 'locked' | 'error';
|
||||
|
||||
/**
|
||||
* Runs `fn` while holding a session-level advisory lock, so a job never runs twice at once
|
||||
* (e.g. two worker containers). Failures are logged to app_errors with the job name as source.
|
||||
*/
|
||||
export async function runJob(pool: Pool, name: string, lockId: number, fn: () => Promise<unknown>): Promise<JobResult> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const { rows } = await client.query<{ locked: boolean }>('SELECT pg_try_advisory_lock($1) AS locked', [lockId]);
|
||||
if (!rows[0]!.locked) return 'locked';
|
||||
try {
|
||||
await fn();
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
await pool
|
||||
.query('INSERT INTO app_errors(source, message) VALUES ($1, $2)', [name, String((err as Error).message ?? err).slice(0, 2000)])
|
||||
.catch(() => {});
|
||||
return 'error';
|
||||
} finally {
|
||||
await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export const LOCKS = {
|
||||
sftp: 820_001,
|
||||
snapshots: 820_002,
|
||||
retention: 820_003,
|
||||
staleSessions: 820_004,
|
||||
} as const;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { deterministicId, type AtmEvent } from '@atm/shared';
|
||||
import type { Pool } from 'pg';
|
||||
import { isQuestConfigFile } from '../parsers/ftbquests.ts';
|
||||
import { parseLogChunk } from '../parsers/log.ts';
|
||||
import { advancementsFileToEvents, statsFileToEvent } from '../parsers/vanilla.ts';
|
||||
import { processEvents } from '../pipeline/process.ts';
|
||||
import type { FileEntry, FileSource } from './fileSource.ts';
|
||||
|
||||
export interface SyncOptions {
|
||||
timeZone: string;
|
||||
serverId: string;
|
||||
worldDir: string;
|
||||
}
|
||||
|
||||
const UUID_FILE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(json|snbt)$/i;
|
||||
const PUSH_FRESH_MS = 10 * 60_000;
|
||||
const LOG_PATH = 'logs/latest.log';
|
||||
const LOG_HEAD_BYTES = 256;
|
||||
const MAX_FILE_BYTES = 1_500_000;
|
||||
const BATCH = 200;
|
||||
|
||||
const sha256 = (b: Buffer | string) => createHash('sha256').update(b).digest('hex');
|
||||
|
||||
interface State {
|
||||
size: number;
|
||||
mtime: number;
|
||||
offset: number;
|
||||
hash: string | null;
|
||||
}
|
||||
|
||||
async function listRecursive(src: FileSource, dir: string): Promise<(FileEntry & { path: string })[]> {
|
||||
const out: (FileEntry & { path: string })[] = [];
|
||||
for (const e of await src.list(dir)) {
|
||||
const path = `${dir}/${e.name}`;
|
||||
if (e.isDir) out.push(...(await listRecursive(src, path)));
|
||||
else out.push({ ...e, path });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls changed files from the game server and feeds them through the same pipeline as the mod's pushes.
|
||||
* Unchanged files (same size and mtime) are not read; latest.log is read incrementally from the stored offset.
|
||||
*/
|
||||
export async function syncOnce(pool: Pool, src: FileSource, opts: SyncOptions): Promise<{ files: number; events: number }> {
|
||||
const stateRows = await pool.query<State & { path: string }>('SELECT path, size, mtime, "offset", hash FROM sync_state');
|
||||
const state = new Map(stateRows.rows.map((r) => [r.path, r as State]));
|
||||
const changed = (path: string, e: FileEntry) => {
|
||||
const s = state.get(path);
|
||||
return !s || s.size !== e.size || s.mtime !== e.mtimeMs;
|
||||
};
|
||||
const updates: { path: string; size: number; mtime: number; offset: number; hash: string | null }[] = [];
|
||||
const events: AtmEvent[] = [];
|
||||
const w = opts.worldDir;
|
||||
// Players whose advancements come from world/advancements: their log advancements would be counted twice.
|
||||
const withAdvancementFile = new Set<string>();
|
||||
|
||||
// Vanilla stats and advancements: one JSON per player uuid.
|
||||
for (const [dir, convert] of [
|
||||
[`${w}/stats`, (uuid: string, text: string, e: FileEntry) => [statsFileToEvent(uuid, text, e.mtimeMs)]],
|
||||
[`${w}/advancements`, (uuid: string, text: string) => advancementsFileToEvents(uuid, text)],
|
||||
] as const) {
|
||||
for (const e of await src.list(dir)) {
|
||||
const m = UUID_FILE.exec(e.name);
|
||||
const path = `${dir}/${e.name}`;
|
||||
if (m && dir.endsWith('/advancements')) withAdvancementFile.add(m[1]!.toLowerCase());
|
||||
if (!m || e.isDir || !changed(path, e)) continue;
|
||||
const text = (await src.read(path)).toString('utf8');
|
||||
try {
|
||||
events.push(...convert(m[1]!.toLowerCase(), text, e));
|
||||
} catch (err) {
|
||||
await pool.query('INSERT INTO app_errors(source, message) VALUES ($1, $2)', ['sftp', `${path}: ${(err as Error).message}`]);
|
||||
}
|
||||
updates.push({ path, size: e.size, mtime: e.mtimeMs, offset: 0, hash: null });
|
||||
}
|
||||
}
|
||||
|
||||
// FTB files are forwarded verbatim as file_sync, exactly like the mod does.
|
||||
// Quest config: only chapters and the English lang files (other languages are ~35 MB and unused).
|
||||
const ftbFiles = [
|
||||
...(await listRecursive(src, `${w}/ftbquests`)).filter((f) => f.name.endsWith('.snbt')),
|
||||
...(await listRecursive(src, `${w}/ftbteams`)).filter((f) => f.name.endsWith('.snbt')),
|
||||
...(await listRecursive(src, 'config/ftbquests/quests/chapters')),
|
||||
...(await listRecursive(src, 'config/ftbquests/quests/lang/en_us')),
|
||||
].filter((f) => !f.path.startsWith('config/') || isQuestConfigFile(f.path));
|
||||
for (const f of ftbFiles) {
|
||||
if (!changed(f.path, f) || f.size > MAX_FILE_BYTES) continue;
|
||||
const content = (await src.read(f.path)).toString('utf8');
|
||||
const hash = sha256(content);
|
||||
// The pipeline expects world-relative paths to start with "world/"; keep that contract for custom world dirs.
|
||||
const path = f.path.startsWith(`${w}/`) ? `world/${f.path.slice(w.length + 1)}` : f.path;
|
||||
events.push({ id: deterministicId(`file:${path}:${hash}`), type: 'file_sync', ts: f.mtimeMs, path, sha256: hash, content });
|
||||
updates.push({ path: f.path, size: f.size, mtime: f.mtimeMs, offset: 0, hash });
|
||||
}
|
||||
|
||||
// latest.log, incrementally; a different head means the file was rotated.
|
||||
const log = (await src.list('logs')).find((e) => e.name === 'latest.log');
|
||||
if (log) {
|
||||
const prev = state.get(LOG_PATH);
|
||||
const head = sha256(await src.read(LOG_PATH, 0, LOG_HEAD_BYTES));
|
||||
let offset = prev && prev.hash === head && log.size >= prev.offset ? prev.offset : 0;
|
||||
if (log.size > offset) {
|
||||
const chunk = await src.read(LOG_PATH, offset);
|
||||
const names = new Map(
|
||||
(await pool.query<{ name: string; uuid: string }>('SELECT name, uuid FROM players WHERE name IS NOT NULL')).rows.map((r) => [
|
||||
r.name,
|
||||
r.uuid,
|
||||
]),
|
||||
);
|
||||
const parsed = parseLogChunk(chunk, offset, names, opts.timeZone);
|
||||
events.push(...(await filterLogEvents(pool, parsed.events, withAdvancementFile)));
|
||||
offset += parsed.consumed;
|
||||
}
|
||||
if (!prev || prev.offset !== offset || prev.hash !== head || prev.size !== log.size) {
|
||||
updates.push({ path: LOG_PATH, size: log.size, mtime: log.mtimeMs, offset, hash: head });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < events.length; i += BATCH) {
|
||||
await processEvents(pool, events.slice(i, i + BATCH), 'sftp', opts.serverId);
|
||||
}
|
||||
for (const u of updates) {
|
||||
await pool.query(
|
||||
`INSERT INTO sync_state(path, size, mtime, "offset", hash, synced_at) VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (path) DO UPDATE SET size = $2, mtime = $3, "offset" = $4, hash = $5, synced_at = now()`,
|
||||
[u.path, u.size, u.mtime, u.offset, u.hash],
|
||||
);
|
||||
}
|
||||
// Marks the time of the last successful run for the status pages, even when no file changed.
|
||||
await pool.query(`INSERT INTO sync_state(path, size, mtime) VALUES ('__last_run__', 0, 0)
|
||||
ON CONFLICT (path) DO UPDATE SET synced_at = now()`);
|
||||
return { files: updates.length, events: events.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-derived events are a fallback: sessions only while the mod is not pushing (otherwise they would be
|
||||
* counted twice), advancements only for players without a synced advancements file (log titles cannot be
|
||||
* mapped to advancement ids) and only if the player has none with the same title yet.
|
||||
*/
|
||||
async function filterLogEvents(pool: Pool, events: AtmEvent[], withAdvancementFile: Set<string>): Promise<AtmEvent[]> {
|
||||
const { rows } = await pool.query<{ fresh: boolean }>(
|
||||
`SELECT COALESCE(max(received_at) > now() - make_interval(secs => $1), false) AS fresh FROM events WHERE source = 'push'`,
|
||||
[PUSH_FRESH_MS / 1000],
|
||||
);
|
||||
const pushFresh = rows[0]!.fresh;
|
||||
const titled = await pool.query<{ uuid: string; title: string }>('SELECT uuid, title FROM advancements WHERE title IS NOT NULL');
|
||||
const known = new Set(titled.rows.map((r) => `${r.uuid}|${r.title}`));
|
||||
return events.filter((e) => {
|
||||
if (e.type === 'player_join' || e.type === 'player_leave') return !pushFresh;
|
||||
if (e.type === 'advancement') return !withAdvancementFile.has(e.uuid) && !known.has(`${e.uuid}|${e.title}`);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the mod reported health before but has been silent for 10 minutes, the server (or the mod) is down:
|
||||
* open sessions that began while the mod was still reporting end at the last health window. Sessions started
|
||||
* later come from the log fallback and stay open. Does nothing when push was never used.
|
||||
*/
|
||||
export async function closeStaleSessions(pool: Pool, now: Date): Promise<number> {
|
||||
const { rowCount } = await pool.query(
|
||||
`WITH last AS (
|
||||
SELECT to_timestamp((payload->>'windowEnd')::bigint / 1000.0) AS at FROM events
|
||||
WHERE type = 'health' ORDER BY ts DESC LIMIT 1)
|
||||
UPDATE sessions s SET ended_at = GREATEST(s.started_at, last.at), end_reason = 'stale'
|
||||
FROM last
|
||||
WHERE s.ended_at IS NULL AND s.started_at <= last.at AND last.at < $1::timestamptz - interval '10 minutes'`,
|
||||
[now],
|
||||
);
|
||||
return rowCount ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createAdmin } from '../src/auth/admin.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, ev } from './helpers/events.ts';
|
||||
import { signedRequest } from './helpers/sign.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app, token } = await makeApp(pool));
|
||||
await createAdmin(pool, 'lucas', 'correct horse battery staple');
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE })], 'push', 'atm10');
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
async function login(password = 'correct horse battery staple') {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/admin/login', payload: { username: 'lucas', password } });
|
||||
const cookie = res.cookies.find((c) => c.name === 'atm_admin');
|
||||
return { res, cookie: cookie ? `atm_admin=${cookie.value}` : '', csrf: res.statusCode === 200 ? res.json().csrf : '' };
|
||||
}
|
||||
|
||||
describe('admin', () => {
|
||||
it('login with wrong password 401', async () => {
|
||||
const { res } = await login('wrong');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('login sets an httpOnly strict cookie and the session endpoint works', async () => {
|
||||
const { res, cookie, csrf } = await login();
|
||||
expect(res.statusCode).toBe(200);
|
||||
const raw = res.cookies.find((c) => c.name === 'atm_admin')!;
|
||||
expect(raw.httpOnly).toBe(true);
|
||||
expect(raw.sameSite).toBe('Strict');
|
||||
const s = await app.inject({ method: 'GET', url: '/api/admin/session', headers: { cookie } });
|
||||
expect(s.json()).toEqual({ username: 'lucas', csrf });
|
||||
});
|
||||
|
||||
it('admin routes require a session', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/api/admin/status' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('mutating route without csrf 403', async () => {
|
||||
const { cookie } = await login();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/admin/players/${ALICE.uuid}/hidden`,
|
||||
headers: { cookie },
|
||||
payload: { hidden: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('hide player then profile 404, unhide restores it', async () => {
|
||||
const { cookie, csrf } = await login();
|
||||
const hide = (hidden: boolean) =>
|
||||
app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/admin/players/${ALICE.uuid}/hidden`,
|
||||
headers: { cookie, 'x-csrf-token': csrf },
|
||||
payload: { hidden },
|
||||
});
|
||||
expect((await hide(true)).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'GET', url: `/api/players/${ALICE.uuid}` })).statusCode).toBe(404);
|
||||
const list = await app.inject({ method: 'GET', url: '/api/admin/players', headers: { cookie } });
|
||||
expect(list.json().players[0]).toMatchObject({ uuid: ALICE.uuid, hidden: true });
|
||||
await hide(false);
|
||||
expect((await app.inject({ method: 'GET', url: `/api/players/${ALICE.uuid}` })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('rotate token invalidates old token for ingest', async () => {
|
||||
const { cookie, csrf } = await login();
|
||||
const rot = await app.inject({ method: 'POST', url: '/api/admin/ingest-token/rotate', headers: { cookie, 'x-csrf-token': csrf } });
|
||||
expect(rot.statusCode).toBe(200);
|
||||
const fresh = rot.json().token as string;
|
||||
const send = (t: string) => {
|
||||
const { headers, payload } = signedRequest({ protocol: 1, events: [ev('server_stop', {})] }, { token: t });
|
||||
return app.inject({ method: 'POST', url: '/api/ingest', headers, payload });
|
||||
};
|
||||
expect((await send(token)).statusCode).toBe(401);
|
||||
expect((await send(fresh)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('status lists errors and a sync request is queued', async () => {
|
||||
const { cookie, csrf } = await login();
|
||||
await pool.query(`INSERT INTO app_errors(source, message) VALUES ('sftp', 'connection refused')`);
|
||||
const status = await app.inject({ method: 'GET', url: '/api/admin/status', headers: { cookie } });
|
||||
expect(status.json().lastErrors[0]).toMatchObject({ source: 'sftp', message: 'connection refused' });
|
||||
const sync = await app.inject({ method: 'POST', url: '/api/admin/sync', headers: { cookie, 'x-csrf-token': csrf } });
|
||||
expect(sync.statusCode).toBe(202);
|
||||
expect((await pool.query('SELECT count(*)::int AS n FROM sync_requests WHERE done_at IS NULL')).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it('logout ends the session', async () => {
|
||||
const { cookie, csrf } = await login();
|
||||
await app.inject({ method: 'POST', url: '/api/admin/logout', headers: { cookie, 'x-csrf-token': csrf } });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/admin/status', headers: { cookie } })).statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { loadConfig } from '../src/config.ts';
|
||||
import { migrate } from '../src/db/migrate.ts';
|
||||
import { closeTestPool, getTestPool } from './helpers/db.ts';
|
||||
|
||||
afterAll(closeTestPool);
|
||||
|
||||
describe('database', () => {
|
||||
it('migrate is idempotent', async () => {
|
||||
const pool = await getTestPool();
|
||||
await migrate(pool);
|
||||
await migrate(pool);
|
||||
const { rows } = await pool.query(`SELECT to_regclass('public.events') AS t`);
|
||||
expect(rows[0].t).toBe('events');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
it('loadConfig rejects missing DATABASE_URL', () => {
|
||||
expect(() => loadConfig({ INGEST_HMAC_SECRET: 'x'.repeat(32) })).toThrow(/DATABASE_URL/);
|
||||
});
|
||||
|
||||
it('loadConfig applies defaults', () => {
|
||||
const c = loadConfig({ DATABASE_URL: 'postgres://x', INGEST_HMAC_SECRET: 'x'.repeat(32) });
|
||||
expect(c.PORT).toBe(3001);
|
||||
expect(c.SFTP_INTERVAL_MIN).toBe(15);
|
||||
expect(c.sftp).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"minecraft:recipes/misc/charcoal": {
|
||||
"criteria": {
|
||||
"has_log": "2026-10-09 18:10:02 +0200"
|
||||
},
|
||||
"done": true
|
||||
},
|
||||
"minecraft:story/root": {
|
||||
"criteria": {
|
||||
"crafting_table": "2026-10-09 18:11:40 +0200"
|
||||
},
|
||||
"done": true
|
||||
},
|
||||
"minecraft:story/enter_the_nether": {
|
||||
"criteria": {
|
||||
"entered_nether": "2026-10-12 12:42:23 +0200"
|
||||
},
|
||||
"done": true
|
||||
},
|
||||
"minecraft:adventure/adventuring_time": {
|
||||
"criteria": {
|
||||
"minecraft:plains": "2026-10-09 18:20:00 +0200"
|
||||
},
|
||||
"done": false
|
||||
},
|
||||
"DataVersion": 3955
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
default_hide_dependency_lines: false
|
||||
filename: "getting_started"
|
||||
group: ""
|
||||
icon: "minecraft:oak_sapling"
|
||||
id: "1A2B3C4D5E6F7081"
|
||||
order_index: 0
|
||||
quests: [
|
||||
{
|
||||
id: "3BC0A50886A3222B"
|
||||
tasks: [{
|
||||
id: "7F00000000000001"
|
||||
item: "minecraft:oak_log"
|
||||
type: "item"
|
||||
}]
|
||||
x: 0.0d
|
||||
y: 0.0d
|
||||
}
|
||||
{
|
||||
id: "33218EFDD498E9DB"
|
||||
title: "Inline Title"
|
||||
x: 1.5d
|
||||
y: 0.0d
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
chapter.1A2B3C4D5E6F7081.title: "Getting Started"
|
||||
chapter.1A2B3C4D5E6F7081.chapter_subtitle: ["A handwritten test chapter"]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
quest.3BC0A50886A3222B.title: "Punch a Tree"
|
||||
quest.3BC0A50886A3222B.quest_desc: ["Line one", "Line two"]
|
||||
quest.33218EFDD498E9DB.title: "From Lang"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
id: "5e0c1a2b-0000-4000-8000-00000000abcd"
|
||||
type: "party"
|
||||
ranks: {
|
||||
308a693c-2bd8-4d6b-8790-670aef5be601: "owner"
|
||||
c25d9ff0-dba3-48f0-b414-fad2b63473a9: "member"
|
||||
0b7f3a6e-1111-4222-8333-444455556666: "invited"
|
||||
7d1e2f3a-aaaa-4bbb-8ccc-dddddddddddd: "ally"
|
||||
}
|
||||
properties: {
|
||||
"ftbteams:display_name": "Die Techniker"
|
||||
"ftbteams:free_to_join": 0b
|
||||
}
|
||||
message_history: [ ]
|
||||
extra: { }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
id: "308a693c-2bd8-4d6b-8790-670aef5be601"
|
||||
type: "player"
|
||||
player_name: "CDOGamer"
|
||||
ranks: {
|
||||
308a693c-2bd8-4d6b-8790-670aef5be601: "owner"
|
||||
}
|
||||
properties: {
|
||||
"ftbteams:display_name": "CDOGamer"
|
||||
"ftbteams:description": ""
|
||||
"ftbteams:color": "#FF6A59"
|
||||
"ftbteams:free_to_join": 0b
|
||||
}
|
||||
message_history: [ ]
|
||||
extra: { }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"stats":{"minecraft:custom":{"minecraft:interact_with_crafting_table":375,"minecraft:open_barrel":22,"minecraft:leave_game":11,"minecraft:time_since_death":93480,"minecraft:play_time":1175736,"minecraft:deaths":8,"minecraft:jump":8719,"minecraft:interact_with_furnace":85,"minecraft:interact_with_cartography_table":1,"minecraft:sleep_in_bed":7,"minecraft:damage_dealt":18793,"waystones:waystone_activated":1,"minecraft:damage_dealt_absorbed":121,"minecraft:sneak_time":21567,"minecraft:total_world_time":1175736,"apotheosis:world_tiers_activated":1,"minecraft:time_since_rest":93509,"minecraft:undead_killed":57,"minecraft:swim_one_cm":203284,"minecraft:fly_one_cm":3233729,"minecraft:open_chest":1929,"minecraft:fall_one_cm":524999,"lootr:looted_stat":67,"minecraft:rite_of_silent_bond":2,"minecraft:damage_resisted":0,"minecraft:sprint_one_cm":1967504,"minecraft:drop":70,"minecraft:talked_to_villager":6,"minecraft:walk_on_water_one_cm":51311,"minecraft:crouch_one_cm":75087,"minecraft:mob_kills":100,"minecraft:damage_dealt_resisted":136,"minecraft:climb_one_cm":5945,"minecraft:walk_one_cm":1890607,"minecraft:walk_under_water_one_cm":26190,"minecraft:boat_one_cm":622649,"minecraft:damage_taken":7023},"minecraft:killed":{"minecraft:cave_spider":2,"minecraft:skeleton":12,"minecraft:zombie":32,"minecraft:enderman":2,"minecraft:spider":16,"minecraft:silverfish":5,"irons_spellbooks:summoned_skeleton":1,"artifacts:mimic":2,"minecraft:zombie_villager":5,"irons_spellbooks:necromancer":2,"minecraft:creeper":2,"irons_spellbooks:summoned_zombie":10,"auroral:auroral_nautilus":2,"minecraft:drowned":2},"minecraft:picked_up":{"silentgear:raw_crimson_iron":8,"herbsandharvest:cauliflower_seeds":16,"minecraft:diamond_sword":2,"herbsandharvest:pineapple":7,"minecraft:enchanted_book":1,"herbsandharvest:turnip_seeds":25,"oritech:raw_uranium":14,"mysticalagriculture:prosperity_seed_base":2,"minecraft:amethyst_block":1,"minecraft:string":11,"minecraft:cobbled_deepslate":1929,"mysticalagriculture:inferium_essence":3143,"minecraft:gold_nugget":31,"mekanism:basic_universal_cable":2,"minecraft:spruce_planks":95,"mekanism:basic_logistical_transporter":5,"rootsclassic:verdant_sprig":1,"mysticalagriculture:prosperity_shard":709,"silentgems:aquamarine":10,"mysticalagriculture:inferium_seeds":47,"minecraft:black_dye":3,"create:scoria":2,"farmersdelight:onion":1,"mysticalagradditions:supremium_apple":2,"minecraft:blue_dye":3,"alltheores:raw_iridium":3,"supplementaries:ash":63,"minecraft:iron_bars":2,"minecraft:flint":8,"mysticalagriculture:netherite_essence":1,"minecraft:map":1,"silentgems:opal":5,"artifacts:shock_pendant":1,"bhc:red_heart":14,"sushigocrafting:wasabi_seeds":3,"theurgy:sal_ammoniac_crystal":36,"herbsandharvest:pineapple_seeds":22,"sophisticatedstorage:chest":1,"forbidden_arcanus:rune":17,"mysticalagriculture:soulium_dust":7,"minecraft:glowstone_dust":16,"minecraft:moss_block":2,"alltheores:fluorite":20,"silentgear:fine_silk":1,"mekanism:metallurgic_infuser":2,"xycraft_world:xychorium_gem_red":29,"minecraft:trapped_chest":1,"xycraft_world:xychorium_gem_dark":6,"minecraft:barrel":3,"mekanism:basic_mechanical_pipe":3,"rootsclassic:blackcurrant":1,"minecolonies:mint":1,"herbsandharvest:green_bean":4,"minecraft:granite":27,"minecraft:bread":1,"alltheores:raw_platinum":12,"alltheores:osmium_ingot":38,"minecraft:crafting_table":1,"minecraft:clay_ball":80,"minecraft:leather_leggings":2,"sushigocrafting:soy_seeds":3,"powah:energizing_rod_starter":1,"alltheores:peridot":3,"minecraft:cartography_table":1,"minecraft:poppy":7,"minecraft:jungle_sapling":1,"minecraft:iron_nugget":11,"minecraft:spruce_slab":2,"minecraft:chest":4,"alltheores:raw_zinc":98,"minecraft:spruce_fence":6,"minecraft:white_dye":3,"minecraft:carrot":190,"minecraft:redstone":373,"mekanism:formulaic_assemblicator":1,"minecraft:hay_block":2,"mysticalagriculture:diamond_seeds":3,"alltheores:raw_osmium":178,"minecraft:white_wool":22,"powah:capacitor_basic_tiny":4,"mysticalagriculture:fertilized_essence":7,"silentgems:black_diamond":12,"minecraft:yellow_dye":3,"alltheores:cinnabar":2,"minecraft:crafter":3,"biomeswevegone:kovan_flower":35,"minecraft:ancient_debris":11,"silentgems:alexandrite":4,"tombstone:gift":4,"pamhc2crops:tealeafitem":1,"sophisticatedbackpacks:upgrade_base":3,"minecraft:stone_bricks":16,"herbsandharvest:raspberry_seeds":8,"minecraft:stone_pickaxe":2,"minecraft:gray_bed":1,"mysticalagriculture:wood_essence":9,"silentgems:turquoise":1,"forbidden_arcanus:darkstone":309,"pamhc2crops:zucchiniitem":1,"modern_industrialization:raw_tungsten":22,"minecraft:dark_oak_slab":1,"mysticalagriculture:iron_essence":8,"herbsandharvest:green_pepper_seeds":15,"minecraft:gravel":124,"powah:uraninite_raw":29,"minecraft:spruce_sapling":2,"minecraft:spruce_log":22,"silentgear:bort":11,"forbidden_arcanus:arcane_crystal":7,"immersiveengineering:seed":9,"refinedstorage:autocrafter":1,"minecraft:short_grass":2,"minecraft:redstone_ore":6,"alltheores:raw_silver":35,"mysticalagriculture:diamoLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"stats":{"minecraft:killed":{"minecraft:spider":2,"minecraft:skeleton":3,"minecraft:chicken":3,"livingthings:shark":1,"irons_spellbooks:summoned_skeleton":1,"irons_spellbooks:necromancer":1,"minecraft:creeper":1,"minecraft:sheep":1,"minecraft:trader_llama":3,"minecraft:pillager":1,"minecraft:zombie":6,"irons_spellbooks:summoned_zombie":7,"livingthings:crab":4,"creeperoverhaul:spruce_creeper":1,"minecraft:enderman":5},"minecraft:custom":{"lootr:looted_stat":3,"minecraft:rite_of_silent_bond":2,"minecraft:interact_with_crafting_table":76,"minecraft:open_barrel":13,"minecraft:leave_game":7,"minecraft:time_since_death":671500,"minecraft:sprint_one_cm":1836705,"minecraft:drop":28,"minecraft:talked_to_villager":8,"minecraft:play_time":672734,"minecraft:deaths":1,"minecraft:jump":3882,"minecraft:walk_on_water_one_cm":9307,"minecraft:raider_killed":1,"minecraft:interact_with_furnace":322,"minecraft:sleep_in_bed":19,"minecraft:damage_dealt":8713,"minecraft:crouch_one_cm":60617,"minecraft:mob_kills":41,"minecraft:damage_dealt_resisted":0,"minecraft:climb_one_cm":19858,"minecraft:walk_one_cm":1367248,"minecraft:sneak_time":21896,"minecraft:walk_under_water_one_cm":13119,"minecraft:boat_one_cm":77411,"minecraft:total_world_time":672734,"minecraft:elyra_diary":1,"minecraft:time_since_rest":49269,"minecraft:damage_taken":2472,"minecraft:undead_killed":11,"minecraft:swim_one_cm":19389,"minecraft:fly_one_cm":661009,"minecraft:open_chest":1327,"minecraft:fall_one_cm":58825},"minecraft:mined":{"mekanismgenerators:wind_generator":4,"chipped:large_diamond_leaded_glass":13,"minecraft:white_bed":2,"minecraft:oak_sapling":7,"mekanism:energized_smelter":1,"herbsandharvest:cauliflower_crop":7,"minecraft:granite":44,"minecraft:grass_block":623,"ars_nouveau:sourceberry_bush":16,"mysticalagriculture:inferium_ore":8,"herbsandharvest:pineapple_crop":15,"trophymanager:trophy":16,"xycraft_world:xychorium_ore_stone_green":5,"herbsandharvest:fruit_leaves":1,"biomeswevegone:zelkova_leaves":2,"minecraft:iron_ore":23,"minecraft:fire":2,"herbsandharvest:broccoli_crop":3,"minecraft:deepslate_lapis_ore":2,"silentgear:deepslate_bort_ore":1,"minecraft:brown_bed":3,"minecraft:enchanting_table":1,"minecraft:dark_oak_stairs":2,"alltheores:fluorite_ore":11,"xycraft_world:xychorium_ore_stone_light":4,"xycraft_world:xychorium_ore_deepslate_green":4,"minecraft:deepslate_redstone_ore":3,"mekanism:enrichment_chamber":1,"minecraft:torch":58,"silentgems:garnet_ore":1,"minecraft:chest":25,"minecraft:spruce_wall_sign":1,"herbsandharvest:green_bean_crop":4,"minecraft:cobblestone":2,"productivebees:dark_oak_wood_nest":11,"pamhc2crops:shadedgarden":7,"minecraft:oak_planks":223,"minecraft:sugar_cane":1,"alltheores:deepslate_nickel_ore":8,"minecraft:wall_torch":25,"biomeswevegone:zelkova_log":1,"minecraft:deepslate_copper_ore":10,"herbsandharvest:zucchini_crop":6,"biomeswevegone:kovan_flower":1,"minecraft:dark_oak_slab":32,"minecraft:dark_oak_door":1,"minecraft:oak_log":207,"minecraft:dark_oak_sapling":4,"minecraft:sand":369,"minecraft:dirt":253,"powah:magmator_starter":1,"herbsandharvest:turnip_crop":14,"mekanism:basic_universal_cable":17,"minecraft:deepslate":168,"minecraft:oak_leaves":39,"mysticalagriculture:deepslate_inferium_ore":7,"minecraft:tuff":14,"ars_elemental:yellow_archwood_leaves":2,"trashcans:item_trash_can":1,"farmersdelight:onions":6,"minecraft:dark_oak_leaves":29,"minecraft:deepslate_diamond_ore":5,"sophisticatedstorage:iron_chest":12,"minecraft:dark_oak_log":684,"xycraft_world:xychorium_ore_stone_blue":4,"minecraft:ladder":10,"minecraft:spruce_fence":22,"alltheores:deepslate_silver_ore":1,"minecraft:spruce_leaves":118,"minecraft:moss_block":1,"xycraft_world:xychorium_ore_deepslate_light":1,"minecraft:large_fern":45,"herbsandharvest:green_pepper_crop":4,"minecraft:furnace":4,"minecraft:carrots":59,"minecraft:andesite":98,"minecraft:potatoes":1,"minecraft:deepslate_iron_ore":14,"alltheores:silver_ore":2,"sophisticatedstorage:iron_barrel":2,"minecraft:stone":1583,"ars_nouveau:purple_archwood_log":53,"deeperdarker:ancient_vase":1,"minecraft:crafting_table":9,"actuallyadditions:black_quartz_ore":4,"herbsandharvest:raspberry_crop":4,"minecraft:short_grass":7,"xycraft_world:xychorium_ore_stone_dark":7,"sophisticatedstorage:copper_chest":2,"minecraft:copper_ore":28,"minecraft:deepslate_coal_ore":13,"theurgy:sal_ammoniac_ore":8,"minecraft:lapis_ore":8,"mekanism:metallurgic_infuser":2,"ars_elemental:yellow_archwood_log":26,"actuallyadditions:flax":1,"minecraft:diorite":41,"supplementaries:urn":2,"ars_nouveau:purple_archwood_leaves":2,"modern_industrialization:antimony_ore":2,"minecraft:spruce_log":446,"minecraft:coal_ore":147,"minecraft:gravel":107,"minecraft:spruce_fence_gate":1,"minecraft:stone_bricks":68,"minecraft:gold_ore":8,"minecraft:fern":8,"herbsandharvest:lime_fruit_sapling":1,"minecraft:dark_oak_planks":1},"minecraft:picked_up":{"minecolonies:supplycampdeployer":1,"livingthings:crab":6,"herbsandharvest:cauliflower_seeds":30,"sophisticatedstorageLine truncated
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { isQuestConfigFile, parseQuestDefs, parseQuestProgress, parseTeam, stripFormatting } from '../src/parsers/ftbquests.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
|
||||
const dir = join(import.meta.dirname, 'fixtures', 'ftbquests');
|
||||
const read = (p: string) => readFileSync(join(dir, p), 'utf8');
|
||||
const ALICE_FILE = '308a693c-2bd8-4d6b-8790-670aef5be601.snbt';
|
||||
|
||||
describe('parseQuestProgress', () => {
|
||||
it('parses real progress file 308a…', () => {
|
||||
const p = parseQuestProgress(read(ALICE_FILE));
|
||||
expect(p.teamId).toBe('308a693c-2bd8-4d6b-8790-670aef5be601');
|
||||
expect(p.name).toBe('CDOGamer#308a693c');
|
||||
expect(p.completed.length).toBeGreaterThan(0);
|
||||
for (const q of p.completed) {
|
||||
expect(q.questId).toMatch(/^[0-9A-F]{16}$/);
|
||||
expect(q.at).toBeGreaterThan(1.7e12);
|
||||
}
|
||||
expect(p.completed.find((q) => q.questId === '3BC0A50886A3222B')?.at).toBe(1791559740996);
|
||||
expect(p.started).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('parses the second real file', () => {
|
||||
expect(parseQuestProgress(read('c25d9ff0-dba3-48f0-b414-fad2b63473a9.snbt')).teamId).toBe(
|
||||
'c25d9ff0-dba3-48f0-b414-fad2b63473a9',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const CHAPTER = { path: 'config/ftbquests/quests/chapters/getting_started.snbt', content: read('config/chapters/getting_started.snbt') };
|
||||
const LANG_CHAPTERS = { path: 'config/ftbquests/quests/lang/en_us/chapter.snbt_merged', content: read('config/lang/en_us/chapter.snbt_merged') };
|
||||
const LANG_QUESTS = {
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapters/getting_started.snbt_merged',
|
||||
content: read('config/lang/en_us/chapters/getting_started.snbt_merged'),
|
||||
};
|
||||
|
||||
describe('parseQuestDefs', () => {
|
||||
it('reads chapters with titles from the split en_us lang files (ftbquestslangsplitter layout)', () => {
|
||||
const defs = parseQuestDefs([CHAPTER, LANG_CHAPTERS, LANG_QUESTS]);
|
||||
expect(defs).toEqual([
|
||||
{ chapterId: '1A2B3C4D5E6F7081', chapterTitle: 'Getting Started', questId: '3BC0A50886A3222B', title: 'Punch a Tree' },
|
||||
{ chapterId: '1A2B3C4D5E6F7081', chapterTitle: 'Getting Started', questId: '33218EFDD498E9DB', title: 'From Lang' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('also reads a single en_us.snbt and ignores other languages', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{ path: 'config/ftbquests/quests/lang/en_us.snbt', content: '{ quest.3BC0A50886A3222B.title: "Single File" }' },
|
||||
{ path: 'config/ftbquests/quests/lang/de_de/chapters/getting_started.snbt_merged', content: '{ quest.3BC0A50886A3222B.title: "Baum schlagen" }' },
|
||||
]);
|
||||
expect(defs[0]!.title).toBe('Single File');
|
||||
});
|
||||
|
||||
it('falls back to inline title and filename when no lang file exists', () => {
|
||||
const defs = parseQuestDefs([CHAPTER]);
|
||||
expect(defs.map((d) => [d.chapterTitle, d.title])).toEqual([
|
||||
['getting_started', null],
|
||||
['getting_started', 'Inline Title'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQuestConfigFile', () => {
|
||||
it('keeps chapters and English texts, skips other languages, reward tables and the big combined file', () => {
|
||||
const wanted = [
|
||||
'config/ftbquests/quests/chapters/aether.snbt',
|
||||
'config/ftbquests/quests/lang/en_us/chapter.snbt_merged',
|
||||
'config/ftbquests/quests/lang/en_us/chapters/aether.snbt_merged',
|
||||
];
|
||||
const skipped = [
|
||||
'config/ftbquests/quests/lang/de_de/chapters/aether.snbt_merged',
|
||||
'config/ftbquests/quests/lang/en_us.snbt',
|
||||
'config/ftbquests/quests/reward_tables/loot.snbt',
|
||||
'config/ftbquests/quests/data.snbt',
|
||||
];
|
||||
expect(wanted.filter(isQuestConfigFile)).toEqual(wanted);
|
||||
expect(skipped.filter(isQuestConfigFile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTeam', () => {
|
||||
const team = (p: string) => readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', p), 'utf8');
|
||||
|
||||
it('reads a player team (real structure)', () => {
|
||||
expect(parseTeam(team('player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt'))).toEqual({
|
||||
teamId: '308a693c-2bd8-4d6b-8790-670aef5be601',
|
||||
type: 'player',
|
||||
name: 'CDOGamer',
|
||||
members: ['308a693c-2bd8-4d6b-8790-670aef5be601'],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a party team; invited players and allies are not members', () => {
|
||||
const t = parseTeam(team('party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt'));
|
||||
expect(t.type).toBe('party');
|
||||
expect(t.name).toBe('Die Techniker');
|
||||
expect(t.members.sort()).toEqual(['308a693c-2bd8-4d6b-8790-670aef5be601', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file_sync projection', () => {
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const fileEvent = (path: string, content: string) => ({
|
||||
id: randomUUID(),
|
||||
type: 'file_sync' as const,
|
||||
ts: Date.now(),
|
||||
path,
|
||||
sha256: createHash('sha256').update(content).digest('hex'),
|
||||
content,
|
||||
});
|
||||
|
||||
it('stores quest progress for the team', async () => {
|
||||
await processEvents(pool, [fileEvent(`world/ftbquests/${ALICE_FILE}`, read(ALICE_FILE))], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM quest_progress WHERE team_id = $1`, [
|
||||
'308a693c-2bd8-4d6b-8790-670aef5be601',
|
||||
]);
|
||||
expect(rows[0].n).toBe(parseQuestProgress(read(ALICE_FILE)).completed.length);
|
||||
});
|
||||
|
||||
it('rebuilds quest_defs from all cached config files, also across batches', async () => {
|
||||
await processEvents(pool, [fileEvent(CHAPTER.path, CHAPTER.content)], 'push', 'atm10');
|
||||
await processEvents(pool, [fileEvent(LANG_CHAPTERS.path, LANG_CHAPTERS.content), fileEvent(LANG_QUESTS.path, LANG_QUESTS.content)], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT quest_id, title, chapter_title FROM quest_defs ORDER BY quest_id`);
|
||||
expect(rows).toEqual([
|
||||
{ quest_id: '33218EFDD498E9DB', title: 'From Lang', chapter_title: 'Getting Started' },
|
||||
{ quest_id: '3BC0A50886A3222B', title: 'Punch a Tree', chapter_title: 'Getting Started' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('party membership decides whose quest progress a player gets', async () => {
|
||||
const party = readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', 'party', '5e0c1a2b-0000-4000-8000-00000000abcd.snbt'), 'utf8');
|
||||
await processEvents(pool, [fileEvent('world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt', party)], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT t.name, t.type, array_agg(m.uuid::text ORDER BY m.uuid) AS members
|
||||
FROM teams t JOIN team_members m USING (team_id) GROUP BY t.name, t.type`);
|
||||
expect(rows).toEqual([
|
||||
{ name: 'Die Techniker', type: 'party', members: ['308a693c-2bd8-4d6b-8790-670aef5be601', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9'] },
|
||||
]);
|
||||
// leaving the party: a newer file without Bob
|
||||
const smaller = party.replace(/\t\tc25d9ff0-dba3-48f0-b414-fad2b63473a9: "member"\n/, '');
|
||||
await processEvents(pool, [fileEvent('world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt', smaller)], 'push', 'atm10');
|
||||
const after = await pool.query(`SELECT uuid::text FROM team_members`);
|
||||
expect(after.rows.map((r) => r.uuid)).toEqual(['308a693c-2bd8-4d6b-8790-670aef5be601']);
|
||||
});
|
||||
|
||||
it('player team files set the team name but no party membership', async () => {
|
||||
const player = readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', 'player', `${ALICE_FILE}`), 'utf8');
|
||||
await processEvents(pool, [fileEvent(`world/ftbteams/player/${ALICE_FILE}`, player)], 'push', 'atm10');
|
||||
expect((await pool.query(`SELECT name, type FROM teams`)).rows).toEqual([{ name: 'CDOGamer', type: 'player' }]);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM team_members`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it('a broken file is cached but does not fail the batch', async () => {
|
||||
const res = await processEvents(pool, [fileEvent(`world/ftbquests/${ALICE_FILE}`, '{ broken')], 'push', 'atm10');
|
||||
expect(res.accepted).toHaveLength(1);
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM app_errors`);
|
||||
expect(rows[0].n).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quest reset', () => {
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
|
||||
it('quests missing from a newer progress file are no longer completed', async () => {
|
||||
const file = (completed: string) =>
|
||||
`{\n\tversion: 1\n\tuuid: "308a693c2bd84d6b8790670aef5be601"\n\tname: "T"\n\tcompleted: {\n${completed}\n\t}\n}\n`;
|
||||
const send = (content: string) =>
|
||||
processEvents(
|
||||
pool,
|
||||
[{ id: randomUUID(), type: 'file_sync', ts: Date.now(), path: `world/ftbquests/${ALICE_FILE}`, sha256: createHash('sha256').update(content).digest('hex'), content }],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
await send(file('\t\tAAAAAAAAAAAAAAAA: 1L\n\t\tBBBBBBBBBBBBBBBB: 2L'));
|
||||
await send(file('\t\tBBBBBBBBBBBBBBBB: 2L'));
|
||||
const { rows } = await pool.query(`SELECT quest_id FROM quest_progress`);
|
||||
expect(rows.map((r) => r.quest_id)).toEqual(['BBBBBBBBBBBBBBBB']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting codes', () => {
|
||||
it('strips Minecraft colour and style codes from chapter and quest titles', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapters/getting_started.snbt_merged',
|
||||
content: '{ chapter.1A2B3C4D5E6F7081.title: "&aChapter 4&r: &dCreative" \n quest.3BC0A50886A3222B.title: "&6&lATM Star§r" }',
|
||||
},
|
||||
]);
|
||||
expect(defs[0]).toMatchObject({ chapterTitle: 'Chapter 4: Creative', title: 'ATM Star' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaped ampersand', () => {
|
||||
it('keeps a literal "&" that FTB escapes as backslash-ampersand', () => {
|
||||
// real lang file: "Forbidden \\& Arcanus" in SNBT, i.e. the parsed string contains a backslash before "&"
|
||||
expect(stripFormatting('Forbidden \\& Arcanus')).toBe('Forbidden & Arcanus');
|
||||
expect(stripFormatting('&6Gold \\&b &lBold')).toBe('Gold &b Bold');
|
||||
});
|
||||
|
||||
it('decodes the escape through the real SNBT path', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapter.snbt_merged',
|
||||
content: '{ chapter.1A2B3C4D5E6F7081.title: "Forbidden \\\\& Arcanus" }',
|
||||
},
|
||||
]);
|
||||
expect(defs[0]!.chapterTitle).toBe('Forbidden & Arcanus');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { buildApp } from '../../src/app.ts';
|
||||
import { loadConfig } from '../../src/config.ts';
|
||||
import { createIngestToken } from '../../src/ingest/tokens.ts';
|
||||
import { TEST_SECRET } from './sign.ts';
|
||||
|
||||
export function testConfig() {
|
||||
return loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
DATABASE_URL: process.env.TEST_DATABASE_URL,
|
||||
INGEST_HMAC_SECRET: TEST_SECRET,
|
||||
LOG_LEVEL: 'silent',
|
||||
});
|
||||
}
|
||||
|
||||
export async function makeApp(pool: Pool): Promise<{ app: FastifyInstance; token: string }> {
|
||||
const app = await buildApp({ pool, config: testConfig() });
|
||||
const token = await createIngestToken(pool);
|
||||
return { app, token };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { createPool } from '../../src/db/pool.ts';
|
||||
import { migrate } from '../../src/db/migrate.ts';
|
||||
|
||||
let pool: Pool | undefined;
|
||||
|
||||
export async function getTestPool(): Promise<Pool> {
|
||||
if (!pool) {
|
||||
pool = createPool(process.env.TEST_DATABASE_URL!);
|
||||
await migrate(pool);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function resetDb(p: Pool): Promise<void> {
|
||||
const { rows } = await p.query<{ tablename: string }>(
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'schema_migrations'`,
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
await p.query(`TRUNCATE ${rows.map((r) => `"${r.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
|
||||
export async function closeTestPool(): Promise<void> {
|
||||
await pool?.end();
|
||||
pool = undefined;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AtmEvent, EventOf, EventType } from '@atm/shared';
|
||||
|
||||
export const ALICE = { uuid: '308a693c-2bd8-4d6b-8790-670aef5be601', name: 'CDOGamer' };
|
||||
export const BOB = { uuid: 'c25d9ff0-dba3-48f0-b414-fad2b63473a9', name: 'Bob' };
|
||||
|
||||
type Payload<T extends EventType> = Omit<EventOf<T>, 'id' | 'type' | 'ts'> & { ts?: number; id?: string };
|
||||
|
||||
/** Test factory: ev('player_join', { ...ALICE, ts }) */
|
||||
export function ev<T extends EventType>(type: T, payload: Payload<T>): AtmEvent {
|
||||
const { ts, id, ...rest } = payload as Payload<T>;
|
||||
return { id: id ?? randomUUID(), type, ts: ts ?? Date.now(), ...rest } as unknown as AtmEvent;
|
||||
}
|
||||
|
||||
export const T0 = Date.UTC(2026, 9, 12, 9, 0, 0);
|
||||
export const MIN = 60_000;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
export const TEST_SECRET = 'test-hmac-secret-0123456789abcdef0123';
|
||||
|
||||
export function signature(secret: string, ts: number, body: Buffer): string {
|
||||
const bodyHash = createHash('sha256').update(body).digest('hex');
|
||||
return createHmac('sha256', secret).update(`${ts}.${bodyHash}`).digest('hex');
|
||||
}
|
||||
|
||||
export interface SignOpts {
|
||||
token: string;
|
||||
secret?: string;
|
||||
ts?: number;
|
||||
gzip?: boolean;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
/** Builds headers + payload exactly as the mod sends them: signature covers the transmitted (gzipped) bytes. */
|
||||
export function signedRequest(body: unknown, opts: SignOpts): { headers: Record<string, string>; payload: Buffer } {
|
||||
const raw = Buffer.from(typeof body === 'string' ? body : JSON.stringify(body));
|
||||
const gzip = opts.gzip ?? true;
|
||||
const payload = gzip ? gzipSync(raw) : raw;
|
||||
const ts = opts.ts ?? Date.now();
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `Bearer ${opts.token}`,
|
||||
'content-type': 'application/json',
|
||||
'x-atm-server': opts.serverId ?? 'atm10',
|
||||
'x-atm-timestamp': String(ts),
|
||||
'x-atm-signature': signature(opts.secret ?? TEST_SECRET, ts, payload),
|
||||
};
|
||||
if (gzip) headers['content-encoding'] = 'gzip';
|
||||
return { headers, payload };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { ALICE, ev } from './helpers/events.ts';
|
||||
import { signature, signedRequest, TEST_SECRET } from './helpers/sign.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app, token } = await makeApp(pool));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
function post(body: unknown, opts: Partial<Parameters<typeof signedRequest>[1]> = {}) {
|
||||
const { headers, payload } = signedRequest(body, { token, ...opts });
|
||||
return app.inject({ method: 'POST', url: '/api/ingest', headers, payload });
|
||||
}
|
||||
|
||||
const batch = () => ({
|
||||
protocol: 1,
|
||||
events: [ev('player_join', { ...ALICE }), ev('player_leave', { ...ALICE })],
|
||||
});
|
||||
|
||||
describe('POST /api/ingest', () => {
|
||||
it('accepts valid batch', async () => {
|
||||
const b = batch();
|
||||
const res = await post(b);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accepted.sort()).toEqual(b.events.map((e) => e.id).sort());
|
||||
const { rows } = await pool.query(`SELECT source FROM events`);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.every((r) => r.source === 'push')).toBe(true);
|
||||
});
|
||||
|
||||
it('same batch twice is idempotent', async () => {
|
||||
const b = batch();
|
||||
await post(b);
|
||||
const res = await post(b);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accepted).toEqual([]);
|
||||
expect(res.json().duplicate.sort()).toEqual(b.events.map((e) => e.id).sort());
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM events`);
|
||||
expect(rows[0].n).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects wrong token', async () => {
|
||||
const res = await post(batch(), { token: 'nope' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: 'bad_token' });
|
||||
});
|
||||
|
||||
it('rejects missing auth', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/ingest', payload: batch() });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: 'missing_auth' });
|
||||
});
|
||||
|
||||
it('rejects bad signature', async () => {
|
||||
const res = await post(batch(), { secret: 'another-secret-another-secret-123' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: 'bad_signature' });
|
||||
});
|
||||
|
||||
it('rejects timestamp 6 minutes old', async () => {
|
||||
const res = await post(batch(), { ts: Date.now() - 6 * 60_000 });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: 'timestamp_out_of_range' });
|
||||
});
|
||||
|
||||
it('rejects invalid event but accepts the rest', async () => {
|
||||
const good = ev('player_join', { ...ALICE });
|
||||
const bad = { id: '0199d6a0-0000-7000-8000-0000000000ff', type: 'player_join', ts: Date.now(), name: 'x' };
|
||||
const res = await post({ protocol: 1, events: [good, bad] });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accepted).toEqual([good.id]);
|
||||
expect(res.json().rejected).toHaveLength(1);
|
||||
expect(res.json().rejected[0].id).toBe(bad.id);
|
||||
expect(res.json().rejected[0].reason).toMatch(/uuid/);
|
||||
});
|
||||
|
||||
it('rejects body over 2 MB unpacked', async () => {
|
||||
const huge = JSON.stringify({ protocol: 1, events: [ev('server_stop', {})], pad: 'x'.repeat(2_200_000) });
|
||||
const res = await post(huge);
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
|
||||
it('accepts uncompressed body too', async () => {
|
||||
const res = await post(batch(), { gzip: false });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/ingest/ping', () => {
|
||||
it('answers with server time for a valid signature', async () => {
|
||||
const ts = Date.now();
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/ingest/ping',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-atm-timestamp': String(ts),
|
||||
'x-atm-signature': signature(TEST_SECRET, ts, Buffer.alloc(0)),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().ok).toBe(true);
|
||||
expect(typeof res.json().serverTime).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signature vector (shared with the mod)', () => {
|
||||
it('matches the constant used in SignerTest.java', () => {
|
||||
expect(signature('test-secret', 1760000000000, Buffer.from('hello'))).toBe(
|
||||
'c9f85674a0d206a6d8e53290fb21af4933f8c8451fb8b2aeefcdf3d2314ef043',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { getLeaderboard, getPlayerRanks } from '../src/stats/leaderboards.ts';
|
||||
import { BOARDS } from '../src/stats/metrics.ts';
|
||||
import { takeSnapshots } from '../src/stats/snapshots.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
async function stats(who: { uuid: string; name: string }, stats: Record<string, Record<string, number>>, ts = T0) {
|
||||
await processEvents(pool, [ev('stats_snapshot', { uuid: who.uuid, name: who.name, full: false, ts, stats })], 'push', 'atm10');
|
||||
}
|
||||
|
||||
describe('boards', () => {
|
||||
it('defines the 14 boards from the spec', () => {
|
||||
expect(BOARDS.map((b) => b.id)).toEqual([
|
||||
'playtime', 'deaths', 'mobkills', 'mined', 'distance', 'flight', 'diamonds',
|
||||
'tech', 'magic', 'farming', 'quests', 'advancements', 'loot', 'worldtier',
|
||||
]);
|
||||
});
|
||||
|
||||
it('playtime converts ticks to hours', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:play_time': 1175736 } });
|
||||
const [top] = await getLeaderboard(pool, 'playtime', 'all');
|
||||
expect(top).toMatchObject({ rank: 1, uuid: ALICE.uuid, name: ALICE.name });
|
||||
expect(top!.value).toBeCloseTo(16.33, 2);
|
||||
});
|
||||
|
||||
it('distance sums all *_one_cm keys except falling, in km', async () => {
|
||||
await stats(ALICE, {
|
||||
'minecraft:custom': {
|
||||
'minecraft:walk_one_cm': 150_000,
|
||||
'minecraft:fly_one_cm': 50_000,
|
||||
'minecraft:fall_one_cm': 999_999,
|
||||
'minecraft:jump': 5,
|
||||
},
|
||||
});
|
||||
const [top] = await getLeaderboard(pool, 'distance', 'all');
|
||||
expect(top!.value).toBe(2);
|
||||
});
|
||||
|
||||
it('tech sums crafted+used of tech namespaces only', async () => {
|
||||
await stats(ALICE, {
|
||||
'minecraft:crafted': { 'mekanism:steel_casing': 3, 'minecraft:stick': 100, 'ars_nouveau:source_gem': 9 },
|
||||
'minecraft:used': { 'powah:energy_cell_basic': 2 },
|
||||
'minecraft:mined': { 'mekanism:osmium_ore': 50 },
|
||||
});
|
||||
const [top] = await getLeaderboard(pool, 'tech', 'all');
|
||||
expect(top!.value).toBe(5);
|
||||
});
|
||||
|
||||
it('hidden player excluded', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 3 } });
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:deaths': 9 } });
|
||||
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
|
||||
const rows = await getLeaderboard(pool, 'deaths', 'all');
|
||||
expect(rows.map((r) => r.uuid)).toEqual([ALICE.uuid]);
|
||||
});
|
||||
|
||||
it('ties share rank, ordered by name; zero values omitted', async () => {
|
||||
const carl = { uuid: randomUUID(), name: 'Carl' };
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:mob_kills': 7 } });
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:mob_kills': 7 } });
|
||||
await stats(carl, { 'minecraft:custom': { 'minecraft:mob_kills': 0 } });
|
||||
const rows = await getLeaderboard(pool, 'mobkills', 'all');
|
||||
expect(rows.map((r) => [r.rank, r.name])).toEqual([
|
||||
[1, 'Bob'],
|
||||
[1, 'CDOGamer'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('7d period subtracts the daily snapshot at the period start', async () => {
|
||||
const now = new Date(T0 + 10 * DAY);
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 4 } }, T0);
|
||||
await takeSnapshots(pool, 'day', new Date(T0 + 2 * DAY));
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 10 } }, T0 + 9 * DAY);
|
||||
const [top] = await getLeaderboard(pool, 'deaths', '7d', 10, now);
|
||||
expect(top!.value).toBe(6);
|
||||
});
|
||||
|
||||
it('quests uses team progress and counts only known quests when defs exist', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:jump': 1 } });
|
||||
await pool.query(
|
||||
`INSERT INTO quest_progress(team_id, quest_id, completed_at) VALUES ($1, 'AAAAAAAAAAAAAAAA', now()), ($1, 'BBBBBBBBBBBBBBBB', now())`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
expect((await getLeaderboard(pool, 'quests', 'all'))[0]!.value).toBe(2);
|
||||
await pool.query(`INSERT INTO quest_defs(quest_id, chapter_id) VALUES ('AAAAAAAAAAAAAAAA', 'C')`);
|
||||
expect((await getLeaderboard(pool, 'quests', 'all'))[0]!.value).toBe(1);
|
||||
});
|
||||
|
||||
it('player ranks cover every board the player appears on', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 3, 'minecraft:play_time': 72000 } });
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:deaths': 9 } });
|
||||
const ranks = await getPlayerRanks(pool, ALICE.uuid);
|
||||
expect(ranks.deaths).toEqual({ rank: 2, value: 3 });
|
||||
expect(ranks.playtime).toEqual({ rank: 1, value: 1 });
|
||||
expect(ranks.magic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app } = await makeApp(pool));
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const addr = app.server.address();
|
||||
base = `http://127.0.0.1:${typeof addr === 'object' && addr ? addr.port : 0}`;
|
||||
await app.liveHub.ready();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
interface Sse {
|
||||
id: number;
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Opens /api/live and collects parsed SSE messages until `until` returns true. */
|
||||
async function collect(until: (msgs: Sse[]) => boolean, headers: Record<string, string> = {}, trigger?: () => Promise<void>) {
|
||||
const ctrl = new AbortController();
|
||||
const res = await fetch(`${base}/api/live`, { headers, signal: ctrl.signal });
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||
const reader = res.body!.getReader();
|
||||
const msgs: Sse[] = [];
|
||||
let buf = '';
|
||||
const timeout = setTimeout(() => ctrl.abort(), 8000);
|
||||
let triggered = false;
|
||||
try {
|
||||
for (;;) {
|
||||
if (!triggered && trigger) {
|
||||
triggered = true;
|
||||
// wait for the stream to be registered before triggering
|
||||
const first = await reader.read();
|
||||
buf += new TextDecoder().decode(first.value);
|
||||
await trigger();
|
||||
}
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
||||
const block = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
const fields = Object.fromEntries(
|
||||
block
|
||||
.split('\n')
|
||||
.filter((l) => !l.startsWith(':'))
|
||||
.map((l) => [l.slice(0, l.indexOf(':')), l.slice(l.indexOf(':') + 1).trim()]),
|
||||
);
|
||||
if (fields.event) msgs.push({ id: Number(fields.id), event: fields.event, data: JSON.parse(fields.data ?? '{}') });
|
||||
}
|
||||
if (until(msgs)) return msgs;
|
||||
const { value, done } = await reader.read();
|
||||
if (done) return msgs;
|
||||
buf += new TextDecoder().decode(value);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
ctrl.abort();
|
||||
}
|
||||
}
|
||||
|
||||
const death = (who: typeof ALICE) =>
|
||||
ev('player_death', { ...who, dimension: 'minecraft:overworld', cause: 'minecraft:fall', killerType: null, killerName: null });
|
||||
|
||||
describe('GET /api/live', () => {
|
||||
it('delivers death event after ingest', async () => {
|
||||
const msgs = await collect(
|
||||
(m) => m.some((x) => x.event === 'death'),
|
||||
{},
|
||||
async () => {
|
||||
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
|
||||
},
|
||||
);
|
||||
const d = msgs.find((m) => m.event === 'death')!;
|
||||
expect(d.data).toMatchObject({ uuid: ALICE.uuid, name: ALICE.name, cause: 'minecraft:fall' });
|
||||
});
|
||||
|
||||
it('resumes from Last-Event-ID', async () => {
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE })], 'push', 'atm10');
|
||||
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const all = app.liveHub.since(0);
|
||||
expect(all.map((e) => e.kind)).toEqual(['online', 'death']);
|
||||
const msgs = await collect((m) => m.length >= 1, { 'last-event-id': String(all[0]!.id) });
|
||||
expect(msgs.map((m) => m.event)).toEqual(['death']);
|
||||
});
|
||||
|
||||
it('hidden player not broadcast', async () => {
|
||||
await processEvents(pool, [ev('player_join', { ...BOB })], 'push', 'atm10');
|
||||
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
|
||||
const msgs = await collect(
|
||||
(m) => m.some((x) => x.event === 'death'),
|
||||
{ 'last-event-id': String(app.liveHub.lastId()) },
|
||||
async () => {
|
||||
await processEvents(pool, [death(BOB)], 'push', 'atm10');
|
||||
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
|
||||
},
|
||||
);
|
||||
const deaths = msgs.filter((m) => m.event === 'death');
|
||||
expect(deaths.map((d) => d.data.uuid)).toEqual([ALICE.uuid]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseLogChunk, parseLogTimestamp } from '../src/parsers/log.ts';
|
||||
|
||||
const log = readFileSync(join(import.meta.dirname, 'fixtures', 'latest.anon.log'));
|
||||
const ALICE = '308a693c-2bd8-4d6b-8790-670aef5be601';
|
||||
const TZ = 'Europe/Berlin';
|
||||
|
||||
describe('parseLogTimestamp', () => {
|
||||
it('interprets log time in the server time zone', () => {
|
||||
// 12 Oct 2026 is CEST (UTC+2)
|
||||
expect(parseLogTimestamp('12Oct2026 09:03:03.006', TZ)).toBe(Date.parse('2026-10-12T07:03:03.006Z'));
|
||||
// 1 Dec 2026 is CET (UTC+1)
|
||||
expect(parseLogTimestamp('01Dec2026 00:00:00.000', TZ)).toBe(Date.parse('2026-11-30T23:00:00.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLogChunk', () => {
|
||||
it('finds join of CDOGamer with uuid 308a…', () => {
|
||||
const { events } = parseLogChunk(log, 0, new Map(), TZ);
|
||||
const join = events.find((e) => e.type === 'player_join');
|
||||
expect(join).toMatchObject({ type: 'player_join', uuid: ALICE, name: 'CDOGamer' });
|
||||
expect(join!.ts).toBe(parseLogTimestamp('12Oct2026 09:03:03.006', TZ));
|
||||
expect(events.filter((e) => e.type === 'player_leave').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('finds advancement Infusing Efficiency and goals', () => {
|
||||
const { events } = parseLogChunk(log, 0, new Map(), TZ);
|
||||
const titles = events.flatMap((e) => (e.type === 'advancement' ? [e.title] : []));
|
||||
expect(titles).toContain('Infusing Efficiency');
|
||||
expect(titles).toContain('The Factory Must Grow!');
|
||||
const adv = events.find((e) => e.type === 'advancement')!;
|
||||
expect(adv).toMatchObject({ uuid: ALICE, advancementId: 'log:Infusing Efficiency' });
|
||||
});
|
||||
|
||||
it('resolves names known from earlier chunks', () => {
|
||||
const line = Buffer.from('[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob left the game\r\n');
|
||||
const known = new Map([['Bob', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9']]);
|
||||
expect(parseLogChunk(line, 0, known, TZ).events[0]).toMatchObject({ type: 'player_leave', name: 'Bob' });
|
||||
expect(parseLogChunk(line, 0, new Map(), TZ).events).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores chat lines that imitate system messages', () => {
|
||||
const line = Buffer.from(
|
||||
'[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: <CDOGamer> Bob joined the game\n',
|
||||
);
|
||||
expect(parseLogChunk(line, 0, new Map([['Bob', ALICE]]), TZ).events).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores incomplete last line', () => {
|
||||
const text = Buffer.from(
|
||||
'[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob left the game\n' +
|
||||
'[12Oct2026 10:00:01.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob joined the ga',
|
||||
);
|
||||
const r = parseLogChunk(text, 100, new Map([['Bob', ALICE]]), TZ);
|
||||
expect(r.events).toHaveLength(1);
|
||||
expect(r.consumed).toBe(text.indexOf('\n') + 1);
|
||||
});
|
||||
|
||||
it('same chunk twice yields same ids; ids depend on absolute offset', () => {
|
||||
const a = parseLogChunk(log, 0, new Map(), TZ).events.map((e) => e.id);
|
||||
const b = parseLogChunk(log, 0, new Map(), TZ).events.map((e) => e.id);
|
||||
const c = parseLogChunk(log, 10, new Map(), TZ).events.map((e) => e.id);
|
||||
expect(a).toEqual(b);
|
||||
expect(a[0]).not.toBe(c[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev, MIN, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const push = (...events: Parameters<typeof processEvents>[1]) => processEvents(pool, events, 'push', 'atm10');
|
||||
const ms = (d: Date) => d.getTime();
|
||||
|
||||
describe('sessions', () => {
|
||||
it('join then leave creates closed session with duration', async () => {
|
||||
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('player_leave', { ...ALICE, ts: T0 + 30 * MIN }));
|
||||
const { rows } = await pool.query('SELECT started_at, ended_at, end_reason FROM sessions');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(ms(rows[0].ended_at) - ms(rows[0].started_at)).toBe(30 * MIN);
|
||||
expect(rows[0].end_reason).toBe('leave');
|
||||
});
|
||||
|
||||
it('closes dangling sessions on server_start', async () => {
|
||||
await push(
|
||||
ev('player_join', { ...ALICE, ts: T0 }),
|
||||
ev('health', {
|
||||
ts: T0 + 10 * MIN,
|
||||
windowStart: T0 + 9 * MIN,
|
||||
windowEnd: T0 + 10 * MIN,
|
||||
msptAvg: 30,
|
||||
msptP95: 40,
|
||||
msptMax: 50,
|
||||
tps: 20,
|
||||
players: 1,
|
||||
chunks: 100,
|
||||
heapUsedMb: 1000,
|
||||
heapMaxMb: 4000,
|
||||
}),
|
||||
);
|
||||
await push(
|
||||
ev('server_start', { ts: T0 + 60 * MIN, mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21', mods: [] }),
|
||||
);
|
||||
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
|
||||
expect(ms(rows[0].ended_at)).toBe(T0 + 10 * MIN);
|
||||
expect(rows[0].end_reason).toBe('crash');
|
||||
const info = await pool.query('SELECT mc_version FROM server_info');
|
||||
expect(info.rows[0].mc_version).toBe('1.21.1');
|
||||
});
|
||||
|
||||
it('server_stop closes open sessions', async () => {
|
||||
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('server_stop', { ts: T0 + 5 * MIN }));
|
||||
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
|
||||
expect(ms(rows[0].ended_at)).toBe(T0 + 5 * MIN);
|
||||
expect(rows[0].end_reason).toBe('stop');
|
||||
});
|
||||
|
||||
it('second join closes the previous open session as superseded', async () => {
|
||||
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('player_join', { ...ALICE, ts: T0 + 5 * MIN }));
|
||||
const { rows } = await pool.query('SELECT end_reason FROM sessions ORDER BY started_at');
|
||||
expect(rows.map((r) => r.end_reason)).toEqual(['superseded', null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('players', () => {
|
||||
it('rename updates name and history', async () => {
|
||||
await push(ev('player_join', { uuid: ALICE.uuid, name: 'OldName', ts: T0 }));
|
||||
await push(ev('player_join', { uuid: ALICE.uuid, name: 'NewName', ts: T0 + MIN }));
|
||||
const p = await pool.query('SELECT name FROM players WHERE uuid = $1', [ALICE.uuid]);
|
||||
expect(p.rows[0].name).toBe('NewName');
|
||||
const h = await pool.query('SELECT name FROM name_history WHERE uuid = $1 ORDER BY seen_at', [ALICE.uuid]);
|
||||
expect(h.rows.map((r) => r.name)).toEqual(['OldName', 'NewName']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
const snap = (ts: number, value: number, source: 'push' | 'sftp' = 'push') =>
|
||||
processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { uuid: ALICE.uuid, name: ALICE.name, full: false, ts, stats: { 'minecraft:custom': { 'minecraft:deaths': value } } })],
|
||||
source,
|
||||
'atm10',
|
||||
);
|
||||
const deaths = async () =>
|
||||
(await pool.query(`SELECT value FROM stat_current WHERE key = 'minecraft:deaths'`)).rows[0]?.value;
|
||||
|
||||
it('newer observation wins even if smaller', async () => {
|
||||
await snap(T0 + 2 * MIN, 5);
|
||||
await snap(T0 + 1 * MIN, 9, 'sftp');
|
||||
expect(await deaths()).toBe(5);
|
||||
await snap(T0 + 3 * MIN, 3);
|
||||
expect(await deaths()).toBe(3);
|
||||
});
|
||||
|
||||
it('full snapshot does not zero missing keys', async () => {
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { uuid: ALICE.uuid, name: null, full: false, ts: T0, stats: { 'minecraft:custom': { 'minecraft:jump': 7 } } })],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { uuid: ALICE.uuid, name: null, full: true, ts: T0 + MIN, stats: { 'minecraft:custom': { 'minecraft:deaths': 1 } } })],
|
||||
'sftp',
|
||||
'atm10',
|
||||
);
|
||||
const { rows } = await pool.query(`SELECT key, value FROM stat_current ORDER BY key`);
|
||||
expect(rows).toEqual([
|
||||
{ key: 'minecraft:deaths', value: 1 },
|
||||
{ key: 'minecraft:jump', value: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates unknown player without name and fills the name later', async () => {
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { uuid: BOB.uuid, name: null, full: true, ts: T0, stats: { 'minecraft:custom': { 'minecraft:jump': 1 } } })],
|
||||
'sftp',
|
||||
'atm10',
|
||||
);
|
||||
expect((await pool.query('SELECT name FROM players')).rows[0].name).toBeNull();
|
||||
await push(ev('player_join', { ...BOB, ts: T0 + MIN }));
|
||||
expect((await pool.query('SELECT name FROM players')).rows[0].name).toBe('Bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancements, combat, dimensions, health', () => {
|
||||
it('advancement keeps earliest timestamp', async () => {
|
||||
await push(ev('advancement', { ...ALICE, ts: T0 + MIN, advancementId: 'a:b', title: null }));
|
||||
await push(ev('advancement', { ...ALICE, ts: T0, advancementId: 'a:b', title: 'Title' }));
|
||||
const { rows } = await pool.query('SELECT achieved_at, title FROM advancements');
|
||||
expect(ms(rows[0].achieved_at)).toBe(T0);
|
||||
expect(rows[0].title).toBe('Title');
|
||||
});
|
||||
|
||||
it('death and boss kill are stored', async () => {
|
||||
await push(
|
||||
ev('player_death', { ...ALICE, ts: T0, dimension: 'minecraft:the_nether', cause: 'minecraft:lava', killerType: null, killerName: null }),
|
||||
ev('entity_kill', { ...ALICE, ts: T0, dimension: 'minecraft:the_end', victimType: 'minecraft:ender_dragon', isBoss: true, weapon: null }),
|
||||
);
|
||||
expect((await pool.query('SELECT cause FROM deaths')).rows[0].cause).toBe('minecraft:lava');
|
||||
expect((await pool.query('SELECT is_boss FROM kills')).rows[0].is_boss).toBe(true);
|
||||
});
|
||||
|
||||
it('dimension visits keep first visit', async () => {
|
||||
await push(ev('dimension_change', { ...ALICE, ts: T0, from: 'minecraft:overworld', to: 'minecraft:the_nether' }));
|
||||
await push(ev('dimension_change', { ...ALICE, ts: T0 + MIN, from: 'minecraft:overworld', to: 'minecraft:the_nether' }));
|
||||
const { rows } = await pool.query('SELECT dimension, first_at FROM dimension_visits ORDER BY dimension');
|
||||
expect(rows.map((r) => r.dimension)).toEqual(['minecraft:overworld', 'minecraft:the_nether']);
|
||||
expect(ms(rows[1].first_at)).toBe(T0);
|
||||
});
|
||||
|
||||
it('health stored per minute bucket', async () => {
|
||||
const h = { msptAvg: 30, msptP95: 40, msptMax: 50, tps: 20, players: 1, chunks: 100, heapUsedMb: 1000, heapMaxMb: 4000 };
|
||||
await push(ev('health', { ...h, ts: T0 + MIN, windowStart: T0 + 5_000, windowEnd: T0 + MIN }));
|
||||
const { rows } = await pool.query('SELECT bucket, tps FROM server_health');
|
||||
expect(ms(rows[0].bucket)).toBe(T0);
|
||||
expect(rows[0].tps).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review fixes', () => {
|
||||
it('a late event with an older name does not revert a rename', async () => {
|
||||
await push(ev('player_join', { uuid: ALICE.uuid, name: 'NewName', ts: T0 + 10 * MIN }));
|
||||
await push(ev('player_leave', { uuid: ALICE.uuid, name: 'OldName', ts: T0 }));
|
||||
expect((await pool.query('SELECT name FROM players WHERE uuid = $1', [ALICE.uuid])).rows[0].name).toBe('NewName');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev, MIN, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app } = await makeApp(pool));
|
||||
const now = Date.now();
|
||||
await processEvents(
|
||||
pool,
|
||||
[
|
||||
ev('player_join', { uuid: ALICE.uuid, name: 'OldAlice', ts: now - 3 * DAY }),
|
||||
ev('player_leave', { uuid: ALICE.uuid, name: 'OldAlice', ts: now - 3 * DAY + 30 * MIN }),
|
||||
ev('player_join', { ...ALICE, ts: now - 10 * MIN }),
|
||||
ev('dimension_change', { ...ALICE, ts: now - 5 * MIN, from: 'minecraft:overworld', to: 'minecraft:the_nether' }),
|
||||
ev('player_join', { ...BOB, ts: now - 2 * MIN }),
|
||||
ev('stats_snapshot', {
|
||||
...ALICE,
|
||||
full: false,
|
||||
ts: now - MIN,
|
||||
stats: {
|
||||
'minecraft:custom': { 'minecraft:play_time': 144000, 'minecraft:deaths': 2, 'minecraft:mob_kills': 5, 'minecraft:walk_one_cm': 1000 },
|
||||
'minecraft:mined': { 'mekanism:osmium_ore': 30, 'mekanism:steel_casing': 1, 'minecraft:stone': 99 },
|
||||
'minecraft:killed': { 'minecraft:zombie': 4 },
|
||||
},
|
||||
}),
|
||||
ev('stats_snapshot', { ...BOB, full: false, ts: now - MIN, stats: { 'minecraft:custom': { 'minecraft:deaths': 7 } } }),
|
||||
ev('player_death', { ...ALICE, ts: now - 4 * MIN, dimension: 'minecraft:the_nether', cause: 'minecraft:lava', killerType: null, killerName: null }),
|
||||
ev('advancement', { ...ALICE, ts: now - 3 * MIN, advancementId: 'minecraft:story/enter_the_nether', title: 'We Need to Go Deeper' }),
|
||||
],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
const get = async (url: string) => {
|
||||
const res = await app.inject({ method: 'GET', url });
|
||||
return { status: res.statusCode, body: res.statusCode < 500 ? res.json() : res.body, headers: res.headers };
|
||||
};
|
||||
|
||||
describe('public api', () => {
|
||||
it('overview counts', async () => {
|
||||
const { status, body, headers } = await get('/api/overview');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ online: 2, totalPlayers: 2, totalPlayHours: 2, totalDeaths: 9, totalMobKills: 5, advancements: 1 });
|
||||
expect(headers['cache-control']).toBe('public, max-age=15');
|
||||
});
|
||||
|
||||
it('online lists open sessions only, with current dimension', async () => {
|
||||
const { body } = await get('/api/online');
|
||||
expect(body.players.map((p: { name: string }) => p.name).sort()).toEqual(['Bob', 'CDOGamer']);
|
||||
expect(body.players.find((p: { name: string }) => p.name === 'CDOGamer').dimension).toBe('minecraft:the_nether');
|
||||
});
|
||||
|
||||
it('profile by uuid, current name and old name', async () => {
|
||||
for (const key of [ALICE.uuid, 'cdogamer', 'OldAlice']) {
|
||||
const { status, body } = await get(`/api/players/${key}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body.uuid).toBe(ALICE.uuid);
|
||||
}
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}`);
|
||||
expect(body).toMatchObject({ name: 'CDOGamer', online: true, playHours: 2, sessions: 2 });
|
||||
expect(body.movement).toEqual({ 'minecraft:walk_one_cm': 1000 });
|
||||
expect(body.general['minecraft:deaths']).toBe(2);
|
||||
expect(body.deathsByCause).toEqual([{ cause: 'minecraft:lava', count: 1 }]);
|
||||
expect(body.killsByType).toEqual([{ entity: 'minecraft:zombie', count: 4 }]);
|
||||
expect(body.advancements[0].title).toBe('We Need to Go Deeper');
|
||||
expect(body.ranks.deaths).toEqual({ rank: 2, value: 2 });
|
||||
expect(body.activityHeatmap).toHaveLength(7);
|
||||
expect(body.activityHeatmap[0]).toHaveLength(24);
|
||||
expect(body.activityHeatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBeGreaterThanOrEqual(39);
|
||||
});
|
||||
|
||||
it('unknown player is 404', async () => {
|
||||
expect((await get('/api/players/Nobody')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('hidden player is 404 and absent from leaderboards, online and overview', async () => {
|
||||
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
|
||||
expect((await get(`/api/players/${BOB.uuid}`)).status).toBe(404);
|
||||
expect((await get('/api/online')).body.players.map((p: { uuid: string }) => p.uuid)).toEqual([ALICE.uuid]);
|
||||
const lb = (await get('/api/leaderboards/deaths')).body;
|
||||
expect(lb.entries.map((e: { uuid: string }) => e.uuid)).toEqual([ALICE.uuid]);
|
||||
expect((await get('/api/overview')).body).toMatchObject({ online: 1, totalPlayers: 1, totalDeaths: 2 });
|
||||
expect((await get('/api/players?search=bo')).body.players).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaderboards list has every board with top entries', async () => {
|
||||
const { body } = await get('/api/leaderboards');
|
||||
expect(body.boards).toHaveLength(14);
|
||||
expect(body.boards.find((b: { id: string }) => b.id === 'deaths').top[0].name).toBe('Bob');
|
||||
expect((await get('/api/leaderboards/nope')).status).toBe(404);
|
||||
expect((await get('/api/leaderboards/deaths?period=1y')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('player search and paging', async () => {
|
||||
const { body } = await get('/api/players?search=cdo');
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.players[0]).toMatchObject({ name: 'CDOGamer', online: true, playHours: 2 });
|
||||
});
|
||||
|
||||
it('items grouped by namespace', async () => {
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}/items?category=mined`);
|
||||
expect(body.mods.map((m: { namespace: string; total: number }) => [m.namespace, m.total])).toEqual([
|
||||
['minecraft', 99],
|
||||
['mekanism', 31],
|
||||
]);
|
||||
expect((await get(`/api/players/${ALICE.uuid}/items?category=bogus`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('history returns daily points and the current value', async () => {
|
||||
await pool.query(
|
||||
`INSERT INTO stat_snapshots(uuid, metric, bucket, granularity, value) VALUES ($1, 'deaths', date_trunc('day', now() - interval '2 days'), 'day', 1)`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}/history?metric=deaths&range=30d`);
|
||||
expect(body.unit).toBe('count');
|
||||
expect(body.points.map((p: { v: number }) => p.v)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('server and mods expose versions from server_start', async () => {
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('server_start', { ts: T0, mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21.0.5', mods: [{ id: 'mekanism', version: '10.7.19', name: 'Mekanism' }] })],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
const mods = (await get('/api/mods')).body;
|
||||
expect(mods.server).toMatchObject({ mcVersion: '1.21.1', neoforgeVersion: '21.1.219', modCount: 1 });
|
||||
expect(mods.mods[0]).toMatchObject({ id: 'mekanism', activity: 31, category: 'tech' });
|
||||
expect((await get('/api/server?range=24h')).body.info.javaVersion).toBe('21.0.5');
|
||||
});
|
||||
|
||||
it('quests and sources respond', async () => {
|
||||
expect((await get('/api/quests')).body).toMatchObject({ defsKnown: false, chapters: [] });
|
||||
const src = (await get('/api/sources')).body;
|
||||
expect(src.push.reachable).toBe(true);
|
||||
expect(src.sftp.enabled).toBe(false);
|
||||
expect(src.limitations.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('avatar rejects invalid uuid with 400', async () => {
|
||||
expect((await get('/api/avatar/not-a-uuid')).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quest titles in the profile', () => {
|
||||
it('lists recently completed quests with titles, newest first, only known quests', async () => {
|
||||
await pool.query(
|
||||
`INSERT INTO quest_defs(quest_id, chapter_id, chapter_title, title) VALUES
|
||||
('AAAAAAAAAAAAAAAA', 'C1', 'Getting Started', 'Punch a Tree'),
|
||||
('BBBBBBBBBBBBBBBB', 'C1', 'Getting Started', NULL),
|
||||
('CCCCCCCCCCCCCCCC', 'C1', 'Getting Started', 'Craft a Table')`,
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO quest_progress(team_id, quest_id, completed_at) VALUES
|
||||
($1, 'AAAAAAAAAAAAAAAA', now() - interval '2 hours'),
|
||||
($1, 'BBBBBBBBBBBBBBBB', now() - interval '1 hour'),
|
||||
($1, 'CCCCCCCCCCCCCCCC', now() - interval '30 minutes'),
|
||||
($1, 'DDDDDDDDDDDDDDDD', now())`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}`);
|
||||
expect(body.quests.completed).toBe(3);
|
||||
expect(body.quests.recent.map((q: { title: string }) => q.title)).toEqual(['Craft a Table', 'Punch a Tree']);
|
||||
expect(body.quests.recent[0]).toMatchObject({ chapterTitle: 'Getting Started' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runJob } from '../src/worker/scheduler.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
describe('runJob', () => {
|
||||
it('never runs the same job twice concurrently', async () => {
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
const job = async () => {
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
running--;
|
||||
};
|
||||
const results = await Promise.all([runJob(pool, 'x', 900_001, job), runJob(pool, 'x', 900_001, job)]);
|
||||
expect(maxRunning).toBe(1);
|
||||
expect(results.sort()).toEqual(['locked', 'ok']);
|
||||
});
|
||||
|
||||
it('records failures in app_errors', async () => {
|
||||
const r = await runJob(pool, 'sftp', 900_002, async () => {
|
||||
throw new Error('connection refused');
|
||||
});
|
||||
expect(r).toBe('error');
|
||||
const { rows } = await pool.query('SELECT source, message FROM app_errors');
|
||||
expect(rows).toEqual([{ source: 'sftp', message: 'connection refused' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { applyRetention, takeSnapshots } from '../src/stats/snapshots.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, ev, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('stats_snapshot', { ...ALICE, full: false, ts: T0, stats: { 'minecraft:custom': { 'minecraft:play_time': 72000 } } })],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const count = async (granularity: string) =>
|
||||
(await pool.query(`SELECT count(*)::int AS n FROM stat_snapshots WHERE granularity = $1`, [granularity])).rows[0].n;
|
||||
|
||||
describe('snapshots', () => {
|
||||
it('takeSnapshots idempotent', async () => {
|
||||
await takeSnapshots(pool, 'hour', new Date(T0 + 1000));
|
||||
const first = await count('hour');
|
||||
await takeSnapshots(pool, 'hour', new Date(T0 + 2000));
|
||||
expect(await count('hour')).toBe(first);
|
||||
const { rows } = await pool.query(`SELECT value FROM stat_snapshots WHERE metric = 'playtime'`);
|
||||
expect(rows[0].value).toBe(72000);
|
||||
});
|
||||
|
||||
it('retention deletes hourly older than 30 days but keeps daily', async () => {
|
||||
await takeSnapshots(pool, 'hour', new Date(T0));
|
||||
await takeSnapshots(pool, 'day', new Date(T0));
|
||||
await applyRetention(pool, new Date(T0 + 31 * DAY));
|
||||
expect(await count('hour')).toBe(0);
|
||||
expect(await count('day')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('retention folds health minutes older than 14 days into hourly aggregates', async () => {
|
||||
for (const m of [0, 1, 2]) {
|
||||
await pool.query(
|
||||
`INSERT INTO server_health VALUES (to_timestamp($1 / 1000.0), $2, 50, 100, 20, 2, 500, 1000, 4000)`,
|
||||
[T0 + m * 60_000, 10 * (m + 1)],
|
||||
);
|
||||
}
|
||||
await applyRetention(pool, new Date(T0 + 15 * DAY));
|
||||
expect((await pool.query('SELECT count(*)::int AS n FROM server_health')).rows[0].n).toBe(0);
|
||||
const { rows } = await pool.query('SELECT mspt_avg, samples FROM server_health_hourly');
|
||||
expect(rows).toEqual([{ mspt_avg: 20, samples: 3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('events retention', () => {
|
||||
it('drops bulky projected events after 30 days but keeps player events', async () => {
|
||||
const old = T0 - 40 * DAY;
|
||||
await processEvents(
|
||||
pool,
|
||||
[
|
||||
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 0, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: old, windowEnd: old, ts: old }),
|
||||
ev('player_join', { ...ALICE, ts: old }),
|
||||
],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
await pool.query(`UPDATE events SET received_at = to_timestamp($1 / 1000.0)`, [old]);
|
||||
await applyRetention(pool, new Date(T0));
|
||||
const { rows } = await pool.query(`SELECT type FROM events ORDER BY type`);
|
||||
expect(rows.map((r) => r.type)).toEqual(['player_join']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSnbt, SnbtError } from '../src/parsers/snbt.ts';
|
||||
|
||||
describe('parseSnbt', () => {
|
||||
it('parses newline-separated compound', () => {
|
||||
expect(parseSnbt('{\n\tversion: 1\n\tlock: false\n\tname: "CDOGamer#308a693c"\n}')).toEqual({
|
||||
version: 1,
|
||||
lock: false,
|
||||
name: 'CDOGamer#308a693c',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses comma-separated compound', () => {
|
||||
expect(parseSnbt('{a: 1, b: "x", c: {d: 2b}}')).toEqual({ a: 1, b: 'x', c: { d: 2 } });
|
||||
});
|
||||
|
||||
it('parses suffixes 1L 2b 1.5f 0.0d -3s', () => {
|
||||
expect(parseSnbt('[1L, 2b, 1.5f, 0.0d, -3s, 1791559740996L]')).toEqual([1, 2, 1.5, 0, -3, 1791559740996]);
|
||||
});
|
||||
|
||||
it('parses keys that start with digits and quoted keys with colons', () => {
|
||||
expect(
|
||||
parseSnbt('{ 0000000000000001: 5L\n 308a693c2bd84d6b: { x: 1 }\n "a:B": 2L\n quest.ABC.title: "t" }'),
|
||||
).toEqual({ '0000000000000001': 5, '308a693c2bd84d6b': { x: 1 }, 'a:B': 2, 'quest.ABC.title': 't' });
|
||||
});
|
||||
|
||||
it('parses typed arrays and empty containers', () => {
|
||||
expect(parseSnbt('{a: [I; 1, 2], b: [L; 3L], c: [B; 1b], d: [], e: { }}')).toEqual({
|
||||
a: [1, 2],
|
||||
b: [3],
|
||||
c: [1],
|
||||
d: [],
|
||||
e: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses string escapes and single quotes', () => {
|
||||
expect(parseSnbt(`["a\\"b", 'c\\'d', "\\\\"]`)).toEqual(['a"b', "c'd", '\\']);
|
||||
});
|
||||
|
||||
it('keeps unquoted non-numeric words as strings', () => {
|
||||
expect(parseSnbt('{type: item, id: 3BC0A50886A3222B}')).toEqual({ type: 'item', id: '3BC0A50886A3222B' });
|
||||
});
|
||||
|
||||
it('reports line on error', () => {
|
||||
try {
|
||||
parseSnbt('{\n a: 1\n b: {\n}');
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SnbtError);
|
||||
expect((e as SnbtError).line).toBe(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { advancementsFileToEvents, statsFileToEvent } from '../src/parsers/vanilla.ts';
|
||||
|
||||
const fx = (p: string) => readFileSync(join(import.meta.dirname, 'fixtures', p), 'utf8');
|
||||
const ALICE = '308a693c-2bd8-4d6b-8790-670aef5be601';
|
||||
|
||||
describe('statsFileToEvent', () => {
|
||||
it('stats file maps play_time', () => {
|
||||
const e = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1760300000000);
|
||||
expect(e.type).toBe('stats_snapshot');
|
||||
if (e.type !== 'stats_snapshot') return;
|
||||
expect(e.full).toBe(true);
|
||||
expect(e.ts).toBe(1760300000000);
|
||||
expect(e.name).toBeNull();
|
||||
expect(e.stats['minecraft:custom']?.['minecraft:play_time']).toBe(1175736);
|
||||
expect(e.stats['minecraft:mined']?.['mekanism:enrichment_chamber']).toBe(2);
|
||||
});
|
||||
|
||||
it('id stable for same content', () => {
|
||||
const a = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1);
|
||||
const b = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1);
|
||||
const c = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 2);
|
||||
expect(a.id).toBe(b.id);
|
||||
expect(a.id).not.toBe(c.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancementsFileToEvents', () => {
|
||||
it('emits done, non-recipe advancements with earliest criterion time', () => {
|
||||
const events = advancementsFileToEvents(ALICE, fx(`advancements/${ALICE}.json`));
|
||||
const ids = events.map((e) => (e.type === 'advancement' ? e.advancementId : ''));
|
||||
expect(ids.sort()).toEqual(['minecraft:story/enter_the_nether', 'minecraft:story/root']);
|
||||
const nether = events.find((e) => e.type === 'advancement' && e.advancementId === 'minecraft:story/enter_the_nether')!;
|
||||
expect(nether.ts).toBe(Date.parse('2026-10-12T12:42:23+02:00'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancement names', () => {
|
||||
it('file-derived advancements carry no name so they never overwrite the player name', () => {
|
||||
const events = advancementsFileToEvents(ALICE, fx(`advancements/${ALICE}.json`));
|
||||
expect(events.every((e) => e.type === 'advancement' && e.name === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { appendFileSync, cpSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { LocalFileSource } from '../src/worker/fileSource.ts';
|
||||
import { closeStaleSessions, syncOnce } from '../src/worker/sync.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, ev, MIN } from './helpers/events.ts';
|
||||
|
||||
const FX = join(import.meta.dirname, 'fixtures');
|
||||
const OPTS = { timeZone: 'Europe/Berlin', serverId: 'atm10', worldDir: 'world' };
|
||||
let pool: Pool;
|
||||
let root: string;
|
||||
|
||||
function makeServerDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'atm-sftp-'));
|
||||
mkdirSync(join(dir, 'world', 'stats'), { recursive: true });
|
||||
mkdirSync(join(dir, 'world', 'advancements'), { recursive: true });
|
||||
mkdirSync(join(dir, 'world', 'ftbquests'), { recursive: true });
|
||||
mkdirSync(join(dir, 'config', 'ftbquests', 'quests', 'chapters'), { recursive: true });
|
||||
mkdirSync(join(dir, 'logs'), { recursive: true });
|
||||
cpSync(join(FX, 'stats'), join(dir, 'world', 'stats'), { recursive: true });
|
||||
cpSync(join(FX, 'advancements'), join(dir, 'world', 'advancements'), { recursive: true });
|
||||
cpSync(join(FX, 'ftbquests', '308a693c-2bd8-4d6b-8790-670aef5be601.snbt'), join(dir, 'world', 'ftbquests', '308a693c-2bd8-4d6b-8790-670aef5be601.snbt'));
|
||||
cpSync(join(FX, 'ftbquests', 'config', 'chapters', 'getting_started.snbt'), join(dir, 'config', 'ftbquests', 'quests', 'chapters', 'getting_started.snbt'));
|
||||
cpSync(join(FX, 'latest.anon.log'), join(dir, 'logs', 'latest.log'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
const LINE = (t: string, msg: string) => `[12Oct2026 ${t}] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: ${msg}\r\n`;
|
||||
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
root = makeServerDir();
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const count = async (sql: string) => (await pool.query(sql)).rows[0].n as number;
|
||||
|
||||
describe('syncOnce', () => {
|
||||
it('first sync imports stats, advancements, quests, quest config and log', async () => {
|
||||
const r = await syncOnce(pool, new LocalFileSource(root), OPTS);
|
||||
expect(r.files).toBe(6);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM stat_current WHERE key = 'minecraft:play_time'`)).toBe(2);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id = 'minecraft:story/root'`)).toBe(1);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM quest_progress`)).toBeGreaterThan(0);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM quest_defs`)).toBe(2);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM sessions`)).toBeGreaterThan(0);
|
||||
expect((await pool.query(`SELECT name FROM players WHERE uuid = $1`, [ALICE.uuid])).rows[0].name).toBe('CDOGamer');
|
||||
expect(await count(`SELECT count(*)::int AS n FROM events WHERE source = 'sftp'`)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('second sync without changes reads nothing', async () => {
|
||||
const src = new LocalFileSource(root);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
const spy = vi.spyOn(src, 'read');
|
||||
const r = await syncOnce(pool, src, OPTS);
|
||||
expect(r.files).toBe(0);
|
||||
// only the 256-byte head of latest.log is read to detect rotation
|
||||
expect(spy.mock.calls.every(([path, start, length]) => path === 'logs/latest.log' && start === 0 && length === 256)).toBe(true);
|
||||
});
|
||||
|
||||
it('appended log lines are imported incrementally', async () => {
|
||||
// log advancements only count for players without a synced advancements file
|
||||
rmSync(join(root, 'world', 'advancements', `${ALICE.uuid}.json`));
|
||||
const src = new LocalFileSource(root);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
const before = await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id LIKE 'log:%'`);
|
||||
appendFileSync(join(root, 'logs', 'latest.log'), LINE('23:50:00.000', 'CDOGamer has made the advancement [Brand New Title]'));
|
||||
const r = await syncOnce(pool, src, OPTS);
|
||||
expect(r.files).toBe(1);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id LIKE 'log:%'`)).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('rotated log resets offset', async () => {
|
||||
rmSync(join(root, 'world', 'advancements', `${ALICE.uuid}.json`));
|
||||
const src = new LocalFileSource(root);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
writeFileSync(
|
||||
join(root, 'logs', 'latest.log'),
|
||||
LINE('23:58:00.000', 'UUID of player CDOGamer is 308a693c-2bd8-4d6b-8790-670aef5be601').replace('MinecraftServer', 'ServerLoginPacketListenerImpl') +
|
||||
LINE('23:58:01.000', 'CDOGamer has made the advancement [After Rotation]'),
|
||||
);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE title = 'After Rotation'`)).toBe(1);
|
||||
const { rows } = await pool.query(`SELECT "offset", size FROM sync_state WHERE path = 'logs/latest.log'`);
|
||||
expect(rows[0].offset).toBe(rows[0].size);
|
||||
});
|
||||
|
||||
it('log joins skipped while push is fresh', async () => {
|
||||
await processEvents(pool, [ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 0, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: Date.now() - MIN, windowEnd: Date.now() })], 'push', 'atm10');
|
||||
await syncOnce(pool, new LocalFileSource(root), OPTS);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM sessions`)).toBe(0);
|
||||
});
|
||||
|
||||
it('log advancement skipped when the player already has it with that title', async () => {
|
||||
await processEvents(pool, [ev('advancement', { ...ALICE, advancementId: 'mysticalagriculture:infusion', title: 'Infusing Efficiency' })], 'push', 'atm10');
|
||||
await syncOnce(pool, new LocalFileSource(root), OPTS);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE title = 'Infusing Efficiency'`)).toBe(1);
|
||||
});
|
||||
|
||||
it('changed stats file is re-imported with its new mtime', async () => {
|
||||
const src = new LocalFileSource(root);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
const f = join(root, 'world', 'stats', '308a693c-2bd8-4d6b-8790-670aef5be601.json');
|
||||
writeFileSync(f, JSON.stringify({ stats: { 'minecraft:custom': { 'minecraft:play_time': 2_000_000 } }, DataVersion: 3955 }));
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
utimesSync(f, future, future);
|
||||
await syncOnce(pool, src, OPTS);
|
||||
const { rows } = await pool.query(`SELECT value FROM stat_current WHERE uuid = $1 AND key = 'minecraft:play_time'`, [ALICE.uuid]);
|
||||
expect(rows[0].value).toBe(2_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeStaleSessions', () => {
|
||||
it('closes open sessions when the mod stopped reporting health', async () => {
|
||||
const now = Date.now();
|
||||
await processEvents(
|
||||
pool,
|
||||
[
|
||||
ev('player_join', { ...ALICE, ts: now - 60 * MIN }),
|
||||
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 1, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: now - 31 * MIN, windowEnd: now - 30 * MIN, ts: now - 30 * MIN }),
|
||||
],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
expect(await closeStaleSessions(pool, new Date(now))).toBe(1);
|
||||
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
|
||||
expect(rows[0].end_reason).toBe('stale');
|
||||
expect(rows[0].ended_at.getTime()).toBe(now - 30 * MIN);
|
||||
});
|
||||
|
||||
it('keeps sessions while health is fresh or when push was never used', async () => {
|
||||
const now = Date.now();
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE, ts: now - 60 * MIN })], 'sftp', 'atm10');
|
||||
expect(await closeStaleSessions(pool, new Date(now))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review fixes: log fallback', () => {
|
||||
const health = (end: number) =>
|
||||
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 1, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: end - MIN, windowEnd: end, ts: end });
|
||||
|
||||
it('a log session that started after the mod went silent stays open', async () => {
|
||||
const now = Date.now();
|
||||
await processEvents(pool, [health(now - 60 * MIN)], 'push', 'atm10');
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE, ts: now - 20 * MIN })], 'sftp', 'atm10');
|
||||
expect(await closeStaleSessions(pool, new Date(now))).toBe(0);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM sessions WHERE ended_at IS NULL`)).toBe(1);
|
||||
});
|
||||
|
||||
it('late push join/leave inside an existing log session do not create a second session', async () => {
|
||||
const t = Date.now() - 120 * MIN;
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE, ts: t }), ev('player_leave', { ...ALICE, ts: t + 60 * MIN })], 'sftp', 'atm10');
|
||||
await processEvents(pool, [ev('player_join', { ...ALICE, ts: t + 1000 }), ev('player_leave', { ...ALICE, ts: t + 60 * MIN + 1000 })], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT started_at, ended_at FROM sessions`);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ended_at.getTime() - rows[0].started_at.getTime()).toBeGreaterThanOrEqual(60 * MIN);
|
||||
});
|
||||
|
||||
it('pull-only: advancements from file and log are counted once', async () => {
|
||||
await syncOnce(pool, new LocalFileSource(root), OPTS);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE uuid = '${ALICE.uuid}' AND advancement_id LIKE 'log:%'`)).toBe(0);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE uuid = '${ALICE.uuid}'`)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quest config and teams via SFTP', () => {
|
||||
it('reads chapters, English split lang files and team files; skips other languages', async () => {
|
||||
const lang = join(root, 'config', 'ftbquests', 'quests', 'lang');
|
||||
mkdirSync(join(lang, 'en_us', 'chapters'), { recursive: true });
|
||||
mkdirSync(join(lang, 'de_de', 'chapters'), { recursive: true });
|
||||
cpSync(join(FX, 'ftbquests', 'config', 'lang', 'en_us'), join(lang, 'en_us'), { recursive: true });
|
||||
writeFileSync(join(lang, 'de_de', 'chapters', 'getting_started.snbt_merged'), '{ quest.3BC0A50886A3222B.title: "Baum schlagen" }');
|
||||
mkdirSync(join(root, 'world', 'ftbteams'), { recursive: true });
|
||||
cpSync(join(FX, 'ftbteams'), join(root, 'world', 'ftbteams'), { recursive: true });
|
||||
|
||||
const src = new LocalFileSource(root);
|
||||
const spy = vi.spyOn(src, 'read');
|
||||
await syncOnce(pool, src, OPTS);
|
||||
expect(spy.mock.calls.some(([p]) => String(p).includes('de_de'))).toBe(false);
|
||||
const { rows } = await pool.query(`SELECT title, chapter_title FROM quest_defs WHERE quest_id = '3BC0A50886A3222B'`);
|
||||
expect(rows).toEqual([{ title: 'Punch a Tree', chapter_title: 'Getting Started' }]);
|
||||
expect(await count(`SELECT count(*)::int AS n FROM team_members`)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../tsconfig.base.json", "include": ["src", "test"] }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/server.ts', 'src/worker.ts', 'src/cli.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node22',
|
||||
platform: 'node',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
// The workspace package ships TypeScript sources; bundle it, keep real npm dependencies external.
|
||||
noExternal: ['@atm/shared'],
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
fileParallelism: false,
|
||||
testTimeout: 20_000,
|
||||
hookTimeout: 30_000,
|
||||
env: {
|
||||
TEST_DATABASE_URL: process.env.TEST_DATABASE_URL ?? 'postgres://postgres:postgres@localhost:54329/atm_test',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
# ---- Domain & Traefik -------------------------------------------------------
|
||||
DOMAIN=stats.example.de
|
||||
# Name des Docker-Netzes, an dem Traefik hängt
|
||||
TRAEFIK_NETWORK=traefik
|
||||
TRAEFIK_ENTRYPOINT=websecure
|
||||
TRAEFIK_CERTRESOLVER=letsencrypt
|
||||
# Öffentliche IP(s) des Mine-Hoster-Gameservers, kommagetrennt (CIDR). Nur sie dürfen /api/ingest erreichen.
|
||||
INGEST_ALLOWED_IPS=203.0.113.10/32
|
||||
|
||||
# ---- Datenbank -------------------------------------------------------------
|
||||
POSTGRES_PASSWORD=bitte-ein-langes-zufaelliges-passwort
|
||||
BACKUP_KEEP_DAYS=14
|
||||
|
||||
# ---- Backend ---------------------------------------------------------------
|
||||
NODE_ENV=production
|
||||
PUBLIC_ORIGIN=https://stats.example.de
|
||||
# Gemeinsames Geheimnis mit der Mod (hmacSecret in config/atmstats-server.toml), mind. 32 Zeichen:
|
||||
# openssl rand -hex 32
|
||||
INGEST_HMAC_SECRET=
|
||||
SERVER_ID=atm10
|
||||
TRUST_PROXY=true
|
||||
TIMEZONE=Europe/Berlin
|
||||
LOG_LEVEL=info
|
||||
|
||||
# ---- SFTP-Abgleich (optional, Mine-Hoster Gamepanel → Einstellungen → SFTP-Details) ----
|
||||
SFTP_HOST=
|
||||
# Port laut Gamepanel (Einstellungen → SFTP-Details)
|
||||
SFTP_PORT=22
|
||||
SFTP_USER=
|
||||
SFTP_PASSWORD=
|
||||
SFTP_ROOT=/
|
||||
SFTP_WORLD_DIR=world
|
||||
SFTP_INTERVAL_MIN=15
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# PostgreSQL-Backup für ATM10 Stats.
|
||||
# backup.sh once – ein Backup jetzt erstellen
|
||||
# backup.sh loop – täglich ein Backup, ältere als BACKUP_KEEP_DAYS (Standard 14) löschen
|
||||
# Erwartet PGHOST, PGUSER, PGDATABASE, PGPASSWORD (im Compose-Service gesetzt).
|
||||
set -u
|
||||
|
||||
DIR=/backups
|
||||
KEEP="${BACKUP_KEEP_DAYS:-14}"
|
||||
|
||||
# Each step checks its own result: inside "backup_once || …" the shell ignores set -e.
|
||||
backup_once() {
|
||||
file="$DIR/atm-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
||||
if ! pg_dump --format=custom --file="$file.partial"; then
|
||||
rm -f "$file.partial"
|
||||
echo "backup: FAILED (pg_dump)" >&2
|
||||
return 1
|
||||
fi
|
||||
mv "$file.partial" "$file" || { echo "backup: FAILED (mv)" >&2; return 1; }
|
||||
find "$DIR" -name 'atm-*.dump' -mtime +"$KEEP" -delete
|
||||
echo "backup: $file ($(du -h "$file" | cut -f1))"
|
||||
}
|
||||
|
||||
mkdir -p "$DIR"
|
||||
case "${1:-once}" in
|
||||
once) backup_once ;;
|
||||
loop)
|
||||
while true; do
|
||||
backup_once
|
||||
sleep 86400
|
||||
done
|
||||
;;
|
||||
*) echo "usage: backup.sh once|loop" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,11 @@
|
||||
# Lokaler Testbetrieb ohne Traefik: veröffentlicht web (3000) und api (3001) auf localhost.
|
||||
# docker network create traefik # einmalig, das Basis-Compose erwartet das externe Netz
|
||||
# docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.local.yml --env-file deploy/.env.local up -d --build
|
||||
services:
|
||||
api:
|
||||
ports: ["127.0.0.1:3001:3001"]
|
||||
environment:
|
||||
# Login-Cookie ohne Secure-Flag, da lokal ohne HTTPS
|
||||
NODE_ENV: development
|
||||
web:
|
||||
ports: ["127.0.0.1:3000:3000"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Test-Datenbank für die Backend-Tests (npm test -w backend)
|
||||
services:
|
||||
db-test:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: atm_test
|
||||
ports:
|
||||
- "127.0.0.1:54329:5432"
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
@@ -0,0 +1,115 @@
|
||||
# ATM10 Stats – Produktion hinter Traefik (DMZ-Server).
|
||||
# cp deploy/.env.example deploy/.env # Werte eintragen
|
||||
# docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
|
||||
#
|
||||
# Traefik muss am externen Netz ${TRAEFIK_NETWORK} hängen. Nur web und api sind dort angebunden;
|
||||
# die Datenbank ist ausschließlich im internen Netz erreichbar.
|
||||
|
||||
name: atm10-stats
|
||||
|
||||
x-backend: &backend
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: backend/Dockerfile
|
||||
image: atm10-stats/backend:latest
|
||||
restart: unless-stopped
|
||||
env_file: ${ENV_FILE:-.env}
|
||||
environment:
|
||||
DATABASE_URL: postgres://atm:${POSTGRES_PASSWORD}@db:5432/atm
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: atm
|
||||
POSTGRES_USER: atm
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
networks: [internal]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U atm -d atm"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
<<: *backend
|
||||
command: ["node", "dist/server.js"]
|
||||
networks: [internal, traefik]
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=${TRAEFIK_NETWORK}
|
||||
# Public API and SSE
|
||||
- traefik.http.routers.atm-api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||
- traefik.http.routers.atm-api.priority=100
|
||||
- traefik.http.routers.atm-api.entrypoints=${TRAEFIK_ENTRYPOINT}
|
||||
- traefik.http.routers.atm-api.tls.certresolver=${TRAEFIK_CERTRESOLVER}
|
||||
- traefik.http.routers.atm-api.service=atm-api
|
||||
# Ingest from the game server only (IP allow list, Traefik v3 "ipallowlist")
|
||||
- traefik.http.routers.atm-ingest.rule=Host(`${DOMAIN}`) && PathPrefix(`/api/ingest`)
|
||||
- traefik.http.routers.atm-ingest.priority=200
|
||||
- traefik.http.routers.atm-ingest.entrypoints=${TRAEFIK_ENTRYPOINT}
|
||||
- traefik.http.routers.atm-ingest.tls.certresolver=${TRAEFIK_CERTRESOLVER}
|
||||
- traefik.http.routers.atm-ingest.middlewares=atm-ingest-allow
|
||||
- traefik.http.routers.atm-ingest.service=atm-api
|
||||
- traefik.http.middlewares.atm-ingest-allow.ipallowlist.sourcerange=${INGEST_ALLOWED_IPS}
|
||||
- traefik.http.services.atm-api.loadbalancer.server.port=3001
|
||||
|
||||
worker:
|
||||
<<: *backend
|
||||
command: ["node", "dist/worker.js"]
|
||||
networks: [internal]
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: web/Dockerfile
|
||||
image: atm10-stats/web:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_INTERNAL_URL: http://api:3001
|
||||
depends_on: [api]
|
||||
networks: [internal, traefik]
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=${TRAEFIK_NETWORK}
|
||||
- traefik.http.routers.atm-web.rule=Host(`${DOMAIN}`)
|
||||
- traefik.http.routers.atm-web.priority=10
|
||||
- traefik.http.routers.atm-web.entrypoints=${TRAEFIK_ENTRYPOINT}
|
||||
- traefik.http.routers.atm-web.tls.certresolver=${TRAEFIK_CERTRESOLVER}
|
||||
- traefik.http.services.atm-web.loadbalancer.server.port=3000
|
||||
|
||||
backup:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGUSER: atm
|
||||
PGDATABASE: atm
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
|
||||
volumes:
|
||||
- ./backup.sh:/backup.sh:ro
|
||||
- backups:/backups
|
||||
entrypoint: ["/bin/sh", "/backup.sh", "loop"]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks: [internal]
|
||||
|
||||
networks:
|
||||
internal:
|
||||
traefik:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:?TRAEFIK_NETWORK fehlt}
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
backups:
|
||||
@@ -0,0 +1,105 @@
|
||||
# Architektur
|
||||
|
||||
Für Entwickler, die am Portal weiterarbeiten. Die vollständige Design-Begründung steht in
|
||||
`docs/superpowers/specs/2026-10-13-atm10-stats-design.md`.
|
||||
|
||||
## Leitidee
|
||||
|
||||
Alle Datenquellen erzeugen **Ereignisse desselben Protokolls** (v1, `shared/src/protocol.ts`) und laufen durch
|
||||
**eine** Pipeline (`backend/src/pipeline/process.ts`):
|
||||
|
||||
```
|
||||
Mod (Push) ──┐
|
||||
├─▶ processEvents(events, source)
|
||||
SFTP (Pull) ─┘ 1. INSERT INTO events … ON CONFLICT (event_id) DO NOTHING ← Idempotenz
|
||||
2. nur neue Ereignisse → Projektionen (sessions, stat_current, deaths, quest_progress …)
|
||||
3. nach COMMIT → pg_notify('atm_live') → LiveHub → SSE-Clients
|
||||
```
|
||||
|
||||
Lese-API und Frontend kennen nur die projizierten Tabellen, nie die Quelle.
|
||||
|
||||
## Komponenten
|
||||
|
||||
### Mod `atmstats` (`mod/`)
|
||||
|
||||
- `core/`: reines Java ohne Minecraft, vollständig unit-getestet
|
||||
- `OutboxJournal`: Journal auf der Festplatte (`current.jsonl` → `seg-<n>.jsonl`; ein Segment = ein Batch,
|
||||
gelöscht erst nach Bestätigung; Limits mit Vorrang für Ereignisse vor Snapshots; abgeschnittene letzte Zeile
|
||||
nach Absturz wird verworfen)
|
||||
- `Shipper`: Queue → Journal → HTTPS; Backoff 1 s … 5 min mit Jitter; 4xx-Batches nach `rejected/`
|
||||
- `Signer`, `UuidV7`, `Backoff`, `EventJson`, `StatsDiff`, `HealthWindow`, `FileChangeTracker`
|
||||
- `mc/`: dünne NeoForge-Schicht
|
||||
- `GameHooks`: Event-Handler auf dem Server-Thread. Sie erzeugen nur Records und legen sie in eine `ConcurrentLinkedQueue`
|
||||
- `StatsCollector`: kopiert alle 5 min die Zählertabelle online Spieler (max. 2 Spieler pro Tick; Zugriff
|
||||
über Access Transformer auf `StatsCounter.stats`); Umrechnung und Diff im Worker-Thread
|
||||
- `HealthMonitor`: misst Tickdauer per `ServerTickEvent.Pre/Post`
|
||||
- `FileWatcher`: reicht geänderte FTB-Dateien unverändert als `file_sync` weiter (Parsing im Backend)
|
||||
- `WorkerThread`: einziger Hintergrund-Thread für Serialisierung, Datei- und Netzwerk-I/O
|
||||
- `ServerInfoCollector`: Versionen und Modliste über `ModList`
|
||||
|
||||
### Backend (`backend/`)
|
||||
|
||||
| Modul | Aufgabe |
|
||||
|---|---|
|
||||
| `ingest/` | `POST /api/ingest`: Bearer-Token (als SHA-256 gespeichert), Zeitfenster ±5 min, HMAC über die übertragenen Bytes, gzip mit 2-MB-Grenze, Validierung je Ereignis |
|
||||
| `pipeline/` | `processEvents`, Projektionen je Ereignistyp |
|
||||
| `parsers/` | SNBT (FTB-Format ohne Kommas), FTB-Quests, `world/stats`, `world/advancements`, `latest.log` |
|
||||
| `stats/` | Ranglisten-Definitionen (`metrics.ts`), SQL je Board, Snapshots, Retention |
|
||||
| `routes/` | öffentliche API, SSE (`/api/live`), Admin |
|
||||
| `worker/` | SFTP-Abgleich (`FileSource`: SFTP oder lokal), Jobs mit `pg_try_advisory_lock` |
|
||||
| `live/` | Notifier (Ereignis → Live-Meldung, ausgeblendete Spieler gefiltert) und LiveHub (LISTEN, Ringpuffer 500, Resume über `Last-Event-ID`) |
|
||||
|
||||
Zwei Prozesse aus einer Codebasis: `server.ts` (api) und `worker.ts`. Migrationen (`backend/migrations/*.sql`)
|
||||
führt der eigene Migrator beim Start aus.
|
||||
|
||||
### Frontend (`web/`)
|
||||
|
||||
Next.js App Router, Server Components holen Daten direkt bei der API (`API_INTERNAL_URL`), der Browser nutzt
|
||||
same-origin `/api`. Live-Teile (Ticker, Diagramm-Umschalter, Admin) sind Client-Komponenten. Design-Tokens in
|
||||
`src/app/globals.css`: Deepslate-Flächen, ATM-Erzfarben als Akzente, validierte Diagrammpalette.
|
||||
|
||||
## Protokoll v1
|
||||
|
||||
```
|
||||
POST /api/ingest
|
||||
Authorization: Bearer <token>
|
||||
X-ATM-Server: <serverId>
|
||||
X-ATM-Timestamp: <unix ms>
|
||||
X-ATM-Signature: hex(HMAC-SHA256(secret, timestamp + "." + hex(sha256(body))))
|
||||
Content-Encoding: gzip
|
||||
{ "protocol": 1, "events": [ … ] } → { accepted, duplicate, rejected }
|
||||
```
|
||||
|
||||
Ereignistypen: `server_start`, `server_stop`, `player_join`, `player_leave`, `player_death`, `entity_kill`,
|
||||
`advancement`, `dimension_change`, `stats_snapshot`, `file_sync`, `health`. Beispiele:
|
||||
`shared/fixtures/protocol/`. Push-Ereignisse tragen UUIDv7, SFTP-Ereignisse deterministische UUIDv5 aus Datei und
|
||||
Inhalt. Dadurch ist auch wiederholtes Einlesen idempotent.
|
||||
|
||||
**Zählerregel**: Werte sind absolut. Pro (Spieler, Kategorie, Schlüssel) gewinnt die Beobachtung mit dem neuesten
|
||||
Zeitstempel, unabhängig von der Quelle und auch wenn der Wert kleiner ist.
|
||||
|
||||
## Datenmodell (Kern)
|
||||
|
||||
`players`, `name_history`, `sessions`, `events`, `stat_current`, `stat_snapshots`, `deaths`, `kills`,
|
||||
`advancements`, `dimension_visits`, `teams`, `team_members`, `quest_progress`, `quest_defs`, `file_cache`,
|
||||
`server_info`, `server_health`, `server_health_hourly`, `sync_state`, `sync_requests`, `app_errors`,
|
||||
`admin_users`, `admin_sessions`, `ingest_tokens`. Ranglisten werden live aus `stat_current` berechnet
|
||||
(Index `(category, key, value DESC)`), Zeiträume aus den Tages-Snapshots.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Ingest: Token + HMAC + Zeitfenster + Rate-Limit + IP-Allowlist in Traefik; Payload-Grenzen; Pfad-Traversal-Schutz für `file_sync`
|
||||
- Admin: argon2id, Session-Cookie `httpOnly; Secure; SameSite=Strict; Path=/api/admin`, CSRF-Token-Header, Login-Rate-Limit
|
||||
- Keine Tokens, Signaturen oder Cookies in Logs (pino-Redaction); Spieler-IPs werden nirgends gespeichert
|
||||
- Spielerköpfe lädt die API serverseitig (Cache 24 h), damit Besucher keine Drittanbieter kontaktieren
|
||||
- Datenbank nur im internen Docker-Netz
|
||||
|
||||
## Tests
|
||||
|
||||
| Ebene | Was |
|
||||
|---|---|
|
||||
| `shared` | Fixtures gegen Schemas |
|
||||
| `backend` (Vitest, echte Postgres) | Ingest-Auth und Idempotenz, alle Projektionen inkl. Absturz-Sitzungen und Namenswechsel, Parser an echten Serverdateien, Ranglisten, Snapshots/Retention, API inkl. ausgeblendeter Spieler, SSE, Admin/CSRF, Worker inkl. Log-Rotation |
|
||||
| `mod` (JUnit) | Journal (Neustart, Absturz, Rotation, Limits), Shipper (Backoff, Ablehnung, Uhrabweichung), Serialisierung gegen `shared/fixtures`, Signatur-Testvektor |
|
||||
| `web` (Vitest + Testing Library) | Formatierung, Ranglisten-Karte, Heatmap |
|
||||
| E2E (Playwright) | Smoke-Test gegen den Compose-Stack, Desktop und Mobil |
|
||||
@@ -0,0 +1,72 @@
|
||||
# Datenquellen und Einschränkungen
|
||||
|
||||
Jede Zahl im Portal stammt aus einer der unten genannten Quellen. Nichts wird geschätzt oder erfunden.
|
||||
Wo eine Quelle fehlt, zeigt das Portal einen leeren Zustand oder den Hinweis, woher die Daten kommen werden.
|
||||
|
||||
## Grundlage: was am echten Server geprüft wurde
|
||||
|
||||
Stand 13.10.2026, aus einer Kopie des Servers (`server-snapshot/`, nicht im Repository, enthält Spieler-IPs):
|
||||
|
||||
| Fakt | Beleg |
|
||||
|---|---|
|
||||
| Minecraft 1.21.1 | `DataVersion 3955` in `world/stats/*.json` |
|
||||
| 464 Mods, u. a. FTB Quests 2101.1.36, FTB Teams 2101.1.11, Mekanism 10.7.19, Apotheosis 8.8.0, Spark 1.10.124 | Modliste des Servers |
|
||||
| FTB-Quest-Fortschritt je Team als SNBT (`completed`, `started`, `task_progress`, `claimed_rewards`) | `world/ftbquests/<team>.snbt` |
|
||||
| Mod-eigene Zähler in den Vanilla-Statistiken: `apotheosis:world_tiers_activated`, `lootr:looted_stat`, `waystones:waystone_activated` | `world/stats/*.json` |
|
||||
| Item-Statistiken enthalten Einträge aus Mekanism, Powah, Mystical Agriculture, Ars Nouveau, Iron's Spellbooks, Create, Modern Industrialization, Oritech u. v. m. | `world/stats/*.json` |
|
||||
| Log enthält Beitritte (mit UUID), Verlassen, Advancements/Ziele/Herausforderungen im Klartext; Zeilenenden CRLF | `logs/latest.log` |
|
||||
| NeoForge 21.1.219 und Java 21 (Testlauf der Mod auf einem lokalen NeoForge-Server) | `server_start`-Ereignis der Mod |
|
||||
| Quest-Konfiguration: 66 Kapitel (`chapters/*.snbt`), 4 790 Quests, Texte über ftbquestslangsplitter in `lang/en_us/**/*.snbt_merged` (decken alle 3 103 Titel der 1,5 MB großen `lang/en_us.snbt` ab); Titel mit Farbcodes (`&a`, `&6&l`, `\&` für ein echtes &) | `config/ftbquests/quests/` |
|
||||
| Von 585 IDs in `completed` (CDOGamer) sind nur 267 Quests, der Rest Aufgaben und Kapitel | Abgleich mit der Quest-Konfiguration |
|
||||
| FTB-Teams: `id`, `type` (player/party/server), `ranks` (UUID → Rang), `properties."ftbteams:display_name"` | `world/ftbteams/player/*.snbt` |
|
||||
|
||||
Die exakte NeoForge- und Java-Version **des Produktionsservers** meldet die Mod beim ersten Start (Seite *Server*).
|
||||
|
||||
## Welche Kennzahl woher kommt
|
||||
|
||||
| Kennzahl | Quelle | Aktualität | Hinweis |
|
||||
|---|---|---|---|
|
||||
| Online-Status, Sitzungen, Heatmap „Wann gespielt wird“ | Mod: Beitritt/Verlassen; Fallback: Server-Log | sofort; Fallback ≤ 15 min | Log-Sitzungen werden nur genutzt, wenn die Mod seit 10 min nichts gesendet hat; liefert die Mod später nach, werden überlappende Sitzungen nicht doppelt gezählt |
|
||||
| Spielzeit, Tode, besiegte Mobs, Sprünge, Schaden | Vanilla-Zähler: Mod liest sie live, SFTP liest `world/stats` | 5 min (Mod) bzw. beim Speichern der Welt (SFTP) | die neueste Beobachtung gewinnt |
|
||||
| Strecken je Fortbewegungsart | Vanilla-Zähler `*_one_cm` | wie oben | „Weltenbummler“ ohne Fallstrecke |
|
||||
| Abgebaut / hergestellt / benutzt / aufgesammelt je Item und Mod | Vanilla-Zähler `minecraft:mined` usw. | wie oben | Mod-Zuordnung über den Namespace der Item-ID |
|
||||
| Technik / Magie / Farming | Summe hergestellter und benutzter Items der Mods einer Kategorie | wie oben | Zuordnung in `backend/config/mod-categories.json` |
|
||||
| Diamantenbank | abgebaute Diamanterze (normal + Tiefenschiefer) | wie oben | misst Abbau, **nicht** Besitz |
|
||||
| Schatzjäger, Weltenstufe | `lootr:looted_stat`, `apotheosis:world_tiers_activated` | wie oben | |
|
||||
| Todesursachen, Killer | Mod: `LivingDeathEvent` | sofort | ohne Mod nur Anzahl und `killed_by`-Zähler |
|
||||
| Boss- und Spieler-Kills mit Waffe | Mod: `LivingDeathEvent`, Boss = NeoForge-Tag `c:bosses` | sofort | normale Mob-Kills nur als Zähler (Last) |
|
||||
| Advancements | Mod: `AdvancementEarnEvent`; SFTP: `world/advancements`; Log (nur für Spieler ohne Advancement-Datei) | sofort / ≤ 15 min | Rezept-Freischaltungen ausgenommen. Die Mod zählt nur Advancements mit Anzeige im Spiel; aus `world/advancements` kommen auch solche ohne Anzeige, weil die Datei keine Anzeige-Informationen enthält. Ohne Mod kann „Abenteurer“ daher etwas höher ausfallen |
|
||||
| Besuchte Dimensionen | Mod: Dimensionswechsel | sofort | erst ab Installation der Mod |
|
||||
| FTB-Quest-Fortschritt | `world/ftbquests/*.snbt` (Mod alle 2 min oder SFTP) | 2–15 min | pro Team; gezählt werden nur IDs, die in der Quest-Konfiguration als Quest vorkommen |
|
||||
| Quest- und Kapiteltitel | `config/ftbquests/quests/chapters/*.snbt` + `lang/en_us/**/*.snbt_merged` (Mod oder SFTP; andere Sprachen und Belohnungstabellen werden nicht übertragen) | bei Änderung | Farbcodes entfernt |
|
||||
| Party-Teams | `world/ftbteams/{player,party}/*.snbt` | bei Änderung | |
|
||||
| TPS, Tickdauer (Ø, 95. Perzentil, Max), Spieler, Chunks, Speicher | eigene Messung der Mod um jeden Tick | jede Minute | unabhängig von Spark |
|
||||
| Versionen, Modliste | Mod beim Serverstart (`ModList`) | bei jedem Start | |
|
||||
| Verlaufsdiagramme, 7-/30-Tage-Ranglisten | stündliche und tägliche Snapshots durch den Worker | stündlich | Verläufe beginnen mit der Inbetriebnahme; Stunden 30 Tage, Tage unbegrenzt |
|
||||
|
||||
## Bewusst nicht erfasst
|
||||
|
||||
Ohne verlässliche serverseitige Quelle oder nur über ungeprüfte Interna einzelner Mods erreichbar:
|
||||
|
||||
- **Energie** (Erzeugung und Verbrauch von Mekanism-, Powah- und anderen Maschinen)
|
||||
- **Inhalte von AE2- und Refined-Storage-Netzen**
|
||||
- **Mana, Source, Zauber, Rituale** (Ars Nouveau, Iron's Spellbooks, Occultism); Magie-Aktivität stammt aus Items
|
||||
- **MineColonies-Kolonien**
|
||||
- **Spielerpositionen, Todesorte, Heatmaps der Welt**: Datenschutz (Basen) und Last
|
||||
- **Inventare/Besitz** (daher misst die Diamantenbank den Abbau)
|
||||
|
||||
Erweiterungen sind über neue Ereignistypen im Protokoll möglich, jeweils erst nach Prüfung der Mod-API in
|
||||
der installierten Version.
|
||||
|
||||
## Einschränkungen durch Hoster und Mods
|
||||
|
||||
| Einschränkung | Auswirkung | Status |
|
||||
|---|---|---|
|
||||
| Ob Mine-Hoster **ausgehendes HTTPS** erlaubt, ist nicht dokumentiert | ohne Freigabe nur SFTP-Modus (keine Echtzeit, keine Todesursachen, keine Leistungsdaten) | wird beim ersten Start geprüft: Log-Zeile `atmstats: backend reachable` |
|
||||
| `world/stats` wird vom Server nur beim Speichern geschrieben | SFTP-Zähler hinken bis zum nächsten Autosave hinterher | Mod liefert Live-Werte alle 5 min |
|
||||
| Der Log-Ausschnitt enthielt keinen Startabschnitt | Versionen nicht aus dem Log lesbar | Mod meldet sie beim Start |
|
||||
| Log-Zeitstempel sind in der lokalen Zeitzone des Servers | Zeitzone muss stimmen (`TIMEZONE`, Standard Europe/Berlin) | für Mine-Hoster nicht bestätigt; betrifft nur log-basierte Daten |
|
||||
| **FTB Teams**: Party-Dateien auf dem Server derzeit nicht vorhanden | Party-Mitgliedschaft wird aus `ranks` (member/officer/owner) gelesen; Format an den echten Spieler-Team-Dateien geprüft, Party-Dateien nutzen dieselben Felder | ohne Party zählt jeder Spieler als eigenes Team (wie im Spiel) |
|
||||
| **Quest-Titel**: 1 789 von 4 790 Quests haben keinen eigenen Titel | sie zählen mit, erscheinen aber nicht namentlich (im Spiel heißen sie nach dem Aufgaben-Item, das serverseitig nicht übersetzbar ist) | geprüft an der echten Konfiguration (66 Kapitel, 3 029 Titel) |
|
||||
| SFTP-Passwort = Gamepanel-Passwort | volle Schreibrechte im Worker-Container | Worker liest nur; `.env` schützen |
|
||||
| Spieler-IPs stehen im Server-Log | Datenschutz | werden nie gespeichert; Test-Fixtures anonymisiert |
|
||||
@@ -0,0 +1,173 @@
|
||||
# Betrieb
|
||||
|
||||
Für den Betreiber des Portals: Installation auf dem DMZ-Server hinter OPNsense und Traefik, Einspielen der Mod
|
||||
bei Mine-Hoster, laufender Betrieb und Fehlerbehebung.
|
||||
|
||||
## 1. Voraussetzungen
|
||||
|
||||
- Server in der DMZ mit Docker und Docker Compose v2
|
||||
- Traefik **v3** mit Docker-Provider, einem HTTPS-Entrypoint und Cert-Resolver; Traefik hängt an einem externen
|
||||
Docker-Netz (z. B. `traefik`)
|
||||
- DNS-Eintrag für die Portal-Domain, der auf die öffentliche IP der OPNsense zeigt
|
||||
- Zugang zum Mine-Hoster-Gamepanel (SFTP-Daten unter *Einstellungen → SFTP-Details*)
|
||||
|
||||
> Traefik **v2**: Die Middleware heißt dort `ipwhitelist` statt `ipallowlist`. In `deploy/docker-compose.yml`
|
||||
> die Zeile `traefik.http.middlewares.atm-ingest-allow.ipallowlist.sourcerange` entsprechend umbenennen.
|
||||
|
||||
## 2. Portal installieren
|
||||
|
||||
```bash
|
||||
git clone <repo> atm10-stats && cd atm10-stats
|
||||
cp deploy/.env.example deploy/.env
|
||||
openssl rand -hex 32 # Wert für INGEST_HMAC_SECRET
|
||||
openssl rand -base64 24 # Wert für POSTGRES_PASSWORD
|
||||
nano deploy/.env # DOMAIN, TRAEFIK_*, INGEST_ALLOWED_IPS, Secrets, optional SFTP_*
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env ps # api, web, db: healthy
|
||||
```
|
||||
|
||||
Admin-Konto anlegen (Passwort mindestens 12 Zeichen, wird interaktiv abgefragt) und Ingest-Token für die Mod erzeugen:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env exec api node dist/cli.js admin:create lucas
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env exec api node dist/cli.js token:create
|
||||
```
|
||||
|
||||
Das Token wird **nur einmal** angezeigt. Später lässt es sich unter `/admin` erneuern.
|
||||
|
||||
### Routing
|
||||
|
||||
| Pfad | Ziel | Hinweis |
|
||||
|---|---|---|
|
||||
| `https://DOMAIN/api/ingest*` | api:3001 | nur von `INGEST_ALLOWED_IPS` (Middleware `atm-ingest-allow`) |
|
||||
| `https://DOMAIN/api/*` | api:3001 | öffentliche Lese-API, `/api/live` (SSE), `/api/admin/*` |
|
||||
| `https://DOMAIN/*` | web:3000 | Dashboard |
|
||||
|
||||
`/api/live` ist ein Server-Sent-Events-Stream. Auf diese Route keine Kompressions- oder Buffering-Middleware legen.
|
||||
Die API sendet dafür `X-Accel-Buffering: no` und alle 25 s einen Heartbeat.
|
||||
|
||||
### OPNsense
|
||||
|
||||
1. **Eingehend**: HTTPS (443) zur DMZ/Traefik wie für andere Dienste. Die Einschränkung von `/api/ingest` auf
|
||||
den Gameserver übernimmt Traefik (`INGEST_ALLOWED_IPS`). Die IP des Mine-Hoster-Servers ergibt sich aus der
|
||||
Serveradresse im Gamepanel (Hostname per `nslookup` auflösen).
|
||||
Optional zusätzlich in OPNsense: Alias mit dieser IP und eine eigene Regel nur für den Ingest-Host.
|
||||
2. **Ausgehend (nur für den SFTP-Abgleich)**: DMZ-Server → SFTP-Host/Port des Mine-Hoster-Servers (TCP).
|
||||
Ohne diese Regel meldet `/sources` „Fehler beim letzten Abgleich“.
|
||||
|
||||
## 3. Mod bei Mine-Hoster einspielen
|
||||
|
||||
Die Mod ist **rein serverseitig**: Spieler installieren nichts, Clients ohne die Mod können normal verbinden.
|
||||
|
||||
1. Mod bauen (oder fertiges JAR aus einem Release verwenden):
|
||||
```bash
|
||||
./mod/gradlew -p mod build # → mod/build/libs/atmstats-1.0.0.jar
|
||||
```
|
||||
2. Im Gamepanel den Server **stoppen**.
|
||||
3. Per SFTP (WinSCP/FileZilla) `atmstats-1.0.0.jar` nach `mods/` hochladen.
|
||||
4. Server starten und nach dem Start wieder stoppen. Dabei entsteht `config/atmstats-server.toml`.
|
||||
5. Konfiguration per SFTP bearbeiten:
|
||||
```toml
|
||||
[atmstats]
|
||||
enabled = true
|
||||
endpoint = "https://stats.example.de"
|
||||
serverId = "atm10"
|
||||
token = "<Token aus token:create>"
|
||||
hmacSecret = "<INGEST_HMAC_SECRET aus deploy/.env>"
|
||||
statsSnapshotMinutes = 5
|
||||
questScanMinutes = 2
|
||||
healthIntervalSeconds = 60
|
||||
maxOutboxMb = 50
|
||||
```
|
||||
6. Server starten und in `logs/latest.log` nach `atmstats:` suchen:
|
||||
- `atmstats: backend reachable`: fertig, Daten laufen.
|
||||
- `atmstats: backend NOT reachable: …`: siehe Abschnitt 6. Die Ereignisse bleiben in `atmstats/outbox/`
|
||||
auf dem Gameserver und werden gesendet, sobald die Verbindung klappt.
|
||||
|
||||
Ohne `endpoint`, `token` oder `hmacSecret` bleibt die Mod passiv und schreibt nur eine Hinweiszeile ins Log.
|
||||
|
||||
**ATM10-Updates**: Die Mod verwendet nur APIs, die in NeoForge 21.1.x stabil sind, und deklariert NeoForge ab
|
||||
21.1.0. Bei Pack-Updates innerhalb von Minecraft 1.21.1 bleibt das JAR gültig. Der Mod-Ordner wird bei
|
||||
Modpack-Updates manchmal ersetzt: danach prüfen, ob `mods/atmstats-*.jar` noch vorhanden ist.
|
||||
|
||||
## 4. SFTP-Abgleich (optional, empfohlen)
|
||||
|
||||
In `deploy/.env` `SFTP_HOST`, `SFTP_PORT`, `SFTP_USER`, `SFTP_PASSWORD` setzen (Gamepanel → Einstellungen →
|
||||
SFTP-Details). `SFTP_ROOT` ist das Serververzeichnis, `SFTP_WORLD_DIR` der Weltordner (Standard `world`,
|
||||
`level-name` aus `server.properties`). Danach `docker compose … up -d worker`.
|
||||
|
||||
Der Worker holt alle 15 Minuten nur geänderte Dateien. Unter `/admin` lässt sich ein Abgleich sofort anstoßen.
|
||||
Ohne funktionierende Mod liefert SFTP trotzdem Gesamtzähler, Advancements, Quests und (aus dem Log) Beitritte;
|
||||
Echtzeit-Daten, Todesursachen und Serverleistung fehlen dann.
|
||||
|
||||
Das SFTP-Passwort ist das des Gamepanels und gewährt Schreibzugriff. Der Worker liest ausschließlich.
|
||||
|
||||
## 5. Laufender Betrieb
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen beim Start von api und worker automatisch (serialisiert über einen Advisory-Lock).
|
||||
|
||||
### Token und Secret erneuern
|
||||
|
||||
- **Ingest-Token**: `/admin` → *Ingest-Token erneuern*. Das alte Token wird sofort ungültig. Das neue in
|
||||
`config/atmstats-server.toml` eintragen und den Gameserver neu starten. Bis dahin puffert die Mod und
|
||||
meldet `authentication failed` im Log; es geht nichts verloren, solange das Outbox-Limit nicht erreicht ist.
|
||||
- **HMAC-Secret**: in `deploy/.env` und in der Mod-Konfiguration gleichzeitig ändern, dann api und Gameserver neu starten.
|
||||
|
||||
### Backups
|
||||
|
||||
Der Container `backup` erstellt täglich `pg_dump`-Dateien im Volume `backups` und löscht sie nach
|
||||
`BACKUP_KEEP_DAYS` (Standard 14). Sofortiges Backup:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env exec backup sh /backup.sh once
|
||||
```
|
||||
|
||||
Wiederherstellung (ersetzt alle Daten):
|
||||
|
||||
```bash
|
||||
C="docker compose -f deploy/docker-compose.yml --env-file deploy/.env"
|
||||
$C stop api worker web
|
||||
$C exec backup ls -t /backups # gewünschte Datei wählen
|
||||
$C exec backup dropdb atm
|
||||
$C exec backup createdb atm
|
||||
$C exec backup pg_restore --dbname=atm --no-owner /backups/<datei>.dump
|
||||
$C start api worker web
|
||||
```
|
||||
|
||||
Das Volume `backups` liegt auf demselben Host. Für echte Ausfallsicherheit die Dateien zusätzlich extern sichern
|
||||
(z. B. per Borg/Restic vom Docker-Volume-Pfad).
|
||||
|
||||
### Verhalten bei Neustarts
|
||||
|
||||
| Situation | Verhalten |
|
||||
|---|---|
|
||||
| Gameserver stoppt normal | Mod schreibt `player_leave` und `server_stop` in die Outbox und sendet sie beim nächsten Start; nach 10 min ohne Health-Meldung schließt der Worker offene Sitzungen |
|
||||
| Gameserver stürzt ab | beim nächsten Start schließt `server_start` offene Sitzungen zum letzten Lebenszeichen (kein Aufblähen der Spielzeit) |
|
||||
| Backend nicht erreichbar | Mod puffert auf der Festplatte (max. `maxOutboxMb`, 7 Tage), sendet mit Backoff 1 s … 5 min weiter; bei Platzmangel werden zuerst alte Statistik-Snapshots verworfen |
|
||||
| Doppelte Lieferung | jede Meldung hat eine eindeutige ID, Duplikate werden ignoriert |
|
||||
| API/Worker-Neustart | zustandslos; der Worker setzt den Log-Abgleich am gespeicherten Byte-Offset fort |
|
||||
|
||||
## 6. Fehlerbilder
|
||||
|
||||
| Meldung | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Mod: `backend NOT reachable: …ConnectException` / `HttpTimeoutException` | Mine-Hoster blockiert ausgehende Verbindungen, DNS falsch oder OPNsense/Traefik lässt nicht durch | `curl -v https://DOMAIN/api/health` von außen testen; Traefik-Log prüfen (403 = IP nicht in `INGEST_ALLOWED_IPS`); falls der Hoster ausgehend blockiert: `enabled = false` und nur SFTP nutzen |
|
||||
| Mod: `clock skew: server clock differs …` | Uhr des Gameservers oder des DMZ-Servers weicht > 5 min ab | NTP auf dem DMZ-Server prüfen; beim Hoster melden, falls der Gameserver falsch geht |
|
||||
| Mod: `authentication failed (bad_token)` | Token falsch oder widerrufen | neues Token erzeugen und eintragen |
|
||||
| Mod: `authentication failed (bad_signature)` | `hmacSecret` ≠ `INGEST_HMAC_SECRET` | Werte angleichen |
|
||||
| Mod: `batch rejected by backend … moved to outbox/rejected` | Daten passten nicht zum Protokoll (z. B. veraltete Mod-Version) | Datei in `atmstats/outbox/rejected/` samt `.reason.txt` prüfen, Mod und Backend auf gleichen Stand bringen |
|
||||
| Mod: `outbox limit reached, dropped …` | Backend länger nicht erreichbar | Verbindung reparieren; ggf. `maxOutboxMb` erhöhen |
|
||||
| `/sources`: SFTP „Fehler beim letzten Abgleich“ | Zugangsdaten, Port oder OPNsense-Regel | Fehlertext unter `/admin` → *Letzte Fehler* |
|
||||
| Live-Ticker bleibt leer, Seite sonst ok | Proxy puffert SSE | keine Kompressions-/Buffering-Middleware auf `/api/live` |
|
||||
| Login unter `/admin` klappt nicht ohne HTTPS | Cookie ist `Secure` | Portal über HTTPS aufrufen (lokal: `NODE_ENV=development`) |
|
||||
|
||||
Spieler, die nicht im Portal erscheinen möchten, lassen sich unter `/admin` ausblenden: Sie verschwinden aus allen
|
||||
öffentlichen Ansichten, Ranglisten und Live-Meldungen. Die Daten bleiben gespeichert, sodass sich das Ausblenden
|
||||
rückgängig machen lässt.
|
||||
@@ -365,4 +365,5 @@ Neustartverhalten, Fehlerbilder), `mod/README.md`.
|
||||
3. Struktur von `config/ftbquests/quests/` und `world/ftbteams/` → Betreiber
|
||||
stellt Kopie bereit, bevor Quest-Titel/Team-Namen-Parser finalisiert werden;
|
||||
bis dahin werden Quest-IDs angezeigt.
|
||||
→ Geklärt am 13.10.2026 anhand der echten Dateien, siehe `docs/DATA-SOURCES.md`.
|
||||
4. SFTP-Host/Port/Benutzer → `.env` des Betreibers (nicht im Repo).
|
||||
@@ -0,0 +1,5 @@
|
||||
# Disable autocrlf on generated files, they always generate with LF
|
||||
# Add any extra files or paths here to make git stop saying they
|
||||
# are changed when only line endings change.
|
||||
src/generated/**/.cache/* text eol=lf
|
||||
src/generated/**/*.json text eol=lf
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
name: Build
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -0,0 +1,3 @@
|
||||
run/
|
||||
build/
|
||||
.gradle/
|
||||
@@ -0,0 +1,41 @@
|
||||
# atmstats (NeoForge-Mod)
|
||||
|
||||
Serverseitige Statistik-Erfassung für das ATM10-Statistikportal. Minecraft 1.21.1, NeoForge ab 21.1.0, Java 21.
|
||||
Clients brauchen die Mod nicht (`displayTest = IGNORE_ALL_VERSION`). Sie registriert keine Blöcke, Items oder Pakete.
|
||||
|
||||
## Bauen und testen
|
||||
|
||||
```bash
|
||||
./gradlew build # JAR in build/libs/atmstats-<version>.jar
|
||||
./gradlew test # Unit-Tests des Kerns
|
||||
./gradlew runServer # lokaler Testserver in run/ (eula.txt und config/atmstats-server.toml vorher anlegen)
|
||||
```
|
||||
|
||||
Für einen lokalen Ende-zu-Ende-Test: Backend starten, Token erzeugen, in `run/config/atmstats-server.toml`
|
||||
`endpoint = "http://127.0.0.1:3001"` eintragen (nicht `localhost`: Java löst das zuerst als IPv6 auf), RCON in
|
||||
`run/server.properties` aktivieren und den Server mit `node tools/rcon.mjs 127.0.0.1 25575 <passwort> stop` beenden.
|
||||
|
||||
## Was die Mod erfasst
|
||||
|
||||
| Ereignis | Auslöser |
|
||||
|---|---|
|
||||
| `server_start` / `server_stop` | Serverstart (Versionen, Modliste) / -stopp |
|
||||
| `player_join` / `player_leave` | Login/Logout echter Spieler (keine FakePlayer) |
|
||||
| `player_death` | Tod eines Spielers: Dimension, Schadensart, Killer |
|
||||
| `entity_kill` | Spieler besiegt einen Boss (`c:bosses`) oder Spieler |
|
||||
| `advancement` | Advancement mit Anzeige (keine Rezepte) |
|
||||
| `dimension_change` | Dimensionswechsel |
|
||||
| `stats_snapshot` | alle 5 min geänderte Vanilla-Zähler online Spieler (erster Snapshot nach Start vollständig) |
|
||||
| `file_sync` | geänderte Dateien: `world/ftbquests/*.snbt`, `world/ftbteams/{player,party}/*.snbt`, `config/ftbquests/quests/chapters/*.snbt`, `config/ftbquests/quests/lang/en_us/**/*.snbt_merged` (rund 4 MB beim ersten Start, danach nur Änderungen) |
|
||||
| `health` | jede Minute: Ø/P95/Max-Tickdauer, TPS, Spieler, geladene Chunks, Heap |
|
||||
|
||||
## Last
|
||||
|
||||
Auf dem Server-Thread passieren nur das Anlegen kleiner Records, `System.nanoTime()` pro Tick und alle 5 Minuten
|
||||
das Kopieren der Zählertabelle von höchstens zwei Spielern pro Tick. JSON, gzip, Dateien und HTTPS laufen im
|
||||
Thread `atmstats-worker` mit niedriger Priorität. Im lokalen Testlauf trat keine „Can't keep up“-Meldung auf.
|
||||
|
||||
## Dateien auf dem Gameserver
|
||||
|
||||
- `config/atmstats-server.toml`: Konfiguration (siehe `docs/OPERATIONS.md`)
|
||||
- `atmstats/outbox/`: noch nicht bestätigte Ereignisse; `rejected/` enthält vom Backend abgelehnte Batches mit Grund
|
||||
@@ -0,0 +1,68 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'net.neoforged.moddev' version '2.0.148'
|
||||
}
|
||||
|
||||
version = mod_version
|
||||
group = mod_group_id
|
||||
|
||||
base {
|
||||
archivesName = mod_id
|
||||
}
|
||||
|
||||
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
|
||||
|
||||
neoForge {
|
||||
version = project.neo_version
|
||||
|
||||
parchment {
|
||||
mappingsVersion = project.parchment_mappings_version
|
||||
minecraftVersion = project.parchment_minecraft_version
|
||||
}
|
||||
|
||||
runs {
|
||||
// Lokaler Testserver: ./gradlew runServer (Arbeitsverzeichnis mod/run)
|
||||
server {
|
||||
server()
|
||||
programArgument '--nogui'
|
||||
}
|
||||
}
|
||||
|
||||
mods {
|
||||
"${mod_id}" {
|
||||
sourceSet(sourceSets.main)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation platform('org.junit:junit-bom:5.11.4')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
testImplementation 'com.google.code.gson:gson:2.11.0'
|
||||
}
|
||||
|
||||
tasks.named('test', Test).configure {
|
||||
useJUnitPlatform()
|
||||
// Protocol fixtures shared with the backend (../shared/fixtures/protocol)
|
||||
systemProperty 'atm.fixtures', file('../shared/fixtures/protocol').absolutePath
|
||||
testLogging { events 'failed'; exceptionFormat 'full' }
|
||||
}
|
||||
|
||||
var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
|
||||
var replaceProperties = [
|
||||
minecraft_version_range: minecraft_version_range,
|
||||
neo_version_range : neo_version_range,
|
||||
loader_version_range : loader_version_range,
|
||||
mod_id : mod_id,
|
||||
mod_name : mod_name,
|
||||
mod_license : mod_license,
|
||||
mod_version : mod_version,
|
||||
]
|
||||
inputs.properties replaceProperties
|
||||
expand replaceProperties
|
||||
from "src/main/templates"
|
||||
into "build/generated/sources/modMetadata"
|
||||
}
|
||||
sourceSets.main.resources.srcDir generateModMetadata
|
||||
neoForge.ideSyncTask generateModMetadata
|
||||
@@ -0,0 +1,21 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
parchment_minecraft_version=1.21.1
|
||||
parchment_mappings_version=2024.11.17
|
||||
|
||||
minecraft_version=1.21.1
|
||||
minecraft_version_range=[1.21.1,1.21.2)
|
||||
# Kompiliert gegen die NeoForge-Version von ATM10 6.1; lauffähig ab 21.1.0 (siehe neo_version_range).
|
||||
neo_version=21.1.219
|
||||
neo_version_range=[21.1.0,)
|
||||
loader_version_range=[1,)
|
||||
|
||||
mod_id=atmstats
|
||||
mod_name=ATM Stats
|
||||
mod_license=MIT
|
||||
mod_version=1.0.0
|
||||
mod_group_id=de.hahmann.atmstats
|
||||
BIN
Binary file not shown.
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user